diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py b/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py index a3e14f7e..49fe45d2 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_cli.py @@ -151,6 +151,7 @@ def parse_args(description: str) -> argparse.Namespace: recipe = register_cli_scan.add_mutually_exclusive_group(required=True) recipe.add_argument("--recipe-json") recipe.add_argument("--recipe-json-stdin", action="store_true") + recipe.add_argument("--registration-json-stdin", action="store_true") register_cli_scan.add_argument("--parent-scan-id") register_cli_scan.add_argument("--archive-existing", action="store_true") register_cli_scan.add_argument("--archived-scan-dir") diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index 746c75a8..dfd22bc8 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -1605,7 +1605,13 @@ def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace) if next(scan_dir.iterdir(), None) is not None: raise SystemExit("The scan artifact directory must be empty before the scan starts.") - recipe_json = sys.stdin.read() if args.recipe_json_stdin else args.recipe_json + user_context = None + if args.registration_json_stdin: + registration = json.load(sys.stdin) + recipe_json = json.dumps(registration["recipe"], ensure_ascii=False, separators=(",", ":")) + user_context = registration.get("userContext") + else: + recipe_json = sys.stdin.read() if args.recipe_json_stdin else args.recipe_json recipe = parse_scan_recipe(recipe_json, repository) requested_target = recipe["target"] paths = requested_target["paths"] @@ -1691,10 +1697,11 @@ def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace) scan_dir=scan_dir, ) connection.execute( - "UPDATE scans SET recipe_json = ?, parent_scan_id = ? WHERE id = ?", + "UPDATE scans SET recipe_json = ?, parent_scan_id = ?, user_context = ? WHERE id = ?", ( json.dumps(recipe, allow_nan=False, separators=(",", ":"), sort_keys=True), parent_scan_id, + user_context, scan_id, ), ) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index a06e4804..e4532c7d 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -758,7 +758,7 @@ export class CodexSecurity { repo, "--scan-dir", scanDir, - "--recipe-json-stdin", + "--registration-json-stdin", ...(options.archiveExisting === true ? ["--archive-existing"] : []), ...(archivedScanDir === null ? [] @@ -767,7 +767,7 @@ export class CodexSecurity { ? [] : ["--parent-scan-id", options.parentScanId]), ], - JSON.stringify(recipe), + JSON.stringify({ recipe, userContext: options.scanPrompt }), ); const scanId = registration["scanId"]; const targetId = registration["targetId"]; diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 10d656c4..7ec51380 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -231,7 +231,7 @@ describe("CodexSecurity orchestration", () => { input?: string, ): Promise => { if (args[0] === "register-cli-scan") { - recipe = JSON.parse(input!); + recipe = JSON.parse(input!).recipe; } return mockWorkbench(args, input); }, @@ -1080,7 +1080,7 @@ describe("CodexSecurity orchestration", () => { input?: string, ): Promise => { if (args[0] === "register-cli-scan") { - savedRecipe = JSON.parse(input!); + savedRecipe = JSON.parse(input!).recipe; } return mockWorkbench(args, input); }, @@ -1980,8 +1980,11 @@ describe("CodexSecurity orchestration", () => { "Additional scan instructions:\nFocus on authentication and authorization.", ); expect(followUpPrompt).toBe("Draft fixes for confirmed findings."); - expect(commands[0]).toContain("--recipe-json-stdin"); - expect(JSON.parse(registrationInput!)).toMatchObject({ + expect(commands[0]).toContain("--registration-json-stdin"); + expect(JSON.parse(registrationInput!).userContext).toBe( + "Focus on authentication and authorization.", + ); + expect(JSON.parse(registrationInput!).recipe).toMatchObject({ repository, target: { kind: "repository", paths: [] }, mode: "standard", @@ -2128,7 +2131,8 @@ describe("CodexSecurity orchestration", () => { falsePositives: [], }; } - recipe = JSON.parse(input!); + expect(JSON.parse(input!).userContext).toBeUndefined(); + recipe = JSON.parse(input!).recipe; return mockScanRegistration(args, input); }, createCodex: () => ({ @@ -3847,6 +3851,7 @@ describe("CodexSecurity orchestration", () => { }); test("provides authoritative knowledge-base context without retaining its documents", async () => { + const scanPrompt = "Review the synthetic authorization boundary."; const root = await temporaryDirectory(); const repository = join(root, "repository"); const codexHome = join(root, "codex-home"); @@ -3883,7 +3888,8 @@ describe("CodexSecurity orchestration", () => { }; } if (args[0] !== "register-cli-scan") return {}; - recipe = JSON.parse(input!); + expect(JSON.parse(input!).userContext).toBe(scanPrompt); + recipe = JSON.parse(input!).recipe; return mockScanRegistration(args, input); }, createCodex: (options: CodexOptions) => ({ @@ -3906,7 +3912,10 @@ describe("CodexSecurity orchestration", () => { ); await expect( - client.run(repository, { knowledgeBasePaths: [knowledgeBase] }), + client.run(repository, { + knowledgeBasePaths: [knowledgeBase], + scanPrompt, + }), ).resolves.toMatchObject({ threadId: "thread-1" }); expect(existsSync(knowledgeDirectory)).toBe(false); expect(prompt).toContain( diff --git a/sdk/typescript/tests-ts/deep-scan-workbench.test.ts b/sdk/typescript/tests-ts/deep-scan-workbench.test.ts index 179156e4..262de005 100644 --- a/sdk/typescript/tests-ts/deep-scan-workbench.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-workbench.test.ts @@ -9,7 +9,8 @@ import { import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; -import { PLUGIN_ROOT } from "./plugin-root.js"; +import { runWorkbench } from "../src/runtime.js"; +import { loadBundledRuntime, PLUGIN_ROOT } from "./plugin-root.js"; const originalClaimToken = "22222222-2222-4222-8222-222222222222"; const replacementClaimToken = "33333333-3333-4333-8333-333333333333"; @@ -186,6 +187,102 @@ test("recovers an interrupted copied Deep Scan publication", async () => { }); }); +test.each([ + ["supplied", " Review café authentication.\r\n\t"], + ["absent", undefined], + ["large", " --café\r\n".repeat(30_000)], +] as const)( + "preserves %s scan instructions from registration through the Deep worker prompt", + async (_label, scanPrompt) => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-deep-context-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(scanDir, { mode: 0o700 }); + await writeFile(join(repository, "source.py"), "# synthetic source\n"); + const python = Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + const command = (args: string[], input?: string) => + runWorkbench( + { + python: python!, + pluginRoot: PLUGIN_ROOT, + environment: { + ...process.env, + CODEX_SECURITY_STATE_DIR: join(root, "state"), + CODEX_HOME: join(root, "codex-home"), + }, + }, + args, + input, + ); + const registration = await command( + [ + "register-cli-scan", + "--repository", + repository, + "--scan-dir", + scanDir, + "--registration-json-stdin", + ], + JSON.stringify({ + recipe: { + config: { + developer_instructions: "Synthetic context. ".repeat(4_000), + }, + mode: "deep", + repository, + target: { kind: "repository", paths: [] }, + }, + userContext: scanPrompt, + }), + ); + const scanId = registration["scanId"] as string; + const context = await command(["get-scan", "--scan-id", scanId]); + expect(context["scan"]).toMatchObject({ userContext: scanPrompt ?? null }); + + const begun = await command([ + "begin-deep-scan", + "--scan-id", + scanId, + "--thread-id", + "synthetic-thread", + "--scan-root", + join(root, "scans"), + "--available-parallelism", + "4", + "--workflow-version", + "deep-scan-mcp/v1", + ]); + const deepScan = begun["deepScan"] as Record; + + const templates = + /\/\/ templates\/deep-scan\/discovery\.md\n([\s\S]*?)\/\/ src\/deep-scan\/worker-runner\.ts/u.exec( + await loadBundledRuntime(), + )?.[1]; + expect(templates).toBeDefined(); + const renderDiscoveryPrompt = new Function( + `${templates}\nreturn renderDiscoveryPrompt;`, + )() as (input: Record) => string; + const prompt = renderDiscoveryPrompt({ + ...deepScan, + pluginRoot: PLUGIN_ROOT, + workerLabel: "synthetic-worker", + subagents: 0, + }); + const workerContext = JSON.parse( + /```json\n([\s\S]*?)\n```/u.exec(prompt)![1]!, + ); + expect(workerContext).toMatchObject({ + scanId, + userContext: scanPrompt ?? null, + }); + }, +); + describe("deep scan workbench ownership", () => { test("starts a Deep Scan with oversized stdin user context", async () => { const root = await realpath( diff --git a/sdk/typescript/tests-ts/support/api-client.ts b/sdk/typescript/tests-ts/support/api-client.ts index da00b6a8..851a440c 100644 --- a/sdk/typescript/tests-ts/support/api-client.ts +++ b/sdk/typescript/tests-ts/support/api-client.ts @@ -9,10 +9,10 @@ export function mockScanRegistration( args: readonly string[], input?: string, ): JsonObject { - if (!args.includes("--recipe-json-stdin") || input === undefined) { - throw new Error("missing stdin scan recipe"); + if (!args.includes("--registration-json-stdin") || input === undefined) { + throw new Error("missing stdin scan registration"); } - const recipe = JSON.parse(input) as { + const recipe = JSON.parse(input).recipe as { repositoryRevision?: string; target: { kind: string }; };