diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 01a3ce941..51beb29d2 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -9,7 +9,7 @@ export function errorMessage(error: unknown): string { export function safeErrorMessage(error: unknown): string { const message = errorMessage(error); const recognizableCredential = - /(?:\b(?:sk-(?:proj-)?|github_pat_|gh[pousr]_|npm_)\S+|\b(?:bearer|basic|token)(?:\s|%20|\+)+\S+|:\/\/[^\s/@]+@|-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----)/iu.test( + /(?:\b(?:sk-(?:proj-)?|github_pat_|gh[pousr]_|npm_)\S+|\b(?:bearer|basic)(?:\s|%20|\+)+\S+|(?:^|[:="'\r\n])\s*token(?:\s|%20|\+)+\S+|:\/\/[^\s/@]+@|-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----)/iu.test( message, ); const assignments = message.matchAll( diff --git a/sdk/typescript/src/publish.ts b/sdk/typescript/src/publish.ts index eb8321ce9..c60b2c161 100644 --- a/sdk/typescript/src/publish.ts +++ b/sdk/typescript/src/publish.ts @@ -25,7 +25,6 @@ import { import { CodexSecurityError, ConfigurationError, - errorMessage, safeErrorMessage, } from "./errors.js"; import { @@ -504,7 +503,7 @@ export async function publishScanInternal( dependencies.recordPublishedIssues ?? recordPublishedIssues )(preparedScan, handoffResults.created, environment); } catch (cause) { - persistenceFailure = { cause, detail: errorMessage(cause) }; + persistenceFailure = { cause, detail: safeErrorMessage(cause) }; } } result.counts.created = result.created.length; @@ -538,7 +537,7 @@ export async function publishScanInternal( try { await saveReceipt(result, environment); } catch (error) { - const detail = errorMessage(error); + const detail = safeErrorMessage(error); throw new CodexSecurityError( `${reason} and its partial receipt could not be saved: ${detail}. ${recoveryDetails}`, { cause: error }, diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index c710d5223..bdbeedeff 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -72,6 +72,9 @@ describe("error messages", () => { test("omits credential-bearing messages at output boundaries", () => { for (const message of [ "request failed: token=SYNTHETIC_TOKEN", + "token SYNTHETIC_TOKEN", + "request failed: token SYNTHETIC_TOKEN", + '{"message":"token SYNTHETIC_TOKEN"}', "Authorization: Bearer sk-proj-SYNTHETIC_KEY_123", 'upstream failed: {"clientSecret":"correct horse battery staple"}', JSON.stringify(JSON.stringify({ clientSecret: "SYNTHETIC_SECRET" })), @@ -98,6 +101,9 @@ describe("error messages", () => { ); expect(safeErrorMessage("author=Michael")).toBe("author=Michael"); expect(safeErrorMessage("signal=active")).toBe("signal=active"); + expect(safeErrorMessage("Synthetic token cache unavailable")).toBe( + "Synthetic token cache unavailable", + ); expect(safeErrorMessage("design=complete")).toBe("design=complete"); expect(safeErrorMessage('worker 1: rg -n "password" src/login.ts')).toBe( 'worker 1: rg -n "password" src/login.ts', diff --git a/sdk/typescript/tests-ts/publication-integration.test.ts b/sdk/typescript/tests-ts/publication-integration.test.ts index 8f8f7f415..5bdece401 100644 --- a/sdk/typescript/tests-ts/publication-integration.test.ts +++ b/sdk/typescript/tests-ts/publication-integration.test.ts @@ -907,6 +907,100 @@ describe("database-backed Linear publication integration", () => { expect(await artifactDigests(completed.scanDirectory)).toEqual(sealed); }); + test("retains uncertain mutations when events.jsonl already exists without losing verified SQLite publications", async () => { + const completed = await fixture(2); + const sealed = await artifactDigests(completed.scanDirectory); + const stdout = capture(); + const stderr = capture(); + const cli = dependencies({ environment: completed.environment }); + let handoffFile = ""; + let events = ""; + + cli.publishScan = async (directory, options) => + publishScanInternal(directory, options, { + environment: completed.environment, + resolveCodex: () => ({ command: "synthetic-codex" }), + runCodex: async (_command, _args, prompt) => { + const payload = await publicationPayload(prompt); + handoffFile = payload.handoffFile; + await writeFile( + join(dirname(handoffFile), "events.jsonl"), + "Existing event log\n", + { flag: "wx", mode: 0o600 }, + ); + events = payload.batches[0]!.map((finding, index) => + JSON.stringify({ + type: "item.completed", + item: { + id: `tool-${index}`, + type: "mcp_tool_call", + server: "codex_apps", + tool: "linear.save_issue", + arguments: + index === 0 + ? finding.arguments + : { ...finding.arguments, priority: 4 }, + status: "completed", + result: { + content: [], + structured_content: { identifier: `SEC-${901 + index}` }, + }, + }, + }), + ).join("\n"); + return { exitCode: 0, stdout: events, stderr: "" }; + }, + }); + + expect( + await main( + [ + "publish", + "scan", + completed.scanDirectory, + "--to", + "linear", + "--linear-team", + OPTIONS.teamId, + "--project", + OPTIONS.projectId, + "--json", + ], + stdout.stream, + stderr.stream, + cli, + ), + ).toBe(2); + + expect(stdout.text()).toBe(""); + expect(stderr.text()).toContain( + "could not verify every completed mutation", + ); + expect(stderr.text()).toContain(handoffFile); + expect( + storedPublications(completed).map(({ external_id }) => external_id), + ).toEqual(["SEC-901"]); + const receipt = JSON.parse( + await readFile(receiptPath(completed), "utf8"), + ) as PublishScanResult; + expect(receipt.indeterminate).toBe(true); + expect(receipt.counts).toEqual({ findings: 2, created: 1, failed: 1 }); + expect(receipt.created[0]?.issueIdentifier).toBe("SEC-901"); + const eventFiles = (await readdir(dirname(handoffFile))).filter( + (name) => name.startsWith("events-") && name.endsWith(".jsonl"), + ); + expect(eventFiles).toHaveLength(1); + const eventsFile = join(dirname(handoffFile), eventFiles[0]!); + expect(await readFile(eventsFile, "utf8")).toBe(`${events}\n`); + expect(receipt.warnings).toContainEqual( + expect.stringContaining(eventsFile), + ); + expect( + await readFile(join(dirname(handoffFile), "events.jsonl"), "utf8"), + ).toBe("Existing event log\n"); + expect(await artifactDigests(completed.scanDirectory)).toEqual(sealed); + }); + test.each([false, true])( "keeps conflicting connector identities out of CLI history and retains recovery evidence with skipExisting=%s", async (skipExisting) => { @@ -1218,7 +1312,6 @@ for (;;) Atomics.wait(waiter, 0, 0, 1000);`, killTestProcess(descendantPid); } }, 30_000); - test("recovers verified SQLite publications before an interrupted CLI exits", async () => { const completed = await fixture(3); const sealed = await artifactDigests(completed.scanDirectory); diff --git a/sdk/typescript/tests-ts/publish.test.ts b/sdk/typescript/tests-ts/publish.test.ts index 62e653ab1..e977b34f4 100644 --- a/sdk/typescript/tests-ts/publish.test.ts +++ b/sdk/typescript/tests-ts/publish.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { appendFile, + mkdir, mkdtemp, readFile, readdir, @@ -2392,6 +2393,59 @@ describe("connected Linear publication", () => { expect(result.counts).toEqual({ findings: 3, created: 3, failed: 0 }); }); + test("retains verified event evidence when an incomplete handoff is rejected", async () => { + const publication = preparedPublication(2); + const incomplete = handoffRecord(publication, publication.issues[0]!); + delete incomplete["issueIdentifier"]; + const output = publication.issues + .map((issue) => issueEvent(issue)) + .join("\n"); + let handoffFile = ""; + let persisted: string[] = []; + let receipt: unknown; + + await expect( + publishScanInternal( + publication.scanDirectory, + OPTIONS, + dependencies( + publication, + {}, + { + runCodex: async (_command, _args, input) => { + handoffFile = publicationData(input).handoffFile; + await writeHandoff(input, [incomplete]); + return { exitCode: 0, stdout: output, stderr: "" }; + }, + recordPublishedIssues: async (_prepared, issues) => { + persisted = issues.map((issue) => issue.issueIdentifier); + return [...issues]; + }, + writeReceipt: async (result) => { + receipt = result; + }, + }, + ), + ), + ).rejects.toThrow("could not verify every completed mutation"); + + expect(persisted).toEqual(["SEC-2"]); + expect(receipt).toMatchObject({ + created: [{ findingId: "finding-2", issueIdentifier: "SEC-2" }], + failed: [{ findingId: "finding-1" }], + counts: { findings: 2, created: 1, failed: 1 }, + }); + expect( + await readFile(await publicationEventsFile(handoffFile), "utf8"), + ).toBe(`${output}\n`); + const recovery = (await readFile(handoffFile, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(recovery[0]).toEqual(incomplete); + expect(recovery[1]?.["issueIdentifier"]).toBe("SEC-2"); + }); + test("retains verified issue mappings after model-authored failures if the publication database fails", async () => { const publication = preparedPublication(2); let handoffFile: string | undefined; @@ -2429,7 +2483,7 @@ describe("connected Linear publication", () => { ), ), ).rejects.toThrow( - /Could not persist created Linear issues: Synthetic local history token=diagnostic-only is unavailable\..*publication handoff remains at.*avoid creating duplicate issues/u, + /Could not persist created Linear issues: \[redacted\]\..*publication handoff remains at.*avoid creating duplicate issues/u, ); const records = (await readFile(handoffFile!, "utf8")) @@ -2614,54 +2668,63 @@ describe("connected Linear publication", () => { ]); }); - test("preserves an ordinary local diagnostic when a cancellation receipt cannot be written", async () => { - const publication = preparedPublication(); - const controller = new AbortController(); - const diagnostic = "Synthetic token cache unavailable"; - let handoffFile: string | undefined; - let persisted = false; - let failure: unknown; + test.each([ + ["Synthetic token cache unavailable", "Synthetic token cache unavailable"], + ["The receipt disk is full.", "The receipt disk is full."], + ["token=SYNTHETIC_TOKEN", "[redacted]"], + ])( + "preserves recovery guidance for receipt error %s", + async (diagnostic, displayed) => { + const publication = preparedPublication(); + const controller = new AbortController(); + let handoffFile: string | undefined; + let persisted = false; + let failure: unknown; - try { - await publishScanInternal( - publication.scanDirectory, - { ...OPTIONS, signal: controller.signal }, - dependencies( - publication, - {}, - { - runCodex: async (_command, _args, input) => { - handoffFile = publicationData(input).handoffFile; - await writeHandoff(input, [ - handoffRecord(publication, publication.issues[0]!, { - identifier: "SEC-SAVED", - }), - ]); - controller.abort("SIGINT"); - return { exitCode: 130, stdout: "", stderr: "" }; - }, - recordPublishedIssues: async (_prepared, issues) => { - persisted = true; - return [...issues]; - }, - writeReceipt: async () => { - throw new Error(diagnostic); + try { + await publishScanInternal( + publication.scanDirectory, + { ...OPTIONS, signal: controller.signal }, + dependencies( + publication, + {}, + { + runCodex: async (_command, _args, input) => { + handoffFile = publicationData(input).handoffFile; + await writeHandoff(input, [ + handoffRecord(publication, publication.issues[0]!, { + identifier: "SEC-SAVED", + }), + ]); + controller.abort("SIGINT"); + return { exitCode: 130, stdout: "", stderr: "" }; + }, + recordPublishedIssues: async (_prepared, issues) => { + persisted = true; + return [...issues]; + }, + writeReceipt: async () => { + throw new Error(diagnostic); + }, }, - }, - ), - ); - } catch (error) { - failure = error; - } + ), + ); + } catch (error) { + failure = error; + } - expect(failure).toBeInstanceOf(Error); - const message = (failure as Error).message; - expect(message).toMatch( - /partial receipt could not be saved: Synthetic token cache unavailable\..*publication handoff remains at.*avoid creating duplicate issues/u, - ); - expect(persisted).toBe(true); - expect(await readFile(handoffFile!, "utf8")).toContain("SEC-SAVED"); - }); + expect(failure).toBeInstanceOf(Error); + const message = (failure as Error).message; + expect(message).toContain( + `partial receipt could not be saved: ${displayed}.`, + ); + expect(message).toMatch( + /publication handoff remains at.*avoid creating duplicate issues/u, + ); + expect(persisted).toBe(true); + expect(await readFile(handoffFile!, "utf8")).toContain("SEC-SAVED"); + }, + ); test("retains distinct duplicate Linear issue IDs for indeterminate recovery", async () => { const publication = preparedPublication(2); @@ -2743,6 +2806,102 @@ describe("connected Linear publication", () => { ).toBe(`${output}\n`); }); + test.each(["none", "both", "final"] as const)( + "retains mutation evidence when payload verification fails (receipt failure: %s)", + async (receiptFailure) => { + const publication = preparedPublication(2); + const changed = JSON.parse(issueEvent(publication.issues[1]!)); + changed.item.arguments.team = "different-team"; + const output = [ + JSON.stringify({ + type: "item.completed", + item: { type: "agent_message", text: "Unrelated diagnostic" }, + }), + issueEvent(publication.issues[0]!), + JSON.stringify(changed), + ].join("\n"); + let handoffFile: string | undefined; + let persisted: string[] = []; + let receipt: unknown; + let receiptWrites = 0; + const updates: PublishScanProgress[] = []; + + await expect( + publishScanInternal( + publication.scanDirectory, + { ...OPTIONS, onProgress: (event) => updates.push(event) }, + dependencies( + publication, + {}, + { + runCodex: async ( + _command, + _args, + input, + _environment, + onEvent, + ) => { + handoffFile = publicationData(input).handoffFile; + onEvent?.(changed); + return { exitCode: 0, stdout: output, stderr: "" }; + }, + recordPublishedIssues: async (_prepared, issues) => { + expect( + (await readdir(dirname(handoffFile!))).filter((name) => + name.startsWith("events-"), + ), + ).toHaveLength(1); + persisted = issues.map((issue) => issue.issueIdentifier); + return [...issues]; + }, + writeReceipt: async (result) => { + receiptWrites += 1; + receipt = result; + if ( + receiptFailure === "both" || + (receiptFailure === "final" && receiptWrites === 2) + ) + throw new Error( + "OPENAI_API_KEY=sk-proj-SYNTHETIC_RECEIPT_SECRET_123", + ); + }, + }, + ), + ), + ).rejects.toThrow( + receiptFailure !== "none" + ? /could not verify every completed mutation and its partial receipt could not be saved: \[redacted\]\..*handoff remains at.*avoid creating duplicate issues/u + : /could not verify every completed mutation.*handoff remains at.*avoid creating duplicate issues/u, + ); + + expect(receiptWrites).toBe(2); + expect(persisted).toEqual(["SEC-1"]); + expect(receipt).toMatchObject({ + indeterminate: true, + counts: { findings: 2, created: 1, failed: 1 }, + }); + expect(await readFile(handoffFile!, "utf8")).toContain("SEC-1"); + const eventsFile = await publicationEventsFile(handoffFile!); + expect(await readFile(eventsFile, "utf8")).toBe( + `${issueEvent(publication.issues[0]!)}\n${JSON.stringify(changed)}\n`, + ); + expect(receipt).toMatchObject({ + warnings: expect.arrayContaining([expect.stringContaining(eventsFile)]), + }); + expect(updates).toContainEqual( + expect.objectContaining({ + type: "issue_completed", + findingId: "finding-2", + error: expect.any(String), + }), + ); + expect(updates.some((event) => event.type === "completed")).toBe(false); + if (process.platform !== "win32") { + expect((await stat(eventsFile)).mode & 0o077).toBe(0); + } + }, + ); + test("keeps recovery-write failures from blocking verified history persistence", async () => { const publication = preparedPublication(2); const changed = JSON.parse(issueEvent(publication.issues[1]!)); @@ -2793,6 +2952,105 @@ describe("connected Linear publication", () => { }); }); + test.each(["history", "handoff", "interruption"] as const)( + "preserves durable indeterminate status across %s failure", + async (failure) => { + const publication = preparedPublication(); + const controller = new AbortController(); + const extra = JSON.parse( + issueEvent(publication.issues[0]!, { identifier: "SEC-EXTRA" }), + ); + extra.item.arguments.description = "An unrelated synthetic completion"; + const output = [ + issueEvent(publication.issues[0]!), + JSON.stringify(extra), + ].join("\n"); + const stateDirectory = await mkdtemp( + join(tmpdir(), "codex-security-indeterminate-receipt-"), + ); + temporaryDirectories.push(stateDirectory); + const receiptDirectory = join(stateDirectory, "publications", "linear"); + const digest = createHash("sha256") + .update(publication.scanId) + .digest("hex"); + const readReceipt = async () => + JSON.parse( + await readFile(join(receiptDirectory, `${digest}.json`), "utf8"), + ) as PublishScanResult; + let handoffFile = ""; + let historyAttempted = false; + const injected = dependencies( + publication, + {}, + { + environment: { CODEX_SECURITY_STATE_DIR: stateDirectory }, + runCodex: async (_command, _args, input) => { + handoffFile = publicationData(input).handoffFile; + if (failure === "handoff") { + await rm(handoffFile); + await mkdir(handoffFile); + } + if (failure === "interruption") controller.abort("SIGINT"); + return { exitCode: 0, stdout: output, stderr: "" }; + }, + recordPublishedIssues: async (_prepared, issues) => { + historyAttempted = true; + expect( + (await readdir(dirname(handoffFile))).filter((name) => + name.startsWith("events-"), + ), + ).toHaveLength(1); + expect(await readReceipt()).toMatchObject({ + indeterminate: true, + created: [], + failed: [], + counts: { findings: 1, created: 0, failed: 0 }, + }); + if (failure === "history") { + throw new Error("Publication history is unavailable."); + } + return [...issues]; + }, + }, + ); + delete injected.writeReceipt; + + await expect( + publishScanInternal( + publication.scanDirectory, + { ...OPTIONS, signal: controller.signal }, + injected, + ), + ).rejects.toThrow( + failure === "interruption" + ? /was interrupted.*indeterminate.*handoff remains at/u + : /Could not persist created Linear issues.*indeterminate.*handoff remains at/u, + ); + + const receipt = await readReceipt(); + const eventsFile = await publicationEventsFile(handoffFile); + expect(receipt.indeterminate).toBe(true); + expect(receipt.warnings).toContainEqual( + expect.stringContaining(eventsFile), + ); + expect(receipt.failed).toEqual([]); + expect(receipt.counts).toEqual({ + findings: 1, + created: failure === "interruption" ? 1 : 0, + failed: 0, + }); + expect(receipt.created.map((issue) => issue.issueIdentifier)).toEqual( + failure === "interruption" ? ["SEC-1"] : [], + ); + expect(historyAttempted).toBe(failure !== "handoff"); + expect(await readFile(eventsFile, "utf8")).toBe(`${output}\n`); + const archived = (await readdir(receiptDirectory)).filter( + (name) => name.startsWith(`${digest}-`) && name.endsWith(".json"), + ); + expect(archived).toHaveLength(2); + }, + ); + test("rejects handoffs contradicted by observed trusted Linear mutations", async () => { const scenarios: Array<{ name: string; @@ -3909,3 +4167,408 @@ describe("connected Linear publication", () => { } }); }); + +describe("publication recovery regressions", () => { + test("retains completion and rejected-retry evidence without claiming a determinate result", async () => { + const publication = preparedPublication(2); + const output = [ + issueEvent(publication.issues[0]!), + issueEvent(publication.issues[0]!, { status: "failed" }), + issueEvent(publication.issues[1]!), + ].join("\n"); + let receipt: PublishScanResult | undefined; + let retained: readonly string[] = []; + await expect( + publishScanInternal( + publication.scanDirectory, + OPTIONS, + dependencies( + publication, + { stdout: output }, + { + writeEvents: async (_directory, events) => { + retained = events; + return "/synthetic/recovery-events.jsonl"; + }, + writeReceipt: async (result) => { + receipt = structuredClone(result); + }, + }, + ), + ), + ).rejects.toThrow("could not verify every completed mutation"); + expect(receipt).toMatchObject({ + indeterminate: true, + created: [{ findingId: "finding-2", issueIdentifier: "SEC-2" }], + failed: [{ findingId: "finding-1" }], + }); + expect(retained.join("\n")).toBe(output); + }); + + test("recovers completed direct issues before honoring cancellation", async () => { + const publication = preparedPublication(23); + const controller = new AbortController(); + const settleRemaining = Promise.withResolvers(); + let started = 0; + let stopped = 0; + let persisted: string[] = []; + let receipt: unknown; + const injected = dependencies( + publication, + {}, + { + linearClient: linearApiClient(publication, { + create: async (input, signal) => { + started += 1; + expect(signal).toBeUndefined(); + if (input.title === publication.issues[0]!.title) return; + await settleRemaining.promise; + stopped += 1; + throw new Error("Synthetic creation failure after cancellation."); + }, + }), + recordPublishedIssues: async (_prepared, issues) => { + persisted = issues.map(({ issueIdentifier }) => issueIdentifier); + return [...issues]; + }, + writeReceipt: async (result) => { + receipt = result; + }, + }, + ); + + await expect( + publishScanInternal( + publication.scanDirectory, + { + ...OPTIONS, + linearApiKey: "synthetic-key", + signal: controller.signal, + onProgress: ({ type }) => { + if (type === "handoff_recorded") { + controller.abort("SIGINT"); + settleRemaining.resolve(); + } + }, + }, + injected, + ), + ).rejects.toThrow(/publication handoff remains at/u); + + expect({ started, stopped, persisted }).toEqual({ + started: 20, + stopped: 19, + persisted: ["SEC-1"], + }); + expect(receipt).toMatchObject({ + counts: { findings: 23, created: 1, failed: 22 }, + }); + }); + + test("does not stage recovery events for a determinate publication", async () => { + const publication = preparedPublication(); + let handoffFile = ""; + let eventWrites = 0; + const result = await publishScanInternal( + publication.scanDirectory, + OPTIONS, + dependencies( + publication, + {}, + { + runCodex: async (_command, _args, input) => { + handoffFile = publicationData(input).handoffFile; + return { + exitCode: 0, + stdout: issueEvent(publication.issues[0]!), + stderr: "", + }; + }, + recordPublishedIssues: async (_prepared, issues) => { + expect( + (await readdir(dirname(handoffFile))).filter((name) => + name.startsWith("events-"), + ), + ).toEqual([]); + return [...issues]; + }, + writeEvents: async () => { + eventWrites += 1; + throw new Error("Determinate publication needs no recovery log."); + }, + }, + ), + ); + + expect(result.counts).toEqual({ findings: 1, created: 1, failed: 0 }); + expect(eventWrites).toBe(0); + }); + + test("retains completed calls with unfamiliar results until a handoff resolves them", async () => { + const publication = preparedPublication(2); + const unresolved = JSON.parse(issueEvent(publication.issues[1]!)); + unresolved.item.result = { + structured_content: { nested_connector_response: "unrecognized" }, + }; + const output = [ + issueEvent(publication.issues[0]!), + JSON.stringify(unresolved), + ].join("\n"); + let handoffFile = ""; + let persisted: string[] = []; + let receipt: unknown; + + await expect( + publishScanInternal( + publication.scanDirectory, + OPTIONS, + dependencies( + publication, + {}, + { + runCodex: async (_command, _args, input) => { + handoffFile = publicationData(input).handoffFile; + return { exitCode: 0, stdout: output, stderr: "" }; + }, + recordPublishedIssues: async (_prepared, issues) => { + persisted = issues.map((issue) => issue.issueIdentifier); + return [...issues]; + }, + writeReceipt: async (result) => { + receipt = result; + }, + }, + ), + ), + ).rejects.toThrow("could not verify every completed mutation"); + + expect(persisted).toEqual(["SEC-1"]); + expect(receipt).toMatchObject({ + counts: { findings: 2, created: 1, failed: 1 }, + failed: [ + { + findingId: "finding-2", + error: expect.stringContaining( + "did not return a created issue identifier", + ), + }, + ], + }); + expect( + await readFile(await publicationEventsFile(handoffFile), "utf8"), + ).toBe(`${output}\n`); + expect(await readFile(handoffFile, "utf8")).toContain("SEC-1"); + }); + + test("retains rejected success-shaped handoffs for recovery", async () => { + const scenarios: Array<{ + name: string; + mutate: (record: Record) => Record[]; + }> = [ + { + name: "another scan", + mutate: (record) => [{ ...record, scanId: "another-scan" }], + }, + { + name: "another occurrence", + mutate: (record) => [{ ...record, occurrenceId: "another-occurrence" }], + }, + { + name: "duplicate finding records", + mutate: (record) => [record, record], + }, + { + name: "an unexpected finding", + mutate: (record) => [{ ...record, findingId: "another-finding" }], + }, + { + name: "an invalid URL", + mutate: (record) => [{ ...record, url: "" }], + }, + { + name: "conflicting identity aliases", + mutate: (record) => [{ ...record, identifier: "" }], + }, + { + name: "conflicting nested identity", + mutate: (record) => { + const changed: Record = { + ...record, + id: record["issueIdentifier"], + issue: { identifier: "SEC-OTHER" }, + }; + delete changed["issueIdentifier"]; + delete changed["arguments"]; + return [changed]; + }, + }, + { + name: "a nested success result", + mutate: (record) => { + const { issueIdentifier, ...rest } = record; + return [{ ...rest, issue: { identifier: issueIdentifier } }]; + }, + }, + { + name: "a structured success result", + mutate: (record) => { + const { issueIdentifier, ...rest } = record; + return [ + { + ...rest, + structured_content: { issue: { identifier: issueIdentifier } }, + }, + ]; + }, + }, + ]; + + for (const scenario of scenarios) { + const publication = preparedPublication(); + let persisted = false; + let handoffFile = ""; + let receipt: unknown; + const operation = publishScanInternal( + publication.scanDirectory, + OPTIONS, + dependencies( + publication, + {}, + { + runCodex: async (_command, _args, input) => { + handoffFile = publicationData(input).handoffFile; + await writeHandoff( + input, + scenario.mutate( + handoffRecord(publication, publication.issues[0]!), + ), + ); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + recordPublishedIssues: async (_prepared, created) => { + persisted = true; + return [...created]; + }, + writeReceipt: async (result) => { + receipt = result; + }, + }, + ), + ); + + await expect(operation).rejects.toThrow( + "could not verify every completed mutation", + ); + expect(receipt, scenario.name).toMatchObject({ + created: [], + failed: [{ findingId: "finding-1" }], + counts: { findings: 1, created: 0, failed: 1 }, + }); + expect(await readFile(handoffFile, "utf8"), scenario.name).toContain( + "SEC-1", + ); + expect(persisted, scenario.name).toBe(false); + } + }); + + test("retains unverified handoffs while persisting independently valid issues", async () => { + const publication = preparedPublication(3); + let handoffFile: string | undefined; + let persisted: string[] = []; + let receipt: unknown; + await expect( + publishScanInternal( + publication.scanDirectory, + OPTIONS, + dependencies( + publication, + {}, + { + runCodex: async (_command, _args, input) => { + handoffFile = publicationData(input).handoffFile; + await writeHandoff( + input, + publication.issues.map((issue, index) => { + const record = handoffRecord(publication, issue); + if (index === 0) { + record["arguments"] = { title: "Normalized issue title" }; + } else if (index === 1) { + delete record["arguments"]; + } else { + record["connectorRequestId"] = "request-example"; + } + return record; + }), + ); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + recordPublishedIssues: async (_prepared, issues) => { + persisted = issues.map((issue) => issue.issueIdentifier); + return [...issues]; + }, + writeReceipt: async (result) => { + receipt = result; + }, + }, + ), + ), + ).rejects.toThrow( + /could not verify every completed mutation.*handoff remains at.*avoid creating duplicate issues/u, + ); + + expect(persisted).toEqual(["SEC-3"]); + expect(receipt).toMatchObject({ + counts: { findings: 3, created: 1, failed: 2 }, + created: [{ findingId: "finding-3", issueIdentifier: "SEC-3" }], + }); + expect(await readFile(handoffFile!, "utf8")).toContain( + "Normalized issue title", + ); + }); + + test("recovers incomplete handoff arguments from an exact observed mutation", async () => { + const publication = preparedPublication(2); + let handoffFile: string | undefined; + const result = await publishScanInternal( + publication.scanDirectory, + OPTIONS, + dependencies( + publication, + {}, + { + runCodex: async (_command, _args, input) => { + handoffFile = publicationData(input).handoffFile; + const records = publication.issues.map((issue) => { + const record = handoffRecord(publication, issue); + delete record["arguments"]; + return record; + }); + await writeHandoff(input, records); + return { + exitCode: 0, + stdout: publication.issues + .map((issue) => issueEvent(issue)) + .join("\n"), + stderr: "", + }; + }, + recordPublishedIssues: async (_prepared, issues) => { + const records = (await readFile(handoffFile!, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records.slice(2)).toEqual( + publication.issues.map((issue) => ({ + ...handoffRecord(publication, issue), + url: `https://linear.app/example/issue/SEC-${issue.findingId.slice(8)}`, + })), + ); + return [...issues]; + }, + }, + ), + ); + + expect(result.counts).toEqual({ findings: 2, created: 2, failed: 0 }); + }); +});