import { spawnSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { codexPromptLiveAuthorizationLintForTest } from "./src/code-queue"; type JsonRecord = Record; function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); } function asRecord(value: unknown): JsonRecord { assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), "expected JSON object", { value }); return value as JsonRecord; } function nestedRecord(value: unknown, path: string[]): JsonRecord { let current: unknown = value; for (const key of path) { current = asRecord(current)[key]; } return asRecord(current); } function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.map((item) => String(item)) : []; } function assertLegacyFrozenWrite(result: { status: number | null; stdout: string; stderr: string; json: JsonRecord | null }, command: string): void { assertCondition(result.status !== 0 && result.json?.ok === false, `${command} should be frozen`, result.json ?? { stdout: result.stdout, stderr: result.stderr }); const data = nestedRecord(result.json?.data, []); assertCondition(data.frozen === true, `${command} frozen payload should expose frozen=true`, data); assertCondition(data.mutation === false, `${command} frozen payload should be non-mutating`, data); assertCondition(data.degradedReason === "legacy-code-queue-frozen", `${command} should use the legacy frozen reason`, data); const replacement = nestedRecord(data, ["replacement"]); assertCondition(String(replacement.queueSubmit || "").includes("agentrun v01 queue submit"), `${command} should point to AgentRun queue submit`, replacement); assertCondition(String(replacement.sessionsSteer || "").includes("agentrun v01 sessions steer"), `${command} should point to AgentRun sessions steer`, replacement); } function runCli(args: string[], stdin?: string): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } { const result = spawnSync("bun", ["scripts/cli.ts", ...args], { cwd: process.cwd(), input: stdin, encoding: "utf8", }); const stdout = String(result.stdout || ""); let json: JsonRecord | null = null; try { json = JSON.parse(stdout) as JsonRecord; } catch { json = null; } return { status: result.status, stdout, stderr: String(result.stderr || ""), json, }; } const readOnlyPrompt = ` DEV test class: read-only 允许的 live mutation: none 禁止动作: prod mutation, secret values, database patch, Code Queue backend restart, interrupt/cancel. closeout fields: report class, live mutation false, commands, target environment, evidence, residual risk. Task: update docs and run contract tests only. Validation: git diff --check and bun scripts/code-queue-prompt-lint-contract-test.ts. `; const unclassifiedM3SmokePrompt = ` Run the HWLAB M3 smoke on DEV and verify res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1. Do not touch production. `; const liveReadPrompt = ` DEV test class: live-read 允许的 live mutation: none 禁止动作: no POST/PUT/PATCH/DELETE, no deploy, no task trigger, no prod mutation. closeout fields: report class, live mutation false, endpoints read, namespace/service, evidence, residual risk. Read live DEV health and status only: GET /health and kubectl get pods. `; const liveMutatingPrompt = ` DEV test class: live-mutating 允许的 live mutation: run exactly one DEV HWLAB M3 operation for res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1, record operation/audit/evidence ids, then observe recovery. 禁止动作: no prod mutation, no secret values, no database manual patch, no Code Queue backend restart, no unrelated interrupt/cancel. closeout fields: report class, live mutation true, command summary, DEV target, operation/audit/evidence ids, rollback observation, residual risk. `; const secretBearingPrompt = ` DEV test class: live-mutating 允许的 live mutation: POST /api/run?token=ds-secret-must-not-print once in DEV only. 禁止动作: no prod mutation, no database patch. closeout fields: report class, live mutation true, command summary and evidence id. `; function assertLintShape(lint: JsonRecord): void { assertCondition(lint.dryRun === true, "lint must be dry-run", lint); assertCondition(lint.mutation === false, "lint must be non-mutating", lint); assertCondition(asRecord(lint.policy).printsPromptText === false, "lint policy must not print full prompt", lint); assertCondition(asRecord(lint.promptShape).textEchoed === false, "lint shape must not echo prompt text", lint); assertCondition(Array.isArray(lint.signals), "lint must expose signals", lint); const json = JSON.stringify(lint); assertCondition(!json.includes("ds-secret-must-not-print"), "lint must not print secret marker", lint); } export function runCodeQueuePromptLintContract(): JsonRecord { const readOnly = asRecord(codexPromptLiveAuthorizationLintForTest(readOnlyPrompt)); assertLintShape(readOnly); assertCondition(readOnly.ok === true, "well-formed read-only prompt should pass", readOnly); assertCondition(readOnly.declaredClass === "read-only", "read-only prompt should declare read-only", readOnly); assertCondition(readOnly.effectiveClass === "read-only", "read-only effective class mismatch", readOnly); assertCondition(readOnly.requiredClass === "read-only", "read-only required class mismatch", readOnly); assertCondition(readOnly.dispatchDisposition === "ready", "read-only prompt should be dispatch-ready", readOnly); const liveRead = asRecord(codexPromptLiveAuthorizationLintForTest(liveReadPrompt)); assertLintShape(liveRead); assertCondition(liveRead.ok === true, "well-formed live-read prompt should pass", liveRead); assertCondition(liveRead.declaredClass === "live-read", "live-read prompt should declare live-read", liveRead); assertCondition(liveRead.requiredClass === "live-read", "live-read required class mismatch", liveRead); assertCondition(liveRead.dispatchDisposition === "ready", "live-read prompt should be dispatch-ready", liveRead); const unclassifiedM3 = asRecord(codexPromptLiveAuthorizationLintForTest(unclassifiedM3SmokePrompt)); assertLintShape(unclassifiedM3); assertCondition(unclassifiedM3.ok === false, "unclassified M3 smoke should fail lint", unclassifiedM3); assertCondition(unclassifiedM3.declaredClass === null, "unclassified prompt should have no declared class", unclassifiedM3); assertCondition(unclassifiedM3.effectiveClass === "read-only", "unclassified prompt should default to read-only", unclassifiedM3); assertCondition(unclassifiedM3.requiredClass === "live-mutating", "M3 smoke should require live-mutating", unclassifiedM3); assertCondition(unclassifiedM3.dispatchDisposition === "needs-authorization", "unclassified live mutation should need authorization", unclassifiedM3); assertCondition(stringArray(unclassifiedM3.missingOrContradictory).some((item) => item.includes("missing DEV test class")), "unclassified prompt should report missing class", unclassifiedM3); const liveMutating = asRecord(codexPromptLiveAuthorizationLintForTest(liveMutatingPrompt)); assertLintShape(liveMutating); assertCondition(liveMutating.ok === true, "well-formed live-mutating prompt should pass", liveMutating); assertCondition(liveMutating.declaredClass === "live-mutating", "live-mutating prompt should declare live-mutating", liveMutating); assertCondition(liveMutating.requiredClass === "live-mutating", "live-mutating required class mismatch", liveMutating); assertCondition(liveMutating.liveMutationAuthorized === true, "live-mutating prompt should be authorized when allowed mutation is enumerated", liveMutating); const secretBearing = asRecord(codexPromptLiveAuthorizationLintForTest(secretBearingPrompt)); assertLintShape(secretBearing); assertCondition(secretBearing.requiredClass === "live-mutating", "secret-bearing live mutation should still classify", secretBearing); assertCondition(!JSON.stringify(secretBearing).includes("ds-secret-must-not-print"), "prompt lint evidence must redact secret-looking values", secretBearing); const tmp = mkdtempSync(join(tmpdir(), "unidesk-code-queue-prompt-lint-")); const promptFile = join(tmp, "prompt.md"); writeFileSync(promptFile, liveMutatingPrompt, "utf8"); try { const cliLint = runCli(["codex", "prompt-lint", "--prompt-file", promptFile]); assertCondition(cliLint.status === 0 && cliLint.json?.ok === true, "prompt-lint CLI should succeed for authorized live-mutating prompt", cliLint.json ?? { stdout: cliLint.stdout }); const lintData = nestedRecord(cliLint.json?.data, []); assertCondition(lintData.dryRun === true && lintData.mutation === false, "prompt-lint CLI should be dry-run and non-mutating", lintData); assertCondition(lintData.declaredClass === "live-mutating", "prompt-lint CLI should classify live-mutating", lintData); assertCondition(!JSON.stringify(lintData).includes("run exactly one DEV HWLAB M3 operation"), "prompt-lint CLI should not echo full prompt text", lintData); } finally { rmSync(tmp, { recursive: true, force: true }); } const submitDryRun = runCli(["codex", "submit", "--prompt-stdin", "--dry-run"], unclassifiedM3SmokePrompt); assertLegacyFrozenWrite(submitDryRun, "codex submit"); assertCondition(!submitDryRun.stdout.includes("res_boxsimu_1"), "frozen submit must not echo prompt text", { stdout: submitDryRun.stdout }); const steerDryRun = runCli(["codex", "steer", "codex_test_task", "--prompt-stdin", "--dry-run"], unclassifiedM3SmokePrompt); assertLegacyFrozenWrite(steerDryRun, "codex steer"); assertCondition(!steerDryRun.stdout.includes("res_boxsimu_1"), "frozen steer must not echo prompt text", { stdout: steerDryRun.stdout }); const help = runCli(["codex", "help"]); assertCondition(help.status === 0 && help.json?.ok === true, "codex help should succeed", help.json ?? { stdout: help.stdout }); const helpData = nestedRecord(help.json?.data, []); const usage = stringArray(helpData.usage); assertCondition(usage.some((line) => line.includes("codex prompt-lint")), "codex help should list prompt-lint", helpData); const authorizationHelp = nestedRecord(helpData, ["promptLiveAuthorization"]); assertCondition(stringArray(authorizationHelp.classes).includes("live-mutating"), "help should document live-mutating class", authorizationHelp); assertCondition(authorizationHelp.defaultWhenMissing === "read-only", "help should document read-only default", authorizationHelp); return { ok: true, checks: [ "prompt-lint classifies read-only/live-read/live-mutating prompts", "unclassified HWLAB M3 smoke defaults read-only but requires live-mutating authorization", "prompt-lint evidence redacts secret-looking values", "prompt-lint CLI is dry-run, non-mutating, and does not echo full prompt text", "legacy submit --dry-run is frozen and points to AgentRun", "legacy steer --dry-run is frozen and points to AgentRun", "codex help documents prompt-lint and authorization classes", ], }; } if (import.meta.main) { process.stdout.write(`${JSON.stringify(runCodeQueuePromptLintContract(), null, 2)}\n`); }