From 94577a2a942b636f8ca2e7f30c11b4d7fc08e0df Mon Sep 17 00:00:00 2001 From: drvoss Date: Fri, 21 Aug 2026 13:05:29 +0900 Subject: [PATCH 1/2] fix: repair quad CLI transport layer --- .../skills/quad-cli-consensus-gate.md | 52 +- scripts/quad-cli-orchestrate.mjs | 271 ++------ scripts/quad-cli-transport.mjs | 633 ++++++++++++++++++ tests/quad-cli-orchestrate.test.js | 9 + tests/quad-cli-transport.test.js | 318 +++++++++ 5 files changed, 1065 insertions(+), 218 deletions(-) create mode 100644 scripts/quad-cli-transport.mjs create mode 100644 tests/quad-cli-transport.test.js diff --git a/orchestration/skills/quad-cli-consensus-gate.md b/orchestration/skills/quad-cli-consensus-gate.md index 3f300a1..8faf173 100644 --- a/orchestration/skills/quad-cli-consensus-gate.md +++ b/orchestration/skills/quad-cli-consensus-gate.md @@ -60,23 +60,39 @@ Hunk anchoring fixes both: any line inside the same changed hunk maps to the sam ## Invocation Contract -`scripts/quad-cli-orchestrate.mjs` uses these non-interactive commands. The diff-bearing prompt is sent over **stdin**, not argv — see "Why stdin, not argv" below. +`scripts/quad-cli-orchestrate.mjs` resolves each command on `PATH` before spawning it. On Windows, launcher priority is `.exe` → `.cmd` → `.bat` → `.ps1`; command shims run through an explicit `cmd.exe` or PowerShell launcher, never `shell: true`. -| Tool | Non-interactive invocation | Notes | -|---|---|---| -| `claude` | `claude -p` (prompt on stdin) | Orchestrator/primary reasoning | -| `codex` | `codex exec --skip-git-repo-check` (prompt on stdin) | Codex's `exec` subcommand reads and reports a `` input block when piped; this is expected, not an error | -| `cursor-agent` | `cursor-agent -f -p` (prompt on stdin) | `-f` (trust) is required or it exits with "Workspace Trust Required"; `--force` enables edits and is not needed for this read-only review | -| `agy` | `agy -p` (prompt on stdin, optionally `--sandbox`) | Antigravity CLI, multi-model backend — see "Model Family Voting" below | +| Tool | Non-interactive invocation | Prompt transport | Launcher notes | +|---|---|---|---| +| `claude` | `claude -p` | stdin | native executable or an explicitly resolved command shim | +| `codex` | `codex exec --skip-git-repo-check` | stdin | Do not add `--output-schema`; the report schema has optional finding fields that Codex's schema mode cannot represent | +| `cursor-agent` | `cursor-agent -f -p` | stdin | `-f` accepts workspace trust; `--force` is not needed for this read-only review | +| `agy` | `agy -p --mode plan --sandbox --print-timeout ` | argv for a native executable below 24,000 rendered UTF-16 units; otherwise an isolated file reference | A `.cmd`/`.ps1` shim always uses file-reference transport. Do not add `--json-schema`; default output is the compatible mode | + +Launcher resolution produces one of `native-exe`, `cmd-shim`, `ps1-shim`, or `not-found`. Native executables run directly. Command shims run as `cmd.exe /d /s /c `; PowerShell shims run as `powershell.exe -NoProfile -ExecutionPolicy Bypass -File `. Missing commands are reported as `unavailable` without a retry. + +### Why transport differs by tool -Each CLI is spawned directly with `child_process.spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] })` (no shell), and the prompt is written to `child.stdin` and closed with `.end()`. Verify stdin-prompt support for your installed CLI versions with `--help`; if a given CLI does not support stdin prompts, add a file-based (`--file`/`@path`) fallback for that tool rather than reverting to argv. +Claude, Codex, and Cursor Agent accept the diff-bearing prompt on stdin. Antigravity's `-p` flag requires the prompt as its value and does not consume the prompt from stdin. +A native Antigravity executable therefore uses argv only below 24,000 rendered UTF-16 code units. This is intentionally below Windows' command-line ceiling; an unexpected `ENAMETOOLONG` immediately falls back once to file-reference transport rather than retrying the same invocation. -### Why stdin, not argv +File-reference transport creates a dedicated temporary directory containing only one prompt file. The bootstrap includes that file's absolute path, UTF-8 byte count, and SHA-256 and grants Antigravity access only to that directory. +Cleanup runs on success, failure, retry, and timeout; cleanup failures are reported rather than hidden. The prompt file is never placed in the repository and the system temp root is never granted wholesale. -Passing the diff-bearing prompt as a single argv element is bounded by the OS argv limit (~32K characters on Windows) — and **`--max-lines` does not protect against this**, because it caps the changed-line *count*, not the prompt's *byte size*. A diff well under the line cap can still contain very long lines and blow past the argv limit. The orchestrator therefore: +`--max-prompt-bytes` (default `262144`, or 256KB) remains the defensive payload cap. It bounds actual UTF-8 prompt bytes, while the Antigravity argv decision separately measures the fully serialized Windows command line in UTF-16 units. -1. Sends the prompt over stdin (no OS argv limit). -2. Additionally enforces `--max-prompt-bytes` (default `262144`, i.e. 256KB) as a defensive upper bound — if exceeded, the gate is skipped with a clear stderr warning and exit `0`, the same as the existing size caps. +### Verified scope + +The transport behavior was verified on Windows on 2026-08-20, not on macOS or Linux. + +| Host | Node | CLI inventory used for the measurement | +|---|---|---| +| Four-CLI measurement host | `v26.4.0` | Claude `2.1.237`, Codex `0.146.1`, Antigravity `1.1.15`, Cursor Agent current as of 2026-08-20 | +| npm-shim regression host | `v22.23.1` | Codex `0.148.0`; Claude, Cursor Agent, and Antigravity absent | + +Large Antigravity prompts use file-reference transport and can take substantially longer; the observed maximum was 219 seconds. These measurements are compatibility evidence, not a latency SLA. + +> **Release note:** restoring additional valid reviewers can increase the number of model-family pairs and therefore produce more BLOCKING findings; the repaired gate can be stricter than the degraded two-reviewer behavior. ## Model Family Voting (B2) @@ -138,11 +154,15 @@ If any cap is exceeded, the gate is **skipped with a warning on stderr** and exi | `--advisory-only` | Never fail the gate outright for below-minimum reviewers; still exits `3` on total (zero-reviewer) outage unless `--allow-zero-reviewers` is also set | | `--min-reviewers ` | Minimum valid reviewers required to trust consensus; default `2`. Below this, the report is marked `status: "advisory-degraded"` | | `--allow-zero-reviewers` | Allow a run with **zero** valid reviewers to exit `0` (marked `status: "no-reviewers"`) instead of exit `3`. Without this flag, zero reviewers always exits `3`, even under `--advisory-only` | -| `--timeout ` | Per-runner timeout; default `120000`. On timeout the runner's entire process tree is terminated, not just the direct child | +| `--timeout ` | Override every per-runner timeout. Defaults are Claude `120000`, Codex `240000`, Cursor Agent `240000`, and Antigravity `360000`; `QUAD_CLI_TIMEOUT__MS` or `QUAD_CLI_TIMEOUT_MS` can override them without code changes | +| `--gate-timeout ` | Whole parallel gate deadline including one transient retry; default `725000`, also configurable with `QUAD_CLI_GATE_TIMEOUT_MS` | | `--diff-file ` | Test-only convenience flag to read a unified diff from a file instead of git | Mock/test execution is controlled solely by the `QUAD_CLI_MOCK_DIR` environment variable (see Usage Examples) — there is no separate test-mode flag to keep in sync with it. +Timeout cleanup targets the runner's process tree (`taskkill /T /F` on Windows and a detached process group on POSIX). This is best-effort: PID reuse and process-snapshot races mean the orchestrator records what it attempted but does not claim that every descendant was certainly terminated. +Antigravity's internal `--print-timeout` stays shorter than its outer timeout (five minutes under the default six-minute outer timeout), so diagnostics can distinguish which layer stopped the run. + ## Structured Output Contract Each raw reviewer must emit JSON matching `schemas/quad-cli-report.json`: @@ -164,6 +184,12 @@ Each raw reviewer must emit JSON matching `schemas/quad-cli-report.json`: } ``` +The parser accepts one complete JSON value, one complete outer JSON Markdown fence, or a measured successful Cursor Agent/Antigravity envelope. It never guesses by slicing from the first `{` to the last `}`; narration containing a schema-valid fake report is rejected. +Unknown top-level metadata is ignored and reported, while every finding remains strict—one malformed finding invalidates that reviewer's entire report. + +Each runner emits exactly one bounded stderr diagnostic with its resolved launcher, status, failure class, exit code, elapsed time, rendered command-line units, output bytes, and a sanitized reason. Only observed transient network failures receive one retry. +Missing launchers, invalid invocations, authentication rejection, timeouts, and invalid/schema-invalid responses do not retry; Antigravity `ENAMETOOLONG` is a transport fallback rather than a retry. + The merged orchestrator output extends each finding with: - `blocking: true | false` — `true` only if `effective_votes >= 2` **and** the finding is anchored to a real changed hunk diff --git a/scripts/quad-cli-orchestrate.mjs b/scripts/quad-cli-orchestrate.mjs index 1f1f007..a3e1c32 100644 --- a/scripts/quad-cli-orchestrate.mjs +++ b/scripts/quad-cli-orchestrate.mjs @@ -1,7 +1,14 @@ -import { execFileSync, spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + buildInvocation, + formatRunnerDiagnostic, + parseJsonReport, + resolveGateTimeout, + resolveToolTimeout, + runRunnerWithRetry, +} from "./quad-cli-transport.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); @@ -72,14 +79,36 @@ export async function main() { const hunkIndex = buildHunkIndex(diffResult.body); - const runnerResults = await Promise.allSettled( - TOOL_ORDER.map((tool) => runRunnerWithRetry(tool, prompt, options.timeout, options.mockDir)) - ); + const deadlineController = new AbortController(); + const deadlineTimer = setTimeout(() => deadlineController.abort(), options.gateTimeout); + let runnerResults; + try { + runnerResults = await Promise.allSettled( + TOOL_ORDER.map((tool) => + runRunnerWithRetry(tool, prompt, resolveToolTimeout(tool, options.timeout), options.mockDir, { + signal: deadlineController.signal, + cwd: ROOT, + }) + ) + ); + } finally { + clearTimeout(deadlineTimer); + } const parsedResults = runnerResults.map((settled, index) => { const tool = TOOL_ORDER[index]; if (settled.status !== "fulfilled") { - return { tool, status: "error", error: settled.reason?.message ?? "Unknown runner failure" }; + return { + tool, + status: "error", + class: "internal", + error: settled.reason?.message ?? "Unknown runner failure", + exitCode: null, + ms: 0, + launcherKind: "-", + units: 0, + bytesOut: 0, + }; } return settled.value; }); @@ -93,18 +122,27 @@ export async function main() { continue; } - const parsed = parseJsonReport(result.stdout); + const parsed = parseJsonReport(result.stdout, result.tool); if (!parsed.ok) { + result.status = "error"; + result.class = "invalid-response"; + result.reason = parsed.reason ?? "Reviewer output was not valid JSON"; erroredReviewers += 1; continue; } const validation = validateRawReport(parsed.value); if (!validation.ok) { + result.status = "error"; + result.class = "schema-invalid"; + result.reason = validation.reason ?? "Reviewer output failed schema validation"; erroredReviewers += 1; continue; } + result.findingsCount = parsed.value.findings.length; + result.ignoredTopLevelKeys = validation.ignoredTopLevelKeys; + validReports.push({ tool: result.tool, findings: parsed.value.findings.map((finding) => ({ @@ -114,6 +152,10 @@ export async function main() { }); } + for (const result of parsedResults) { + console.error(formatRunnerDiagnostic(result)); + } + const mergedFindings = mergeFindings( validReports.flatMap((report) => report.findings), ruleAliases, @@ -192,7 +234,8 @@ function parseArgs(argv) { maxFiles: 60, maxPromptBytes: DEFAULT_MAX_PROMPT_BYTES, advisoryOnly: false, - timeout: 120000, + timeout: null, + gateTimeout: resolveGateTimeout(), diffFile: null, minReviewers: 2, allowZeroReviewers: false, @@ -232,6 +275,12 @@ function parseArgs(argv) { case "--timeout": options.timeout = parsePositiveInteger(requireValue(argv, ++index, "--timeout"), "--timeout"); break; + case "--gate-timeout": + options.gateTimeout = parsePositiveInteger( + requireValue(argv, ++index, "--gate-timeout"), + "--gate-timeout" + ); + break; case "--diff-file": options.diffFile = resolve(requireValue(argv, ++index, "--diff-file")); break; @@ -599,218 +648,30 @@ function buildPrompt(diffBody) { ].join("\n"); } -async function runRunnerWithRetry(tool, prompt, timeout, mockDir) { - let lastResult = null; - - for (let attempt = 1; attempt <= 2; attempt += 1) { - lastResult = mockDir ? await runMockRunner(tool, mockDir) : await spawnRunner(tool, prompt, timeout); - if (lastResult.status === "ok") { - return lastResult; - } - } - - return lastResult ?? { tool, status: "error", error: "Runner did not produce a result" }; -} - -async function runMockRunner(tool, mockDir) { - const filePath = join(mockDir, `${tool}.json`); - if (!existsSync(filePath)) { - return { tool, status: "error", error: `Mock response not found: ${filePath}` }; - } - - return { - tool, - status: "ok", - stdout: readFileSync(filePath, "utf8"), - stderr: "", - }; -} - -function spawnRunner(tool, prompt, timeout) { - const invocation = buildInvocation(tool); - - return new Promise((resolve) => { - let stdout = ""; - let stderr = ""; - let settled = false; - let timedOut = false; - - // Prompt (which embeds the full diff) is sent over stdin rather than argv: argv has - // hard platform limits (~32K chars on Windows) that --max-lines does not bound, since - // it caps changed-line COUNT, not byte size. Only short static flags go in argv. - const child = spawn(invocation.command, invocation.args, { - cwd: ROOT, - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - // detached on POSIX creates a new process group so killProcessTree can signal the - // whole group (including grandchildren) on timeout, not just the direct child. - detached: process.platform !== "win32", - }); - - const timer = setTimeout(() => { - timedOut = true; - killProcessTree(child); - }, timeout); - - child.stdin.on("error", () => { - // Ignore EPIPE/ECONNRESET if the child exits before we finish writing the prompt; - // the close/error handlers below already report the failure. - }); - child.stdin.write(prompt, "utf8"); - child.stdin.end(); - - child.stdout.on("data", (chunk) => { - stdout += chunk.toString(); - }); - - child.stderr.on("data", (chunk) => { - stderr += chunk.toString(); - }); - - child.on("error", (error) => { - finish({ - tool, - status: "error", - error: error.message, - stdout, - stderr, - }); - }); - - child.on("close", (code, signal) => { - if (timedOut) { - finish({ - tool, - status: "error", - error: `Timed out after ${timeout}ms`, - stdout, - stderr, - }); - return; - } - - if (code !== 0) { - finish({ - tool, - status: "error", - error: `Exited with code ${code}${signal ? ` (${signal})` : ""}`, - stdout, - stderr, - }); - return; - } - - finish({ - tool, - status: "ok", - stdout, - stderr, - }); - }); - - function finish(result) { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - resolve(result); - } - }); -} - -// Terminates the runner's entire process tree, not just the direct child, so a timed-out -// CLI cannot leave grandchild processes (or their held resources/pipes) running. -function killProcessTree(child) { - if (process.platform === "win32") { - try { - execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }); - } catch { - try { - child.kill(); - } catch { - // best-effort - } - } - return; - } - - try { - // Negative pid targets the whole process group created by `detached: true` above. - process.kill(-child.pid, "SIGKILL"); - } catch { - try { - child.kill("SIGKILL"); - } catch { - // best-effort - } - } -} - -function buildInvocation(tool) { - switch (tool) { - case "claude": - return { command: "claude", args: ["-p"] }; - case "codex": - return { command: "codex", args: ["exec", "--skip-git-repo-check"] }; - case "cursor-agent": - return { command: "cursor-agent", args: ["-f", "-p"] }; - case "agy": - return { command: "agy", args: ["-p"] }; - default: - throw new Error(`Unsupported tool: ${tool}`); - } -} - -function parseJsonReport(stdout) { - const direct = tryParse(stdout); - if (direct.ok) { - return direct; - } - - const firstBrace = stdout.indexOf("{"); - const lastBrace = stdout.lastIndexOf("}"); - if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { - return tryParse(stdout.slice(firstBrace, lastBrace + 1)); - } - - return { ok: false }; -} - -function tryParse(value) { - try { - return { ok: true, value: JSON.parse(value) }; - } catch { - return { ok: false }; - } -} - function validateRawReport(report) { if (!report || typeof report !== "object" || Array.isArray(report)) { - return { ok: false }; + return { ok: false, reason: "report must be an object" }; } const topLevelKeys = Object.keys(report); - if (!topLevelKeys.every((key) => ALLOWED_TOP_LEVEL_KEYS.has(key))) { - return { ok: false }; - } + const ignoredTopLevelKeys = topLevelKeys.filter((key) => !ALLOWED_TOP_LEVEL_KEYS.has(key)); if (report.schema_version !== "1" || report.generated_by !== "quad-cli-orchestrate") { - return { ok: false }; + return { ok: false, reason: "schema_version or generated_by is invalid", ignoredTopLevelKeys }; } if (!Array.isArray(report.findings)) { - return { ok: false }; + return { ok: false, reason: "findings must be an array", ignoredTopLevelKeys }; } for (const finding of report.findings) { if (!finding || typeof finding !== "object" || Array.isArray(finding)) { - return { ok: false }; + return { ok: false, reason: "each finding must be an object", ignoredTopLevelKeys }; } const findingKeys = Object.keys(finding); if (!findingKeys.every((key) => ALLOWED_FINDING_KEYS.has(key))) { - return { ok: false }; + return { ok: false, reason: "finding contains an unknown key", ignoredTopLevelKeys }; } if ( @@ -819,19 +680,19 @@ function validateRawReport(report) { typeof finding.message !== "string" || !Object.hasOwn(SEVERITY_RANK, finding.severity) ) { - return { ok: false }; + return { ok: false, reason: "finding required fields are invalid", ignoredTopLevelKeys }; } if (Object.hasOwn(finding, "line") && !Number.isInteger(finding.line)) { - return { ok: false }; + return { ok: false, reason: "finding line must be an integer", ignoredTopLevelKeys }; } if (Object.hasOwn(finding, "snippet") && typeof finding.snippet !== "string") { - return { ok: false }; + return { ok: false, reason: "finding snippet must be a string", ignoredTopLevelKeys }; } } - return { ok: true }; + return { ok: true, ignoredTopLevelKeys }; } // Validates the FINAL orchestrator output against schemas/quad-cli-merged-report.json's diff --git a/scripts/quad-cli-transport.mjs b/scripts/quad-cli-transport.mjs new file mode 100644 index 0000000..19835ad --- /dev/null +++ b/scripts/quad-cli-transport.mjs @@ -0,0 +1,633 @@ +import { execFileSync, spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { delimiter, extname, isAbsolute, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +export const TOOL_TIMEOUT_DEFAULTS = Object.freeze({ + claude: 120_000, + codex: 240_000, + "cursor-agent": 240_000, + agy: 360_000, +}); + +// Includes one transient retry plus a short retry delay. These values are based on +// one day of measurements, not an SLA, and remain configurable without code edits. +export const DEFAULT_GATE_TIMEOUT_MS = 725_000; +export const AGY_ARGV_THRESHOLD_UNITS = 24_000; +const RETRY_DELAY_MS = 250; +const WINDOWS_EXTENSION_PRIORITY = [".exe", ".cmd", ".bat", ".ps1"]; +const ANSI_PATTERN = /[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g; + +const ADAPTERS = Object.freeze({ + claude: { command: "claude", args: ["-p"], channel: "stdin" }, + codex: { command: "codex", args: ["exec", "--skip-git-repo-check"], channel: "stdin" }, + "cursor-agent": { command: "cursor-agent", args: ["-f", "-p"], channel: "stdin" }, + agy: { command: "agy", args: [], channel: "agy" }, +}); + +function fileKind(path, platform) { + const extension = extname(path).toLowerCase(); + if (platform === "win32") { + if (extension === ".cmd" || extension === ".bat") return "cmd-shim"; + if (extension === ".ps1") return "ps1-shim"; + } + return "native-exe"; +} + +function safeStat(path, stat) { + try { + const value = stat(path); + return value.isFile() ? value : null; + } catch { + return null; + } +} + +export function resolveLauncher(command, options = {}) { + const platform = options.platform ?? process.platform; + const stat = options.statSync ?? statSync; + const pathDirs = options.pathDirs ?? String(options.pathValue ?? process.env.PATH ?? "").split(platform === "win32" ? ";" : delimiter); + + if (isAbsolute(command) || command.includes("/") || command.includes("\\")) { + const resolvedPath = resolve(command); + const info = safeStat(resolvedPath, stat); + if (!info) return { kind: "not-found", resolvedPath: null }; + if (platform !== "win32" && (info.mode & 0o111) === 0) return { kind: "not-found", resolvedPath: null }; + return { kind: fileKind(resolvedPath, platform), resolvedPath }; + } + + if (platform === "win32") { + const baseExtension = extname(command).toLowerCase(); + const extensions = baseExtension ? [""] : WINDOWS_EXTENSION_PRIORITY; + for (const extension of extensions) { + for (const pathDir of pathDirs.filter(Boolean)) { + const candidate = resolve(pathDir, command + extension); + if (safeStat(candidate, stat)) { + return { kind: fileKind(candidate, platform), resolvedPath: candidate }; + } + } + } + return { kind: "not-found", resolvedPath: null }; + } + + for (const pathDir of pathDirs.filter(Boolean)) { + const candidate = resolve(pathDir, command); + const info = safeStat(candidate, stat); + if (info && (info.mode & 0o111) !== 0) return { kind: "native-exe", resolvedPath: candidate }; + } + return { kind: "not-found", resolvedPath: null }; +} + +export function quoteWindowsArg(value) { + const arg = String(value); + if (arg.length > 0 && !/[\s"]/u.test(arg)) return arg; + return `"${arg.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/g, "$1$1")}"`; +} + +export function renderWindowsCommandLine(command, args) { + return [command, ...args].map(quoteWindowsArg).join(" "); +} + +export function commandLineUtf16Units(command, args, platform = process.platform) { + const rendered = platform === "win32" ? renderWindowsCommandLine(command, args) : [command, ...args].join(" "); + return rendered.length; +} + +function quoteCmdToken(value) { + return `"${String(value).replace(/"/g, '""')}"`; +} + +function serializeCmdShimCommand(resolvedPath, args) { + // cmd.exe /s /c requires an outer quote pair around a command whose executable + // token is itself quoted. The resulting shape is: ""C:\path with space\x.cmd" "arg"". + return `"${[resolvedPath, ...args].map(quoteCmdToken).join(" ")}"`; +} + +export function buildSpawnSpec(launcher, args, platform = process.platform) { + if (launcher.kind === "native-exe") { + return { command: launcher.resolvedPath, args: [...args] }; + } + if (launcher.kind === "cmd-shim") { + // /s /c reparses everything after /c as one command string. Serialize that string + // in one place so a shim path containing spaces is still the executable token. + return { + command: "cmd.exe", + args: ["/d", "/s", "/c", serializeCmdShimCommand(launcher.resolvedPath, args)], + windowsVerbatimArguments: true, + }; + } + if (launcher.kind === "ps1-shim") { + return { + command: "powershell.exe", + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", launcher.resolvedPath, ...args], + }; + } + return { command: null, args: [] }; +} + +function parsePositiveEnvironment(value, name) { + if (value === undefined || value === "") return null; + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`); + return parsed; +} + +export function resolveToolTimeout(tool, cliOverride = null, env = process.env) { + if (!Object.hasOwn(TOOL_TIMEOUT_DEFAULTS, tool)) throw new Error(`Unsupported tool: ${tool}`); + if (cliOverride !== null) return cliOverride; + const specificName = `QUAD_CLI_TIMEOUT_${tool.toUpperCase().replace(/-/g, "_")}_MS`; + return ( + parsePositiveEnvironment(env[specificName], specificName) ?? + parsePositiveEnvironment(env.QUAD_CLI_TIMEOUT_MS, "QUAD_CLI_TIMEOUT_MS") ?? + TOOL_TIMEOUT_DEFAULTS[tool] + ); +} + +export function resolveGateTimeout(cliOverride = null, env = process.env) { + if (cliOverride !== null) return cliOverride; + return parsePositiveEnvironment(env.QUAD_CLI_GATE_TIMEOUT_MS, "QUAD_CLI_GATE_TIMEOUT_MS") ?? DEFAULT_GATE_TIMEOUT_MS; +} + +function formatAgyInternalTimeout(externalTimeoutMs) { + const internalMs = Math.max(1_000, externalTimeoutMs - 60_000); + if (internalMs % 60_000 === 0) return `${internalMs / 60_000}m`; + return `${Math.floor(internalMs / 1_000)}s`; +} + +function createPromptReference(prompt, options = {}) { + const tempRoot = options.tempRoot ?? tmpdir(); + const tempDir = mkdtempSync(join(tempRoot, "quad-cli-agy-")); + const promptPath = join(tempDir, "prompt.txt"); + try { + writeFileSync(promptPath, prompt, "utf8"); + } catch (error) { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch (cleanupError) { + error.message += `; temporary directory cleanup also failed: ${cleanupError.message}`; + } + throw error; + } + const bytes = Buffer.byteLength(prompt, "utf8"); + const digest = createHash("sha256").update(prompt).digest("hex"); + const bootstrap = [ + `Read the ENTIRE file ${promptPath} as your first action, then follow the instructions inside it exactly.`, + `The file is ${bytes} bytes and its SHA-256 is ${digest}.`, + "Do not summarize it; read all of it. Output only what it asks for.", + ].join("\n"); + return { + tempDir, + promptPath, + bootstrap, + cleanup() { + rmSync(tempDir, { recursive: true, force: true }); + }, + }; +} + +function adapterDefinition(tool, runtime = {}) { + const adapter = ADAPTERS[tool]; + if (!adapter) throw new Error(`Unsupported tool: ${tool}`); + const override = runtime.adapterOverrides?.[tool]; + return { + ...adapter, + command: override?.command ?? adapter.command, + prefixArgs: override?.prefixArgs ?? [], + }; +} + +export function buildInvocation(tool, prompt = "", options = {}) { + const platform = options.platform ?? process.platform; + const timeout = options.timeout ?? TOOL_TIMEOUT_DEFAULTS[tool]; + const adapter = adapterDefinition(tool, options.runtime); + const launcher = options.launcher ?? resolveLauncher(adapter.command, { platform, ...options.resolveOptions }); + if (launcher.kind === "not-found") { + return { tool, launcher, command: null, args: [], input: "", transport: "unavailable", units: 0, cleanup: null }; + } + + let args; + let input = ""; + let transport; + let promptReference = null; + if (adapter.channel === "stdin") { + args = [...adapter.prefixArgs, ...adapter.args]; + input = prompt; + transport = "stdin"; + } else { + const argvArgs = [ + ...adapter.prefixArgs, + "-p", + prompt, + "--mode", + "plan", + "--sandbox", + "--print-timeout", + formatAgyInternalTimeout(timeout), + ]; + const argvSpec = buildSpawnSpec(launcher, argvArgs, platform); + const argvUnits = commandLineUtf16Units(argvSpec.command, argvSpec.args, platform); + const useFileReference = options.forceFileReference || launcher.kind !== "native-exe" || argvUnits >= AGY_ARGV_THRESHOLD_UNITS; + if (useFileReference) { + promptReference = createPromptReference(prompt, options.runtime); + args = [ + ...adapter.prefixArgs, + "-p", + promptReference.bootstrap, + "--add-dir", + promptReference.tempDir, + "--mode", + "plan", + "--sandbox", + "--print-timeout", + formatAgyInternalTimeout(timeout), + ]; + transport = "file-reference"; + } else { + args = argvArgs; + transport = "argv"; + } + } + + const spawnSpec = buildSpawnSpec(launcher, args, platform); + return { + tool, + launcher, + ...spawnSpec, + input, + transport, + units: spawnSpec.windowsVerbatimArguments + ? [spawnSpec.command, ...spawnSpec.args].join(" ").length + : commandLineUtf16Units(spawnSpec.command, spawnSpec.args, platform), + windowsVerbatimArguments: Boolean(spawnSpec.windowsVerbatimArguments), + tempDir: promptReference?.tempDir ?? null, + promptPath: promptReference?.promptPath ?? null, + cleanup: promptReference?.cleanup ?? null, + }; +} + +export function killProcessTree(child, platform = process.platform) { + const actions = []; + if (!child?.pid) return { bestEffort: true, actions, error: "missing-pid" }; + if (platform === "win32") { + try { + const configuredRoot = process.env.SystemRoot || process.env.WINDIR; + const systemRoot = configuredRoot && isAbsolute(configuredRoot) ? configuredRoot : "C:\\Windows"; + const taskkillPath = join(systemRoot, "System32", "taskkill.exe"); + execFileSync(taskkillPath, ["/PID", String(child.pid), "/T", "/F"], { + stdio: "ignore", + timeout: 5_000, + }); + actions.push(`taskkill-tree:${child.pid}`); + return { bestEffort: true, actions, error: null }; + } catch (error) { + actions.push(`taskkill-tree-failed:${child.pid}`); + try { + child.kill(); + actions.push(`child-kill:${child.pid}`); + } catch {} + return { bestEffort: true, actions, error: error.message }; + } + } + try { + process.kill(-child.pid, "SIGKILL"); + actions.push(`process-group-kill:${child.pid}`); + return { bestEffort: true, actions, error: null }; + } catch (error) { + actions.push(`process-group-kill-failed:${child.pid}`); + try { + child.kill("SIGKILL"); + actions.push(`child-kill:${child.pid}`); + } catch {} + return { bestEffort: true, actions, error: error.message }; + } +} + +function strictDecode(chunks) { + return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); +} + +function sanitizeReason(value, maxLength = 240) { + const firstLine = (String(value ?? "").split(/\r?\n/, 1)[0] ?? "") + .replace(/[\u0000-\u001f\u007f]+/g, " ") + .trim(); + return firstLine.slice(0, maxLength); +} + +const TRANSIENT_PATTERNS = [ + /\bEOF\b/i, + /TLS/i, + /timed? out/i, + /timeout/i, + /ECONN(?:RESET|REFUSED|ABORTED)/i, + /connection (?:reset|refused|closed)/i, + /network (?:error|failure|unreachable)/i, + /Eligibility check failed.*(?:userinfo|oauth2)/i, + /authentication failed or timed out/i, +]; +const AUTHENTICATION_PATTERNS = [ + /invalid credentials?/i, + /credentials? (?:were )?rejected/i, + /unauthorized/i, + /token (?:is )?expired/i, + /not logged in/i, + /login required/i, +]; + +export function classifyFailure(details = {}) { + if (details.timedOut) return "timeout"; + if (details.errorCode === "ENOENT") return "unavailable"; + const message = `${details.stderr ?? ""}\n${details.error ?? ""}`; + // Network evidence wins over broad authentication wording. In particular, + // "authentication failed or timed out" and oauth2/userinfo EOF were observed + // transient failures that succeeded on one retry. + if (TRANSIENT_PATTERNS.some((pattern) => pattern.test(message))) return "transient"; + if (AUTHENTICATION_PATTERNS.some((pattern) => pattern.test(message))) return "authentication"; + if (["EINVAL", "ENAMETOOLONG"].includes(details.errorCode)) return "invocation"; + if (details.errorCode || (details.exitCode !== null && details.exitCode !== 0)) return "invocation"; + return "internal"; +} + +function spawnAttempt(invocation, timeout, runtime = {}) { + const spawnImpl = runtime.spawn ?? spawn; + const platform = runtime.platform ?? process.platform; + const signal = runtime.signal; + return new Promise((resolveAttempt) => { + const started = Date.now(); + const stdoutChunks = []; + const stderrChunks = []; + let child; + let settled = false; + let timedOut = false; + let killResult = null; + let timer = null; + + const finish = (result) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + let stdout = ""; + let decodeError = null; + try { + stdout = strictDecode(stdoutChunks); + } catch (error) { + decodeError = error.message; + } + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + const base = { + tool: invocation.tool, + launcherKind: invocation.launcher.kind, + resolvedPath: invocation.launcher.resolvedPath, + transport: invocation.transport, + units: invocation.units, + ms: Date.now() - started, + stdout, + stderr, + bytesOut: Buffer.concat(stdoutChunks).length, + timedOut, + killResult, + decodeError, + ...result, + }; + if (decodeError) { + resolveAttempt({ ...base, status: "error", class: "invalid-response", error: `stdout is not strict UTF-8: ${decodeError}` }); + return; + } + resolveAttempt(base); + }; + + const terminate = (reason) => { + if (settled) return; + timedOut = reason === "timeout"; + killResult = killProcessTree(child, platform); + try { child.stdin.destroy(); } catch {} + try { child.stdout.destroy(); } catch {} + try { child.stderr.destroy(); } catch {} + try { child.unref(); } catch {} + finish({ + status: "error", + class: "timeout", + error: timedOut ? `Timed out after ${timeout}ms` : "Gate deadline exceeded", + errorCode: null, + exitCode: null, + signal: null, + }); + }; + const onAbort = () => terminate("deadline"); + + try { + child = spawnImpl(invocation.command, invocation.args, { + cwd: runtime.cwd, + env: runtime.env ?? process.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + detached: platform !== "win32", + }); + } catch (error) { + finish({ + status: "error", + class: classifyFailure({ errorCode: error.code, error: error.message }), + error: error.message, + errorCode: error.code ?? null, + exitCode: null, + signal: null, + }); + return; + } + + timer = setTimeout(() => terminate("timeout"), timeout); + signal?.addEventListener("abort", onAbort, { once: true }); + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.stdin.on("error", () => {}); + child.on("error", (error) => { + finish({ + status: "error", + class: classifyFailure({ errorCode: error.code, error: error.message }), + error: error.message, + errorCode: error.code ?? null, + exitCode: null, + signal: null, + }); + }); + child.on("close", (exitCode, closeSignal) => { + if (timedOut || signal?.aborted) { + finish({ status: "error", class: "timeout", error: timedOut ? `Timed out after ${timeout}ms` : "Gate deadline exceeded", errorCode: null, exitCode, signal: closeSignal }); + } else if (exitCode !== 0) { + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + finish({ status: "error", class: classifyFailure({ exitCode, stderr }), error: `Exited with code ${exitCode}${closeSignal ? ` (${closeSignal})` : ""}`, errorCode: null, exitCode, signal: closeSignal }); + } else { + finish({ status: "ok", class: null, error: null, errorCode: null, exitCode, signal: closeSignal }); + } + }); + try { + child.stdin.end(invocation.input, "utf8"); + } catch (error) { + finish({ status: "error", class: "invocation", error: error.message, errorCode: error.code ?? null, exitCode: null, signal: null }); + } + }); +} + +async function runBuiltInvocation(invocation, timeout, runtime) { + let result; + try { + result = await spawnAttempt(invocation, timeout, runtime); + } finally { + if (invocation.cleanup) { + try { + invocation.cleanup(); + } catch (error) { + if (result) { + result = { ...result, status: "error", class: "internal", cleanupError: error.message, error: `Temporary prompt cleanup failed: ${error.message}` }; + } else { + throw error; + } + } + } + } + return result; +} + +export async function spawnRunner(tool, prompt, timeout, runtime = {}) { + const launcher = runtime.launchers?.[tool] ?? resolveLauncher(adapterDefinition(tool, runtime).command, { + platform: runtime.platform, + pathDirs: runtime.pathDirs, + statSync: runtime.statSync, + }); + if (launcher.kind === "not-found") { + return { + tool, + status: "error", + class: "unavailable", + error: `${tool} was not found on PATH`, + errorCode: "ENOENT", + exitCode: null, + signal: null, + stdout: "", + stderr: "", + launcherKind: "not-found", + resolvedPath: null, + transport: "unavailable", + units: 0, + ms: 0, + bytesOut: 0, + attempts: 1, + }; + } + + let invocation = buildInvocation(tool, prompt, { launcher, timeout, platform: runtime.platform, runtime }); + let result = await runBuiltInvocation(invocation, timeout, runtime); + if (tool === "agy" && invocation.transport === "argv" && result.errorCode === "ENAMETOOLONG") { + invocation = buildInvocation(tool, prompt, { launcher, timeout, platform: runtime.platform, runtime, forceFileReference: true }); + result = await runBuiltInvocation(invocation, timeout, runtime); + result.transportFallback = "ENAMETOOLONG->file-reference"; + } + return result; +} + +export async function runRunnerWithRetry(tool, prompt, timeout, mockDir, runtime = {}) { + if (mockDir) return runMockRunner(tool, mockDir); + let result = null; + for (let attempt = 1; attempt <= 2; attempt += 1) { + if (runtime.signal?.aborted) { + return result ?? { tool, status: "error", class: "timeout", error: "Gate deadline exceeded", attempts: attempt - 1 }; + } + result = await spawnRunner(tool, prompt, timeout, runtime); + result.attempts = attempt; + if (result.status === "ok" || result.class !== "transient") return result; + if (attempt === 1) await new Promise((resolveDelay) => setTimeout(resolveDelay, runtime.retryDelayMs ?? RETRY_DELAY_MS)); + } + return result; +} + +export function runMockRunner(tool, mockDir) { + const filePath = join(mockDir, `${tool}.json`); + if (!existsSync(filePath)) { + return { tool, status: "error", class: "internal", error: `Mock response not found: ${filePath}`, attempts: 1 }; + } + const stdout = readFileSync(filePath, "utf8"); + return { + tool, + status: "ok", + class: null, + stdout, + stderr: "", + exitCode: 0, + signal: null, + errorCode: null, + launcherKind: "mock", + resolvedPath: filePath, + transport: "mock", + units: 0, + ms: 0, + bytesOut: Buffer.byteLength(stdout), + attempts: 1, + }; +} + +function parseNormalizedJson(text) { + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false }; + } +} + +function unwrapEnvelope(value, tool) { + if (tool === "cursor-agent" && value?.type === "result" && typeof value.is_error === "boolean") { + if (value.is_error !== false || typeof value.result !== "string") return { ok: false, envelope: "result", envelopeError: true }; + const inner = parseNormalizedJson(value.result.trim()); + return inner.ok ? { ...inner, envelope: "result" } : { ok: false, envelope: "result" }; + } + if (tool === "agy" && ["SUCCESS", "ERROR"].includes(value?.status)) { + if (value.status !== "SUCCESS" || typeof value.response !== "string") return { ok: false, envelope: "response", envelopeError: true }; + const inner = parseNormalizedJson(value.response.trim()); + return inner.ok ? { ...inner, envelope: "response" } : { ok: false, envelope: "response" }; + } + return null; +} + +export function parseJsonReport(stdout, tool = null) { + let text; + try { + text = Buffer.isBuffer(stdout) || stdout instanceof Uint8Array ? new TextDecoder("utf-8", { fatal: true }).decode(stdout) : String(stdout); + } catch { + return { ok: false, failureClass: "invalid-response", reason: "stdout is not strict UTF-8" }; + } + text = text.replace(ANSI_PATTERN, "").trim(); + if (text.startsWith("\uFEFF")) text = text.slice(1); + let parsed = parseNormalizedJson(text); + let fenced = false; + if (!parsed.ok) { + const fence = text.match(/^```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/i); + if (fence) { + parsed = parseNormalizedJson(fence[1].trim()); + fenced = parsed.ok; + } + } + if (!parsed.ok) return { ok: false, failureClass: "invalid-response", reason: "stdout is not one complete JSON value" }; + const envelope = unwrapEnvelope(parsed.value, tool); + if (envelope) return { ...envelope, fenced }; + return { ok: true, value: parsed.value, envelope: null, fenced }; +} + +export function formatRunnerDiagnostic(result) { + const reason = sanitizeReason(result.reason ?? result.stderr ?? result.error ?? "-"); + const parts = [ + `tool=${result.tool}`, + `status=${result.status}`, + `class=${result.class ?? "-"}`, + `exit=${result.exitCode ?? "-"}`, + `ms=${result.ms ?? 0}`, + `launcher=${result.launcherKind ?? "-"}`, + `resolved=${JSON.stringify(result.resolvedPath ?? "-")}`, + `units=${result.units ?? 0}`, + `bytes_out=${result.bytesOut ?? 0}`, + ]; + if (Number.isInteger(result.findingsCount)) parts.push(`findings=${result.findingsCount}`); + if (result.ignoredTopLevelKeys?.length) parts.push(`ignored_keys=${result.ignoredTopLevelKeys.join(",")}`); + parts.push(`reason=${JSON.stringify(reason || "-")}`); + return parts.join(" "); +} diff --git a/tests/quad-cli-orchestrate.test.js b/tests/quad-cli-orchestrate.test.js index 68c8e8d..8f3be63 100644 --- a/tests/quad-cli-orchestrate.test.js +++ b/tests/quad-cli-orchestrate.test.js @@ -13,6 +13,7 @@ import { createReport, validateRawReport, validateMergedReport, + parseArgs, } from "../scripts/quad-cli-orchestrate.mjs"; const ROOT = resolve(import.meta.dirname, ".."); @@ -312,3 +313,11 @@ describe("createReport", () => { assert.ok(!("reviewers_effective" in report)); }); }); + +describe("quad CLI timeout flags", () => { + it("parses global runner and whole-gate timeout overrides", () => { + const options = parseArgs(["--timeout", "3333", "--gate-timeout", "4444"]); + assert.equal(options.timeout, 3333); + assert.equal(options.gateTimeout, 4444); + }); +}); diff --git a/tests/quad-cli-transport.test.js b/tests/quad-cli-transport.test.js new file mode 100644 index 0000000..3d44517 --- /dev/null +++ b/tests/quad-cli-transport.test.js @@ -0,0 +1,318 @@ +import { after, before, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawn as nodeSpawn } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + buildInvocation, + classifyFailure, + formatRunnerDiagnostic, + parseJsonReport, + resolveGateTimeout, + resolveLauncher, + resolveToolTimeout, + runRunnerWithRetry, + spawnRunner, +} from "../scripts/quad-cli-transport.mjs"; +import { validateRawReport } from "../scripts/quad-cli-orchestrate.mjs"; + +const REPORT = { + schema_version: "1", + generated_by: "quad-cli-orchestrate", + findings: [], +}; +const PROMPT = "HEAD-NONCE\nreview this diff\nTAIL-NONCE"; +let fixtureRoot; +let stubPath; +let cmdPath; +let ps1Path; + +function runtimeFor(tool, launcher, env = {}, extra = {}) { + return { + cwd: fixtureRoot, + platform: process.platform, + launchers: { [tool]: launcher }, + adapterOverrides: { [tool]: { command: tool, prefixArgs: [stubPath] } }, + env: { ...process.env, ...env }, + ...extra, + }; +} + +function nativeLauncher() { + return { kind: "native-exe", resolvedPath: process.execPath }; +} + +function parseAndValidate(result, tool) { + assert.equal(result.status, "ok", JSON.stringify(result, null, 2)); + const parsed = parseJsonReport(result.stdout, tool); + assert.equal(parsed.ok, true, JSON.stringify(parsed)); + assert.equal(validateRawReport(parsed.value).ok, true); + return parsed; +} + +before(() => { + fixtureRoot = mkdtempSync(join(tmpdir(), "quad-cli-transport-test-")); + stubPath = join(fixtureRoot, "stub.mjs"); + cmdPath = join(fixtureRoot, "shim space", "claude.cmd"); + ps1Path = join(fixtureRoot, "shim space", "claude.ps1"); + mkdirSync(join(fixtureRoot, "shim space"), { recursive: true }); + writeFileSync( + stubPath, + `import { spawn } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +const args = process.argv.slice(2); +const mode = process.env.QUAD_STUB_MODE || "success"; +const countFile = process.env.QUAD_STUB_COUNT_FILE; +let count = 0; +if (countFile && existsSync(countFile)) count = Number(readFileSync(countFile, "utf8")) || 0; +if (countFile) writeFileSync(countFile, String(count + 1)); +if (mode === "transient" && count === 0) { + console.error('Eligibility check failed: Get "https://www.googleapis.com/oauth2/v2/userinfo": EOF'); + process.exit(1); +} +if (mode === "authentication") { + console.error("invalid credentials were rejected"); + process.exit(1); +} +if (mode === "grandchild") { + const child = spawn(process.execPath, ["-e", 'setTimeout(()=>require("fs").writeFileSync(process.env.QUAD_STUB_SENTINEL,"alive"),900)'], { + stdio: "ignore", + env: process.env, + }); + await new Promise((resolve) => child.on("exit", resolve)); + process.exit(0); +} +let prompt = ""; +const promptIndex = args.indexOf("-p"); +const addDirIndex = args.indexOf("--add-dir"); +if (addDirIndex >= 0 && promptIndex >= 0) { + const bootstrap = args[promptIndex + 1]; + const match = bootstrap.match(/Read the ENTIRE file (.*?) as your first action/); + if (!match) process.exit(9); + prompt = readFileSync(match[1], "utf8"); +} else if (promptIndex >= 0 && args[promptIndex + 1] && !args[promptIndex + 1].startsWith("--")) { + prompt = args[promptIndex + 1]; +} else { + for await (const chunk of process.stdin) prompt += chunk; +} +if (!prompt.includes("HEAD-NONCE") || !prompt.includes("TAIL-NONCE")) process.exit(8); +const report = { schema_version: "1", generated_by: "quad-cli-orchestrate", findings: [] }; +if (mode === "cursor-envelope") console.log(JSON.stringify({ type: "result", is_error: false, result: JSON.stringify(report) })); +else if (mode === "agy-envelope") console.log(JSON.stringify({ status: "SUCCESS", response: JSON.stringify(report), error: null })); +else console.log(JSON.stringify(report)); +`, + "utf8" + ); + writeFileSync(cmdPath, `@echo off\r\n"${process.execPath}" "${stubPath}" %*\r\n`, "utf8"); + writeFileSync( + ps1Path, + `& '${process.execPath.replaceAll("'", "''")}' '${stubPath.replaceAll("'", "''")}' @args\nexit $LASTEXITCODE\n`, + "utf8" + ); +}); + +after(() => { + rmSync(fixtureRoot, { recursive: true, force: true }); +}); + +describe("quad CLI transport regression matrix", () => { + it("01 stdin channel normal: schema-valid output", async () => { + const result = await spawnRunner("claude", PROMPT, 5_000, runtimeFor("claude", nativeLauncher())); + parseAndValidate(result, "claude"); + assert.equal(result.transport, "stdin"); + }); + + it("02 argv channel normal: schema-valid output", async () => { + const result = await spawnRunner("agy", PROMPT, 5_000, runtimeFor("agy", nativeLauncher())); + parseAndValidate(result, "agy"); + assert.equal(result.transport, "argv"); + }); + + it("03 file-reference normal: schema-valid and temporary directory removed", async () => { + const tempRoot = join(fixtureRoot, "prompt-temp"); + mkdirSync(tempRoot); + const result = await spawnRunner( + "agy", + PROMPT, + 5_000, + runtimeFor("agy", { kind: "ps1-shim", resolvedPath: ps1Path }, {}, { tempRoot }) + ); + parseAndValidate(result, "agy"); + assert.equal(result.transport, "file-reference"); + assert.deepEqual(readdirSync(tempRoot), []); + }); + + it("04 Windows .cmd shim launcher executes through cmd.exe", { skip: process.platform !== "win32" }, async () => { + const result = await spawnRunner("claude", PROMPT, 5_000, runtimeFor("claude", { kind: "cmd-shim", resolvedPath: cmdPath })); + parseAndValidate(result, "claude"); + assert.equal(result.launcherKind, "cmd-shim"); + }); + + it("05 Windows .ps1 shim launcher executes through powershell.exe", { skip: process.platform !== "win32" }, async () => { + const result = await spawnRunner("claude", PROMPT, 5_000, runtimeFor("claude", { kind: "ps1-shim", resolvedPath: ps1Path })); + parseAndValidate(result, "claude"); + assert.equal(result.launcherKind, "ps1-shim"); + }); + + it("06 resolver priority is .exe then .cmd then .bat then .ps1", () => { + const firstDir = join(fixtureRoot, "priority-first"); + const secondDir = join(fixtureRoot, "priority-second"); + const existing = new Set([ + resolve(firstDir, "tool.cmd"), + resolve(firstDir, "tool.ps1"), + resolve(secondDir, "tool.exe"), + ]); + const launcher = resolveLauncher("tool", { + platform: "win32", + pathDirs: [firstDir, secondDir], + statSync(path) { + if (!existing.has(path)) throw Object.assign(new Error("missing"), { code: "ENOENT" }); + return { isFile: () => true, mode: 0o755 }; + }, + }); + assert.equal(launcher.kind, "native-exe"); + assert.equal(launcher.resolvedPath, resolve(secondDir, "tool.exe")); + }); + + it("07 shell-less full .cmd synchronous throw is caught as launcher-failure/invocation", { skip: process.platform !== "win32" }, async () => { + const result = await spawnRunner("claude", PROMPT, 5_000, runtimeFor("claude", { kind: "native-exe", resolvedPath: cmdPath })); + assert.equal(result.status, "error"); + assert.equal(result.class, "invocation"); + assert.equal(result.errorCode, "EINVAL"); + }); + + it("08 missing bare name is unavailable with zero retries", async () => { + const result = await runRunnerWithRetry("claude", PROMPT, 5_000, null, { pathDirs: [], retryDelayMs: 0 }); + assert.equal(result.class, "unavailable"); + assert.equal(result.attempts, 1); + }); + + it("09 ENAMETOOLONG falls back once to file-reference and is not a retry", async () => { + let spawnCalls = 0; + const injectedSpawn = (...args) => { + spawnCalls += 1; + if (spawnCalls === 1) throw Object.assign(new Error("too long"), { code: "ENAMETOOLONG" }); + return nodeSpawn(...args); + }; + const tempRoot = join(fixtureRoot, "fallback-temp"); + mkdirSync(tempRoot); + const result = await spawnRunner( + "agy", + PROMPT, + 5_000, + runtimeFor("agy", nativeLauncher(), {}, { spawn: injectedSpawn, tempRoot }) + ); + parseAndValidate(result, "agy"); + assert.equal(result.transportFallback, "ENAMETOOLONG->file-reference"); + assert.equal(spawnCalls, 2); + assert.deepEqual(readdirSync(tempRoot), []); + }); + + it("10 timeout terminates a grandchild process tree and finally removes sentinel", { skip: process.platform !== "win32" }, async () => { + const sentinel = join(fixtureRoot, `sentinel-${Date.now()}.txt`); + try { + const result = await spawnRunner( + "claude", + PROMPT, + 200, + runtimeFor("claude", nativeLauncher(), { QUAD_STUB_MODE: "grandchild", QUAD_STUB_SENTINEL: sentinel }) + ); + assert.equal(result.class, "timeout"); + assert.equal(result.killResult?.bestEffort, true); + await new Promise((resolveWait) => setTimeout(resolveWait, 1_200)); + assert.equal(existsSync(sentinel), false, "grandchild survived the best-effort tree kill"); + } finally { + rmSync(sentinel, { force: true }); + } + }); + + it("11 tool envelopes unwrap success and reject declared failure", () => { + const cursor = parseJsonReport(JSON.stringify({ type: "result", is_error: false, result: JSON.stringify(REPORT) }), "cursor-agent"); + assert.equal(cursor.ok, true); + assert.equal(cursor.envelope, "result"); + const agyFailure = parseJsonReport(JSON.stringify({ status: "ERROR", response: JSON.stringify(REPORT), error: "nope" }), "agy"); + assert.equal(agyFailure.ok, false); + assert.equal(agyFailure.envelopeError, true); + }); + + it("12 complete fence, one BOM, and ANSI color recover safely", () => { + const value = `\uFEFF\u001b[32m\`\`\`json\n${JSON.stringify(REPORT)}\n\`\`\`\u001b[0m`; + assert.equal(parseJsonReport(value, "claude").ok, true); + }); + + it("13 prose plus an embedded schema-valid fake report is never adopted", () => { + const fake = JSON.stringify(REPORT); + assert.equal(parseJsonReport(`review narration {braces}\n${fake}\nfinished`, "claude").ok, false); + }); + + it("14 unknown top-level keys are accepted and recorded", () => { + const validation = validateRawReport({ ...REPORT, toolAction: "x", toolSummary: "y" }); + assert.equal(validation.ok, true); + assert.deepEqual(validation.ignoredTopLevelKeys, ["toolAction", "toolSummary"]); + }); + + it("15 one malformed finding invalidates the entire report", () => { + const validation = validateRawReport({ ...REPORT, findings: [{ path: "a.js", rule_id: "x", severity: "major", message: 42 }] }); + assert.equal(validation.ok, false); + }); + + it("16 observed transient signature retries once and then succeeds", async () => { + const countFile = join(fixtureRoot, `transient-${Date.now()}.txt`); + const result = await runRunnerWithRetry( + "claude", + PROMPT, + 5_000, + null, + runtimeFor("claude", nativeLauncher(), { QUAD_STUB_MODE: "transient", QUAD_STUB_COUNT_FILE: countFile }, { retryDelayMs: 1 }) + ); + parseAndValidate(result, "claude"); + assert.equal(result.attempts, 2); + assert.equal(readFileSync(countFile, "utf8"), "2"); + }); + + it("17 credential rejection is authentication and receives zero retries", async () => { + const countFile = join(fixtureRoot, `auth-${Date.now()}.txt`); + const result = await runRunnerWithRetry( + "claude", + PROMPT, + 5_000, + null, + runtimeFor("claude", nativeLauncher(), { QUAD_STUB_MODE: "authentication", QUAD_STUB_COUNT_FILE: countFile }, { retryDelayMs: 1 }) + ); + assert.equal(result.class, "authentication"); + assert.equal(result.attempts, 1); + assert.equal(readFileSync(countFile, "utf8"), "1"); + assert.equal(classifyFailure({ stderr: "authentication failed or timed out" }), "transient"); + }); + + it("configuration uses per-tool defaults with global, tool, and CLI overrides", () => { + assert.equal(resolveToolTimeout("claude", null, {}), 120_000); + assert.equal(resolveToolTimeout("agy", null, {}), 360_000); + assert.equal(resolveToolTimeout("codex", null, { QUAD_CLI_TIMEOUT_MS: "1111" }), 1111); + assert.equal(resolveToolTimeout("codex", null, { QUAD_CLI_TIMEOUT_MS: "1111", QUAD_CLI_TIMEOUT_CODEX_MS: "2222" }), 2222); + assert.equal(resolveToolTimeout("codex", 3333, {}), 3333); + assert.equal(resolveGateTimeout(null, {}), 725_000); + assert.equal(resolveGateTimeout(null, { QUAD_CLI_GATE_TIMEOUT_MS: "4444" }), 4444); + const agy = buildInvocation("agy", PROMPT, { launcher: nativeLauncher(), timeout: 360_000, runtime: {} }); + assert.equal(agy.args[agy.args.indexOf("--print-timeout") + 1], "5m"); + }); + + it("diagnostic is one sanitized line and records launcher resolution", () => { + const line = formatRunnerDiagnostic({ + tool: "agy", + status: "error", + class: "invocation", + exitCode: 2, + ms: 31, + launcherKind: "native-exe", + resolvedPath: "agy.exe", + units: 2847, + bytesOut: 0, + stderr: "flag needs an argument: -p\nsecond line", + }); + assert.equal(line.includes("\n"), false); + assert.match(line, /resolved="agy\.exe"/); + assert.match(line, /reason="flag needs an argument: -p"/); + }); +}); From ea3dd250176859ab363234b3fc8f087638f7594e Mon Sep 17 00:00:00 2001 From: drvoss Date: Fri, 21 Aug 2026 13:09:23 +0900 Subject: [PATCH 2/2] test: make file transport regression portable --- tests/quad-cli-transport.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/quad-cli-transport.test.js b/tests/quad-cli-transport.test.js index 3d44517..1abc569 100644 --- a/tests/quad-cli-transport.test.js +++ b/tests/quad-cli-transport.test.js @@ -134,9 +134,9 @@ describe("quad CLI transport regression matrix", () => { mkdirSync(tempRoot); const result = await spawnRunner( "agy", - PROMPT, + `HEAD-NONCE\n${"x".repeat(24_000)}\nTAIL-NONCE`, 5_000, - runtimeFor("agy", { kind: "ps1-shim", resolvedPath: ps1Path }, {}, { tempRoot }) + runtimeFor("agy", nativeLauncher(), {}, { tempRoot }) ); parseAndValidate(result, "agy"); assert.equal(result.transport, "file-reference");