diff --git a/packages/cli/templates/receipts/collect.mjs b/packages/cli/templates/receipts/collect.mjs index 93379e6..70a84a7 100644 --- a/packages/cli/templates/receipts/collect.mjs +++ b/packages/cli/templates/receipts/collect.mjs @@ -28,7 +28,8 @@ export function collectReceipt(env = process.env, now = new Date()) { const engine = parseEngineEvidence(env.FACILITY_RECEIPT_ENGINE_JSONL); const checkEvidence = parseChecks(env.FACILITY_RECEIPT_CHECKS_FILE); const target = githubTarget(env.GITHUB_EVENT_PATH); - const git = gitActivity(env.FACILITY_RECEIPT_BASE_SHA, env.GITHUB_WORKSPACE); + const baseSha = gitCommitSha(env.FACILITY_RECEIPT_BASE_SHA); + const git = gitActivity(baseSha, env.GITHUB_WORKSPACE); const actor = env.GITHUB_ACTOR; const receipt = { schema: "facility.run.v1", @@ -59,6 +60,7 @@ export function collectReceipt(env = process.env, now = new Date()) { repo: env.GITHUB_REPOSITORY?.split("/")[1], issue: target.issue, pr: target.pr, + ...(baseSha ? { base_sha: baseSha } : {}), ...(actor ? { actor_sha256: sha256(actor) } : {}), }, timing: { @@ -250,6 +252,12 @@ function normalizeResult(value) { return "failed"; } +function gitCommitSha(value) { + return typeof value === "string" && /^[0-9a-f]{40}$/i.test(value) + ? value.toLowerCase() + : undefined; +} + function requiredChoice(value, choices, name) { if (!value || !choices.has(value)) throw new Error(`FACILITY_RECEIPT_${name.toUpperCase()} is invalid`); diff --git a/packages/cli/test/receipts.test.mjs b/packages/cli/test/receipts.test.mjs index e3e7305..f638361 100644 --- a/packages/cli/test/receipts.test.mjs +++ b/packages/cli/test/receipts.test.mjs @@ -29,6 +29,7 @@ test("collects a privacy-preserving, tamper-evident agent receipt", async () => FACILITY_RECEIPT_RESULT: "success", FACILITY_RECEIPT_STARTED_AT: "2026-07-19T00:00:00.000Z", FACILITY_RECEIPT_ENGINE_JSONL: enginePath, + FACILITY_RECEIPT_BASE_SHA: "a".repeat(40), FACILITY_RECEIPT_OUTPUT: outputPath, GITHUB_EVENT_PATH: eventPath, GITHUB_REPOSITORY: "theam/mirror", @@ -40,6 +41,7 @@ test("collects a privacy-preserving, tamper-evident agent receipt", async () => const receipt = collectReceipt(env, new Date("2026-07-19T00:01:00.000Z")); assert.equal(receipt.schema, "facility.run.v1"); assert.equal(receipt.github.pr, 42); + assert.equal(receipt.github.base_sha, "a".repeat(40)); assert.equal(receipt.usage.input_tokens, 10); assert.equal(receipt.activity.turns, 1); assert.equal(receipt.activity.shell_commands, 1); @@ -85,5 +87,6 @@ test("reports the full check count when receipt details are bounded", async () = assert.equal(receipt.checks_truncated, true); assert.equal(receipt.checks[0].name, "check-1"); assert.equal(receipt.checks.at(-1).name, "check-200"); + assert.equal(receipt.github.base_sha, undefined); assert.equal(verifyReceipt(receipt), true); }); diff --git a/packages/core/src/receipts.ts b/packages/core/src/receipts.ts index b080fb3..3d087fe 100644 --- a/packages/core/src/receipts.ts +++ b/packages/core/src/receipts.ts @@ -51,6 +51,10 @@ export const FacilityReceiptSchema = z.object({ repo: z.string().optional(), issue: z.number().int().optional(), pr: z.number().int().optional(), + base_sha: z + .string() + .regex(/^[0-9a-f]{40}$/i) + .optional(), actor_sha256: z.string().optional(), }) .optional(), @@ -155,6 +159,10 @@ const LegacyAgentReceiptSchema = z.object({ repo: z.string().optional(), issue: z.number().int().optional(), pr: z.number().int().optional(), + base_sha: z + .string() + .regex(/^[0-9a-f]{40}$/i) + .optional(), actor: z.string().optional(), actor_sha256: z.string().optional(), }) @@ -206,6 +214,7 @@ export function parseLegacyAgentReceipt(json: unknown): FacilityReceipt { repo: receipt.github.repo, issue: receipt.github.issue, pr: receipt.github.pr, + base_sha: receipt.github.base_sha, actor_sha256: receipt.github.actor_sha256 ?? (receipt.github.actor ? hashActor(receipt.github.actor) : undefined), diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 27d227d..88f001b 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -404,13 +404,14 @@ describe("receipts", () => { result: "succeeded", usage: { input_tokens: 100, output_tokens: 50, cost_usd: 1.235, cost_source: "provider" }, activity: { turns: 2, shell_commands: 1, file_changes: 3, mcp_tool_calls: 0, errors: 0 }, - github: { owner: "example", repo: "product", actor: "octo" }, + github: { owner: "example", repo: "product", base_sha: "b".repeat(40), actor: "octo" }, timing: { started_at: "2026-01-01T00:00:00Z", duration_ms: 1000 }, checks: [{ name: "pnpm test", status: "passed", source: "platform", exit_code: 0 }], }); expect(receipt.schema).toBe("facility.run.v1"); expect(receipt.usage.cost_cents).toBe(124); expect(receipt.github?.actor_sha256).toMatch(/^[0-9a-f]{64}$/); + expect(receipt.github?.base_sha).toBe("b".repeat(40)); expect(receipt.checks).toEqual([ { name: "pnpm test", status: "passed", source: "platform", exit_code: 0 }, ]); @@ -444,4 +445,27 @@ describe("receipts", () => { }), ).toBe(false); }); + + it("keeps the optional base commit inside receipt integrity", () => { + const baseSha = "c".repeat(40); + const receipt = parseLegacyAgentReceipt({ + schema: "example.agent_sdlc.run.v1", + provider: "codex_cli", + mode: "builder", + result: "succeeded", + usage: { input_tokens: 1, output_tokens: 1, cost_source: "provider" }, + activity: {}, + github: { base_sha: baseSha }, + timing: { started_at: "2026-08-16T00:00:00.000Z" }, + }); + const sealed = sealFacilityReceipt(receipt, null); + + expect(verifyFacilityReceipt(sealed)).toBe(true); + expect( + verifyFacilityReceipt({ + ...sealed, + github: { ...sealed.github, base_sha: "d".repeat(40) }, + }), + ).toBe(false); + }); }); diff --git a/packages/db/migrations/0042_run_base_sha_provenance.sql b/packages/db/migrations/0042_run_base_sha_provenance.sql new file mode 100644 index 0000000..4bb5db5 --- /dev/null +++ b/packages/db/migrations/0042_run_base_sha_provenance.sql @@ -0,0 +1,5 @@ +ALTER TABLE runs + ADD COLUMN workspace_base_sha text; + +ALTER TABLE run_deliveries + ADD COLUMN base_sha text; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index eba9b04..09f7381 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -492,6 +492,7 @@ export const runs = pgTable( ciRepairKey: text("ci_repair_key"), transcriptUri: text("transcript_uri"), sessionStateUri: text("session_state_uri"), + workspaceBaseSha: text("workspace_base_sha"), error: text("error"), queuedAt: timestamp("queued_at", { withTimezone: true }).defaultNow().notNull(), startedAt: timestamp("started_at", { withTimezone: true }), @@ -550,6 +551,7 @@ export const runDeliveries = pgTable( repoName: text("repo_name").notNull(), headBranch: text("head_branch").notNull(), expectedHeadSha: text("expected_head_sha").notNull(), + baseSha: text("base_sha"), baseBranch: text("base_branch").notNull(), title: text("title").notNull(), body: text("body").notNull(), diff --git a/packages/db/test/db.test.ts b/packages/db/test/db.test.ts index f0aabec..5919843 100644 --- a/packages/db/test/db.test.ts +++ b/packages/db/test/db.test.ts @@ -743,6 +743,8 @@ describe("db", async () => { OR (table_name = 'spend_counters' AND column_name = 'spent_cents') OR (table_name = 'analytics_daily' AND column_name IN ('cost_cents', 'outcomes_assessed', 'outcomes_accepted')) OR (table_name = 'provider_credentials' AND column_name = 'auth_mode') + OR (table_name = 'runs' AND column_name = 'workspace_base_sha') + OR (table_name = 'run_deliveries' AND column_name = 'base_sha') `, )) as Iterable<{ table_name: string; column_name: string; data_type: string }>; const columnTypes = new Map( @@ -757,6 +759,8 @@ describe("db", async () => { expect(columnTypes.get("analytics_daily.outcomes_assessed")).toBe("integer"); expect(columnTypes.get("analytics_daily.outcomes_accepted")).toBe("integer"); expect(columnTypes.get("provider_credentials.auth_mode")).toBe("text"); + expect(columnTypes.get("runs.workspace_base_sha")).toBe("text"); + expect(columnTypes.get("run_deliveries.base_sha")).toBe("text"); const indexes = (await db.execute( sql` SELECT indexname @@ -874,7 +878,9 @@ describe("db", async () => { // A developer database can include later migrations from another worktree; // assert this checkout's latest migration was applied without assuming it // is the newest row in that shared database. - expect(Array.from(applied).map((row) => row.name)).toContain("0041_github_ci_story_events.sql"); + expect(Array.from(applied).map((row) => row.name)).toContain( + "0042_run_base_sha_provenance.sql", + ); const providerCredentialChecks = (await db.execute( sql` SELECT conname diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 882c05d..e70dbff 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6639,6 +6639,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -6685,6 +6689,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -6968,6 +6973,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -7014,6 +7023,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -7463,6 +7473,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -7509,6 +7523,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -7923,6 +7938,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -7989,6 +8008,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -8234,6 +8254,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -8280,6 +8304,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -8534,6 +8559,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -8580,6 +8609,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -9809,6 +9839,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -9855,6 +9889,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -13420,6 +13455,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -13466,6 +13505,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", diff --git a/packages/sdk/src/schema.d.ts b/packages/sdk/src/schema.d.ts index b4aa324..f32c8fa 100644 --- a/packages/sdk/src/schema.d.ts +++ b/packages/sdk/src/schema.d.ts @@ -6130,6 +6130,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -6295,6 +6296,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -6559,6 +6561,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -6809,6 +6812,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -6962,6 +6966,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -7113,6 +7118,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -7891,6 +7897,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -9663,6 +9670,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; diff --git a/runner/src/index.ts b/runner/src/index.ts index 1dd4617..7bfc5a9 100644 --- a/runner/src/index.ts +++ b/runner/src/index.ts @@ -137,6 +137,7 @@ async function main() { await prepareRunnerRuntime(); }); restoredSessionState = await restoreSessionState(activeBundle); + await recordWorkspaceProvenance(activeBundle); steerStop = startSteeringPoll(); const packageInstallCmd = activeBundle.packageInstallCmd; if (packageInstallCmd) { @@ -545,6 +546,22 @@ export async function prepareWorkspace( } } +export async function preparedWorkspaceBaseSha(bundle: RunBundle, root = workRoot) { + if (!bundle.repo.cloneUrl) return null; + const baseSha = (await gitOutput(cwdFor(bundle, root), ["rev-parse", "HEAD"])).trim(); + if (!/^[0-9a-f]{40}$/i.test(baseSha)) throw new Error("workspace_base_sha_invalid"); + return baseSha.toLowerCase(); +} + +export async function recordWorkspaceProvenance(bundle: RunBundle, root = workRoot) { + const baseSha = await preparedWorkspaceBaseSha(bundle, root); + if (!baseSha) return null; + return api<{ baseSha: string }>(`/internal/runs/${currentRunId()}/workspace`, { + method: "POST", + body: JSON.stringify({ baseSha }), + }); +} + async function pathExists(path: string) { try { await lstat(path); @@ -1922,6 +1939,7 @@ export async function shipGitChanges( return { branch: published.branch, headSha: published.headSha, + baseSha, changed: true, ...(pullRequest ? { diff --git a/runner/test/github-delivery.integration.test.ts b/runner/test/github-delivery.integration.test.ts index 9793641..7965355 100644 --- a/runner/test/github-delivery.integration.test.ts +++ b/runner/test/github-delivery.integration.test.ts @@ -440,6 +440,7 @@ describe.sequential("signed GitHub delivery integration", () => { expect(result).toEqual({ branch: "feature/task", headSha: "signed_sha", + baseSha: fixture.baseSha, changed: true, pullRequestTitle: "fix!: deliver signed task", pullRequestBody: "## Summary\n\n- Deliver the signed task.", @@ -579,7 +580,12 @@ describe.sequential("signed GitHub delivery integration", () => { githubFetch: github.githubFetch, }); - expect(result).toEqual({ branch: "feature/task", headSha: "signed_sha", changed: true }); + expect(result).toEqual({ + branch: "feature/task", + headSha: "signed_sha", + baseSha: fixture.baseSha, + changed: true, + }); expect(facilityRequests(facility.requests, "/push-token")).toHaveLength(1); expect(github.originalUrls).toEqual(["https://api.github.com/graphql"]); expect(github.committed()).toBe(true); @@ -606,7 +612,12 @@ describe.sequential("signed GitHub delivery integration", () => { githubFetch: github.githubFetch, }); - expect(result).toEqual({ branch: "feature/task", headSha: "signed_sha", changed: true }); + expect(result).toEqual({ + branch: "feature/task", + headSha: "signed_sha", + baseSha: fixture.baseSha, + changed: true, + }); expect(facilityRequests(facility.requests, "/push-token")).toHaveLength(1); expect(github.originalUrls).toEqual(["https://api.github.com/graphql"]); expect(github.committed()).toBe(true); diff --git a/runner/test/workspace.test.ts b/runner/test/workspace.test.ts index 97268bf..2ae6d9d 100644 --- a/runner/test/workspace.test.ts +++ b/runner/test/workspace.test.ts @@ -33,6 +33,7 @@ import { gitOutput, handleControlMessage, parseGitNameStatus, + preparedWorkspaceBaseSha, prepareWorkspace, privateRegistryInstallCommand, privateRegistryNpmrc, @@ -1403,9 +1404,11 @@ describe("Claude resume controls", () => { it("replays a workspace checkpoint when the admitted base changed", async () => { const root = await mkdtemp(join(tmpdir(), "facility-runner-resume-stale-")); const source = join(root, "source"); - const target = join(root, "target"); + const workspace = join(root, "workspace"); + const target = join(workspace, "repo"); const checkpoint = join(root, "checkpoint"); await mkdir(source); + await mkdir(workspace); execFileSync("git", ["init", "--initial-branch=main"], { cwd: source }); execFileSync("git", ["config", "user.name", "Facility Test"], { cwd: source }); execFileSync("git", ["config", "user.email", "facility@example.test"], { cwd: source }); @@ -1413,6 +1416,10 @@ describe("Claude resume controls", () => { execFileSync("git", ["add", "task.txt"], { cwd: source }); execFileSync("git", ["commit", "-m", "chore: initialize stale fixture"], { cwd: source }); execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: source }); + const originalBaseSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: source, + encoding: "utf8", + }).trim(); await writeFile(join(source, "task.txt"), "resumable work\n"); await createWorkspaceCheckpoint(source, checkpoint, "main"); @@ -1423,10 +1430,32 @@ describe("Claude resume controls", () => { execFileSync("git", ["add", "new-base.txt"], { cwd: target }); execFileSync("git", ["commit", "-m", "chore: advance admitted base"], { cwd: target }); execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: target }); + const currentBaseSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: target, + encoding: "utf8", + }).trim(); await expect(restoreWorkspaceCheckpoint(target, checkpoint, "main")).resolves.toBe(true); - await expect(readFile(join(target, "task.txt"), "utf8")).resolves.toBe("resumable work\n"); - await expect(readFile(join(target, "new-base.txt"), "utf8")).resolves.toBe("new base\n"); + await expect( + preparedWorkspaceBaseSha( + bundle({ + repo: { + cloneUrl: "https://github.com/acme/widget.git", + branch: "main", + expectedHeadSha: null, + installationTokenRef: null, + }, + }), + workspace, + ), + ).resolves.toBe(currentBaseSha); + expect(currentBaseSha).not.toBe(originalBaseSha); + await expect( + readFile(join(target, "task.txt"), "utf8").then((body) => body.replace(/\r\n/g, "\n")), + ).resolves.toBe("resumable work\n"); + await expect( + readFile(join(target, "new-base.txt"), "utf8").then((body) => body.replace(/\r\n/g, "\n")), + ).resolves.toBe("new base\n"); expect( execFileSync("git", ["branch", "--show-current"], { cwd: target, encoding: "utf8" }).trim(), ).toBe("main"); diff --git a/services/api/src/routes/internal.ts b/services/api/src/routes/internal.ts index b4ba4c2..69ad884 100644 --- a/services/api/src/routes/internal.ts +++ b/services/api/src/routes/internal.ts @@ -24,6 +24,7 @@ import { import type { AppConfig } from "../types.js"; const Params = z.object({ runId: z.string() }); +const GitCommitSha = z.string().regex(/^[0-9a-f]{40}$/i); const TRANSCRIPT_MAX_BYTES = 50 * 1024 * 1024; const SESSION_STATE_MAX_BYTES = 200 * 1024 * 1024; const EventBatch = z.array( @@ -168,6 +169,45 @@ export async function registerInternalRoutes(app: FastifyInstance, config: AppCo }, ); + app.post( + "/internal/runs/:runId/workspace", + { + config: { public: true }, + preHandler: authenticate, + schema: { + params: Params, + body: z.object({ baseSha: GitCommitSha }), + response: { 200: z.object({ baseSha: GitCommitSha }) }, + }, + }, + async (request) => { + const run = (request as RunnerRequest).runnerRun; + if (!run) throw notFound("Run not found"); + const baseSha = (request.body as { baseSha: string }).baseSha.toLowerCase(); + const [recorded] = await db + .update(runs) + .set({ workspaceBaseSha: baseSha, updatedAt: new Date() }) + .where(and(eq(runs.orgId, run.orgId), eq(runs.id, run.id), isNull(runs.workspaceBaseSha))) + .returning({ baseSha: runs.workspaceBaseSha }); + if (recorded?.baseSha) return { baseSha: recorded.baseSha }; + + // Runner lifecycle requests may be replayed after a lost response. The + // checkpoint is immutable: an exact replay succeeds, while a different + // SHA cannot rewrite the provenance already bound to this run. + const [current] = await db + .select({ baseSha: runs.workspaceBaseSha }) + .from(runs) + .where(and(eq(runs.orgId, run.orgId), eq(runs.id, run.id))) + .limit(1); + if (current?.baseSha === baseSha) return { baseSha }; + throw new ApiError( + 409, + "workspace_base_mismatch", + "Run workspace base commit was already recorded", + ); + }, + ); + app.post( "/internal/runs/:runId/events", { @@ -426,6 +466,7 @@ export async function registerInternalRoutes(app: FastifyInstance, config: AppCo .object({ branch: z.string().optional(), headSha: z.string().optional(), + baseSha: GitCommitSha.optional(), changed: z.boolean(), pushError: z.string().optional(), pullRequestTitle: z.string().optional(), @@ -448,6 +489,7 @@ export async function registerInternalRoutes(app: FastifyInstance, config: AppCo git?: { branch?: string; headSha?: string; + baseSha?: string; changed: boolean; pushError?: string; pullRequestTitle?: string; diff --git a/services/api/src/routes/v1/shared.ts b/services/api/src/routes/v1/shared.ts index f1368cb..bccf7fd 100644 --- a/services/api/src/routes/v1/shared.ts +++ b/services/api/src/routes/v1/shared.ts @@ -202,6 +202,7 @@ export const RunSchema = z.object({ engineSessionId: z.string().nullable(), transcriptUri: z.string().nullable(), sessionStateUri: z.string().nullable(), + workspaceBaseSha: z.string().nullable(), error: z.string().nullable(), queuedAt: DateValue, startedAt: DateValue.nullable(), diff --git a/services/api/src/sandbox/orchestrator.ts b/services/api/src/sandbox/orchestrator.ts index f4b653d..f0713ea 100644 --- a/services/api/src/sandbox/orchestrator.ts +++ b/services/api/src/sandbox/orchestrator.ts @@ -288,6 +288,7 @@ export async function finishRun( git?: { branch?: string; headSha?: string; + baseSha?: string; changed: boolean; pushError?: string; pullRequestTitle?: string; @@ -347,7 +348,17 @@ export async function finishRun( } const sandbox = readSandbox(run.sandbox); const aggregate = await gatewayAggregate(db, run.id); - let receipt = await canonicalRunReceipt(db, run, input.receipt, aggregate, status); + // A delivery receipt names the published range. Runs without a delivery + // still expose the prepared workspace base they actually inspected. + const receiptBaseSha = input.git?.baseSha ?? run.workspaceBaseSha; + let receipt = await canonicalRunReceipt( + db, + run, + input.receipt, + aggregate, + status, + receiptBaseSha, + ); const claimed = await db.transaction(async (tx) => { const terminal = ( await tx @@ -418,7 +429,14 @@ export async function finishRun( const message = errorMessage(planError); status = "failed"; error = `plan_publication_failed:${message}`; - receipt = await canonicalRunReceipt(db, run, input.receipt, aggregate, status); + receipt = await canonicalRunReceipt( + db, + run, + input.receipt, + aggregate, + status, + receiptBaseSha, + ); await db .update(runs) .set({ status, receipt, error, updatedAt: new Date() }) @@ -459,7 +477,14 @@ export async function finishRun( const message = errorMessage(syncError); status = "failed"; error = `security_issue_sync_failed:${message}`; - receipt = await canonicalRunReceipt(db, run, input.receipt, aggregate, status); + receipt = await canonicalRunReceipt( + db, + run, + input.receipt, + aggregate, + status, + receiptBaseSha, + ); await db .update(runs) .set({ status, receipt, error, updatedAt: new Date() }) @@ -890,6 +915,7 @@ async function prepareRunDelivery( git: { branch?: string; headSha?: string; + baseSha?: string; changed: boolean; pushError?: string; pullRequestTitle?: string; @@ -967,6 +993,7 @@ async function prepareRunDelivery( repoName: repo.name, headBranch: git.branch, expectedHeadSha: git.headSha, + baseSha: git.baseSha, baseBranch: repo.defaultBranch, title: git.pullRequestTitle, body: pullRequestBody, @@ -2211,6 +2238,7 @@ async function canonicalRunReceipt( runnerReceipt: Record | undefined, aggregate: Awaited>, status: "succeeded" | "failed" | "canceled", + baseSha: string | null | undefined, ): Promise { const runner = objectOrEmpty(runnerReceipt); const runnerTiming = objectOrEmpty(runner.timing); @@ -2264,6 +2292,7 @@ async function canonicalRunReceipt( repo: stringValue(gh.repo), issue: integerValue(gh.issueNumber), pr: integerValue(objectOrEmpty(gh.pr).number), + base_sha: stringValue(baseSha), }, timing: { started_at: stringValue(runnerTiming.started_at) ?? startedAt.toISOString(), diff --git a/services/api/test/github-platform-lane.test.ts b/services/api/test/github-platform-lane.test.ts index 6e8b7ad..c54f4f8 100644 --- a/services/api/test/github-platform-lane.test.ts +++ b/services/api/test/github-platform-lane.test.ts @@ -3677,6 +3677,7 @@ describe("github platform lane", async () => { const run = await insertRun({ status: "running", gh: { owner: repo.owner, repo: repo.name, issueNumber: 91 }, + workspaceBaseSha: "b".repeat(40), }); const queued: Array<{ queue: string; data: Record }> = []; await finishRun( @@ -3688,6 +3689,7 @@ describe("github platform lane", async () => { changed: true, branch: "feature/exact-delivery", headSha: "expected-sha", + baseSha: "a".repeat(40), pullRequestTitle: "fix: bind delivery to the pushed commit", pullRequestBody: "Exact delivery", }, @@ -3707,7 +3709,12 @@ describe("github platform lane", async () => { owner: repo.owner, repoName: repo.name, expectedHeadSha: "expected-sha", + baseSha: "a".repeat(40), }); + const [finishedRun] = await db.select().from(runs).where(eq(runs.id, run.id)); + expect((finishedRun?.receipt as { github?: { base_sha?: string } })?.github?.base_sha).toBe( + "a".repeat(40), + ); let createCalls = 0; const blocked = await deliverPendingRunDeliveries(db, config, { @@ -5222,6 +5229,7 @@ describe("github platform lane", async () => { trigger?: Record; gh?: Record; sandbox?: Record; + workspaceBaseSha?: string; } = {}, ) { const row = ( @@ -5237,6 +5245,7 @@ describe("github platform lane", async () => { trigger: input.trigger ?? {}, sandbox: input.sandbox ?? {}, gh: input.gh ?? {}, + workspaceBaseSha: input.workspaceBaseSha, createdBy: { type: "user", id: "test" }, }) .returning() diff --git a/services/api/test/sandbox.test.ts b/services/api/test/sandbox.test.ts index 81d8f38..a1f0061 100644 --- a/services/api/test/sandbox.test.ts +++ b/services/api/test/sandbox.test.ts @@ -940,6 +940,62 @@ describe("sandbox api", async () => { expect(stored).toEqual({ engineSessionId: "sess_early_123", status: "running" }); }); + it("records an authenticated runner workspace base once and accepts exact replays", async () => { + const token = "frt_workspace_base"; + const run = await insertRunnerRun(token, "running"); + const otherToken = "frt_workspace_other_run"; + await insertRunnerRun(otherToken, "running"); + const baseSha = "A".repeat(40); + + const invalid = await app.inject({ + method: "POST", + url: `/internal/runs/${run.id}/workspace`, + headers: { authorization: `Bearer ${token}` }, + payload: { baseSha: "not-a-commit" }, + }); + expect(invalid.statusCode).toBe(400); + + const crossRun = await app.inject({ + method: "POST", + url: `/internal/runs/${run.id}/workspace`, + headers: { authorization: `Bearer ${otherToken}` }, + payload: { baseSha }, + }); + expect(crossRun.statusCode).toBe(401); + + const first = await app.inject({ + method: "POST", + url: `/internal/runs/${run.id}/workspace`, + headers: { authorization: `Bearer ${token}` }, + payload: { baseSha }, + }); + expect(first.statusCode, first.body).toBe(200); + expect(first.json()).toEqual({ baseSha: "a".repeat(40) }); + + const replay = await app.inject({ + method: "POST", + url: `/internal/runs/${run.id}/workspace`, + headers: { authorization: `Bearer ${token}` }, + payload: { baseSha: "a".repeat(40) }, + }); + expect(replay.statusCode, replay.body).toBe(200); + + const mismatch = await app.inject({ + method: "POST", + url: `/internal/runs/${run.id}/workspace`, + headers: { authorization: `Bearer ${token}` }, + payload: { baseSha: "b".repeat(40) }, + }); + expect(mismatch.statusCode).toBe(409); + expect(mismatch.json()).toMatchObject({ error: { code: "workspace_base_mismatch" } }); + + const [stored] = await db + .select({ workspaceBaseSha: runs.workspaceBaseSha }) + .from(runs) + .where(eq(runs.id, run.id)); + expect(stored?.workspaceBaseSha).toBe("a".repeat(40)); + }); + it("finishRun synchronizes only trusted qualifying security findings", async () => { const suffix = Date.now(); const installation = ( @@ -1238,6 +1294,8 @@ describe("sandbox api", async () => { it("stores actual check outcomes and provenance in the run receipt", async () => { const token = "frt_receipt_checks"; const run = await insertRunnerRun(token, "running"); + const workspaceBaseSha = "c".repeat(40); + await db.update(runs).set({ workspaceBaseSha }).where(eq(runs.id, run.id)); await appendRunEvents(db, orgId, run.id, [ { type: "check", @@ -1266,6 +1324,9 @@ describe("sandbox api", async () => { { name: "pnpm test", status: "passed", source: "platform", exit_code: 0 }, { name: "agent smoke", status: "skipped", source: "agent" }, ]); + expect((finished?.receipt as { github?: { base_sha?: string } })?.github?.base_sha).toBe( + workspaceBaseSha, + ); expect((finished?.receipt as { checks_truncated?: boolean })?.checks_truncated).toBe(false); });