Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions cli/bun/test/agent-account-globals.test.ts
Original file line number Diff line number Diff line change
@@ -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 }); }
});
12 changes: 12 additions & 0 deletions cli/protocol/OWNERSHIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
62 changes: 43 additions & 19 deletions cli/rust/crates/prose-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -419,6 +419,46 @@ fn execution_image(
}
}

fn account_command_outcome(
parsed: &ParsedInvocation,
args: &[String],
system: &SystemContext,
cancellation: &CancellationToken,
) -> Option<CommandOutcome> {
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,
Expand Down Expand Up @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions cli/rust/crates/prose-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
126 changes: 126 additions & 0 deletions cli/shared/fixtures/account-global-boundaries.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
}
20 changes: 20 additions & 0 deletions docs/agent-interface-parity.md
Original file line number Diff line number Diff line change
@@ -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.
Loading