diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 4bb081be..16a83223 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -115,6 +115,10 @@ import { validatedGitEnvironment, validateMode, } from "./targets.js"; +import { + startHeadDriftMonitor, + type HeadDriftMonitor, +} from "./target-drift.js"; interface CodexThreadLike { readonly id: string | null; @@ -425,6 +429,7 @@ export class CodexSecurity { id: string; options: WorkbenchCommandOptions; } | null = null; + let headDriftMonitor: HeadDriftMonitor | null = null; const workbench = this.#dependencies.runWorkbench ?? runWorkbench; try { const checkOpen = (): void => { @@ -676,11 +681,11 @@ export class CodexSecurity { ); } checkOpen(); + const readRepositoryRevision = + this.#dependencies.repositoryRevision ?? repositoryRevision; const expectation: ScanExpectation = { repository: repo, - repositoryRevision: await ( - this.#dependencies.repositoryRevision ?? repositoryRevision - )(repo, signal), + repositoryRevision: await readRepositoryRevision(repo, signal), target: normalized, mode, pluginVersion: runtime.plugin.version, @@ -869,6 +874,27 @@ export class CodexSecurity { ); } activeScan = { id: scanId, options: workbenchOptions }; + const monitorsHeadDrift = + registeredRevision !== "unversioned" && + (targetKind === "git_revision" || + targetKind === "git_worktree" || + (targetKind === "git_diff" && normalized.kind === "working_tree")); + if (monitorsHeadDrift) { + headDriftMonitor = startHeadDriftMonitor({ + expectedRevision: registeredRevision, + readRevision: (revisionSignal) => + readRepositoryRevision(repo, revisionSignal), + signal, + onDrift: () => + notifyObserver( + "onWarning", + options.onWarning, + options.onObserverError, + "Repository HEAD changed while the scan was running; results remain bound to the original revision.", + { kind: "target_changed" }, + ), + }); + } checkOpen(); const basePrompt = scanPrompt( normalized, @@ -1241,6 +1267,7 @@ export class CodexSecurity { } throw failure; } finally { + headDriftMonitor?.stop(); // Removing the temporary scan inputs is best effort. A throw here would replace the // outcome the try and catch blocks already produced, so these failures are reported // as warnings: a scan that failed has to say why it failed, not why its temporary diff --git a/sdk/typescript/src/target-drift.ts b/sdk/typescript/src/target-drift.ts new file mode 100644 index 00000000..b6251b9c --- /dev/null +++ b/sdk/typescript/src/target-drift.ts @@ -0,0 +1,63 @@ +export interface HeadDriftMonitorOptions { + expectedRevision: string; + readRevision: (signal: AbortSignal) => Promise; + signal: AbortSignal; + onDrift: () => void; + intervalMs?: number; +} + +export interface HeadDriftMonitor { + readonly ready: Promise; + check(): Promise; + stop(): void; +} + +const DEFAULT_HEAD_DRIFT_INTERVAL_MS = 1_000; + +export function startHeadDriftMonitor( + options: HeadDriftMonitorOptions, +): HeadDriftMonitor { + let stopped = false; + let warned = false; + let checking = false; + + const check = async (): Promise => { + if (stopped || warned || options.signal.aborted || checking) return; + checking = true; + try { + const revision = await options.readRevision(options.signal); + if ( + !stopped && + !options.signal.aborted && + revision !== null && + revision !== options.expectedRevision && + !warned + ) { + warned = true; + options.onDrift(); + } + } catch { + // A transient inability to read HEAD should not interrupt a scan. The + // final target validation remains authoritative if the repository is + // unavailable or changes before completion. + } finally { + checking = false; + } + }; + + const timer = setInterval(() => { + void check(); + }, options.intervalMs ?? DEFAULT_HEAD_DRIFT_INTERVAL_MS); + timer.unref(); + const ready = check(); + + return { + ready, + check, + stop: () => { + if (stopped) return; + stopped = true; + clearInterval(timer); + }, + }; +} diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 03b373af..64637ebb 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -1942,6 +1942,61 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test("warns before completion when the registered repository HEAD drifts", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(codexHome); + await mkdir(scanDir, { mode: 0o700 }); + let revisionReads = 0; + const warnings: string[] = []; + const warningDetails: Array<{ kind: "target_changed" } | undefined> = []; + + const client = new TestClient( + {}, + { + environment: { PATH: "/usr/bin", OPENAI_API_KEY: "" }, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment: { CODEX_HOME: codexHome, PATH: "/usr/bin" }, + }), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => { + revisionReads += 1; + return revisionReads === 1 ? "before" : "after"; + }, + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed() { + await copyCompletedScan(root); + return { events: completedEvents() }; + }, + }), + }), + }, + ); + + try { + await client.run(repository, { + onWarning: (warning, details) => { + warnings.push(warning); + warningDetails.push(details); + }, + }); + expect(warnings).toContain( + "Repository HEAD changed while the scan was running; results remain bound to the original revision.", + ); + expect(warningDetails).toContainEqual({ kind: "target_changed" }); + expect(revisionReads).toBeGreaterThanOrEqual(2); + } finally { + await client.close(); + } + }); + test("passes the workbench snapshot contract to dirty Git scans", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); diff --git a/sdk/typescript/tests-ts/target-drift.test.ts b/sdk/typescript/tests-ts/target-drift.test.ts new file mode 100644 index 00000000..1e6fe87e --- /dev/null +++ b/sdk/typescript/tests-ts/target-drift.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { startHeadDriftMonitor } from "../src/target-drift.js"; + +describe("head drift monitor", () => { + test("warns once after the repository revision changes", async () => { + const controller = new AbortController(); + let revision = "before"; + const warnings: string[] = []; + const monitor = startHeadDriftMonitor({ + expectedRevision: "before", + readRevision: async () => revision, + signal: controller.signal, + onDrift: () => warnings.push("changed"), + intervalMs: 60_000, + }); + + try { + await monitor.ready; + expect(warnings).toEqual([]); + + revision = "after"; + await monitor.check(); + await monitor.check(); + + expect(warnings).toEqual(["changed"]); + } finally { + monitor.stop(); + } + }); + + test("ignores an unavailable revision and stops cleanly", async () => { + const controller = new AbortController(); + const warnings: string[] = []; + const monitor = startHeadDriftMonitor({ + expectedRevision: "before", + readRevision: async () => null, + signal: controller.signal, + onDrift: () => warnings.push("changed"), + intervalMs: 60_000, + }); + + await monitor.ready; + monitor.stop(); + await monitor.check(); + + expect(warnings).toEqual([]); + }); +});