diff --git a/cli/bun/test/agent-account-globals.test.ts b/cli/bun/test/agent-account-globals.test.ts new file mode 100644 index 00000000..2361b43f --- /dev/null +++ b/cli/bun/test/agent-account-globals.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { runCli } from "../src/cli"; +import boundaries from "../../shared/fixtures/account-global-boundaries.json" with { type: "json" }; + +test("account commands reject execution-only globals without touching their credential fixture", async () => { + const root = await mkdtemp(join(tmpdir(), "prose-account-boundaries-")); + const fixture = join(root, "service.json"); + const original = "malformed fixture must not be read"; + await writeFile(fixture, original); + try { + for (const command of boundaries.commands) for (const prefix of boundaries.deniedPrefixes) { + let stdout = "", stderr = ""; + const code = await runCli(["--output", "json", ...prefix, ...command], { + env: { PROSE_TEST_SERVICE_FIXTURE: fixture }, processCwd: root, + userConfigPath: join(root, "absent.toml"), + clock: { now: () => "2026-01-01T00:00:00Z", monotonicMs: () => 0 }, + ids: { invocationId: () => "account-boundary" }, + writeStdout: text => { stdout += text; }, writeStderr: text => { stderr += text; }, + }); + const report = JSON.parse(stdout); + expect(code, JSON.stringify({ command, prefix })).toBe(boundaries.expected.exitCode); + expect(report.schema).toBe(boundaries.expected.schema); + expect(report.problem.code).toBe(boundaries.expected.problemCode); + expect(report.result).toBeNull(); + expect(stderr).toBe(boundaries.expected.stderr); + expect(await readFile(fixture, "utf8")).toBe(original); + } + await writeFile(fixture, JSON.stringify({ credential: null, storeAvailable: true, exchanges: [] })); + for (const prefix of boundaries.allowedPrefixes) { + let stdout = "", stderr = ""; + const code = await runCli([...prefix, "cli", "auth", "status"], { + env: { PROSE_TEST_SERVICE_FIXTURE: fixture }, processCwd: root, + userConfigPath: join(root, "absent.toml"), + clock: { now: () => "2026-01-01T00:00:00Z", monotonicMs: () => 0 }, + ids: { invocationId: () => "account-boundary" }, + writeStdout: text => { stdout += text; }, writeStderr: text => { stderr += text; }, + }); + expect(code).toBe(0); + expect(JSON.parse(stdout).result.authenticated).toBeFalse(); + expect(stderr).toBe(""); + } + } finally { await rm(root, { recursive: true }); } +}); diff --git a/cli/protocol/OWNERSHIP.md b/cli/protocol/OWNERSHIP.md index 031d4a5d..36516f41 100644 --- a/cli/protocol/OWNERSHIP.md +++ b/cli/protocol/OWNERSHIP.md @@ -777,3 +777,15 @@ 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 agent-interface parity — active branch-scoped lease + +Root `/root` owns branch `codex/imp-089-agent-parity` from main625106e: +`cli/rust/crates/prose-cli/src/main.rs`, +`cli/rust/crates/prose-cli/tests/cli.rs`, +`cli/bun/test/agent-account-globals.test.ts`, +`cli/shared/fixtures/account-global-boundaries.json`, and +`docs/agent-interface-parity.md`. Scope: existing account commands must reject +inapplicable runner globals before credential access or side effects, retaining +machine envelopes and allowed rendering flags. Shared controls precede changes. +No CLI redesign, new Python, provider calls, release or deployment. diff --git a/cli/rust/crates/prose-cli/src/main.rs b/cli/rust/crates/prose-cli/src/main.rs index 150f7776..eef06be7 100644 --- a/cli/rust/crates/prose-cli/src/main.rs +++ b/cli/rust/crates/prose-cli/src/main.rs @@ -1,7 +1,7 @@ mod weave_host; use prose_runner_core::error::{ErrorCode, RunnerError}; use prose_runner_core::image::RuntimeImage; -use prose_runner_core::invocation::{Action, ParsedInvocation, RunnerCommand}; +use prose_runner_core::invocation::{Action, GlobalFlags, ParsedInvocation, RunnerCommand}; use prose_runner_core::output::{CommandOutcome, error_outcome}; use prose_runner_core::runner::{ HELP, RUNNER_VERSION, execute_prime_cleanup, execute_with_cancellation_and_human_stream, @@ -419,6 +419,46 @@ fn execution_image( } } +fn account_command_outcome( + parsed: &ParsedInvocation, + args: &[String], + system: &SystemContext, + cancellation: &CancellationToken, +) -> Option { + let Action::Runner { ref command, json } = parsed.action else { + return None; + }; + if !prose_runner_core::service_account::is_service_command(command) { + return None; + } + let mode = if json { + OutputMode::Json + } else { + parsed.globals.output.unwrap_or_default() + }; + // Reject execution controls before account operations access credentials. + let rendering_flags = GlobalFlags { + output: parsed.globals.output, + no_color: parsed.globals.no_color, + verbose: parsed.globals.verbose, + ..GlobalFlags::default() + }; + let result = if parsed.globals == rendering_flags { + prose_runner_core::service_account::execute_user_command( + command, + system, + mode, + cancellation, + ) + } else { + Err(RunnerError::catalog(ErrorCode::InvocationInvalid)) + }; + Some(result.unwrap_or_else(|error| { + prose_runner_core::service::argv_error_outcome(args, &error, mode, Some(system)) + .unwrap_or_else(|| error_outcome(error, mode, &SystemClock, &SystemIdSource)) + })) +} + fn prepare( args: &[String], cancellation: &CancellationToken, @@ -461,24 +501,8 @@ fn prepare( &mut stderr, ); } - if let Action::Runner { ref command, json } = parsed.action { - if prose_runner_core::service_account::is_service_command(command) { - let mode = if json { - OutputMode::Json - } else { - parsed.globals.output.unwrap_or_default() - }; - return prose_runner_core::service_account::execute_user_command( - command, - &system, - mode, - cancellation, - ) - .unwrap_or_else(|error| { - prose_runner_core::service::argv_error_outcome(args, &error, mode, Some(&system)) - .unwrap_or_else(|| error_outcome(error, mode, &clock, &ids)) - }); - } + if let Some(outcome) = account_command_outcome(&parsed, args, &system, cancellation) { + return outcome; } let config = match resolve_config(&parsed.globals, &system) { Ok(config) => config, diff --git a/cli/rust/crates/prose-cli/tests/cli.rs b/cli/rust/crates/prose-cli/tests/cli.rs index fb0471e6..937273e5 100644 --- a/cli/rust/crates/prose-cli/tests/cli.rs +++ b/cli/rust/crates/prose-cli/tests/cli.rs @@ -5684,3 +5684,83 @@ fn closed_stdout_pipe_is_silent_success() { assert_eq!(output.status.code(), Some(0)); assert_eq!(String::from_utf8_lossy(&output.stderr), ""); } + +#[cfg(feature = "test-seams")] +#[test] +fn account_commands_reject_execution_only_globals_before_credential_access() { + let controls: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/account-global-boundaries.json" + )) + .unwrap(); + let temp = TempDir::new().unwrap(); + let fixture = temp.path().join("service.json"); + let original = "malformed fixture must not be read"; + fs::write(&fixture, original).unwrap(); + for command in controls["commands"].as_array().unwrap() { + for prefix in controls["deniedPrefixes"].as_array().unwrap() { + let mut args = vec!["--output", "json"]; + args.extend( + prefix + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()), + ); + args.extend( + command + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()), + ); + let output = Command::new(env!("CARGO_BIN_EXE_prose")) + .args(&args) + .current_dir(temp.path()) + .env_clear() + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path()) + .env("PATH", "") + .env("PROSE_TEST_SERVICE_FIXTURE", &fixture) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2), "{args:?}"); + assert!(output.stderr.is_empty(), "{args:?}"); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["schema"], controls["expected"]["schema"]); + assert_eq!( + report["problem"]["code"], + controls["expected"]["problemCode"] + ); + assert!(report["result"].is_null()); + assert_eq!(fs::read_to_string(&fixture).unwrap(), original); + } + } + fs::write( + &fixture, + json!({"credential":null,"storeAvailable":true,"exchanges":[]}).to_string(), + ) + .unwrap(); + for prefix in controls["allowedPrefixes"].as_array().unwrap() { + let mut args: Vec<&str> = prefix + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + args.extend(["cli", "auth", "status"]); + let output = Command::new(env!("CARGO_BIN_EXE_prose")) + .args(&args) + .current_dir(temp.path()) + .env_clear() + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path()) + .env("PATH", "") + .env("PROSE_TEST_SERVICE_FIXTURE", &fixture) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(0), "{args:?}"); + assert!(output.stderr.is_empty()); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["result"]["authenticated"], false); + } +} diff --git a/cli/shared/fixtures/account-global-boundaries.json b/cli/shared/fixtures/account-global-boundaries.json new file mode 100644 index 00000000..0de1bfe1 --- /dev/null +++ b/cli/shared/fixtures/account-global-boundaries.json @@ -0,0 +1,126 @@ +{ + "schema": "openprose.account-global-boundaries/1", + "summary": "Account and package commands reject execution-only runner globals before credential or network access. Rendering flags remain accepted.", + "commands": [ + [ + "cli", + "auth", + "status" + ], + [ + "cli", + "auth", + "login" + ], + [ + "cli", + "auth", + "logout" + ], + [ + "cli", + "org", + "list" + ], + [ + "cli", + "package", + "list" + ] + ], + "deniedPrefixes": [ + [ + "--harness", + "invalid" + ], + [ + "--transport", + "rpc" + ], + [ + "--cwd", + "missing" + ], + [ + "--model", + "fixture/model" + ], + [ + "--auth-profile", + "openai" + ], + [ + "--native-log", + "private-log" + ], + [ + "--output-contract", + "native" + ], + [ + "--permission-mode", + "workspace-write" + ], + [ + "--codex-compatibility", + "probe" + ], + [ + "--native-profile", + "default" + ], + [ + "--native-max-turns", + "1" + ], + [ + "--native-timeout", + "1m" + ], + [ + "--native-tool-timeout", + "1m" + ], + [ + "--native-output-bytes", + "1048576" + ], + [ + "--native-add-dir", + "extra" + ], + [ + "--native-allow-tool", + "read" + ], + [ + "--timeout", + "0" + ], + [ + "--dry-run" + ] + ], + "allowedPrefixes": [ + [ + "--output", + "json" + ], + [ + "--output", + "json", + "--no-color" + ], + [ + "--output", + "json", + "--verbose" + ] + ], + "expected": { + "exitCode": 2, + "problemCode": "INVOCATION_INVALID", + "schema": "openprose.service-operation/1", + "stderr": "" + } +} diff --git a/docs/agent-interface-parity.md b/docs/agent-interface-parity.md new file mode 100644 index 00000000..76fdb73a --- /dev/null +++ b/docs/agent-interface-parity.md @@ -0,0 +1,20 @@ +# Existing account command machine-interface boundaries + +Account and package commands accept rendering globals (`--output`, `--no-color` +and `--verbose`). Execution-only globals do not apply to them and must be +rejected before credential access or account side effects. Both implementations +emit the existing service-operation envelope with `INVOCATION_INVALID`, exit 2, +null result and empty stderr in JSON mode. + +A provider-free audit compared 54 command/global combinations against compiled +Bun/Rust products at the integrated Codex-compatibility tree. Three cells showed +Rust silently ignoring `--harness`, `--timeout` or `--cwd` on account status, +while Bun rejected them. Shared controls now cover all 18 execution globals +against five existing account/package commands and retain rendering positives. +A malformed credential fixture establishes that rejection precedes fixture +parsing; a separate valid fixture checks the allowed signed-out response. + +The Rust regression fails on the unchanged source and passes after adding the +rendering-only boundary. Bun passes the same independently frozen controls +without a product change. This fixes a concrete existing interface discrepancy; +it does not redesign discovery or qualify a live service or model route.