diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 9837cfee9..a2509e6f4 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -934,6 +934,20 @@ added to successful publication results, scan history, or sealed scan artifacts. Error messages are preserved as returned. `--dry-run` never contacts Linear in either mode. +Use `publish check` to verify that the completed scan and its findings match +local history, and to see which findings already have recorded Linear issues: + +```bash +npx @openai/codex-security publish check /path/to/completed-scan \ + --to linear --linear-team TEAM_ID --json +``` + +The check does not create issues, migrate scan history, or change sealed scan +artifacts. With a Linear API key, it also makes read-only authentication, team, +optional project, and assignee checks. Without a key, connected-app access is +reported as `not-checked`. Issue-creation permission is always `not-tested`; +successful read access does not prove write permission. + Each finding creates a separate new issue titled `[Codex Security][HIGH] Finding title`. The issue includes the scan ID, repository, scanned scope, source locations and code snippets, severity, @@ -942,9 +956,18 @@ Verified immutable Git revisions include source links. Findings are published concurrently in batches of up to 20. Successful issue identifiers are linked to their findings in the local scan-history database, and structured results are read back from that database rather than generated by Codex. The completed -scan must already exist in the local scan history. Running publication again -creates another set of issues for the same scan; existing issues are not -matched, updated, or reused. +scan must already exist in the local scan history. By default, running +publication again creates another set of issues for the same scan. Add +`--skip-existing` to skip findings with a recorded issue for the exact scan +occurrence, team, and optional project. Combine it with `--dry-run` to preview +only the remaining findings. Results distinguish newly `created` issues from +previously recorded `skipped` issues. + +This option uses local publication history; it does not search, update, or +verify the continued existence of remote issues. Recover any retained handoff +from an interrupted or uncertain publication before retrying. Concurrent +publishers and remote creations that were never recorded can still create +duplicates. Issue descriptions contain source code and vulnerability details. Select a Linear destination authorized to receive that information. Publication receipts @@ -962,7 +985,8 @@ Do not immediately rerun an indeterminate publication. Inspect the retained evidence and the selected Linear destination, reconcile every issue that may already have been created, and retry only after confirming that no unrecorded issue would be duplicated. Publication does not perform a fresh remote readback -or deduplicate issues on retry. +or recover unrecorded remote creations. The opt-in `--skip-existing` behavior +only skips issues already recorded in local history. You can also publish a scan from TypeScript: @@ -988,7 +1012,9 @@ console.log(publication.created.length); ``` Add `projectId: "PROJECT_ID"` to the options to publish into a specific Linear -project instead of directly to the team. +project instead of directly to the team. Pass `skipExisting: true` to skip +recorded successes, or import `checkScanPublication` and call it with the same +destination options for a read-only preflight. Pass `linearApiKey` to publish directly through the Linear API. Omit `assigneeId` to leave issues unassigned, or supply a Linear user ID or email diff --git a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json index 76688fe96..f16a9e7ce 100644 --- a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json +++ b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-security", - "version": "0.1.22", + "version": "0.1.37", "description": "Codex Security workflows for security scans, analysis, and investigation.", "author": { "name": "OpenAI" diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py b/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py index 045cd8376..fb6506523 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py @@ -330,7 +330,11 @@ def parse_args(description: str) -> argparse.Namespace: export_findings.add_argument("--scan-id", required=True) export_findings.add_argument("--format", choices=EXPORT_FORMATS, required=True) - for command in ("prepare-linear-publication", "record-linear-publications"): + for command in ( + "inspect-linear-publication", + "prepare-linear-publication", + "record-linear-publications", + ): publication = subparsers.add_parser(command) publication.add_argument("--input-file", required=True) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index e3717c1da..f703f1269 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -21,6 +21,7 @@ from datetime import datetime, timedelta, timezone from pathlib import Path, PurePosixPath from typing import Any +from urllib.parse import quote try: import fcntl as posix_file_lock @@ -2499,6 +2500,8 @@ def verify_linear_publication_scan( raise SystemExit( "The selected scan directory does not match its local Codex Security scan history." ) + if "seal_manifest_digest" in scan.keys(): + require_recorded_manifest_digest(scan, recorded_directory) stored_findings = { row["id"]: row["finding_id"] @@ -2520,6 +2523,52 @@ def verify_linear_publication_scan( return scan +def inspect_linear_publication(args: argparse.Namespace) -> dict[str, Any]: + payload, destination, findings = linear_publication_input(args, recording=False) + database_uri = f"file:{quote(str(database_path()), safe='')}?mode=ro" + with closing(sqlite3.connect(database_uri, uri=True, timeout=5)) as connection: + connection.row_factory = sqlite3.Row + connection.execute("BEGIN") + scan = verify_linear_publication_scan(connection, payload, findings) + recorded: dict[str, dict[str, str]] = {} + if connection.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'finding_publications'" + ).fetchone(): + for row in connection.execute( + """ + SELECT finding_id, occurrence_id, external_id, external_url + FROM finding_publications + WHERE scan_id = ? AND destination_type = ? AND team_id = ? AND project_id IS ? + ORDER BY created_at, external_id + """, + ( + scan["id"], + destination["type"], + destination["teamId"], + destination.get("projectId"), + ), + ): + recorded.setdefault( + row["occurrence_id"], + { + "findingId": row["finding_id"], + "occurrenceId": row["occurrence_id"], + "issueIdentifier": row["external_id"], + **({"url": row["external_url"]} if row["external_url"] is not None else {}), + }, + ) + return { + "scanId": scan["id"], + "destination": destination, + "findingCount": len(findings), + "recorded": [ + recorded[finding["occurrenceId"]] + for finding in findings + if finding["occurrenceId"] in recorded + ], + } + + def prepare_linear_publication( connection: sqlite3.Connection, args: argparse.Namespace ) -> dict[str, Any]: @@ -3810,6 +3859,10 @@ def main() -> None: result = inspect_setup(args) print(json.dumps(result, allow_nan=False, sort_keys=True)) return + if args.command == "inspect-linear-publication": + result = inspect_linear_publication(args) + print(json.dumps(result, allow_nan=False, sort_keys=True)) + return with closing(connect()) as connection: remediation.require_available(connection, args, require_scan) if args.command == "create-workspace": diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index bafd80ff8..eff872755 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -348,7 +348,7 @@ try { [ "--input-type=module", "--eval", - `const sdk = await import(${JSON.stringify(packageManifest.name)}); if (typeof sdk.CodexSecurity !== "function") throw new Error("The installed package does not export CodexSecurity."); if (typeof sdk.publishScan !== "function") throw new Error("The installed package does not export publishScan.");`, + `const sdk = await import(${JSON.stringify(packageManifest.name)}); for (const name of ["CodexSecurity", "publishScan", "checkScanPublication"]) if (typeof sdk[name] !== "function") throw new Error("The installed package does not export " + name + ".");`, ], { cwd: consumer }, ); @@ -470,6 +470,43 @@ try { assert.equal(publication.counts.findings, 1); assert.equal(publication.counts.created, 0); assert.match(publication.issues[0].title, /^\[Codex Security\]\[HIGH\] /u); + assert.match( + run(process.execPath, [launcher, "publish", "scan", "--help"], { + cwd: consumer, + capture: true, + }), + /--skip-existing/u, + ); + const missingHistory = spawnSync( + process.execPath, + [ + launcher, + "publish", + "check", + publicationScan, + "--to", + "linear", + "--linear-team", + "team-example", + "--json", + ], + { + cwd: consumer, + encoding: "utf8", + env: { + ...process.env, + CODEX_SECURITY_LINEAR_API_KEY: "", + CODEX_SECURITY_STATE_DIR: join(consumer, "publication-state"), + }, + timeout: PACKAGE_SMOKE_TIMEOUT_MS, + windowsHide: true, + }, + ); + assert.equal(missingHistory.status, 2, missingHistory.stderr); + assert.match(missingHistory.stderr, /scan-history database does not exist/u); + await assert.rejects(stat(join(consumer, "publication-state")), { + code: "ENOENT", + }); const networkGuard = join(consumer, "reject-publication-network.cjs"); await writeFile( diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 6cc822b5d..f5c614e99 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -101,7 +101,9 @@ import { runMultiscan } from "./multiscan.js"; import { componentPlanSchema, planComponents } from "./component-plan.js"; import { runComponentScans } from "./component-scan.js"; import { + checkScanPublication, publishScan, + type CheckScanPublicationOptions, type PublishScanProgress, type PublishScanResult, } from "./publish.js"; @@ -289,6 +291,79 @@ function linearApiKeyOption() { ); } +const PUBLICATION_DESTINATION_OPTIONS = z.object({ + to: z.literal("linear").describe("Publication destination."), + linearTeam: optionValue("--linear-team") + .optional() + .describe("Linear team ID; defaults to CODEX_SECURITY_LINEAR_TEAM."), + linearApiKey: linearApiKeyOption(), + linearProject: optionValue("--linear-project") + .optional() + .describe( + "Optional Linear project ID; defaults to CODEX_SECURITY_LINEAR_PROJECT.", + ), + project: optionValue("--project") + .optional() + .describe("Alias for --linear-project.") + .meta({ deprecated: true }), + linearAssignee: optionValue("--linear-assignee") + .optional() + .describe( + "Linear assignee email or user ID; omit to leave issues unassigned.", + ), +}); + +function publicationDestination( + options: z.infer, + environment: NodeJS.ProcessEnv, +): CheckScanPublicationOptions { + const linearApiKey = resolveLinearApiKey(environment, options.linearApiKey); + const assigneeId = options.linearAssignee?.trim(); + if (options.linearAssignee !== undefined && !assigneeId) { + throw new CodexSecurityError("--linear-assignee must not be empty."); + } + if (assigneeId !== undefined && linearApiKey === undefined) { + throw new CodexSecurityError( + "--linear-assignee requires --linear-api-key or CODEX_SECURITY_LINEAR_API_KEY.", + ); + } + const teamId = + options.linearTeam?.trim() || + environment["CODEX_SECURITY_LINEAR_TEAM"]?.trim(); + if (!teamId) { + throw new CodexSecurityError( + "--linear-team or CODEX_SECURITY_LINEAR_TEAM is required.", + ); + } + if ( + options.linearProject !== undefined && + options.project !== undefined && + options.linearProject.trim() !== options.project.trim() + ) { + throw new CodexSecurityError( + "--linear-project and --project must select the same project.", + ); + } + const projectOption = options.linearProject ?? options.project; + const selectedProject = projectOption?.trim(); + if (projectOption !== undefined && !selectedProject) { + throw new CodexSecurityError( + `${options.linearProject === undefined ? "--project" : "--linear-project"} must not be empty.`, + ); + } + const projectId = + selectedProject || + environment["CODEX_SECURITY_LINEAR_PROJECT"]?.trim() || + undefined; + return { + destination: options.to, + teamId, + ...(projectId === undefined ? {} : { projectId }), + ...(linearApiKey === undefined ? {} : { linearApiKey }), + ...(assigneeId === undefined ? {} : { assigneeId }), + }; +} + function publicationScanAge(timestamp: string, now: number): string { const completedAt = Date.parse(timestamp); if (!Number.isFinite(completedAt)) return "unknown"; @@ -400,6 +475,12 @@ function renderPublicationSummary( `${created} total issue${created === 1 ? "" : "s"} created`, `${failed} total issue${failed === 1 ? "" : "s"} failed`, ); + if (result.skipped !== undefined) { + const skipped = result.skipped.length; + lines.push( + `${skipped} previously recorded issue${skipped === 1 ? "" : "s"} skipped`, + ); + } return `${lines.join("\n")}\n`; } @@ -977,6 +1058,7 @@ interface CliDependencies { hasStoredChatGPTSignIn?: (signal?: AbortSignal) => Promise; scanAuthenticationPrompt?: Pick; publishPrompt?: Pick; + checkScanPublication?: typeof checkScanPublication; publishScan?: typeof publishScan; publishScanToCloud?: typeof publishScanToCloud; confirmPatchReview?: (question: string) => Promise; @@ -1897,6 +1979,21 @@ export async function main( ); }, }); + const reportPublicationError = (error: unknown, signal: unknown): void => { + if (signal === "SIGINT" || signal === "SIGTERM") { + const reason = + signal === "SIGINT" + ? "Publication canceled by Ctrl-C." + : "Publication terminated by SIGTERM."; + const recovery = + error === signal ? "" : ` ${diagnosticValue(safeErrorMessage(error))}`; + errorOutput.write(`codex-security: ${reason}${recovery}\n`); + exitCode = signal === "SIGINT" ? 130 : 143; + } else { + errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + exitCode = 2; + } + }; const publication = Cli.create("publish", { description: "Publish completed Codex Security scan findings.", }).command("scan", { @@ -1909,7 +2006,7 @@ export async function main( .optional() .describe("Completed scan directory; omit to select a saved scan."), }), - options: z.object({ + options: PUBLICATION_DESTINATION_OPTIONS.extend({ scan: z .array(optionValue("--scan")) .default([]) @@ -1929,28 +2026,16 @@ export async function main( message: "Unsupported publication destination. Use --to linear.", }) .describe("Publication destination (linear)."), - linearTeam: optionValue("--linear-team") - .optional() - .describe("Linear team ID; defaults to CODEX_SECURITY_LINEAR_TEAM."), - linearApiKey: linearApiKeyOption(), - linearProject: optionValue("--linear-project") - .optional() - .describe( - "Optional Linear project ID; defaults to CODEX_SECURITY_LINEAR_PROJECT.", - ), - project: optionValue("--project") - .optional() - .describe("Alias for --linear-project.") - .meta({ deprecated: true }), - linearAssignee: optionValue("--linear-assignee") - .optional() - .describe( - "Linear assignee email or user ID; omit to leave issues unassigned.", - ), dryRun: z .boolean() .default(false) .describe("Preview the findings without creating Linear issues."), + skipExisting: z + .boolean() + .default(false) + .describe( + "Skip findings already recorded for this exact Linear destination.", + ), }), output: z.record(z.string(), z.unknown()).optional(), async run({ args, format, formatExplicit, options }) { @@ -2039,64 +2124,28 @@ export async function main( } if ( options.to === "cloud" && - [ - options.linearTeam, - options.linearApiKey, - options.linearProject, - options.project, - options.linearAssignee, - ].some((value) => value !== undefined) + (options.skipExisting || + [ + options.linearTeam, + options.linearApiKey, + options.linearProject, + options.project, + options.linearAssignee, + ].some((value) => value !== undefined)) ) { throw new CodexSecurityError( "Cloud publication cannot be combined with Linear options.", ); } - const linearApiKey = + const destination = options.to === "linear" - ? resolveLinearApiKey( + ? publicationDestination( + { ...options, to: "linear" }, dependencies.environment, - options.linearApiKey, ) : undefined; - directApiPublication = linearApiKey !== undefined; - const assigneeId = options.linearAssignee?.trim(); - if (options.linearAssignee !== undefined && !assigneeId) { - throw new CodexSecurityError("--linear-assignee must not be empty."); - } - if (assigneeId !== undefined && linearApiKey === undefined) { - throw new CodexSecurityError( - "--linear-assignee requires --linear-api-key or CODEX_SECURITY_LINEAR_API_KEY.", - ); - } - const teamId = - options.linearTeam?.trim() || - dependencies.environment["CODEX_SECURITY_LINEAR_TEAM"]?.trim() || - ""; - if (options.to === "linear" && !teamId) { - throw new CodexSecurityError( - "--linear-team or CODEX_SECURITY_LINEAR_TEAM is required.", - ); - } - if ( - options.linearProject !== undefined && - options.project !== undefined && - options.linearProject.trim() !== options.project.trim() - ) { - throw new CodexSecurityError( - "--linear-project and --project must select the same project.", - ); - } - const projectOption = options.linearProject ?? options.project; - const selectedProject = projectOption?.trim(); - if (projectOption !== undefined && !selectedProject) { - throw new CodexSecurityError( - `${options.linearProject === undefined ? "--project" : "--linear-project"} must not be empty.`, - ); - } - const projectId = - selectedProject || - dependencies.environment["CODEX_SECURITY_LINEAR_PROJECT"]?.trim() || - undefined; + directApiPublication = + !options.dryRun && destination?.linearApiKey !== undefined; if (options.to === "cloud") { dependencies.addSignalListener("SIGINT", onInterrupt); @@ -2389,10 +2438,10 @@ export async function main( publicationRepository, ); presentation = progress; + dependencies.addSignalListener("SIGINT", onInterrupt); + dependencies.addSignalListener("SIGTERM", onTerminate); + observingSignals = true; if (!options.dryRun) { - dependencies.addSignalListener("SIGINT", onInterrupt); - dependencies.addSignalListener("SIGTERM", onTerminate); - observingSignals = true; progress.start(); } let result; @@ -2400,19 +2449,16 @@ export async function main( result = await (dependencies.publishScan ?? publishScan)( resolveCliPath(currentDirectory, scanDir), { - destination: "linear", + ...destination!, ...(selectedScans[0]?.scanId === undefined ? {} : { expectedScanId: selectedScans[0].scanId }), - teamId, - ...(projectId === undefined ? {} : { projectId }), dryRun: options.dryRun, - ...(linearApiKey === undefined ? {} : { linearApiKey }), - ...(assigneeId === undefined ? {} : { assigneeId }), + signal: controller.signal, + ...(options.skipExisting ? { skipExisting: true } : {}), ...(options.dryRun ? {} : { - signal: controller.signal, onProgress: (event: PublishScanProgress) => progress.observe(event), }), @@ -2458,6 +2504,39 @@ export async function main( } }, }); + publication.command("check", { + description: + "Check saved scan history and Linear access without creating issues.", + mcp: false, + args: z.object({ + scanDir: z.string().describe("Completed scan directory."), + }), + options: PUBLICATION_DESTINATION_OPTIONS, + output: z.record(z.string(), z.unknown()).optional(), + async run({ args, options }) { + const controller = new AbortController(); + const onInterrupt = (): void => controller.abort("SIGINT"); + const onTerminate = (): void => controller.abort("SIGTERM"); + dependencies.addSignalListener("SIGINT", onInterrupt); + dependencies.addSignalListener("SIGTERM", onTerminate); + try { + const result = await ( + dependencies.checkScanPublication ?? checkScanPublication + )(resolve(dependencies.currentDirectory(), args.scanDir), { + ...publicationDestination(options, dependencies.environment), + signal: controller.signal, + }); + controller.signal.throwIfAborted(); + return { ...result }; + } catch (error) { + reportPublicationError(error, controller.signal.reason); + return undefined; + } finally { + dependencies.removeSignalListener("SIGINT", onInterrupt); + dependencies.removeSignalListener("SIGTERM", onTerminate); + } + }, + }); const cli = Cli.create("codex-security", { description: "Run, validate, patch, verify fixes, export, and publish Codex Security findings.", diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 2b3154a9f..b29340fd5 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -63,8 +63,10 @@ export type { CodexSecurityConfig, JsonObject, JsonValue } from "./config.js"; export { loadContract, requireScanFile } from "./contract.js"; export type { LoadedContract, ScanExpectation } from "./contract.js"; export type * from "./models.js"; -export { publishScan } from "./publish.js"; +export { checkScanPublication, publishScan } from "./publish.js"; export type { + CheckScanPublicationOptions, + CheckScanPublicationResult, PublishScanOptions, PublishScanProgress, PublishScanResult, diff --git a/sdk/typescript/src/publication-store.ts b/sdk/typescript/src/publication-store.ts index 866f5e06a..7c740bb08 100644 --- a/sdk/typescript/src/publication-store.ts +++ b/sdk/typescript/src/publication-store.ts @@ -1,4 +1,5 @@ -import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { CodexSecurityError } from "./errors.js"; import type { PreparedScanPublication } from "./publication.js"; @@ -6,10 +7,51 @@ import type { PublishedScanIssue } from "./publish.js"; import { bundledPluginRoot, codexSecurityStateDirectory, + requireOutputOutsideRepository, resolvePluginPython, runWorkbench, } from "./runtime.js"; +export async function inspectPublicationStore( + publication: PreparedScanPublication, + environment: NodeJS.ProcessEnv, + signal?: AbortSignal, +): Promise { + const result = await runPublicationWorkbench( + "inspect-linear-publication", + publication, + environment, + undefined, + signal, + ); + const recorded = result["recorded"]; + if ( + !matchesPublication(result, publication) || + result["findingCount"] !== publication.issues.length || + !Array.isArray(recorded) + ) { + throw invalidPublicationRecords(); + } + const expected = new Map( + publication.issues.map((issue) => [issue.findingId, issue.occurrenceId]), + ); + const found = new Map(); + for (const value of recorded) { + const issue = readPublicationRecord(value); + if ( + expected.get(issue.findingId) !== issue.occurrenceId || + found.has(issue.findingId) + ) { + throw invalidPublicationRecords(); + } + found.set(issue.findingId, issue); + } + return publication.issues.flatMap(({ findingId }) => { + const issue = found.get(findingId); + return issue === undefined ? [] : [issue]; + }); +} + export async function preparePublicationStore( publication: PreparedScanPublication, environment: NodeJS.ProcessEnv, @@ -41,13 +83,8 @@ export async function recordPublishedIssues( issues, ); const created = result["created"]; - const destination = result["destination"]; if ( - result["scanId"] !== publication.scanId || - !isRecord(destination) || - destination["type"] !== publication.destination.type || - destination["teamId"] !== publication.destination.teamId || - destination["projectId"] !== publication.destination.projectId || + !matchesPublication(result, publication) || !Array.isArray(created) || created.length !== issues.length ) { @@ -65,33 +102,31 @@ export async function recordPublishedIssues( return created.map((value, index) => { const expectedIssue = ordered[index]; + const issue = readPublicationRecord(value); if ( - !isRecord(value) || expectedIssue === undefined || - value["findingId"] !== expectedIssue.findingId || - value["occurrenceId"] !== expectedIssue.occurrenceId || - value["issueIdentifier"] !== expectedIssue.issueIdentifier || - (value["url"] !== undefined && typeof value["url"] !== "string") || - (expectedIssue.url !== undefined && value["url"] !== expectedIssue.url) + issue.findingId !== expectedIssue.findingId || + issue.occurrenceId !== expectedIssue.occurrenceId || + issue.issueIdentifier !== expectedIssue.issueIdentifier || + (expectedIssue.url !== undefined && issue.url !== expectedIssue.url) ) { throw invalidPublicationRecords(); } - - return { - findingId: value["findingId"] as string, - occurrenceId: value["occurrenceId"] as string, - issueIdentifier: value["issueIdentifier"] as string, - ...(typeof value["url"] === "string" ? { url: value["url"] } : {}), - }; + return issue; }); } async function runPublicationWorkbench( - command: "prepare-linear-publication" | "record-linear-publications", + command: + | "inspect-linear-publication" + | "prepare-linear-publication" + | "record-linear-publications", publication: PreparedScanPublication, environment: NodeJS.ProcessEnv, issues?: readonly PublishedScanIssue[], + signal?: AbortSignal, ): Promise> { + signal?.throwIfAborted(); const stateDirectory = codexSecurityStateDirectory(environment); const database = join(stateDirectory, "workbench.sqlite3"); try { @@ -106,14 +141,22 @@ async function runPublicationWorkbench( resolvePluginPython({ environment, protectedRoot: publication.scanDirectory, + ...(signal === undefined ? {} : { signal }), }), bundledPluginRoot(), ]); + signal?.throwIfAborted(); const findings = publication.issues.map(({ findingId, occurrenceId }) => ({ findingId, occurrenceId, })); - const directory = await mkdtemp(join(stateDirectory, "publication-")); + let temporaryRoot = stateDirectory; + if (command === "inspect-linear-publication") { + temporaryRoot = await realpath(tmpdir()); + const scanRoot = await realpath(publication.scanDirectory); + requireOutputOutsideRepository(scanRoot, temporaryRoot, "temporary"); + } + const directory = await mkdtemp(join(temporaryRoot, "publication-")); try { const input = join(directory, "publication.json"); await writeFile( @@ -132,10 +175,11 @@ async function runPublicationWorkbench( python, pluginRoot, environment, + ...(signal === undefined ? {} : { signal }), failureMessage: - command === "prepare-linear-publication" - ? "Cannot publish findings without their existing local Codex Security scan history" - : "Could not persist created Linear issues in the local Codex Security scan history", + command === "record-linear-publications" + ? "Could not persist created Linear issues in the local Codex Security scan history" + : "Cannot publish findings without their existing local Codex Security scan history", }, [command, "--input-file", input], ); @@ -146,6 +190,39 @@ async function runPublicationWorkbench( } } +function matchesPublication( + result: Record, + publication: PreparedScanPublication, +): boolean { + const destination = result["destination"]; + return ( + result["scanId"] === publication.scanId && + isRecord(destination) && + destination["type"] === publication.destination.type && + destination["teamId"] === publication.destination.teamId && + destination["projectId"] === publication.destination.projectId + ); +} + +function readPublicationRecord(value: unknown): PublishedScanIssue { + if ( + !isRecord(value) || + typeof value["findingId"] !== "string" || + typeof value["occurrenceId"] !== "string" || + typeof value["issueIdentifier"] !== "string" || + !value["issueIdentifier"].trim() || + (value["url"] !== undefined && typeof value["url"] !== "string") + ) { + throw invalidPublicationRecords(); + } + return { + findingId: value["findingId"], + occurrenceId: value["occurrenceId"], + issueIdentifier: value["issueIdentifier"], + ...(typeof value["url"] === "string" ? { url: value["url"] } : {}), + }; +} + function invalidPublicationRecords(): CodexSecurityError { return new CodexSecurityError( "The workbench returned invalid persisted Linear publication records.", diff --git a/sdk/typescript/src/publication.ts b/sdk/typescript/src/publication.ts index f1296c02f..dfa3bef36 100644 --- a/sdk/typescript/src/publication.ts +++ b/sdk/typescript/src/publication.ts @@ -22,6 +22,7 @@ export interface PrepareScanPublicationOptions { teamId: string; projectId?: string; uploadedAt?: string; + signal?: AbortSignal; expectedScanId?: string; } @@ -74,6 +75,7 @@ export async function prepareScanPublication( const { contract, scanDirectory: canonicalScanDirectory } = await loadContractWithScanDirectory(scanDirectory, { pluginRoot: await bundledPluginRoot(), + ...(options.signal === undefined ? {} : { signal: options.signal }), expectedScanId: options.expectedScanId, }); const uploadedAt = options.uploadedAt ?? new Date().toISOString(); diff --git a/sdk/typescript/src/publish.ts b/sdk/typescript/src/publish.ts index 85c840439..00b922c34 100644 --- a/sdk/typescript/src/publish.ts +++ b/sdk/typescript/src/publish.ts @@ -15,6 +15,8 @@ import { NetworkLinearError, UnknownLinearError, type LinearClient, + type Team, + type User, } from "@linear/sdk"; import { CodexSecurityError, @@ -46,6 +48,7 @@ import { type PublicationEventEvidence, } from "./publication-events.js"; import { + inspectPublicationStore, preparePublicationStore, recordPublishedIssues, } from "./publication-store.js"; @@ -63,6 +66,7 @@ export interface PublishScanOptions { linearApiKey?: string; assigneeId?: string; dryRun?: boolean; + skipExisting?: boolean; signal?: AbortSignal; onProgress?: (event: PublishScanProgress) => void; } @@ -104,10 +108,12 @@ export interface PublishScanResult { destination: LinearPublicationDestination; created: PublishedScanIssue[]; failed: FailedScanPublication[]; + skipped?: PublishedScanIssue[]; counts: { findings: number; created: number; failed: number; + skipped?: number; }; dryRun?: boolean; issues?: PreparedPublicationIssue[]; @@ -115,6 +121,40 @@ export interface PublishScanResult { warnings?: string[]; } +export type CheckScanPublicationOptions = Pick< + PublishScanOptions, + | "destination" + | "teamId" + | "projectId" + | "linearApiKey" + | "assigneeId" + | "signal" +>; + +export interface CheckScanPublicationResult { + scanId: string; + destination: LinearPublicationDestination; + recorded: PublishedScanIssue[]; + counts: { findings: number; recorded: number; pending: number }; + access: { + transport: "linear-api" | "connected-app"; + authentication: "verified" | "not-checked"; + team: "verified" | "not-checked"; + project: "verified" | "not-checked" | "not-requested"; + assignee: "verified" | "not-checked" | "not-requested"; + issueCreation: "not-tested"; + }; +} + +export interface CheckScanPublicationDependencies { + environment?: NodeJS.ProcessEnv; + prepare?: typeof prepareScanPublication; + inspectPublicationStore?: typeof inspectPublicationStore; + linearClient?: LinearClientFactory< + "viewer" | "team" | "project" | "user" | "users" + >; +} + export interface PublicationCodexResult { exitCode: number; stdout: string; @@ -135,6 +175,7 @@ export interface PublishScanDependencies { onEvent?: (event: unknown) => void, signal?: AbortSignal, ) => Promise; + inspectPublicationStore?: typeof inspectPublicationStore; preparePublicationStore?: typeof preparePublicationStore; recordPublishedIssues?: typeof recordPublishedIssues; writeEvents?: ( @@ -221,30 +262,14 @@ export async function publishScanInternal( dependencies: PublishScanDependencies = {}, ): Promise { options.signal?.throwIfAborted(); - if (options.destination !== "linear") { - throw new ConfigurationError("The publication destination must be linear."); - } - if (!options.teamId.trim()) { - throw new ConfigurationError("A Linear team is required for publication."); - } - if (options.projectId !== undefined && !options.projectId.trim()) { - throw new ConfigurationError( - "A Linear project cannot be blank when provided.", - ); - } - const environment = dependencies.environment ?? process.env; - const linearApiKey = resolveLinearApiKey(environment, options.linearApiKey); - if (options.assigneeId !== undefined && linearApiKey === undefined) { - throw new ConfigurationError( - "A Linear API key is required to select a publication assignee.", - ); - } + const linearApiKey = publicationApiKey(options, environment); - const prepared = await (dependencies.prepare ?? prepareScanPublication)( + const preparedScan = await (dependencies.prepare ?? prepareScanPublication)( scanDirectory, options, ); + let prepared = preparedScan; options.signal?.throwIfAborted(); const result: PublishScanResult = { scanId: prepared.scanId, @@ -258,6 +283,20 @@ export async function publishScanInternal( failed: 0, }, }; + if (options.skipExisting) { + result.skipped = await ( + dependencies.inspectPublicationStore ?? inspectPublicationStore + )(preparedScan, environment, options.signal); + result.counts.skipped = result.skipped.length; + const recorded = new Set(result.skipped.map((issue) => issue.findingId)); + prepared = { + ...preparedScan, + issues: preparedScan.issues.filter( + (issue) => !recorded.has(issue.findingId), + ), + }; + options.signal?.throwIfAborted(); + } const saveReceipt = dependencies.writeReceipt ?? writePublicationReceipt; if (options.dryRun) { return { ...result, dryRun: true, issues: prepared.issues }; @@ -265,7 +304,7 @@ export async function publishScanInternal( if (prepared.issues.length === 0) return result; await (dependencies.preparePublicationStore ?? preparePublicationStore)( - prepared, + preparedScan, environment, ); options.signal?.throwIfAborted(); @@ -283,19 +322,10 @@ export async function publishScanInternal( }, dependencies.linearClient, ); - let assigneeId = options.assigneeId; - if (linearClient !== undefined && assigneeId?.includes("@")) { - const users = await linearClient.users({ - filter: { email: { eqIgnoreCase: assigneeId } }, - first: 2, - }); - if (users.nodes.length !== 1) { - throw new ConfigurationError( - "Linear could not resolve exactly one matching issue assignee.", - ); - } - assigneeId = users.nodes[0]!.id; - } + const assigneeId = + linearClient === undefined || options.assigneeId === undefined + ? options.assigneeId + : await resolvePublicationAssignee(linearClient, options.assigneeId); if (linearApiKey !== undefined && usesAbortableLinearClient) { options.signal?.throwIfAborted(); linearClient = createLinearClient( @@ -468,7 +498,7 @@ export async function publishScanInternal( ); result.created = await ( dependencies.recordPublishedIssues ?? recordPublishedIssues - )(prepared, handoffResults.created, environment); + )(preparedScan, handoffResults.created, environment); } catch (cause) { persistenceFailure = { cause, detail: errorMessage(cause) }; } @@ -529,11 +559,186 @@ export async function publishScanInternal( type: "completed", created: result.counts.created, failed: result.counts.failed, - total: result.counts.findings, + total: prepared.issues.length, }); return result; } +export async function checkScanPublication( + scanDirectory: string, + options: CheckScanPublicationOptions, +): Promise { + return checkScanPublicationInternal(scanDirectory, options); +} + +export async function checkScanPublicationInternal( + scanDirectory: string, + options: CheckScanPublicationOptions, + dependencies: CheckScanPublicationDependencies = {}, +): Promise { + options.signal?.throwIfAborted(); + const environment = dependencies.environment ?? process.env; + const linearApiKey = publicationApiKey(options, environment); + const prepared = await (dependencies.prepare ?? prepareScanPublication)( + scanDirectory, + options, + ); + options.signal?.throwIfAborted(); + const recorded = await ( + dependencies.inspectPublicationStore ?? inspectPublicationStore + )(prepared, environment, options.signal); + options.signal?.throwIfAborted(); + const result: CheckScanPublicationResult = { + scanId: prepared.scanId, + destination: prepared.destination, + recorded, + counts: { + findings: prepared.issues.length, + recorded: recorded.length, + pending: prepared.issues.length - recorded.length, + }, + access: { + transport: linearApiKey === undefined ? "connected-app" : "linear-api", + authentication: "not-checked", + team: "not-checked", + project: + options.projectId === undefined ? "not-requested" : "not-checked", + assignee: + options.assigneeId === undefined ? "not-requested" : "not-checked", + issueCreation: "not-tested", + }, + }; + if (linearApiKey === undefined) return result; + + const client = createLinearClient( + { + apiKey: linearApiKey, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }, + dependencies.linearClient, + ); + let step = "authentication"; + try { + await client.viewer; + result.access.authentication = "verified"; + step = "team access"; + const team = await client.team(prepared.destination.teamId); + if (team.archivedAt || team.retiredAt) { + throw new ConfigurationError( + "The selected Linear team is archived or retired.", + ); + } + result.access.team = "verified"; + if (prepared.destination.projectId !== undefined) { + step = "project access"; + const project = await client.project(prepared.destination.projectId); + if (project.archivedAt || project.autoArchivedAt || project.trashed) { + throw new ConfigurationError( + "The selected Linear project is archived or deleted.", + ); + } + const teams = await project.teams({ + filter: { id: { eq: team.id } }, + first: 1, + }); + if (!teams.nodes.some(({ id }) => id === team.id)) { + throw new ConfigurationError( + "The selected Linear project does not belong to the selected team.", + ); + } + result.access.project = "verified"; + } + if (options.assigneeId !== undefined) { + step = "assignee access"; + const assigneeId = await resolvePublicationAssignee( + client, + options.assigneeId, + ); + const assignee = await client.user(assigneeId); + if (!assignee.active) { + throw new ConfigurationError( + "The selected Linear assignee is inactive.", + ); + } + if (!assignee.isAssignable) { + throw new ConfigurationError( + "The selected Linear user cannot be assigned to issues.", + ); + } + if (!(await assigneeCanAccessTeam(team, assignee))) { + throw new ConfigurationError( + "The selected Linear assignee cannot access the selected team.", + ); + } + result.access.assignee = "verified"; + } + } catch (error) { + options.signal?.throwIfAborted(); + if (error instanceof ConfigurationError) throw error; + throw new CodexSecurityError( + `Could not verify Linear ${step}. Check the API key and publication destination.`, + { cause: error }, + ); + } + options.signal?.throwIfAborted(); + return result; +} + +async function assigneeCanAccessTeam( + team: Team, + assignee: User, +): Promise { + if (team.visibility === "public" && assignee.canAccessAnyPublicTeam) { + return true; + } + const members = await team.members({ + filter: { id: { eq: assignee.id } }, + first: 1, + }); + return members.nodes.some(({ id }) => id === assignee.id); +} + +function publicationApiKey( + options: CheckScanPublicationOptions, + environment: NodeJS.ProcessEnv, +): string | undefined { + if (options.destination !== "linear") { + throw new ConfigurationError("The publication destination must be linear."); + } + if (!options.teamId.trim()) { + throw new ConfigurationError("A Linear team is required for publication."); + } + if (options.projectId !== undefined && !options.projectId.trim()) { + throw new ConfigurationError( + "A Linear project cannot be blank when provided.", + ); + } + const linearApiKey = resolveLinearApiKey(environment, options.linearApiKey); + if (options.assigneeId !== undefined && linearApiKey === undefined) { + throw new ConfigurationError( + "A Linear API key is required to select a publication assignee.", + ); + } + return linearApiKey; +} + +async function resolvePublicationAssignee( + client: Pick, + assigneeId: string, +): Promise { + if (!assigneeId.includes("@")) return assigneeId; + const users = await client.users({ + filter: { email: { eqIgnoreCase: assigneeId } }, + first: 2, + }); + if (users.nodes.length !== 1) { + throw new ConfigurationError( + "Linear could not resolve exactly one matching issue assignee.", + ); + } + return users.nodes[0]!.id; +} + async function publishLinearApiIssues( publication: PreparedScanPublication, handoffFile: string, diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts index 241666585..df84da361 100644 --- a/sdk/typescript/src/version.ts +++ b/sdk/typescript/src/version.ts @@ -9,7 +9,7 @@ const PACKAGE_VERSIONS = packageVersions( export const VERSION = PACKAGE_VERSIONS.package; export const CODEX_SDK_VERSION = PACKAGE_VERSIONS.sdk; export const CODEX_EXECUTABLE_VERSION = PACKAGE_VERSIONS.executable; -export const BUNDLED_PLUGIN_VERSION = "0.1.22" as const; +export const BUNDLED_PLUGIN_VERSION = "0.1.37" as const; const PACKAGE_NAME = "@openai/codex-security"; diff --git a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts index 4d09cfa0e..aac3928dd 100644 --- a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts +++ b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts @@ -828,12 +828,13 @@ describe("publish scan to Cloud", () => { }); test("rejects Linear-specific options before uploading to Cloud", async () => { - for (const flag of [ - "--linear-team", - "--linear-project", - "--project", - "--linear-assignee", - "--linear-api-key", + for (const linearOptions of [ + ["--linear-team", "synthetic-value"], + ["--linear-project", "synthetic-value"], + ["--project", "synthetic-value"], + ["--linear-assignee", "synthetic-value"], + ["--linear-api-key", "synthetic-value"], + ["--skip-existing"], ]) { const deps = dependencies(); let calls = 0; @@ -851,8 +852,7 @@ describe("publish scan to Cloud", () => { "completed-scan", "--to", "cloud", - flag, - "synthetic-value", + ...linearOptions, ], stdout.stream, stderr.stream, diff --git a/sdk/typescript/tests-ts/cli-publish.test.ts b/sdk/typescript/tests-ts/cli-publish.test.ts index 094fcde99..c1cc4fb6e 100644 --- a/sdk/typescript/tests-ts/cli-publish.test.ts +++ b/sdk/typescript/tests-ts/cli-publish.test.ts @@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path"; import { stripVTControlCharacters } from "node:util"; import { afterEach, describe, expect, test } from "bun:test"; import { main } from "../src/cli.js"; +import type { CheckScanPublicationResult } from "../src/publish.js"; import { capture, dependencies, FakeSignals } from "./cli-fixtures.js"; const DESTINATION_OPTIONS = [ @@ -73,46 +74,273 @@ function publicationResult( }; } -describe("publish scan", () => { - test("resolves a saved scan ID for Linear publication", async () => { - const scanDir = await publicationDirectory(); +describe("publish check", () => { + test("resolves the shared destination options without invoking publication", async () => { + const stdout = capture(); + const stderr = capture(); + const currentDirectory = join(tmpdir(), "codex-security-check-current"); + const result: CheckScanPublicationResult = { + scanId: "scan-example", + destination: { + type: "linear", + teamId: "team-from-flags", + projectId: "project-from-flags", + }, + recorded: [], + counts: { findings: 2, recorded: 0, pending: 2 }, + access: { + transport: "linear-api", + authentication: "verified", + team: "verified", + project: "verified", + assignee: "verified", + issueCreation: "not-tested", + }, + }; const deps = dependencies({ - onWorkbench: (args) => { - expect(args).toEqual(["get-scan", "--scan-id", "scan-123"]); - return { - scan: { - scanId: "scan-123", - scanDir, - progress: { status: "complete" }, - }, - }; + currentDirectory, + environment: { + CODEX_SECURITY_LINEAR_API_KEY: "environment-key", + CODEX_SECURITY_LINEAR_TEAM: "environment-team", }, }); - let calls = 0; - deps.publishScan = async (directory, options) => { - calls++; - expect(directory).toBe(scanDir); - expect(options.expectedScanId).toBe("scan-123"); - return publicationResult(); + deps.publishScan = async () => { + throw new Error("Check must not publish."); + }; + deps.checkScanPublication = async (directory, options) => { + expect(directory).toBe(resolve(currentDirectory, "completed-scan")); + expect(options).toEqual({ + destination: "linear", + teamId: "team-from-flags", + projectId: "project-from-flags", + linearApiKey: "explicit-key", + assigneeId: "teammate@example.com", + signal: expect.any(AbortSignal), + }); + return result; }; expect( await main( [ "publish", - "scan", - "--scan", - "scan-123", + "check", + "completed-scan", ...DESTINATION_OPTIONS, + "--linear-api-key", + "explicit-key", + "--linear-assignee", + "teammate@example.com", "--json", ], - capture().stream, - capture().stream, + stdout.stream, + stderr.stream, deps, ), ).toBe(0); - expect(calls).toBe(1); + expect(JSON.parse(stdout.text())).toEqual(result); + expect(stderr.text()).toBe(""); + expect(stdout.text()).not.toContain("explicit-key"); + expect(stdout.text()).not.toContain("teammate@example.com"); + }); + + test("reports a failed check without publishing or returning a successful result", async () => { + const stdout = capture(); + const stderr = capture(); + const deps = dependencies(); + deps.checkScanPublication = async () => { + throw new Error("The selected project is unavailable."); + }; + deps.publishScan = async () => { + throw new Error("Check must not publish."); + }; + expect( + await main( + [ + "publish", + "check", + "completed-scan", + ...DESTINATION_OPTIONS, + "--json", + ], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(2); + expect(stderr.text()).toContain("The selected project is unavailable."); + expect(stdout.text().trim()).toBe(""); }); + test("waits for read-only publication cleanup on terminal signals", async () => { + for (const command of [ + ["check"], + ["scan", "--dry-run", "--skip-existing"], + ]) { + for (const [signal, expectedCode] of [ + ["SIGINT", 130], + ["SIGTERM", 143], + ] as const) { + const stdout = capture(); + const stderr = capture(); + const signals = new FakeSignals(); + const deps = dependencies({ + signals, + environment: { CODEX_SECURITY_LINEAR_API_KEY: "synthetic-key" }, + }); + let now = 0; + let forced = false; + deps.now = () => now; + deps.forceExit = () => { + forced = true; + }; + let started!: () => void; + const operationStarted = new Promise((resolve) => { + started = resolve; + }); + let finishCleanup!: () => void; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + const operation = async ( + _directory: string, + options: { signal?: AbortSignal }, + ): Promise => { + expect(options.signal).toBeInstanceOf(AbortSignal); + started(); + await cleanup; + expect(options.signal?.reason).toBe(signal); + options.signal!.throwIfAborted(); + throw new Error("Expected cancellation."); + }; + deps.publishScan = operation; + deps.checkScanPublication = operation; + let finished = false; + const running = main( + [ + "publish", + command[0]!, + "completed-scan", + ...command.slice(1), + ...DESTINATION_OPTIONS, + "--json", + ], + stdout.stream, + stderr.stream, + deps, + ).then((status) => { + finished = true; + return status; + }); + await operationStarted; + signals.emit(signal); + expect(finished).toBe(false); + now = 500; + signals.emit(signal); + expect(forced).toBe(false); + finishCleanup(); + expect(await running).toBe(expectedCode); + expect(stdout.text()).toBe(""); + expect(stderr.text()).toContain( + signal === "SIGINT" ? "canceled by Ctrl-C" : "terminated by SIGTERM", + ); + expect(signals.listeners.get("SIGINT")?.size).toBe(0); + expect(signals.listeners.get("SIGTERM")?.size).toBe(0); + } + } + }); +}); + +describe("publish scan", () => { + test("forwards opt-in retry and reports skipped issues separately", async () => { + for (const dryRun of [false, true]) { + const stdout = capture(); + const stderr = capture(); + const deps = dependencies(); + deps.publishScan = async (_directory, options) => { + expect(options.skipExisting).toBe(true); + expect(options.dryRun).toBe(dryRun); + const previous = publicationResult(); + return { + ...previous, + created: [], + skipped: previous.created, + counts: { findings: 1, created: 0, failed: 0, skipped: 1 }, + ...(dryRun ? { dryRun: true, issues: [] } : {}), + }; + }; + expect( + await main( + [ + "publish", + "scan", + "completed-scan", + ...DESTINATION_OPTIONS, + "--skip-existing", + ...(dryRun ? ["--dry-run", "--json"] : []), + ], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(0); + if (dryRun) { + expect(JSON.parse(stdout.text()).counts).toEqual({ + findings: 1, + created: 0, + failed: 0, + skipped: 1, + }); + } else { + expect(stdout.text()).toContain("0 total issues created"); + expect(stdout.text()).toContain("1 previously recorded issue skipped"); + } + } + }); + + test.each([false, true])( + "resolves a saved scan ID for Linear publication with skipExisting=%s", + async (skipExisting) => { + const scanDir = await publicationDirectory(); + const deps = dependencies({ + onWorkbench: (args) => { + expect(args).toEqual(["get-scan", "--scan-id", "scan-123"]); + return { + scan: { + scanId: "scan-123", + scanDir, + progress: { status: "complete" }, + }, + }; + }, + }); + let calls = 0; + deps.publishScan = async (directory, options) => { + calls++; + expect(directory).toBe(scanDir); + expect(options.expectedScanId).toBe("scan-123"); + expect(options.skipExisting).toBe(skipExisting ? true : undefined); + return publicationResult(); + }; + expect( + await main( + [ + "publish", + "scan", + "--scan", + "scan-123", + ...DESTINATION_OPTIONS, + ...(skipExisting ? ["--skip-existing"] : []), + "--json", + ], + capture().stream, + capture().stream, + deps, + ), + ).toBe(0); + expect(calls).toBe(1); + }, + ); + test("accepts the Linear project flag and its published alias", async () => { for (const flag of ["--linear-project", "--project"]) { let projectId: string | undefined; @@ -762,13 +990,11 @@ describe("publish scan", () => { const stdout = capture(); const stderr = capture(true); - const deps = dependencies(); - let observedSignals = false; - deps.addSignalListener = () => { - observedSignals = true; - }; + const signals = new FakeSignals(); + const deps = dependencies({ signals }); deps.publishScan = async (_scanDirectory, options) => { expect(options.onProgress).toBeUndefined(); + expect(options.signal).toBeInstanceOf(AbortSignal); return { ...publicationResult(), dryRun: true, issues: [] }; }; @@ -789,7 +1015,8 @@ describe("publish scan", () => { expect(stdout.text()).toContain("dryRun: true"); expect(stdout.text()).not.toContain("Linear publication complete"); expect(stderr.text()).toBe(""); - expect(observedSignals).toBe(false); + expect(signals.listeners.get("SIGINT")?.size).toBe(0); + expect(signals.listeners.get("SIGTERM")?.size).toBe(0); }); test("reports every created Linear issue with a successful exit code", async () => { diff --git a/sdk/typescript/tests-ts/publication-check.test.ts b/sdk/typescript/tests-ts/publication-check.test.ts new file mode 100644 index 000000000..28e2ec3a7 --- /dev/null +++ b/sdk/typescript/tests-ts/publication-check.test.ts @@ -0,0 +1,470 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, test } from "bun:test"; +import { + checkScanPublicationInternal, + type CheckScanPublicationDependencies, + type CheckScanPublicationOptions, +} from "../src/publish.js"; +import type { PreparedScanPublication } from "../src/publication.js"; + +const OPTIONS: CheckScanPublicationOptions = { + destination: "linear", + teamId: "team-example", + projectId: "project-example", +}; +const PUBLICATION: PreparedScanPublication = { + scanId: "scan-example", + uploadId: "scan-example", + scanDirectory: join(tmpdir(), "completed-scan"), + destination: { + type: "linear", + teamId: "team-example", + projectId: "project-example", + }, + issues: [1, 2].map((number) => ({ + findingId: `finding-${number}`, + occurrenceId: `occurrence-${number}`, + title: `Synthetic finding ${number}`, + description: "Synthetic description that must stay local during preflight.", + })), +}; +const RECORDED = { + findingId: "finding-1", + occurrenceId: "occurrence-1", + issueIdentifier: "EXAMPLE-101", +}; +type ReadClient = ReturnType< + NonNullable +>; + +function dependencies( + overrides: Partial = {}, +): CheckScanPublicationDependencies { + return { + environment: {}, + prepare: async () => PUBLICATION, + inspectPublicationStore: async (publication) => { + expect(publication).toBe(PUBLICATION); + return [RECORDED]; + }, + ...overrides, + }; +} + +function readClient( + calls: unknown[], + options: { + teams?: readonly string[]; + active?: boolean; + assignable?: boolean; + publicTeamAccess?: boolean; + memberTeams?: readonly string[]; + teamHierarchy?: readonly { + id: string; + visibility: "public" | "private" | "restricted"; + }[]; + archivedProject?: boolean; + autoArchivedProject?: boolean; + trashedProject?: boolean; + retiredTeam?: boolean; + } = {}, +): ReadClient { + const hierarchy = options.teamHierarchy ?? [ + { id: "canonical-team", visibility: "public" }, + ]; + function teamAt(index: number): object { + const team = hierarchy[index]!; + return { + ...team, + retiredAt: options.retiredTeam ? new Date(0) : undefined, + members: async (variables: { filter: { id: { eq: string } } }) => { + calls.push(["team.members", team.id, variables]); + return { + nodes: (options.memberTeams ?? ["canonical-team"]).includes(team.id) + ? [{ id: variables.filter.id.eq }] + : [], + }; + }, + parent: + index + 1 < hierarchy.length + ? Promise.resolve(teamAt(index + 1)) + : undefined, + }; + } + return { + get viewer() { + calls.push("viewer"); + return Promise.resolve({ id: "viewer-example" }); + }, + team: async (id: string) => { + calls.push(["team", id]); + return teamAt(0); + }, + project: async (id: string) => { + calls.push(["project", id]); + return { + archivedAt: options.archivedProject ? new Date(0) : undefined, + autoArchivedAt: options.autoArchivedProject ? new Date(0) : undefined, + trashed: options.trashedProject ?? false, + teams: async (variables: unknown) => { + calls.push(["project.teams", variables]); + return { + nodes: (options.teams ?? ["canonical-team"]).map((team) => ({ + id: team, + })), + }; + }, + }; + }, + users: async (variables: unknown) => { + calls.push(["users", variables]); + return { nodes: [{ id: "assignee-example" }] }; + }, + user: async (id: string) => { + calls.push(["user", id]); + return { + id, + active: options.active ?? true, + isAssignable: options.assignable ?? true, + canAccessAnyPublicTeam: options.publicTeamAccess ?? true, + }; + }, + createIssue: () => { + throw new Error("Preflight must not create issues."); + }, + } as unknown as ReadClient; +} + +describe("read-only publication preflight", () => { + test("reports local history without claiming connected-app access", async () => { + const result = await checkScanPublicationInternal( + "scan", + OPTIONS, + dependencies({ + linearClient: () => { + throw new Error("No remote client should be constructed."); + }, + }), + ); + expect(result).toEqual({ + scanId: PUBLICATION.scanId, + destination: PUBLICATION.destination, + recorded: [RECORDED], + counts: { findings: 2, recorded: 1, pending: 1 }, + access: { + transport: "connected-app", + authentication: "not-checked", + team: "not-checked", + project: "not-checked", + assignee: "not-requested", + issueCreation: "not-tested", + }, + }); + expect(JSON.stringify(result)).not.toContain( + PUBLICATION.issues[0]!.description, + ); + }); + + test("uses only read queries for direct API access and omits credentials and identities", async () => { + const calls: unknown[] = []; + const key = "lin_api_SYNTHETIC_PREFLIGHT_KEY"; + const assignee = "teammate@example.com"; + const signal = new AbortController().signal; + const result = await checkScanPublicationInternal( + "scan", + { ...OPTIONS, linearApiKey: key, assigneeId: assignee, signal }, + dependencies({ + environment: { CODEX_SECURITY_LINEAR_API_KEY: "environment-key" }, + linearClient: (configuration) => { + expect(configuration).toEqual({ + apiKey: key, + redirect: "error", + signal, + }); + return readClient(calls); + }, + }), + ); + expect(calls).toEqual([ + "viewer", + ["team", "team-example"], + ["project", "project-example"], + ["project.teams", { filter: { id: { eq: "canonical-team" } }, first: 1 }], + ["users", { filter: { email: { eqIgnoreCase: assignee } }, first: 2 }], + ["user", "assignee-example"], + ]); + expect(result.access).toEqual({ + transport: "linear-api", + authentication: "verified", + team: "verified", + project: "verified", + assignee: "verified", + issueCreation: "not-tested", + }); + for (const privateValue of [ + key, + assignee, + "viewer-example", + "assignee-example", + PUBLICATION.issues[0]!.description, + ]) { + expect(JSON.stringify(result)).not.toContain(privateValue); + } + }); + + test("checks team-only destinations without requesting a project or assignee", async () => { + const calls: unknown[] = []; + const publication = { + ...PUBLICATION, + destination: { type: "linear" as const, teamId: OPTIONS.teamId }, + }; + const result = await checkScanPublicationInternal( + "scan", + { destination: "linear", teamId: OPTIONS.teamId }, + dependencies({ + environment: { CODEX_SECURITY_LINEAR_API_KEY: "environment-key" }, + prepare: async () => publication, + inspectPublicationStore: async () => [], + linearClient: () => readClient(calls), + }), + ); + expect(calls).toEqual(["viewer", ["team", "team-example"]]); + expect(result.access.project).toBe("not-requested"); + expect(result.access.assignee).toBe("not-requested"); + expect(result.access.issueCreation).toBe("not-tested"); + }); + + test("rejects unavailable destinations and unusable assignees", async () => { + for (const [clientOptions, message] of [ + [{ teams: [] }, /does not belong/u], + [{ archivedProject: true }, /project is archived/u], + [{ autoArchivedProject: true }, /project is archived/u], + [{ trashedProject: true }, /project is archived or deleted/u], + [{ retiredTeam: true }, /team is archived or retired/u], + [{ active: false }, /assignee is inactive/u], + [{ assignable: false }, /cannot be assigned/u], + ] as const) { + await expect( + checkScanPublicationInternal( + "scan", + { + ...OPTIONS, + linearApiKey: "synthetic-key", + assigneeId: "assignee-example", + }, + dependencies({ + linearClient: () => readClient([], clientOptions), + }), + ), + ).rejects.toThrow(message); + } + }); + + test("checks assignee access without requiring public-team membership", async () => { + const cases = [ + { + name: "public workspace member", + visibility: "public", + publicTeamAccess: true, + memberTeams: [], + allowed: true, + checkedTeams: [], + }, + { + name: "limited user outside a public team", + visibility: "public", + publicTeamAccess: false, + memberTeams: [], + allowed: false, + checkedTeams: ["canonical-team"], + }, + { + name: "limited user in a public team", + visibility: "public", + publicTeamAccess: false, + memberTeams: ["canonical-team"], + allowed: true, + checkedTeams: ["canonical-team"], + }, + { + name: "private-team member", + visibility: "private", + publicTeamAccess: true, + memberTeams: ["canonical-team"], + allowed: true, + checkedTeams: ["canonical-team"], + }, + { + name: "private-team nonmember", + visibility: "private", + publicTeamAccess: true, + memberTeams: [], + allowed: false, + checkedTeams: ["canonical-team"], + }, + { + name: "restricted-team parent member", + visibility: "restricted", + publicTeamAccess: true, + memberTeams: ["private-parent"], + allowed: false, + checkedTeams: ["canonical-team"], + }, + { + name: "restricted-team nonmember", + visibility: "restricted", + publicTeamAccess: true, + memberTeams: [], + allowed: false, + checkedTeams: ["canonical-team"], + }, + { + name: "limited user outside a restricted team", + visibility: "restricted", + publicTeamAccess: false, + memberTeams: ["private-parent"], + allowed: false, + checkedTeams: ["canonical-team"], + }, + ] as const; + + for (const example of cases) { + const calls: unknown[] = []; + const checking = checkScanPublicationInternal( + "scan", + { + ...OPTIONS, + linearApiKey: "synthetic-key", + assigneeId: "assignee-example", + }, + dependencies({ + linearClient: () => + readClient(calls, { + ...example, + teamHierarchy: [ + { id: "canonical-team", visibility: example.visibility }, + { id: "private-parent", visibility: "private" }, + ], + }), + }), + ); + if (example.allowed) { + expect((await checking).access.assignee, example.name).toBe("verified"); + } else { + await expect(checking, example.name).rejects.toThrow( + /assignee cannot access the selected team/u, + ); + } + expect( + calls.filter( + (call) => Array.isArray(call) && call[0] === "team.members", + ), + example.name, + ).toEqual( + example.checkedTeams.map((id) => [ + "team.members", + id, + { filter: { id: { eq: "assignee-example" } }, first: 1 }, + ]), + ); + } + }); + + test("verifies local history before contacting Linear and preserves cancellation", async () => { + const calls: unknown[] = []; + await expect( + checkScanPublicationInternal( + "scan", + { ...OPTIONS, linearApiKey: "synthetic-key" }, + dependencies({ + inspectPublicationStore: async () => { + throw new Error("Missing local history."); + }, + linearClient: () => readClient(calls), + }), + ), + ).rejects.toThrow("Missing local history."); + const controller = new AbortController(); + controller.abort(new Error("Canceled preflight.")); + await expect( + checkScanPublicationInternal( + "scan", + { ...OPTIONS, signal: controller.signal }, + dependencies({ + prepare: async () => { + throw new Error("Must not prepare after cancellation."); + }, + }), + ), + ).rejects.toThrow("Canceled preflight."); + expect(calls).toEqual([]); + }); + + test("forwards cancellation while history inspection is in progress", async () => { + const controller = new AbortController(); + const reason = new Error("History check canceled."); + await expect( + checkScanPublicationInternal( + "scan", + { ...OPTIONS, signal: controller.signal }, + dependencies({ + inspectPublicationStore: async ( + _publication, + _environment, + signal, + ) => { + expect(signal).toBe(controller.signal); + controller.abort(reason); + signal!.throwIfAborted(); + return []; + }, + linearClient: () => { + throw new Error("Canceled checks must not contact Linear."); + }, + }), + ), + ).rejects.toBe(reason); + }); + + test("stops before inspecting history when preparation is canceled", async () => { + const controller = new AbortController(); + const reason = new Error("Preparation canceled."); + let inspected = false; + await expect( + checkScanPublicationInternal( + "scan", + { ...OPTIONS, signal: controller.signal }, + dependencies({ + prepare: async (_directory, options) => { + expect(options.signal).toBe(controller.signal); + controller.abort(reason); + return PUBLICATION; + }, + inspectPublicationStore: async () => { + inspected = true; + return []; + }, + }), + ), + ).rejects.toBe(reason); + expect(inspected).toBe(false); + }); + + test("does not echo provider response data on an access failure", async () => { + const key = "lin_api_SYNTHETIC_PRIVATE_KEY"; + const client = readClient([]); + client.team = (() => { + throw new Error(`Provider response included ${key}`); + }) as ReadClient["team"]; + await expect( + checkScanPublicationInternal( + "scan", + { ...OPTIONS, linearApiKey: key }, + dependencies({ linearClient: () => client }), + ), + ).rejects.toThrow( + "Could not verify Linear team access. Check the API key and publication destination.", + ); + }); +}); diff --git a/sdk/typescript/tests-ts/publication-integration.test.ts b/sdk/typescript/tests-ts/publication-integration.test.ts index caceee754..09a204797 100644 --- a/sdk/typescript/tests-ts/publication-integration.test.ts +++ b/sdk/typescript/tests-ts/publication-integration.test.ts @@ -16,6 +16,8 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { main } from "../src/cli.js"; +import { prepareScanPublication } from "../src/publication.js"; +import { recordPublishedIssues } from "../src/publication-store.js"; import type { CoverageDocument, Finding, @@ -23,10 +25,12 @@ import type { ScanManifest, } from "../src/models.js"; import { + checkScanPublicationInternal, publishScanInternal, type PublishScanDependencies, type PublishScanProgress, type PublishScanResult, + type PublishedScanIssue, } from "../src/publish.js"; import { runWorkbench } from "../src/runtime.js"; import { capture, dependencies, FakeSignals } from "./cli-fixtures.js"; @@ -280,6 +284,158 @@ function receiptPath(fixture: PublicationFixture): string { } describe("database-backed Linear publication integration", () => { + test("checks and retries a partial publication without duplicating recorded successes", async () => { + const completed = await fixture(2); + const sealed = await artifactDigests(completed.scanDirectory); + const environment = { + ...completed.environment, + CODEX_SECURITY_LINEAR_API_KEY: "lin_api_SYNTHETIC_RETRY_KEY", + }; + const cli = dependencies({ environment }); + type LinearClient = ReturnType< + NonNullable + >; + type IssueInput = Parameters[0]; + const attempted: string[] = []; + let failSecond = true; + let issueNumber = 500; + cli.publishScan = (directory, options) => + publishScanInternal(directory, options, { + environment, + resolveCodex: () => { + throw new Error("Direct publication must not start Codex."); + }, + linearClient: () => + ({ + users: async () => { + throw new Error("Unassigned publication must not look up users."); + }, + createIssue: async (input: IssueInput) => { + const index = completed.findings.findIndex(({ findingId }) => + input.description?.includes(findingId), + ); + expect(index).toBeGreaterThanOrEqual(0); + attempted.push(completed.findings[index]!.findingId); + if (failSecond && index === 1) + throw new Error("Synthetic creation failure."); + const identifier = `EXAMPLE-${++issueNumber}`; + return { + success: true, + issue: Promise.resolve({ + identifier, + url: `https://linear.app/example/issue/${identifier}`, + }), + }; + }, + }) as unknown as LinearClient, + }); + const command = [ + "publish", + "scan", + completed.scanDirectory, + "--to", + "linear", + "--linear-team", + OPTIONS.teamId, + "--project", + OPTIONS.projectId, + "--json", + ]; + const run = async (flags: string[] = []) => { + const stdout = capture(); + const stderr = capture(); + const code = await main( + [...command, ...flags], + stdout.stream, + stderr.stream, + cli, + ); + return { code, result: JSON.parse(stdout.text()) as PublishScanResult }; + }; + + const initial = await run(); + expect(initial.code).toBe(2); + expect(initial.result.counts).toEqual({ + findings: 2, + created: 1, + failed: 1, + }); + expect(storedPublications(completed)).toHaveLength(1); + + const localEnvironment = { + ...completed.environment, + CODEX_SECURITY_LINEAR_API_KEY: undefined, + }; + const checkCli = dependencies({ environment: localEnvironment }); + checkCli.checkScanPublication = (directory, options) => + checkScanPublicationInternal(directory, options, { + environment: localEnvironment, + }); + const checkOutput = capture(); + const database = join(completed.stateDirectory, "workbench.sqlite3"); + const before = sha256(await readFile(database)); + expect( + await main( + [ + "publish", + "check", + completed.scanDirectory, + "--to", + "linear", + "--linear-team", + OPTIONS.teamId, + "--project", + OPTIONS.projectId, + "--json", + ], + checkOutput.stream, + capture().stream, + checkCli, + ), + ).toBe(0); + const checked = JSON.parse(checkOutput.text()); + expect(checked.counts).toEqual({ findings: 2, recorded: 1, pending: 1 }); + expect(checked.access.issueCreation).toBe("not-tested"); + expect(checked.recorded).toEqual(initial.result.created); + expect(sha256(await readFile(database))).toBe(before); + + failSecond = false; + attempted.length = 0; + const retry = await run(["--skip-existing"]); + expect(retry.code).toBe(0); + expect(attempted).toEqual([completed.findings[1]!.findingId]); + expect(retry.result.skipped).toEqual(initial.result.created); + expect(retry.result.counts).toEqual({ + findings: 2, + created: 1, + failed: 0, + skipped: 1, + }); + expect(storedPublications(completed)).toHaveLength(2); + + const receipt = await readFile(receiptPath(completed), "utf8"); + attempted.length = 0; + const repeated = await run(["--skip-existing"]); + expect(repeated.code).toBe(0); + expect(repeated.result.counts).toEqual({ + findings: 2, + created: 0, + failed: 0, + skipped: 2, + }); + expect(attempted).toEqual([]); + expect(await readFile(receiptPath(completed), "utf8")).toBe(receipt); + expect(storedPublications(completed)).toHaveLength(2); + + expect((await run()).result.counts).toEqual({ + findings: 2, + created: 2, + failed: 0, + }); + expect(storedPublications(completed)).toHaveLength(4); + expect(await artifactDigests(completed.scanDirectory)).toEqual(sealed); + }); + test("persists unassigned direct team-only publication", async () => { const completed = await fixture(23); const sealed = await artifactDigests(completed.scanDirectory); @@ -710,107 +866,135 @@ describe("database-backed Linear publication integration", () => { expect(await artifactDigests(completed.scanDirectory)).toEqual(sealed); }); - test("keeps conflicting connector identities out of CLI history and retains recovery evidence", async () => { - const completed = await fixture(1); - const sealed = await artifactDigests(completed.scanDirectory); - const stdout = capture(); - const stderr = capture(); - const cli = dependencies({ environment: completed.environment }); - let handoffFile = ""; - let handoffLine = ""; - let completedEvent = ""; + test.each([false, true])( + "keeps conflicting connector identities out of CLI history and retains recovery evidence with skipExisting=%s", + async (skipExisting) => { + const completed = await fixture(1 + Number(skipExisting)); + const sealed = await artifactDigests(completed.scanDirectory); + const recorded: PublishedScanIssue[] = []; + if (skipExisting) { + const prepared = await prepareScanPublication( + completed.scanDirectory, + OPTIONS, + ); + recorded.push({ + findingId: prepared.issues[0]!.findingId, + occurrenceId: prepared.issues[0]!.occurrenceId, + issueIdentifier: "SEC-500", + }); + await recordPublishedIssues(prepared, recorded, completed.environment); + } + const storedBefore = storedPublications(completed); + const pending = completed.findings[Number(skipExisting)]!; + const stdout = capture(); + const stderr = capture(); + const cli = dependencies({ environment: completed.environment }); + let handoffFile = ""; + let handoffLine = ""; + let completedEvent = ""; - 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); - const finding = payload.batches[0]![0]!; - handoffFile = payload.handoffFile; - handoffLine = JSON.stringify({ - scanId: payload.scanId, - findingId: finding.findingId, - occurrenceId: finding.occurrenceId, - issueIdentifier: "SYNTH-A", - arguments: finding.arguments, - }); - await appendFile(handoffFile, `${handoffLine}\n`, "utf8"); - completedEvent = JSON.stringify({ - type: "item.completed", - item: { - id: "tool-conflicting-publication", - type: "mcp_tool_call", - server: "codex_apps", - tool: "linear.save_issue", + 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); + expect( + payload.batches.flat().map((finding) => finding.findingId), + ).toEqual([pending.findingId]); + const finding = payload.batches[0]![0]!; + handoffFile = payload.handoffFile; + handoffLine = JSON.stringify({ + scanId: payload.scanId, + findingId: finding.findingId, + occurrenceId: finding.occurrenceId, + issueIdentifier: "SYNTH-A", arguments: finding.arguments, - status: "completed", - result: { - structured_content: { identifier: "SYNTH-A" }, - content: [ - { - type: "text", - text: JSON.stringify({ identifier: "SYNTH-B" }), - }, - ], + }); + await appendFile(handoffFile, `${handoffLine}\n`, "utf8"); + completedEvent = JSON.stringify({ + type: "item.completed", + item: { + id: "tool-conflicting-publication", + type: "mcp_tool_call", + server: "codex_apps", + tool: "linear.save_issue", + arguments: finding.arguments, + status: "completed", + result: { + structured_content: { identifier: "SYNTH-A" }, + content: [ + { + type: "text", + text: JSON.stringify({ identifier: "SYNTH-B" }), + }, + ], + }, }, - }, - }); - return { exitCode: 0, stdout: completedEvent, stderr: "" }; - }, - }); + }); + return { exitCode: 0, stdout: completedEvent, 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( + await main( + [ + "publish", + "scan", + completed.scanDirectory, + "--to", + "linear", + "--linear-team", + OPTIONS.teamId, + "--project", + OPTIONS.projectId, + ...(skipExisting ? ["--skip-existing"] : []), + "--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)).toEqual([]); - const receipt = JSON.parse( - await readFile(receiptPath(completed), "utf8"), - ) as PublishScanResult; - expect(receipt).toMatchObject({ - indeterminate: true, - created: [], - failed: [ - { - findingId: completed.findings[0]!.findingId, - error: - "The connected Linear app returned conflicting created issue identifiers or URLs.", + expect(stdout.text()).toBe(""); + expect(stderr.text()).toContain( + "could not verify every completed mutation", + ); + expect(stderr.text()).toContain(handoffFile); + expect(storedPublications(completed)).toEqual(storedBefore); + const receipt = JSON.parse( + await readFile(receiptPath(completed), "utf8"), + ) as PublishScanResult; + expect(receipt.skipped).toEqual(skipExisting ? recorded : undefined); + expect(receipt).toMatchObject({ + indeterminate: true, + created: [], + failed: [ + { + findingId: pending.findingId, + error: + "The connected Linear app returned conflicting created issue identifiers or URLs.", + }, + ], + counts: { + findings: completed.findings.length, + created: 0, + failed: 1, + ...(skipExisting ? { skipped: 1 } : {}), }, - ], - counts: { findings: 1, created: 0, failed: 1 }, - }); - expect(await readFile(handoffFile, "utf8")).toBe(`${handoffLine}\n`); - const eventFiles = (await readdir(dirname(handoffFile))).filter( - (name) => name.startsWith("events-") && name.endsWith(".jsonl"), - ); - expect(eventFiles).toHaveLength(1); - expect( - await readFile(join(dirname(handoffFile), eventFiles[0]!), "utf8"), - ).toBe(`${completedEvent}\n`); - expect(await artifactDigests(completed.scanDirectory)).toEqual(sealed); - }); + }); + expect(await readFile(handoffFile, "utf8")).toBe(`${handoffLine}\n`); + const eventFiles = (await readdir(dirname(handoffFile))).filter( + (name) => name.startsWith("events-") && name.endsWith(".jsonl"), + ); + expect(eventFiles).toHaveLength(1); + expect( + await readFile(join(dirname(handoffFile), eventFiles[0]!), "utf8"), + ).toBe(`${completedEvent}\n`); + expect(await artifactDigests(completed.scanDirectory)).toEqual(sealed); + }, + ); test("recovers verified SQLite publications before an interrupted CLI exits", async () => { const completed = await fixture(3); diff --git a/sdk/typescript/tests-ts/publication-store.test.ts b/sdk/typescript/tests-ts/publication-store.test.ts index 92a927763..ca85ddcfd 100644 --- a/sdk/typescript/tests-ts/publication-store.test.ts +++ b/sdk/typescript/tests-ts/publication-store.test.ts @@ -1,17 +1,35 @@ import { spawnSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { + cp, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import * as os from "node:os"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, test } from "bun:test"; +import { dirname, join, toNamespacedPath } from "node:path"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { + inspectPublicationStore, preparePublicationStore, recordPublishedIssues, } from "../src/publication-store.js"; -import type { PreparedScanPublication } from "../src/publication.js"; +import { + prepareScanPublication, + type PreparedScanPublication, +} from "../src/publication.js"; +import type { FindingsDocument, ScanManifest } from "../src/models.js"; import type { PublishedScanIssue } from "../src/publish.js"; import { runWorkbench } from "../src/runtime.js"; +import * as runtime from "../src/runtime.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; const SCAN_ID = "22222222-2222-4222-8222-222222222222"; @@ -38,6 +56,7 @@ async function publicationFixture( count?: number; createDatabase?: boolean; seedScan?: boolean; + stateDirectoryName?: string; } = {}, ): Promise { const root = await realpath( @@ -46,7 +65,7 @@ async function publicationFixture( temporaryDirectories.push(root); const scanDirectory = join(root, "completed-scan"); await mkdir(scanDirectory, { mode: 0o700 }); - const stateDirectory = join(root, "state"); + const stateDirectory = join(root, options.stateDirectoryName ?? "state"); const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); if (python === null) { throw new Error( @@ -164,6 +183,391 @@ function publishedIssue( }; } +describe("read-only publication history", () => { + test("preserves native paths in the read-only SQLite URI", async () => { + const fixture = await publicationFixture({ + stateDirectoryName: "state #% data", + }); + const stateDirectories = [fixture.stateDirectory]; + if (process.platform === "win32") { + stateDirectories.push(toNamespacedPath(fixture.stateDirectory)); + } + for (const stateDirectory of stateDirectories) { + await expect( + inspectPublicationStore(fixture.publication, { + ...fixture.environment, + CODEX_SECURITY_STATE_DIR: stateDirectory, + }), + ).resolves.toEqual([]); + } + + const check = spawnSync( + fixture.python, + [ + "-I", + "-B", + "-c", + [ + "import sys", + "from pathlib import PureWindowsPath", + "from urllib.parse import unquote, urlsplit", + "sys.path.insert(0, sys.argv[1])", + "import workbench_db as workbench", + "class Captured(Exception): pass", + "def capture(filename, **options):", + " parsed = urlsplit(filename)", + " assert parsed.scheme == 'file' and parsed.netloc == ''", + " assert unquote(parsed.path) == str(path)", + " assert parsed.query == 'mode=ro' and options == {'uri': True, 'timeout': 5}", + " raise Captured", + "workbench.sqlite3.connect = capture", + "workbench.linear_publication_input = lambda *_args, **_options: ({}, {}, [])", + "for value in ['C:/state/history.sqlite3', '//server/share/state/history.sqlite3', '//?/C:/state/history.sqlite3', '//?/UNC/server/share/history.sqlite3']:", + " path = PureWindowsPath(value)", + " workbench.database_path = lambda: path", + " try: workbench.inspect_linear_publication(None)", + " except Captured: pass", + " else: raise AssertionError('SQLite connection was not attempted')", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + ], + { encoding: "utf8" }, + ); + expect(check.status, check.stderr).toBe(0); + }); + + test("matches the manifest recorded when the scan completed", async () => { + const fixture = await publicationFixture({ seedScan: false }); + const scanDirectory = fixture.publication.scanDirectory; + await cp(join(PLUGIN_ROOT, "examples", "completed-scan"), scanDirectory, { + recursive: true, + }); + const options = { + destination: "linear" as const, + teamId: "team-example", + projectId: "project-example", + }; + const original = await prepareScanPublication(scanDirectory, options); + seedPublicationScan(fixture, original); + const manifestPath = join(scanDirectory, "scan-manifest.json"); + const originalManifest = await readFile(manifestPath); + const digest = `sha256:${createHash("sha256").update(originalManifest).digest("hex")}`; + databaseRows( + fixture, + "UPDATE scans SET seal_manifest_digest = ? WHERE id = ?", + [digest, original.scanId], + ); + await expect( + inspectPublicationStore(original, fixture.environment), + ).resolves.toEqual([]); + + const findingsPath = join(scanDirectory, "findings.json"); + const findings = JSON.parse( + await readFile(findingsPath, "utf8"), + ) as FindingsDocument; + findings.findings[0]!.summary = "Updated synthetic finding summary."; + await writeFile(findingsPath, `${JSON.stringify(findings, null, 2)}\n`); + const manifest = JSON.parse(originalManifest.toString()) as ScanManifest; + for (const artifact of manifest.scan.artifacts) { + artifact.sha256 = createHash("sha256") + .update(await readFile(join(scanDirectory, artifact.path))) + .digest("hex"); + } + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + const changed = await prepareScanPublication(scanDirectory, options); + expect(changed.issues[0]!.description).toContain( + "Updated synthetic finding summary.", + ); + for (const operation of [ + inspectPublicationStore, + preparePublicationStore, + ]) { + await expect(operation(changed, fixture.environment)).rejects.toThrow( + /sealed scan manifest changed after completion/u, + ); + } + expect( + databaseRows( + fixture, + "SELECT seal_manifest_digest FROM scans WHERE id = ?", + [original.scanId], + ), + ).toEqual([{ seal_manifest_digest: digest }]); + }); + + test("keeps inspection temporaries outside the completed scan", async () => { + const fixture = await publicationFixture(); + const scan = fixture.publication.scanDirectory; + const nested = join(scan, "temporary"); + const alias = join(dirname(fixture.stateDirectory), "temporary-link"); + await mkdir(nested); + await symlink( + nested, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + const temporary = spyOn(os, "tmpdir"); + try { + for (const root of [scan, nested, alias]) { + temporary.mockReturnValue(root); + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).rejects.toThrow(/temporary directory must be outside/u); + expect(await readdir(scan)).toEqual(["temporary"]); + expect(await readdir(nested)).toEqual([]); + } + } finally { + temporary.mockRestore(); + } + }); + + test("forwards cancellation to Python discovery and the workbench and cleans up its input", async () => { + const fixture = await publicationFixture(); + const controller = new AbortController(); + const reason = new Error("Synthetic inspection cancellation."); + let inputFile = ""; + let started!: () => void; + const inspecting = new Promise((resolve) => { + started = resolve; + }); + const python = spyOn(runtime, "resolvePluginPython").mockImplementation( + async (options) => { + expect(options?.signal).toBe(controller.signal); + return fixture.python; + }, + ); + const workbench = spyOn(runtime, "runWorkbench").mockImplementation( + async (options, args) => { + started(); + expect(options.signal).toBe(controller.signal); + expect(args[0]).toBe("inspect-linear-publication"); + inputFile = args[args.indexOf("--input-file") + 1]!; + return new Promise((_resolve, reject) => { + options.signal!.addEventListener( + "abort", + () => reject(options.signal!.reason), + { once: true }, + ); + }); + }, + ); + try { + const pending = inspectPublicationStore( + fixture.publication, + fixture.environment, + controller.signal, + ); + await inspecting; + controller.abort(reason); + await expect(pending).rejects.toBe(reason); + expect(inputFile).not.toBe(""); + expect(existsSync(dirname(inputFile))).toBe(false); + } finally { + controller.abort(reason); + workbench.mockRestore(); + python.mockRestore(); + } + }); + + test("rejects a pre-aborted inspection before looking for local history", async () => { + const fixture = await publicationFixture({ createDatabase: false }); + const controller = new AbortController(); + const reason = new Error("Inspection already canceled."); + controller.abort(reason); + await expect( + inspectPublicationStore( + fixture.publication, + fixture.environment, + controller.signal, + ), + ).rejects.toBe(reason); + expect(existsSync(fixture.stateDirectory)).toBe(false); + }); + + test("does not create a missing database or migrate old history", async () => { + const missing = await publicationFixture({ createDatabase: false }); + await expect( + inspectPublicationStore(missing.publication, missing.environment), + ).rejects.toThrow(/scan-history database does not exist/u); + expect(existsSync(missing.stateDirectory)).toBe(false); + + const fixture = await publicationFixture(); + databaseRows(fixture, "DROP TABLE finding_publications"); + databaseRows(fixture, "DELETE FROM schema_migrations WHERE version >= ?", [ + 29, + ]); + const database = join(fixture.stateDirectory, "workbench.sqlite3"); + const before = await readFile(database); + const mode = (await stat(database)).mode; + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).resolves.toEqual([]); + expect(await readFile(database)).toEqual(before); + expect((await stat(database)).mode).toBe(mode); + expect( + (await readdir(fixture.stateDirectory)).some((name) => + name.startsWith("publication-"), + ), + ).toBe(false); + expect( + databaseRows( + fixture, + "SELECT version FROM schema_migrations WHERE version >= ?", + [29], + ), + ).toEqual([]); + expect( + databaseRows( + fixture, + "SELECT name FROM sqlite_master WHERE name = 'finding_publications'", + ), + ).toEqual([]); + }); + + test("reads history from before recorded manifest digests without migrating it", async () => { + const fixture = await publicationFixture({ createDatabase: false }); + await mkdir(fixture.stateDirectory, { mode: 0o700 }); + const database = join(fixture.stateDirectory, "workbench.sqlite3"); + const setup = spawnSync( + fixture.python, + [ + "-I", + "-B", + "-c", + [ + "import sqlite3, sys", + "sys.path.insert(0, sys.argv[1])", + "from workbench_schema import MIGRATIONS, apply_migrations", + "connection = sqlite3.connect(sys.argv[2])", + "connection.row_factory = sqlite3.Row", + "apply_migrations(connection, tuple(item for item in MIGRATIONS if item[0] < 8), lambda: '2026-08-01T00:00:00Z', lambda _: None)", + "connection.close()", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + database, + ], + { encoding: "utf8" }, + ); + expect(setup.status, setup.stderr).toBe(0); + seedPublicationScan(fixture, fixture.publication); + const before = await readFile(database); + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).resolves.toEqual([]); + expect(await readFile(database)).toEqual(before); + expect( + databaseRows( + fixture, + "SELECT MAX(version) AS version FROM schema_migrations", + ), + ).toEqual([{ version: 7 }]); + }); + + test("returns one recorded issue per exact scan occurrence and destination", async () => { + const fixture = await publicationFixture(); + const first = publishedIssue(fixture.publication, 0, "EXAMPLE-101"); + const second = publishedIssue(fixture.publication, 1, "EXAMPLE-102"); + await recordPublishedIssues( + fixture.publication, + [second, first], + fixture.environment, + ); + await recordPublishedIssues( + fixture.publication, + [publishedIssue(fixture.publication, 0, "EXAMPLE-201")], + fixture.environment, + ); + const teamOnly: PreparedScanPublication = { + ...fixture.publication, + destination: { type: "linear", teamId: "team-example" }, + }; + const withoutProject = publishedIssue(teamOnly, 1, "EXAMPLE-301"); + await recordPublishedIssues( + teamOnly, + [withoutProject], + fixture.environment, + ); + + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).resolves.toEqual([first, second]); + await expect( + inspectPublicationStore(teamOnly, fixture.environment), + ).resolves.toEqual([withoutProject]); + for (const destination of [ + { ...fixture.publication.destination, teamId: "another-team" }, + { ...fixture.publication.destination, projectId: "another-project" }, + ]) { + await expect( + inspectPublicationStore( + { ...fixture.publication, destination }, + fixture.environment, + ), + ).resolves.toEqual([]); + } + + const scanDirectory = join(fixture.stateDirectory, "another-scan"); + await mkdir(scanDirectory, { mode: 0o700 }); + const otherScan: PreparedScanPublication = { + ...fixture.publication, + scanId: OTHER_SCAN_ID, + uploadId: OTHER_SCAN_ID, + scanDirectory, + issues: fixture.publication.issues.map((issue) => ({ + ...issue, + occurrenceId: `other-${issue.occurrenceId}`, + })), + }; + seedPublicationScan(fixture, otherScan); + await expect( + inspectPublicationStore(otherScan, fixture.environment), + ).resolves.toEqual([]); + await expect( + inspectPublicationStore( + { ...fixture.publication, issues: [fixture.publication.issues[0]!] }, + fixture.environment, + ), + ).rejects.toThrow(/exactly match/u); + }); + + test("includes committed associations that are still in the WAL", async () => { + const fixture = await publicationFixture({ count: 1 }); + const original = publishedIssue(fixture.publication, 0, "EXAMPLE-401"); + const changed = publishedIssue(fixture.publication, 0, "EXAMPLE-402"); + await recordPublishedIssues( + fixture.publication, + [original], + fixture.environment, + ); + const database = join(fixture.stateDirectory, "workbench.sqlite3"); + const update = spawnSync( + fixture.python, + [ + "-I", + "-B", + "-c", + [ + "import os, sqlite3, sys", + "connection = sqlite3.connect(sys.argv[1])", + "connection.execute('PRAGMA wal_autocheckpoint = 0')", + "connection.execute('UPDATE finding_publications SET external_id = ?, external_url = ?', (sys.argv[2], sys.argv[3]))", + "connection.commit()", + "os._exit(0)", + ].join("\n"), + database, + changed.issueIdentifier, + changed.url!, + ], + { encoding: "utf8" }, + ); + expect(update.status, update.stderr).toBe(0); + expect(existsSync(`${database}-wal`)).toBe(true); + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).resolves.toEqual([changed]); + }); +}); + describe("persisted finding publication associations", () => { test("rolls back failed migrations without losing populated scan history", async () => { const fixture = await publicationFixture({ count: 1 }); diff --git a/sdk/typescript/tests-ts/publication.test.ts b/sdk/typescript/tests-ts/publication.test.ts index d09c4599d..5581d118e 100644 --- a/sdk/typescript/tests-ts/publication.test.ts +++ b/sdk/typescript/tests-ts/publication.test.ts @@ -68,6 +68,20 @@ async function reseal(scanDirectory: string): Promise { } describe("scan publication preparation", () => { + test("preserves cancellation while loading the sealed scan", async () => { + const scanDirectory = await copyExample(); + const controller = new AbortController(); + const reason = new Error("Publication preparation canceled."); + controller.abort(reason); + + await expect( + prepareScanPublication(scanDirectory, { + ...DESTINATION, + signal: controller.signal, + }), + ).rejects.toBe(reason); + }); + test("requires artifacts to match the selected saved scan", async () => { const scanDirectory = await copyExample(); await expect( diff --git a/sdk/typescript/tests-ts/publish.test.ts b/sdk/typescript/tests-ts/publish.test.ts index db5eb7cd1..a276e27f1 100644 --- a/sdk/typescript/tests-ts/publish.test.ts +++ b/sdk/typescript/tests-ts/publish.test.ts @@ -23,6 +23,7 @@ import { type PublishScanDependencies, type PublishScanOptions, type PublishScanProgress, + type PublishedScanIssue, type PublishScanResult, } from "../src/publish.js"; import type { @@ -325,6 +326,244 @@ async function processHasExited(pid: number): Promise { return false; } +describe("skip-recorded publication", () => { + test("forwards cancellation into read-only history inspection", async () => { + const publication = preparedPublication(); + const controller = new AbortController(); + const reason = new Error("Retry inspection canceled."); + await expect( + publishScanInternal( + "scan", + { ...OPTIONS, skipExisting: true, signal: controller.signal }, + dependencies( + publication, + {}, + { + inspectPublicationStore: async ( + _publication, + _environment, + signal, + ) => { + expect(signal).toBe(controller.signal); + controller.abort(reason); + signal!.throwIfAborted(); + return []; + }, + preparePublicationStore: async () => { + throw new Error("Canceled retries must not write history."); + }, + resolveCodex: () => { + throw new Error("Canceled retries must not start Codex."); + }, + }, + ), + ), + ).rejects.toBe(reason); + }); + + test("keeps the default create-new behavior and makes opt-in previews read-only", async () => { + const publication = preparedPublication(2); + const recorded: PublishedScanIssue = { + findingId: "finding-1", + occurrenceId: "occurrence-1", + issueIdentifier: "SEC-101", + }; + const injected = dependencies( + publication, + {}, + { + inspectPublicationStore: async (prepared) => { + expect(prepared).toBe(publication); + return [recorded]; + }, + preparePublicationStore: async () => { + throw new Error("Previews must not write history."); + }, + resolveCodex: () => { + throw new Error("Previews must not start Codex."); + }, + writeReceipt: async () => { + throw new Error("Previews must not write receipts."); + }, + }, + ); + const ordinary = await publishScanInternal( + "scan", + { ...OPTIONS, dryRun: true }, + { + ...injected, + inspectPublicationStore: async () => { + throw new Error("Ordinary previews stay offline."); + }, + }, + ); + expect(ordinary.issues).toEqual(publication.issues); + expect(ordinary).not.toHaveProperty("skipped"); + const preview = await publishScanInternal( + "scan", + { ...OPTIONS, dryRun: true, skipExisting: true }, + injected, + ); + expect(preview.issues).toEqual([publication.issues[1]!]); + expect(preview.skipped).toEqual([recorded]); + expect(preview.counts).toEqual({ + findings: 2, + created: 0, + failed: 0, + skipped: 1, + }); + }); + + test("does nothing remotely or locally when every finding is already recorded", async () => { + const publication = preparedPublication(); + const recorded: PublishedScanIssue = { + findingId: "finding-1", + occurrenceId: "occurrence-1", + issueIdentifier: "SEC-101", + }; + const result = await publishScanInternal( + "scan", + { ...OPTIONS, skipExisting: true }, + dependencies( + publication, + {}, + { + inspectPublicationStore: async () => [recorded], + preparePublicationStore: async () => { + throw new Error("Nothing needs publication."); + }, + resolveCodex: () => { + throw new Error("Nothing needs publication."); + }, + linearClient: () => { + throw new Error("Nothing needs publication."); + }, + recordPublishedIssues: async () => { + throw new Error("Nothing needs publication."); + }, + writeReceipt: async () => { + throw new Error("Nothing needs publication."); + }, + }, + ), + ); + expect(result.created).toEqual([]); + expect(result.skipped).toEqual([recorded]); + expect(result.counts).toEqual({ + findings: 1, + created: 0, + failed: 0, + skipped: 1, + }); + }); + + test("publishes only pending findings while validating and recording against the full scan", async () => { + for (const transport of ["connected-app", "linear-api"] as const) { + const publication = preparedPublication(2); + const recorded: PublishedScanIssue = { + findingId: "finding-1", + occurrenceId: "occurrence-1", + issueIdentifier: "SEC-101", + }; + const attempted: string[] = []; + const progress: PublishScanProgress[] = []; + const injected = dependencies( + publication, + {}, + { + inspectPublicationStore: async () => [recorded], + preparePublicationStore: async (prepared) => { + expect(prepared).toBe(publication); + }, + recordPublishedIssues: async (prepared, issues) => { + expect(prepared).toBe(publication); + expect(issues.map((issue) => issue.findingId)).toEqual([ + "finding-2", + ]); + return [...issues]; + }, + runCodex: async (_command, _args, input) => { + const payload = publicationData(input); + attempted.push( + ...payload.batches.flat().map((issue) => issue.findingId), + ); + return { + exitCode: 0, + stdout: issueEvent(publication.issues[1]!), + stderr: "", + }; + }, + linearClient: linearApiClient(publication, { + create: (input) => { + attempted.push( + publication.issues.find((issue) => issue.title === input.title)! + .findingId, + ); + }, + }), + }, + ); + delete injected.environment!["CODEX_SECURITY_LINEAR_API_KEY"]; + const result = await publishScanInternal( + "scan", + { + ...OPTIONS, + skipExisting: true, + ...(transport === "linear-api" + ? { linearApiKey: "synthetic-key" } + : {}), + onProgress: (event) => progress.push(event), + }, + injected, + ); + expect(attempted).toEqual(["finding-2"]); + expect(result.skipped).toEqual([recorded]); + expect(result.created.map((issue) => issue.findingId)).toEqual([ + "finding-2", + ]); + expect(result.counts).toEqual({ + findings: 2, + created: 1, + failed: 0, + skipped: 1, + }); + expect(progress[0]).toEqual({ + type: "started", + scanId: publication.scanId, + total: 1, + }); + expect(progress.at(-1)).toEqual({ + type: "completed", + created: 1, + failed: 0, + total: 1, + }); + } + }); + + test("stops an opt-in retry when its history cannot be verified", async () => { + const publication = preparedPublication(); + await expect( + publishScanInternal( + "scan", + { ...OPTIONS, skipExisting: true }, + dependencies( + publication, + {}, + { + inspectPublicationStore: async () => { + throw new Error("History is unavailable."); + }, + resolveCodex: () => { + throw new Error("Must not publish without verified history."); + }, + }, + ), + ), + ).rejects.toThrow("History is unavailable."); + }); +}); + describe("direct Linear API publication", () => { test("leaves issues unassigned unless an email or user ID is selected", async () => { for (const scenario of [ @@ -714,86 +953,109 @@ describe("direct Linear API publication", () => { ); }); - test("lets active direct mutations settle after external cancellation", async () => { - const publication = preparedPublication(23); - const controller = new AbortController(); - const firstBatchStarted = Promise.withResolvers(); - const releaseBatch = Promise.withResolvers(); - let started = 0; - let stopped = 0; - let persisted: string[] = []; - let receipt: PublishScanResult | undefined; - const injected = dependencies( - publication, - {}, - { - linearClient: linearApiClient(publication, { - create: async (_input, signal) => { - started += 1; - if (started === 20) { - firstBatchStarted.resolve(); - } - await releaseBatch.promise; - if (signal?.aborted) { - stopped += 1; - throw new Error("Publication canceled."); - } + test.each([false, true])( + "lets active direct mutations settle after external cancellation with skipExisting=%s", + async (skipExisting) => { + const publication = preparedPublication(23); + const recorded = { + findingId: publication.issues[0]!.findingId, + occurrenceId: publication.issues[0]!.occurrenceId, + issueIdentifier: "SEC-1", + }; + const controller = new AbortController(); + const firstBatchStarted = Promise.withResolvers(); + const releaseBatch = Promise.withResolvers(); + let started = 0; + let stopped = 0; + let persisted: string[] = []; + let receipt: PublishScanResult | undefined; + const injected = dependencies( + publication, + {}, + { + inspectPublicationStore: async (prepared) => { + expect(prepared).toBe(publication); + return [recorded]; + }, + linearClient: linearApiClient(publication, { + create: async (_input, signal) => { + started += 1; + if (started === 20) { + firstBatchStarted.resolve(); + } + await releaseBatch.promise; + if (signal?.aborted) { + stopped += 1; + throw new Error("Publication canceled."); + } + }, + }), + recordPublishedIssues: async (prepared, issues) => { + expect(prepared).toBe(publication); + persisted = issues.map(({ issueIdentifier }) => issueIdentifier); + return [...issues]; + }, + writeReceipt: async (result) => { + receipt = result; }, - }), - recordPublishedIssues: async (_prepared, issues) => { - persisted = issues.map(({ issueIdentifier }) => issueIdentifier); - return [...issues]; - }, - writeReceipt: async (result) => { - receipt = result; }, - }, - ); + ); - const publicationPromise = publishScanInternal( - publication.scanDirectory, - { - ...OPTIONS, - linearApiKey: "synthetic-key", - signal: controller.signal, - }, - injected, - ); - await firstBatchStarted.promise; - controller.abort("external cancellation"); - releaseBatch.resolve(); - await expect(publicationPromise).rejects.toThrow( - /publication handoff remains at/u, - ); + const publicationPromise = publishScanInternal( + publication.scanDirectory, + { + ...OPTIONS, + linearApiKey: "synthetic-key", + signal: controller.signal, + ...(skipExisting ? { skipExisting: true } : {}), + }, + injected, + ); + await firstBatchStarted.promise; + controller.abort("external cancellation"); + releaseBatch.resolve(); + await expect(publicationPromise).rejects.toThrow( + /publication handoff remains at/u, + ); - expect({ started, stopped, persisted }).toEqual({ - started: 20, - stopped: 0, - persisted: Array.from({ length: 20 }, (_, index) => `SEC-${index + 1}`), - }); - expect(receipt).toMatchObject({ - counts: { findings: 23, created: 20, failed: 3 }, - }); - const stateDirectory = injected.environment!["CODEX_SECURITY_STATE_DIR"]!; - const handoffRoot = join( - stateDirectory, - "publications", - "linear", - "handoffs", - ); - const handoffDirectories = await readdir(handoffRoot); - expect(handoffDirectories).toHaveLength(1); - expect( - ( - await readFile( - join(handoffRoot, handoffDirectories[0]!, "issues.jsonl"), - "utf8", + expect({ started, stopped, persisted }).toEqual({ + started: 20, + stopped: 0, + persisted: Array.from( + { length: 20 }, + (_, index) => `SEC-${index + 1 + Number(skipExisting)}`, + ), + }); + expect(receipt?.skipped).toEqual(skipExisting ? [recorded] : undefined); + expect(receipt).toMatchObject({ + counts: { + findings: 23, + created: 20, + failed: 3 - Number(skipExisting), + ...(skipExisting ? { skipped: 1 } : {}), + }, + }); + const stateDirectory = injected.environment!["CODEX_SECURITY_STATE_DIR"]!; + const handoffRoot = join( + stateDirectory, + "publications", + "linear", + "handoffs", + ); + const handoffDirectories = await readdir(handoffRoot); + expect(handoffDirectories).toHaveLength(1); + expect( + ( + await readFile( + join(handoffRoot, handoffDirectories[0]!, "issues.jsonl"), + "utf8", + ) ) - ) - .trim() - .split("\n"), - ).toHaveLength(20); - }); + .trim() + .split("\n"), + ).toHaveLength(20); + }, + ); }); describe("connected Linear publication", () => { diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 179db5b5c..d81aad747 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1753,9 +1753,9 @@ describe("plugin runtime preparation", () => { } }); - test("refreshes cached plugins before forwarding delegated scan attribution", async () => { + test("refreshes the prior bundle before using new runtime helpers", async () => { const root = await temporaryDirectory(); - const previous = await plugin(join(root, "previous"), "0.1.19"); + const previous = await plugin(join(root, "previous"), "0.1.22"); await writeFile( join(previous, ".mcp.json"), JSON.stringify({ @@ -1798,7 +1798,7 @@ describe("plugin runtime preparation", () => { }; expect((await bootstrapPlugin(home, previous, options)).version).toBe( - "0.1.19", + "0.1.22", ); const upgraded = await bootstrapPlugin(home, PLUGIN_ROOT, options); const configuration = JSON.parse( @@ -1809,10 +1809,22 @@ describe("plugin runtime preparation", () => { ) as { mcpServers: Record }; expect(upgraded.version).toBe(BUNDLED_PLUGIN_VERSION); - expect(upgraded.version).not.toBe("0.1.19"); + expect(upgraded.version).not.toBe("0.1.22"); expect(configuration.mcpServers["codex-security"]?.env_vars).toContain( "CODEX_SECURITY_SURFACE", ); + expect( + await readFile( + join( + marketplace, + "plugins", + "codex-security", + "scripts", + "workbench_cli.py", + ), + "utf8", + ), + ).toContain('"inspect-linear-publication"'); }); test("rejects plugin installs without the selected path and version", async () => {