fix(code-queue): harden codex submit prompt input

Host commander merge after read-only audit and post-#72 mergeability refresh. PR #71 is CLEAN/MERGEABLE and limited to UniDesk Code Queue CLI prompt input hardening, help/check docs, and submit prompt contract test. It does not touch HWLAB business code.
This commit is contained in:
Lyon
2026-05-23 00:36:45 +08:00
committed by GitHub
parent 2a8e9a5cd3
commit 77b577a2cc
6 changed files with 184 additions and 4 deletions
+37 -1
View File
@@ -30,11 +30,47 @@ const commandName = displayCommandName(args);
function displayCommandName(parts: string[]): string {
if (parts.length === 0) return "help";
if (parts[0] === "codex" && (parts[1] === "submit" || parts[1] === "enqueue")) {
const shown = ["codex", parts[1]];
const shownValueOptions = new Set([
"--prompt-file",
"--file",
"--queue",
"--queue-id",
"--provider",
"--provider-id",
"--cwd",
"--workdir",
"--model",
"--reasoning-effort",
"--execution-mode",
"--mode",
"--max-attempts",
"--reference-task-id",
"--reference",
"--ref",
]);
const hasPromptFile = parts.includes("--prompt-file") || parts.includes("--file");
const hasPromptStdin = parts.includes("--prompt-stdin") || parts.includes("--stdin");
const hasHelp = parts.slice(2).some(isHelpToken);
if (!hasPromptFile && !hasPromptStdin && !hasHelp) shown.push("<prompt:redacted>");
for (let index = 2; index < parts.length; index += 1) {
const part = parts[index] ?? "";
if (!part.startsWith("--")) continue;
shown.push(part);
if (shownValueOptions.has(part)) {
shown.push(parts[index + 1] ?? "<missing>");
index += 1;
}
}
return shown.join(" ");
}
if (parts[0] === "codex" && parts[1] === "steer" && parts[2] !== undefined) {
const shown = ["codex", "steer", parts[2]];
const hasPromptFile = parts.includes("--prompt-file") || parts.includes("--file");
const hasPromptStdin = parts.includes("--prompt-stdin") || parts.includes("--stdin");
if (!hasPromptFile && !hasPromptStdin) shown.push("<prompt:redacted>");
const hasHelp = parts.slice(3).some(isHelpToken);
if (!hasPromptFile && !hasPromptStdin && !hasHelp) shown.push("<prompt:redacted>");
for (let index = 3; index < parts.length; index += 1) {
const part = parts[index] ?? "";
if (!part.startsWith("--")) continue;
@@ -0,0 +1,123 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
type JsonRecord = Record<string, unknown>;
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
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,
};
}
function nestedRecord(value: unknown, path: string[]): JsonRecord {
let current: unknown = value;
for (const key of path) {
assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected object while traversing JSON", { path, key, current });
current = (current as JsonRecord)[key];
}
assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected nested object", { path, current });
return current as JsonRecord;
}
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item)) : [];
}
function assertDryRunPrompt(response: JsonRecord, expectedText: string): void {
assertCondition(response.ok === true, "submit dry-run should succeed", response);
const data = nestedRecord(response.data, []);
assertCondition(data.dryRun === true, "submit dry-run should expose dryRun=true", data);
const request = nestedRecord(response.data, ["request"]);
const prompt = nestedRecord(request, ["prompt"]);
assertCondition(prompt.text === expectedText, "submit dry-run prompt text mismatch", prompt);
assertCondition(prompt.chars === expectedText.length, "submit dry-run prompt char count mismatch", prompt);
assertCondition(prompt.truncated === false, "submit dry-run prompt must expose the full prompt", prompt);
}
export function runCodeQueueCliSubmitPromptContract(): JsonRecord {
const multilinePrompt = [
"Goal: verify stdin prompt path",
"JSON: {\"quote\":\"'single' and \\\"double\\\"\"}",
"Markdown: `code` | table | value",
"Backslash: C:\\tmp\\prompt",
"",
].join("\n");
const stdin = runCli(["codex", "submit", "--prompt-stdin", "--queue", "prompt-contract", "--dry-run"], multilinePrompt);
assertDryRunPrompt(stdin.json ?? {}, multilinePrompt);
assertCondition(String(stdin.json?.command || "") === "codex submit --prompt-stdin --queue prompt-contract --dry-run", "stdin command should list flags without echoing prompt", stdin.json ?? {});
const tmp = mkdtempSync(join(tmpdir(), "unidesk-code-queue-submit-"));
const promptFile = join(tmp, "prompt.md");
const filePrompt = `${multilinePrompt}file prompt tail\n`;
writeFileSync(promptFile, filePrompt, "utf8");
try {
const fromFile = runCli(["codex", "submit", "--prompt-file", promptFile, "--queue", "prompt-contract", "--dry-run"]);
assertDryRunPrompt(fromFile.json ?? {}, filePrompt);
assertCondition(String(fromFile.json?.command || "").includes(`--prompt-file ${promptFile}`), "prompt-file command should retain file path for review", fromFile.json ?? {});
assertCondition(!String(fromFile.json?.command || "").includes("file prompt tail"), "prompt-file command must not echo file prompt text", fromFile.json ?? {});
} finally {
rmSync(tmp, { recursive: true, force: true });
}
const positional = runCli(["codex", "submit", "short smoke prompt", "--dry-run"]);
assertDryRunPrompt(positional.json ?? {}, "short smoke prompt");
assertCondition(String(positional.json?.command || "").includes("<prompt:redacted>"), "outer command should redact positional submit prompt", positional.json ?? {});
assertCondition(!String(positional.json?.command || "").includes("short smoke prompt"), "outer command must not echo positional submit prompt", positional.json ?? {});
const duplicateSource = runCli(["codex", "submit", "positional", "--prompt-stdin", "--dry-run"], "stdin\n");
assertCondition(duplicateSource.status !== 0, "duplicate prompt source should fail", duplicateSource.json ?? { stdout: duplicateSource.stdout });
const duplicateMessage = String(nestedRecord(duplicateSource.json, ["error"]).message || "");
assertCondition(duplicateMessage.includes("exactly one prompt source"), "duplicate prompt source error should be explicit", { duplicateMessage });
const help = runCli(["codex", "submit", "--help"]);
assertCondition(help.status === 0 && help.json?.ok === true, "codex submit help should succeed", help.json ?? { stdout: help.stdout });
const data = nestedRecord(help.json?.data, []);
const usage = stringArray(data.usage);
const promptInput = nestedRecord(data, ["promptInput"]);
const recommended = stringArray(promptInput.recommended);
const examples = nestedRecord(data, ["examples"]);
assertCondition(usage.some((line) => line.includes("--prompt-stdin")), "help usage should include --prompt-stdin", { usage });
assertCondition(usage.some((line) => line.includes("--prompt-file")), "help usage should include --prompt-file", { usage });
assertCondition(usage.some((line) => line.includes("cat <<'PROMPT'")), "help usage should include a quoted heredoc example", { usage });
assertCondition(recommended.includes("--prompt-stdin") && recommended.includes("--prompt-file"), "help should recommend stdin and file prompt sources", promptInput);
assertCondition(String(promptInput.sourceRule || "").includes("Exactly one prompt source"), "help should document exact prompt source rule", promptInput);
assertCondition(stringArray(examples.stdin).some((line) => line.includes("--prompt-stdin")), "help examples should include stdin command", examples);
assertCondition(String(examples.file || "").includes("--prompt-file"), "help examples should include file command", examples);
return {
ok: true,
checks: [
"submit --prompt-stdin preserves multiline quotes and newlines",
"submit --prompt-file preserves reviewed file contents",
"submit positional prompt is redacted from the outer command envelope",
"duplicate submit prompt source fails explicitly",
"codex submit help documents stdin/file recommendations and copyable examples",
],
};
}
if (import.meta.main) {
process.stdout.write(`${JSON.stringify(runCodeQueueCliSubmitPromptContract(), null, 2)}\n`);
}
+3
View File
@@ -30,6 +30,7 @@ const syntaxFiles = [
"scripts/host-codex-commander-no-daemon-smoke-contract-test.ts",
"scripts/host-codex-commander-skeleton-contract-test.ts",
"scripts/auth-broker-contract-test.ts",
"scripts/code-queue-cli-submit-prompt-contract-test.ts",
"scripts/code-queue-supervisor-disclosure-contract-test.ts",
"src/components/frontend/src/index.ts",
"src/components/frontend/src/app.tsx",
@@ -341,6 +342,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
items.push(commandItem("code-queue:trace-summary-contract", ["bun", "scripts/code-queue-trace-summary-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:runner-skills-contract", ["bun", "scripts/code-queue-runner-skills-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:submit-prompt-contract", ["bun", "scripts/code-queue-cli-submit-prompt-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000));
items.push(commandItem("code-queue:supervisor-disclosure-contract", ["bun", "scripts/code-queue-supervisor-disclosure-contract-test.ts"], 30_000));
items.push(commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000));
@@ -367,6 +369,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
items.push(skippedItem("code-queue:trace-summary-contract", "Code Queue trace summary contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:pr-preflight-contract", "Code Queue PR preflight contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:runner-skills-contract", "Code Queue runner skill availability contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:submit-prompt-contract", "Code Queue submit prompt contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:submit-routing-contract", "Code Queue submit routing contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("code-queue:supervisor-disclosure-contract", "Code Queue supervisor disclosure contract is opt-in with script checks", "--scripts-typecheck or --full"));
items.push(skippedItem("host-codex-commander:skeleton-contract", "host Codex commander skeleton contract is opt-in with script checks", "--scripts-typecheck or --full"));
+19 -1
View File
@@ -248,7 +248,9 @@ function codexHelp(): unknown {
output: "json",
usage: [
"bun scripts/cli.ts codex deploy <commitId> # disabled legacy deployment entry",
"bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue id] [--dry-run]",
"bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue id] [--model model] [--dry-run]",
"cat <<'PROMPT' | bun scripts/cli.ts codex submit --prompt-stdin --queue <id> --dry-run",
"bun scripts/cli.ts codex submit --prompt-file /tmp/code-queue-prompt.md --queue <id> --dry-run",
"bun scripts/cli.ts codex task <taskId> [--detail] [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]",
"bun scripts/cli.ts codex tasks [--view supervisor|full] [--queue id] [--status succeeded,running] [--unread|--unread-only] [--limit N] [--before-id id]",
"bun scripts/cli.ts codex output <taskId> [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]",
@@ -260,6 +262,22 @@ function codexHelp(): unknown {
"bun scripts/cli.ts codex interrupt|cancel <taskId>",
"bun scripts/cli.ts codex queues [--full|--all] | queue create <queueId> | queue merge <sourceQueueId> --into <targetQueueId> | move <taskId> --queue <queueId>",
],
promptInput: {
recommended: ["--prompt-stdin", "--prompt-file"],
stdin: "Use a quoted heredoc or pipe when the prompt contains newlines, quotes, backticks, JSON, Markdown tables, or shell-sensitive text.",
file: "Use --prompt-file for reviewed or reusable dispatch prompts; --file is an alias.",
positional: "Keep positional prompt usage to short one-line smoke prompts only.",
sourceRule: "Exactly one prompt source is accepted: positional prompt, --prompt-file, or --prompt-stdin.",
},
examples: {
stdin: [
"cat <<'PROMPT' | bun scripts/cli.ts codex submit --prompt-stdin --queue <id> --dry-run",
"<multi-line prompt body>",
"PROMPT",
],
file: "bun scripts/cli.ts codex submit --prompt-file /tmp/code-queue-prompt.md --queue <id> --dry-run",
dryRunThenSubmit: "Run with --dry-run first; remove --dry-run to submit exactly the same payload.",
},
description: "Operate Code Queue through the stable backend-core private proxy path.",
};
}