diff --git a/cli/bun/src/adapters/output-budget.ts b/cli/bun/src/adapters/output-budget.ts index 3d6dc69a..949eb11d 100644 --- a/cli/bun/src/adapters/output-budget.ts +++ b/cli/bun/src/adapters/output-budget.ts @@ -1,4 +1,5 @@ import { failure } from "../core/errors"; +import transportLimits from "../../../shared/capabilities/transport-limits.v1.json"; export const DEFAULT_NATIVE_OUTPUT_BYTES = 67_108_864; type Selection = { nativeOutputBytes?: string; outputContract?: string; nativeLog?: string }; export function validateNativeOutputBytes(value: string): number { @@ -7,7 +8,7 @@ export function validateNativeOutputBytes(value: string): number { return n; } export function nativeOutputBytes(input: Selection): number { return input.nativeOutputBytes===undefined?DEFAULT_NATIVE_OUTPUT_BYTES:validateNativeOutputBytes(input.nativeOutputBytes); } -export function nativeOutputLimits(input: Selection): {maxAggregateStdoutBytes:number;maxNativeCaptureBytes:number;captureEnabled:boolean}|undefined { +export function nativeOutputLimits(input: Selection): {maxRecordBytes:number;maxAggregateStdoutBytes:number;maxNativeCaptureBytes:number;captureEnabled:boolean}|undefined { if(input.nativeOutputBytes!==undefined&&input.outputContract!=="native")throw failure("CONFIG_INVALID",{reason:"Native output bytes require native output mode."}); - return input.outputContract==="native"?{maxAggregateStdoutBytes:nativeOutputBytes(input),maxNativeCaptureBytes:nativeOutputBytes(input),captureEnabled:input.nativeLog!==undefined}:undefined; + return input.outputContract==="native"?{maxRecordBytes:transportLimits.maxRecordBytes,maxAggregateStdoutBytes:nativeOutputBytes(input),maxNativeCaptureBytes:nativeOutputBytes(input),captureEnabled:input.nativeLog!==undefined}:undefined; } diff --git a/cli/bun/src/cli.ts b/cli/bun/src/cli.ts index e49673cb..513b2e2d 100644 --- a/cli/bun/src/cli.ts +++ b/cli/bun/src/cli.ts @@ -318,6 +318,7 @@ async function runOperation( selectedHarness: selected.id, selectedHarnessVersion: selectedStatus.detectedVersion, ...(selected.id === "codex" ? {codexCompatibility:{qualification:codexQualification(selectedStatus.detectedVersion),policy:config.values.codexCompatibility ?? "qualified"}} : {}), + ...(nativeOutputLimits(config.values) ? { nativeOutputLimits: nativeOutputLimits(config.values)! } : {}), selectedTransport: transport, selectedAdapterId: adapterId(selected, transport), promptPlacement: selected.id === "mock" diff --git a/cli/bun/src/core/errors.ts b/cli/bun/src/core/errors.ts index a017fc37..c540bf94 100644 --- a/cli/bun/src/core/errors.ts +++ b/cli/bun/src/core/errors.ts @@ -2,6 +2,10 @@ import taxonomy from "../../../shared/errors/taxonomy.v1.json" with { type: "jso import { RunnerFailure, type RunnerErrorCode, type RunnerErrorShape } from "./types"; export function failure(code: RunnerErrorCode, details?: Record): RunnerFailure { + const diagnostic = details?.transportDiagnostic as { reason?: unknown } | undefined; + if ((code === "PROTOCOL_MALFORMED" || code === "HARNESS_FAILED") && diagnostic?.reason === "record-byte-limit") { + return new RunnerFailure({ ...taxonomy.recordByteLimit, code: "HARNESS_FAILED", boundary: "process", ...(details === undefined ? {} : { details }) }); + } const definition = taxonomy.errors.find((item) => item.code === code); if (definition === undefined) throw new Error(`Missing shared error taxonomy entry for ${code}`); return new RunnerFailure({ diff --git a/cli/bun/src/supervision/fake-failure.ts b/cli/bun/src/supervision/fake-failure.ts index 22c8c673..bc7550eb 100644 --- a/cli/bun/src/supervision/fake-failure.ts +++ b/cli/bun/src/supervision/fake-failure.ts @@ -3,6 +3,9 @@ import type { ProcessSupervisionResult } from "./types"; /** Preserve bounded, runner-authored diagnostics on the test transport. */ export function fakeProtocolFailureDetails(outcome: Pick): Record { const error = outcome.error; + if (error?.code === "HARNESS_FAILED" && (error.details?.transportDiagnostic as {reason?: unknown} | undefined)?.reason === "record-byte-limit") { + return { transportDiagnostic: error.details?.transportDiagnostic, admittedRecordCount: outcome.events.length }; + } if (error?.code !== "PROTOCOL_MALFORMED" && error?.code !== "PROTOCOL_TRUNCATED") return {}; const diagnostic = error.details?.transportDiagnostic as Record | undefined; const framingReason = diagnostic?.reason; diff --git a/cli/bun/test/native-output-budget.test.ts b/cli/bun/test/native-output-budget.test.ts index f0ccc171..2531d939 100644 --- a/cli/bun/test/native-output-budget.test.ts +++ b/cli/bun/test/native-output-budget.test.ts @@ -1,4 +1,6 @@ import {test,expect} from "bun:test"; +import Ajv2020 from "ajv/dist/2020"; +import nativeOutputLimitsSchema from "../../shared/schemas/native-output-limits.schema.json"; import {mkdtemp,writeFile,rm,mkdir,readFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import {join} from "node:path"; @@ -7,12 +9,24 @@ import {nativeOutputBytes,nativeOutputLimits,validateNativeOutputBytes} from ".. import {parseEntrypoint} from "../src/core/args"; import {resolveConfiguration} from "../src/core/config"; import {NativeCapture} from "../src/adapters/native-capture"; +test("native output schema requires the fixed record cap for every reported budget",()=>{ + const validate=new Ajv2020({strict:true}).compile(nativeOutputLimitsSchema); + for(const selection of [{outputContract:"native"},{outputContract:"native",nativeOutputBytes:"134217728",nativeLog:"/fixture/capture"}]){ + const limits=nativeOutputLimits(selection)!; + expect(validate(limits)).toBe(true); + const missingCap:Record={...limits};delete missingCap.maxRecordBytes; + expect(validate(missingCap)).toBe(false); + expect(validate.errors?.some(error=>error.keyword==="required"&&error.params.missingProperty==="maxRecordBytes")).toBe(true); + expect(validate({...limits,maxRecordBytes:1048577})).toBe(false); + } + expect(nativeOutputLimits({outputContract:"image-envelope"})).toBeUndefined(); +}); test("native output shared bounds and mode",()=>{ expect(nativeOutputBytes({})).toBe(fixture.default); for(const v of fixture.valid)expect(validateNativeOutputBytes(v)).toBe(Number(v)); for(const v of fixture.invalid)expect(()=>validateNativeOutputBytes(v)).toThrow(); expect(()=>nativeOutputLimits({nativeOutputBytes:"1048576"})).toThrow(); - expect(nativeOutputLimits({outputContract:"native"})).toEqual({maxAggregateStdoutBytes:fixture.default,maxNativeCaptureBytes:fixture.default,captureEnabled:false}); + expect(nativeOutputLimits({outputContract:"native"})).toEqual({maxRecordBytes:1048576,maxAggregateStdoutBytes:fixture.default,maxNativeCaptureBytes:fixture.default,captureEnabled:false}); expect(()=>parseEntrypoint(["--native-output-bytes","1048576","--native-output-bytes","2097152","task"])).toThrow(); }); test("native output provenance follows file environment flag precedence",async()=>{ diff --git a/cli/bun/test/record-limit-blackbox.test.ts b/cli/bun/test/record-limit-blackbox.test.ts new file mode 100644 index 00000000..e4455356 --- /dev/null +++ b/cli/bun/test/record-limit-blackbox.test.ts @@ -0,0 +1,118 @@ +import { test, expect } from "bun:test"; +import { mkdtemp, writeFile, readFile, unlink, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import fixture from "../../shared/fixtures/adapters/native-output.v1.json"; +import transportLimits from "../../shared/capabilities/transport-limits.v1.json"; +import { runCli, type CliDependencies } from "../src/cli"; +import { sentinelImage } from "../src/assets/sentinel"; + +// The same frozen controls can also assess an independently compiled Rust CLI. +test("frozen record controls agree with authoritative transport facts", () => { + expect(fixture.recordLimits.recordLimitBytes).toBe(transportLimits.maxRecordBytes); + expect(fixture.recordLimits.error.code).toBe(transportLimits.oversizedRecordFailure); +}); +for (const cell of fixture.recordLimits.cases) { + test(`record limit black box: ${cell.name}`, async () => { + const root = await mkdtemp(join(tmpdir(), "prose-record-limit-")); + const pidFile = join(root, "native.pid"); + async function removeOwnedNative() { + let pid: number; + try { pid = Number(await readFile(pidFile, "utf8")); } + catch (error: any) { if (error.code === "ENOENT") return; throw error; } + if (!Number.isSafeInteger(pid) || pid <= 1) throw new Error("Invalid synthetic child PID"); + try { process.kill(pid, "SIGKILL"); } + catch (error: any) { if (error.code !== "ESRCH") throw error; } + for (let count = 0; count < 100; count++) { + try { process.kill(pid, 0); await Bun.sleep(10); } + catch (error: any) { if (error.code !== "ESRCH") throw error; return; } + } + throw new Error("Synthetic native child did not disappear during failure cleanup"); + } + try { + const record = JSON.stringify({ type: "item.completed", item: { type: "command_execution", id: "tool1", command: "fixture", aggregated_output: "", exit_code: 0, status: "completed" } }); + const padding = cell.recordBytes - Buffer.byteLength(record); + const source = `#!${process.execPath} +const fs = require('node:fs'); +if (process.argv.includes('--version')) { console.log('codex-cli 0.149.0-alpha.4.1'); } +else if (process.argv.slice(2).join(' ') === 'login status') { console.log('Logged in using ChatGPT'); } +else { +fs.writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); +console.log(JSON.stringify({type:'thread.started',thread_id:'fixture'})); +console.log(JSON.stringify({type:'turn.started'})); +console.log(${JSON.stringify(record)}.replace('"aggregated_output":""', '"aggregated_output":"' + 'x'.repeat(${padding}) + '"')); +${cell.accepted ? "console.log(JSON.stringify({type:'turn.completed',usage:{input_tokens:1,output_tokens:1,cached_input_tokens:0}}));" : "setInterval(() => {},1000);"} +} +`; + await writeFile(join(root, "codex"), source, { mode: 0o700 }); + const argv = ["--harness", "codex", "--auth-profile", "cached-chatgpt-login", "--output-contract", "native", "--native-output-bytes", String(cell.aggregateBytes), "--timeout", "5s", "--output", "json", "--", "execute", "fixture.md"]; + let stdout = ""; + let stderr = ""; + const dependencies: CliDependencies = { + platform: process.platform, arch: process.arch, processCwd: root, + env: { PATH: root, HOME: root }, userConfigPath: join(root, "absent.toml"), + imageBundle: sentinelImage, + clock: { now: () => "2026-01-01T00:00:00.000Z", monotonicMs: () => performance.now() }, + ids: { invocationId: () => "00000000-0000-7000-8000-000000008989" }, + writeStdout: text => { stdout += text; }, writeStderr: text => { stderr += text; }, + }; + const code = await runCli(argv, dependencies); + expect(stderr).toBe(""); + check(JSON.parse(stdout.trim()), code); + await checkCleanup(); + stdout = ""; + const doctorCode = await runCli(["--harness", "codex", "--auth-profile", "cached-chatgpt-login", "--output-contract", "native", "--native-output-bytes", String(cell.aggregateBytes), "cli", "doctor", "--json"], dependencies); + expect(doctorCode).toBe(0); + expect(stderr).toBe(""); + expect(JSON.parse(stdout.trim()).nativeOutputLimits).toEqual({ maxRecordBytes: fixture.recordLimits.recordLimitBytes, maxAggregateStdoutBytes: cell.aggregateBytes, maxNativeCaptureBytes: cell.aggregateBytes, captureEnabled: false }); + for (const binary of [process.env.PROSE_RECORD_LIMIT_RUST_BINARY, process.env.PROSE_RECORD_LIMIT_BUN_BINARY]) { + if (binary === undefined) continue; + const child = Bun.spawn([binary, ...argv], { cwd: root, env: { PATH: root, HOME: root, XDG_CONFIG_HOME: root }, stdout: "pipe", stderr: "pipe" }); + const timer = setTimeout(() => child.kill("SIGKILL"), 8000); + try { + const [output, diagnostic, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + expect(diagnostic).toBe(""); + check(JSON.parse(output.trim()), exit); + await checkCleanup(); + } finally { + clearTimeout(timer); + try { await removeOwnedNative(); } + finally { + if (child.exitCode === null) child.kill("SIGKILL"); + await child.exited; + } + } + } + async function checkCleanup() { + const pid = Number(await readFile(pidFile, "utf8")); + let missing = false; + try { process.kill(pid, 0); } + catch (error: any) { if (error.code !== "ESRCH") throw error; missing = true; } + expect(missing).toBe(true); + await unlink(pidFile); + } + function check(result: any, code: number) { + expect(result.nativeOutputLimits.maxRecordBytes).toBe(fixture.recordLimits.recordLimitBytes); + expect(result.nativeOutputLimits.maxAggregateStdoutBytes).toBe(cell.aggregateBytes); + if (cell.accepted) { + expect(code).toBe(0); + expect(result.terminal.transportCompleted).toBe(true); + expect(result.semantic.status).toBe("not-applicable"); + } else { + expect(code).toBe(fixture.recordLimits.error.exitCode); + for (const [key, value] of Object.entries(fixture.recordLimits.error)) expect(result.error[key]).toEqual(value); + expect(result.error.details.transportDiagnostic.reason).toBe("record-byte-limit"); + expect(result.error.details.transportDiagnostic.limitBytes).toBe(fixture.recordLimits.recordLimitBytes); + expect(result.error.details.transportDiagnostic.observedBytes).toBeGreaterThan(fixture.recordLimits.recordLimitBytes); + expect(result.error.details.terminalEventObserved).toBe(false); + expect(result.terminal.transportCompleted).toBe(false); + expect(result.terminal.terminalEventObserved).toBe(false); + expect(result.semantic.status).toBe("unknown"); + } + } + } finally { + try { await removeOwnedNative(); } + finally { await rm(root, { recursive: true, force: true }); } + } + }, 30000); +} diff --git a/cli/bun/test/supervision-jsonl.test.ts b/cli/bun/test/supervision-jsonl.test.ts index 7b6b52e1..6c5eaa17 100644 --- a/cli/bun/test/supervision-jsonl.test.ts +++ b/cli/bun/test/supervision-jsonl.test.ts @@ -49,7 +49,7 @@ describe("bounded JSONL framing", () => { ["malformed JSON", chunks('{"bad":}\n'), "PROTOCOL_MALFORMED"], ["truncated EOF", chunks('{"unfinished":'), "PROTOCOL_TRUNCATED"], ["empty record", chunks("\n"), "PROTOCOL_MALFORMED"], - ["oversized record", chunks(`${JSON.stringify({ value: "x".repeat(130) })}\n`), "PROTOCOL_MALFORMED"], + ["oversized record", chunks(`${JSON.stringify({ value: "x".repeat(130) })}\n`), "HARNESS_FAILED"], ["aggregate overflow", chunks(`${JSON.stringify({ a: "x".repeat(80) })}\n`, `${JSON.stringify({ b: "y".repeat(80) })}\n`), "PROTOCOL_MALFORMED"], ])("rejects %s deterministically", async (_label, stream, code) => { const constrained = _label === "aggregate overflow" ? { ...limits, maxAggregateStdoutBytes: 150 } : limits; @@ -61,7 +61,7 @@ import diagnosticCases from "../../shared/fixtures/transport-diagnostics.json"; for (const fixture of diagnosticCases) test(`safe transport diagnostic: ${fixture.name}`, async()=>{ let caught:any; try {await readBoundedJsonLines(chunks(fixture.input), {...limits,maxRecordBytes:fixture.recordLimit,maxAggregateStdoutBytes:fixture.aggregateLimit},()=>{});}catch(e){caught=e;} - expect(caught.code).toBe("PROTOCOL_MALFORMED"); + expect(caught.code).toBe(fixture.reason === "record-byte-limit" ? "HARNESS_FAILED" : "PROTOCOL_MALFORMED"); expect(caught.details.transportDiagnostic.reason).toBe(fixture.reason); if("limitBytes" in fixture)expect(caught.details.transportDiagnostic.limitBytes).toBe(fixture.limitBytes); if("observedBytes" in fixture)expect(caught.details.transportDiagnostic.observedBytes).toBe(fixture.observedBytes); diff --git a/cli/protocol/OWNERSHIP.md b/cli/protocol/OWNERSHIP.md index 031d4a5d..de128f62 100644 --- a/cli/protocol/OWNERSHIP.md +++ b/cli/protocol/OWNERSHIP.md @@ -777,3 +777,43 @@ IMP-086 lease extension: `cli/ci/test_rehearse_release.py` for exact current 64- IMP-086 lease extension: `cli/conformance/fixtures/adapter-host-expectations.json` and the existing leased host runner/tests for independently frozen inventory expectations across admitted POSIX hosts; keep all Codex blocked-state and full-inventory assertions. IMP-086 lease extension: `cli/ci/test_run_local.py` solely to make the interrupt-tree fixture reap its controlled descendant and publish readiness after signal-safe setup; supervisor behavior, 130/143 exits and PID-absence assertions remain unchanged. + +## IMP-089: native record limit diagnostics (October 5, 2026) + +Codex `/root/record_limits` owns narrowly scoped edits on isolated branch +`codex/imp-089-record-limits`, coordinated by lead `/root`. Exact paths: +`cli/shared/errors/taxonomy.v1.json`, +`cli/shared/schemas/native-output-limits.schema.json`, +`cli/shared/schemas/runner-error.schema.json`, +`cli/shared/fixtures/native-record-limits.json`, +`cli/shared/fixtures/transport-diagnostics.json`, +`cli/shared/fixtures/adapters/native-output.v1.json`, +`cli/bun/src/core/errors.ts`, `cli/bun/src/core/types.ts`, +`cli/bun/src/supervision/jsonl.ts`, `cli/bun/src/adapters/native-capture.ts`, +`cli/bun/src/adapters/output-budget.ts`, `cli/bun/src/cli.ts`, +`cli/bun/test/supervision-jsonl.test.ts`, +`cli/bun/test/native-output-budget.test.ts`, +`cli/bun/test/record-limit-blackbox.test.ts`, +`cli/rust/crates/prose-runner-core/src/error.rs`, +`cli/rust/crates/prose-runner-core/src/runner.rs`, +`cli/rust/crates/prose-runner-core/src/config.rs`. +The agent may commit its own scoped branch after review of its diff; root owns +push, PR and integration. Shared controls precede product changes. No limit +increase, new setting, provider calls, releases, kernel semantics or Python. +IMP-089 record-limit lease extension: `cli/bun/src/supervision/fake-failure.ts` +solely to preserve diagnosed resource-limit evidence in final test-transport +receipts after honest HARNESS_FAILED classification. +IMP-089 record-limit lease extension: `cli/shared/schemas/doctor-report.schema.json` +for the optional effective native output budgets in readiness reports. Frozen +record controls are nested in the already admitted shared native-output fixture; +no new Python or architecture allowlist change is necessary. +IMP-089 record-limit lease extension: `cli/shared/capabilities/transport-limits.v1.json` +solely to reconcile the authoritative oversized-record error category with the +shared controls and both products. Numeric bounds and other failures stay fixed. +IMP-089 record-limit documentation lease: `docs/native-output-budgets.md` for +accurate fixed record-budget reporting and resource-failure recovery only. +IMP-089 mandatory Rust black-box lease: `cli/rust/crates/prose-cli/tests/native_record_limits.rs` +for provider-free compiled test-seam admission against the same frozen native +record controls. Fake Codex uses the required Bun toolchain; no Python, shell, +provider credentials or network. This supplements rather than replaces existing +framing, mapping and shared differential checks. diff --git a/cli/rust/crates/prose-cli/tests/native_record_limits.rs b/cli/rust/crates/prose-cli/tests/native_record_limits.rs new file mode 100644 index 00000000..873d021e --- /dev/null +++ b/cli/rust/crates/prose-cli/tests/native_record_limits.rs @@ -0,0 +1,230 @@ +#![cfg(all(feature = "test-seams", unix))] + +use rustix::process::{Pid, Signal, kill_process, test_kill_process}; +use serde_json::Value; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +struct Attempt { + child: Option, + pid_file: PathBuf, +} + +impl Attempt { + fn native_pid(&self) -> Option { + fs::read_to_string(&self.pid_file) + .ok() + .and_then(|text| text.parse::().ok()) + .filter(|raw| *raw > 1) + .and_then(Pid::from_raw) + } + + fn output(&mut self) -> Output { + let deadline = Instant::now() + Duration::from_secs(8); + while self.child.as_mut().unwrap().try_wait().unwrap().is_none() { + assert!( + Instant::now() < deadline, + "compiled CLI did not settle within eight seconds" + ); + thread::sleep(Duration::from_millis(10)); + } + // Fail while the guard still owns the wrapper: a leaked synthetic + // native process must not keep an inherited output pipe open forever. + if let Some(pid) = self.native_pid() { + assert_eq!(test_kill_process(pid), Err(rustix::io::Errno::SRCH)); + } + self.child.take().unwrap().wait_with_output().unwrap() + } + + fn assert_native_absent(&self) { + let pid = self + .native_pid() + .expect("the synthetic native child published its PID"); + assert_eq!( + test_kill_process(pid), + Err(rustix::io::Errno::SRCH), + "native child remains alive or its absence is unestablished" + ); + fs::remove_file(&self.pid_file).unwrap(); + } +} + +impl Drop for Attempt { + fn drop(&mut self) { + let pid = self.native_pid(); + if let Some(pid) = pid { + let _ = kill_process(pid, Signal::KILL); + } + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + if let Some(pid) = pid { + let deadline = Instant::now() + Duration::from_secs(1); + while test_kill_process(pid).is_ok() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + } + } +} + +fn required_bun() -> PathBuf { + std::env::split_paths(&std::env::var_os("PATH").expect("Bun admission requires PATH")) + .map(|directory| directory.join("bun")) + .find(|candidate| { + candidate.is_file() + && fs::metadata(candidate).unwrap().permissions().mode() & 0o111 != 0 + }) + .expect("Bun is a required admission tool; install the pinned version before testing") + .canonicalize() + .unwrap() +} + +fn invoke(root: &Path, arguments: &[&str], pid_file: &Path) -> Attempt { + let child = Command::new(env!("CARGO_BIN_EXE_prose")) + .args(arguments) + .current_dir(root) + .env_clear() + .env("PATH", root) + .env("HOME", root) + .env("XDG_CONFIG_HOME", root) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + Attempt { + child: Some(child), + pid_file: pid_file.to_owned(), + } +} + +fn write_harness(root: &Path, bun: &Path, pid_file: &Path, cell: &Value) { + let alias = root.join("bun"); + std::os::unix::fs::symlink(bun, &alias).unwrap(); + let source = format!( + r"#!{} +const fs = require('node:fs'); +if (process.argv.includes('--version')) console.log('codex-cli 0.149.0-alpha.4.1'); +else if (process.argv.slice(2).join(' ') === 'login status') console.log('Logged in using ChatGPT'); +else {{ + fs.writeFileSync({}, String(process.pid)); + console.log(JSON.stringify({{type:'thread.started',thread_id:'fixture'}})); + console.log(JSON.stringify({{type:'turn.started'}})); + const record = {{type:'item.completed',item:{{type:'command_execution',id:'tool1',command:'fixture',aggregated_output:'',exit_code:0,status:'completed'}}}}; + record.item.aggregated_output = 'x'.repeat({} - Buffer.byteLength(JSON.stringify(record))); + console.log(JSON.stringify(record)); + if ({}) console.log(JSON.stringify({{type:'turn.completed',usage:{{input_tokens:1,output_tokens:1,cached_input_tokens:0}}}})); + else setInterval(() => {{}}, 1000); +}} +", + alias.display(), + serde_json::to_string(&pid_file).unwrap(), + cell["recordBytes"], + cell["accepted"] + ); + let executable = root.join("codex"); + fs::write(&executable, source).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); +} + +#[test] +fn compiled_native_record_boundary_and_recovery_match_shared_controls() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/adapters/native-output.v1.json" + )) + .unwrap(); + let limits = &fixture["recordLimits"]; + let bun = required_bun(); + for cell in limits["cases"].as_array().unwrap() { + let root = tempfile::tempdir().unwrap(); + let pid_file = root.path().join("native.pid"); + write_harness(root.path(), &bun, &pid_file, cell); + let aggregate = cell["aggregateBytes"].to_string(); + let arguments = [ + "--harness", + "codex", + "--auth-profile", + "cached-chatgpt-login", + "--output-contract", + "native", + "--native-output-bytes", + aggregate.as_str(), + "--timeout", + "5s", + "--output", + "json", + "--", + "execute", + "fixture.md", + ]; + let mut attempt = invoke(root.path(), &arguments, &pid_file); + let output = attempt.output(); + assert!( + output.stderr.is_empty(), + "{}: unexpected stderr", + cell["name"] + ); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + result["nativeOutputLimits"]["maxRecordBytes"], + limits["recordLimitBytes"], + "{}: compiled CLI exit {:?}, result {result}", + cell["name"], + output.status.code() + ); + assert_eq!( + result["nativeOutputLimits"]["maxAggregateStdoutBytes"], + cell["aggregateBytes"] + ); + if cell["accepted"] == true { + assert!(output.status.success()); + assert_eq!(result["terminal"]["transportCompleted"], true); + assert_eq!(result["semantic"]["status"], "not-applicable"); + } else { + assert_eq!(output.status.code(), Some(22)); + for (key, expected) in limits["error"].as_object().unwrap() { + assert_eq!(&result["error"][key], expected, "{}: {key}", cell["name"]); + } + let diagnostic = &result["error"]["details"]["transportDiagnostic"]; + assert_eq!(diagnostic["reason"], "record-byte-limit"); + assert_eq!(diagnostic["limitBytes"], limits["recordLimitBytes"]); + assert!( + diagnostic["observedBytes"].as_u64().unwrap() + > limits["recordLimitBytes"].as_u64().unwrap() + ); + assert_eq!(result["error"]["details"]["terminalEventObserved"], false); + assert_eq!(result["terminal"]["transportCompleted"], false); + assert_eq!(result["terminal"]["terminalEventObserved"], false); + assert_eq!(result["semantic"]["status"], "unknown"); + } + attempt.assert_native_absent(); + let arguments = [ + "--harness", + "codex", + "--auth-profile", + "cached-chatgpt-login", + "--output-contract", + "native", + "--native-output-bytes", + aggregate.as_str(), + "cli", + "doctor", + "--json", + ]; + let mut doctor = invoke(root.path(), &arguments, &pid_file); + let output = doctor.output(); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["nativeOutputLimits"], result["nativeOutputLimits"]); + assert!( + !pid_file.exists(), + "doctor must not launch a native execution" + ); + } +} diff --git a/cli/rust/crates/prose-runner-core/src/config.rs b/cli/rust/crates/prose-runner-core/src/config.rs index 2f6de317..8ab543de 100644 --- a/cli/rust/crates/prose-runner-core/src/config.rs +++ b/cli/rust/crates/prose-runner-core/src/config.rs @@ -1648,6 +1648,10 @@ mod tests { c.native_output_bytes.value = None; assert_eq!(native_output_bytes(&c), 67_108_864); assert_eq!(native_output_limits(&c).unwrap()["captureEnabled"], false); + assert_eq!( + native_output_limits(&c).unwrap()["maxRecordBytes"], + 1_048_576 + ); } #[test] @@ -2397,7 +2401,7 @@ pub(crate) fn native_output_bytes(config: &EffectiveConfig) -> usize { }) } pub(crate) fn native_output_limits(config: &EffectiveConfig) -> Option { - (config.output_contract.value == "native").then(|| serde_json::json!({"maxAggregateStdoutBytes":native_output_bytes(config),"maxNativeCaptureBytes":native_output_bytes(config),"captureEnabled":config.native_log.value.is_some()})) + (config.output_contract.value == "native").then(|| serde_json::json!({"maxRecordBytes":prose_process_supervisor::StreamLimits::default().max_record_bytes,"maxAggregateStdoutBytes":native_output_bytes(config),"maxNativeCaptureBytes":native_output_bytes(config),"captureEnabled":config.native_log.value.is_some()})) } #[cfg(test)] diff --git a/cli/rust/crates/prose-runner-core/src/runner.rs b/cli/rust/crates/prose-runner-core/src/runner.rs index c91cec69..854a09c9 100644 --- a/cli/rust/crates/prose-runner-core/src/runner.rs +++ b/cli/rust/crates/prose-runner-core/src/runner.rs @@ -538,6 +538,9 @@ fn execute_runner_command( if config.harness.value == "codex" { report["codexCompatibility"] = json!({"qualification":installed_adapters::InstalledAdapter::CodexExecJson.codex_qualification(selected_status.and_then(|status| status.detected_version.as_deref())),"policy":config.codex_compatibility.value}); } + if let Some(limits) = crate::config::native_output_limits(config) { + report["nativeOutputLimits"] = limits; + } if crate::kernel_startup::PUBLISHED_KERNEL_STARTUP && !cfg!(test) { report["imageSource"] = json!("published-on-run"); } @@ -3512,6 +3515,14 @@ fn parse_duration(value: &str) -> Option { } fn map_supervisor_failure(failure: &SupervisorFailure) -> RunnerError { + let diagnostic = failure.transport_diagnostic(); + let record_limit = diagnostic + .as_ref() + .is_some_and(|value| value["reason"] == "record-byte-limit") + && matches!( + failure.kind, + FailureKind::ProtocolMalformed | FailureKind::HarnessFailed + ); let code = match failure.kind { FailureKind::HarnessUnavailable => ErrorCode::HarnessUnavailable, FailureKind::HarnessIncompatible | FailureKind::ContainmentUnsupported => { @@ -3520,6 +3531,7 @@ fn map_supervisor_failure(failure: &SupervisorFailure) -> RunnerError { FailureKind::RecursiveInvocation => ErrorCode::RecursiveInvocation, FailureKind::StartupTimeout => ErrorCode::StartupTimeout, FailureKind::RunTimeout | FailureKind::HarnessFailed => ErrorCode::HarnessFailed, + FailureKind::ProtocolMalformed if record_limit => ErrorCode::HarnessFailed, FailureKind::ProtocolMalformed => ErrorCode::ProtocolMalformed, FailureKind::ProtocolTruncated => ErrorCode::ProtocolTruncated, FailureKind::Cancelled => ErrorCode::Cancelled, @@ -3539,9 +3551,26 @@ fn map_supervisor_failure(failure: &SupervisorFailure) -> RunnerError { .map_or(Value::Null, |signal| Value::from(signal.clone())), ) .with_detail("terminalEventObserved", failure.terminal_observed); - if let Some(diagnostic) = failure.transport_diagnostic() { + if let Some(diagnostic) = diagnostic { error = error.with_detail("transportDiagnostic", diagnostic); } + if record_limit { + let taxonomy: Value = + serde_json::from_str(include_str!("../../../../shared/errors/taxonomy.v1.json")) + .expect("shared error taxonomy"); + taxonomy["recordByteLimit"]["message"] + .as_str() + .expect("record limit message") + .clone_into(&mut error.message); + taxonomy["recordByteLimit"]["action"] + .as_str() + .expect("record limit action") + .clone_into(&mut error.action); + error.retryable = taxonomy["recordByteLimit"]["retryable"] + .as_bool() + .expect("record limit retryability"); + return error.with_detail("admittedRecordCount", failure.records.len()); + } if matches!( failure.kind, FailureKind::ProtocolMalformed | FailureKind::ProtocolTruncated @@ -5686,6 +5715,34 @@ fn protocol_diagnostics_do_not_echo_native_or_observer_content() { ); } +#[test] +fn record_limit_is_resource_failure_with_frozen_recovery() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/adapters/native-output.v1.json" + )) + .unwrap(); + let mut failure = stream_observer_failure( + FailureKind::ProtocolMalformed, + "harness structured output exceeded a fixed record limit", + ); + failure.transport_diagnostic = Some(json!({"schema":"openprose.transport-diagnostic/1", + "reason":"record-byte-limit","observedBytes":1_048_577,"limitBytes":1_048_576,"saturated":false})); + let rendered = serde_json::to_value(map_supervisor_failure(&failure)).unwrap(); + for (key, expected) in fixture["recordLimits"]["error"].as_object().unwrap() { + assert_eq!(&rendered[key], expected, "{key}"); + } + assert_eq!(rendered["details"]["terminalEventObserved"], false); + assert_eq!( + rendered["details"]["transportDiagnostic"]["observedBytes"], + 1_048_577 + ); + failure.transport_diagnostic.as_mut().unwrap()["reason"] = json!("aggregate-stdout-limit"); + assert_eq!( + map_supervisor_failure(&failure).code, + ErrorCode::ProtocolMalformed + ); +} + #[test] fn rendered_error_keeps_safe_transport_diagnostic() { let failure = stream_observer_failure( diff --git a/cli/shared/capabilities/transport-limits.v1.json b/cli/shared/capabilities/transport-limits.v1.json index 0c05c44a..87506b03 100644 --- a/cli/shared/capabilities/transport-limits.v1.json +++ b/cli/shared/capabilities/transport-limits.v1.json @@ -21,6 +21,6 @@ } }, "backpressureFailure": "HARNESS_FAILED", - "oversizedRecordFailure": "PROTOCOL_MALFORMED", + "oversizedRecordFailure": "HARNESS_FAILED", "silentDropPermitted": false } diff --git a/cli/shared/errors/taxonomy.v1.json b/cli/shared/errors/taxonomy.v1.json index c0c46a39..fb2281f7 100644 --- a/cli/shared/errors/taxonomy.v1.json +++ b/cli/shared/errors/taxonomy.v1.json @@ -345,5 +345,13 @@ "message": "This example is published outside the OpenProse service and cannot be shown here.", "action": "Read the example on the web at details.webUrl when it is present, or pick an example that the `cli example list` runner operation does not mark viewOnWeb." } - ] + ], + "recordByteLimit": { + "code": "HARNESS_FAILED", + "boundary": "process", + "exitCode": 22, + "retryable": false, + "message": "A harness structured record exceeded the per-record byte limit; its JSON validity was not established.", + "action": "Reduce individual tool results or save bulk data to an artifact and return a bounded summary. Raising --native-output-bytes changes aggregate and capture budgets, not this per-record limit. Reconcile any partial effects before starting another attempt." + } } diff --git a/cli/shared/fixtures/adapters/native-output.v1.json b/cli/shared/fixtures/adapters/native-output.v1.json index a6ac26eb..a827fa06 100644 --- a/cli/shared/fixtures/adapters/native-output.v1.json +++ b/cli/shared/fixtures/adapters/native-output.v1.json @@ -33,5 +33,36 @@ "retainedRecords": 0, "observedBytes": 24 } - ] + ], + "recordLimits": { + "recordLimitBytes": 1048576, + "error": { + "code": "HARNESS_FAILED", + "boundary": "process", + "exitCode": 22, + "retryable": false, + "message": "A harness structured record exceeded the per-record byte limit; its JSON validity was not established.", + "action": "Reduce individual tool results or save bulk data to an artifact and return a bounded summary. Raising --native-output-bytes changes aggregate and capture budgets, not this per-record limit. Reconcile any partial effects before starting another attempt." + }, + "cases": [ + { + "name": "exact-record", + "recordBytes": 1048576, + "aggregateBytes": 67108864, + "accepted": true + }, + { + "name": "one-over-record", + "recordBytes": 1048577, + "aggregateBytes": 67108864, + "accepted": false + }, + { + "name": "raised-aggregate-is-not-record", + "recordBytes": 1048577, + "aggregateBytes": 134217728, + "accepted": false + } + ] + } } diff --git a/cli/shared/schemas/doctor-report.schema.json b/cli/shared/schemas/doctor-report.schema.json index 88b1d817..f9c1b75e 100644 --- a/cli/shared/schemas/doctor-report.schema.json +++ b/cli/shared/schemas/doctor-report.schema.json @@ -6,6 +6,7 @@ "additionalProperties": false, "required": ["schema", "runner", "build", "ready", "cwd", "selectedHarness", "selectedHarnessVersion", "selectedTransport", "selectedAdapterId", "promptPlacement", "isolation", "authCategory", "selectedAuthReadiness", "billingOwner", "image", "configuration", "harnesses", "problems"], "properties": { + "nativeOutputLimits": { "$ref": "native-output-limits.schema.json" }, "codexCompatibility": { "$ref": "codex-compatibility.schema.json" }, "schema": { "const": "openprose.doctor-report/1" }, "imageSource": { "const": "published-on-run", "description": "Kernel is resolved on language execution; image describes only the embedded local diagnostic fixture." }, diff --git a/cli/shared/schemas/native-output-limits.schema.json b/cli/shared/schemas/native-output-limits.schema.json index 5757e5aa..980c4189 100644 --- a/cli/shared/schemas/native-output-limits.schema.json +++ b/cli/shared/schemas/native-output-limits.schema.json @@ -4,6 +4,7 @@ "type": "object", "additionalProperties": false, "required": [ + "maxRecordBytes", "maxAggregateStdoutBytes", "maxNativeCaptureBytes", "captureEnabled" @@ -21,6 +22,9 @@ }, "captureEnabled": { "type": "boolean" + }, + "maxRecordBytes": { + "const": 1048576 } } } diff --git a/docs/native-output-budgets.md b/docs/native-output-budgets.md index a8d34720..f43d8378 100644 --- a/docs/native-output-budgets.md +++ b/docs/native-output-budgets.md @@ -1,6 +1,6 @@ # Native output budget -`--native-output-bytes 134217728` raises each native output allowance to 128 MiB. Use with `--output-contract native`. It is a runner limit, not a model token limit or instruction to change the program. +`--native-output-bytes 134217728` raises the native aggregate stdout and capture allowances to 128 MiB. Use with `--output-contract native`. It is a runner limit, not a model token limit or instruction to change the program. The omitted default remains 67,108,864 bytes (64 MiB). Explicit values are strict decimal integer bytes from 1,048,576 through 268,435,456 inclusive; no unit suffixes. Quoted TOML `native_output_bytes="134217728"` and environment `PROSE_NATIVE_OUTPUT_BYTES=134217728` follow normal flag > environment > project > user precedence. Other output modes reject an explicit selection. @@ -8,4 +8,6 @@ The allowance applies independently to raw aggregate child stdout and, when requ Single records remain limited to 1 MiB; stderr, queues, image sizes and native framing constraints retain their existing limits. Reaching a ceiling still fails and settles the process; it never silently drops output, retries, increases the allowance or synthesizes completion. Larger allowances permit additional memory/disk use and are not a guarantee of completion or exact resident-memory bounds. +Native doctor JSON, dry-run and run receipts expose the fixed 1,048,576-byte record limit as `nativeOutputLimits.maxRecordBytes`, separately from `maxAggregateStdoutBytes` and `maxNativeCaptureBytes`. A `record-byte-limit` transport diagnostic reports `HARNESS_FAILED` with exit 22 and resource-specific recovery advice; it does not establish malformed JSON or an unsupported harness. `observedBytes` is the size detected before rejection, not the complete eventual record size. Return bounded tool summaries and put bulk data in artifacts. There is no per-record setting: raising `--native-output-bytes` cannot solve this limit. Reconcile any partial effects before another attempt; the CLI does not retry automatically. + Windows stdout validation supports the same maximum in the updated bundled host; stderr validation remains unchanged. Older hosts can reject an explicitly larger request. Actual Windows runtime qualification is not established by source/schema and provider-free tests on another OS.