oneharness_core/domain/harness.rs
1//! The harness registry: one declarative adapter per supported CLI.
2//!
3//! An adapter is data — a canonical id, a default binary, an install hint, an
4//! output format — plus one pure function that builds the argv. Adding a harness
5//! is adding an entry here; `run`, the runner, and the report shape are untouched.
6//!
7//! The flags encoded below mirror the known-good non-interactive invocations used
8//! to drive each real CLI headlessly (deny prompts, pick the model, request a
9//! parseable format). Source new flags from a working driver, not by guessing.
10
11use crate::domain::gate::DenyShape;
12use crate::domain::hooks::HookShape;
13use crate::domain::mock::{MockDelivery, RewriteShape};
14use crate::domain::mode::{ModeHeadless, PermissionMode};
15use crate::domain::report::OutputFormat;
16use crate::domain::structured::NativeSchema;
17
18/// Everything `build_argv` needs, with no I/O: the resolved binary, the prompt,
19/// the optional model, whether to request the harness's "don't prompt" mode, and
20/// the effective output format (the harness default, or a `--output-format`
21/// override) for harnesses that take a format flag.
22pub struct BuildCtx<'a> {
23 pub bin: &'a str,
24 pub prompt: &'a str,
25 pub model: Option<&'a str>,
26 /// System prompt to apply. Adapters with a native system flag map it (Claude
27 /// Code's `--append-system-prompt`, Goose's `--system`); adapters without one
28 /// prepend it to the prompt via `prompt_with_system` so the instructions
29 /// still reach the model, rather than dropping it.
30 pub system: Option<&'a str>,
31 /// Session id to continue, for harnesses that support resumption. Only set
32 /// after the command layer has verified the selected harness's
33 /// `supports_resume`, so an adapter that maps it can assume support.
34 pub resume: Option<&'a str>,
35 /// When resuming, branch a *new* session from the resumed one instead of
36 /// appending to it — so the original (and its cached prefix) is untouched and
37 /// can seed independent follow-ups. Only honored alongside `resume`, and only
38 /// set after the command layer has verified `supports_fork`; an adapter maps it
39 /// to its native fork flag (Claude Code's `--fork-session`, OpenCode's
40 /// `--fork`). Ignored by adapters that cannot fork (they are never selected
41 /// with it).
42 pub fork: bool,
43 /// The normalized approval mode to request. Each adapter maps it to its
44 /// harness's native mechanism (argv flags here; any environment via the
45 /// matching [`ModeSpec`]). The command layer guarantees the selected harness
46 /// actually supports `mode` before calling `build_argv`, so an adapter only
47 /// needs correct output for the modes in its [`HarnessSpec::modes`].
48 pub mode: PermissionMode,
49 pub output_format: OutputFormat,
50 /// Inline JSON-Schema text to deliver through the harness's *native*
51 /// structured-output flag, set only for an adapter with a
52 /// [`HarnessSpec::native_schema`] when a schema run is requested. Adapters
53 /// without native support ignore it — the command layer instead appends the
54 /// schema instruction to the prompt — so it is never silently dropped.
55 pub schema: Option<&'a str>,
56}
57
58/// The CLI token for a format, as the harnesses spell it.
59fn format_flag(format: OutputFormat) -> &'static str {
60 match format {
61 OutputFormat::Text => "text",
62 OutputFormat::Json => "json",
63 OutputFormat::StreamJson => "stream-json",
64 }
65}
66
67/// The prompt an adapter should send, with the system instructions prepended when
68/// the harness has no native system flag. This is how `--system` reaches models
69/// on harnesses like Codex/OpenCode that expose no system-prompt option — without
70/// it the instructions would be silently dropped. A blank system prompt is a
71/// no-op. Adapters with a native flag (claude-code, goose) pass `c.prompt`
72/// directly and map `c.system` separately instead of calling this.
73fn prompt_with_system(c: &BuildCtx) -> String {
74 match c.system {
75 Some(s) if !s.is_empty() => format!("{s}\n\n{}", c.prompt),
76 _ => c.prompt.to_string(),
77 }
78}
79
80/// A single harness adapter.
81pub struct HarnessSpec {
82 /// Canonical id used on the CLI and in JSON (e.g. `claude-code`).
83 pub id: &'static str,
84 /// Human-friendly name for `list`.
85 pub display: &'static str,
86 /// Binary name looked up on PATH unless overridden.
87 pub default_bin: &'static str,
88 /// How a user installs the CLI (shown when it is missing).
89 pub install_hint: &'static str,
90 /// The format the adapter requests, which drives text extraction.
91 pub output_format: OutputFormat,
92 /// The format to switch this harness to when the caller asks for tool
93 /// **events** (`--events` / `--stream`) and its *default* format carries no
94 /// machine-readable tool transcript — so `events` can be surfaced without the
95 /// caller knowing each CLI's quirk. `None` means either the default already
96 /// carries a transcript (OpenCode's `json`, Cursor's `stream-json` — no
97 /// upgrade needed) or the harness exposes no events-capable format at all
98 /// (the plain-text harnesses); in both cases `--events` leaves the format
99 /// unchanged. When `Some`, the command layer selects it under `--events`
100 /// unless the caller set an explicit `--output-format`. Claude Code needs
101 /// `stream-json` (its default single-document `json` result omits the
102 /// transcript). Sourced from each CLI's real output, never guessed; text/usage
103 /// extraction must still work under the upgraded format (verified live).
104 pub events_format: Option<OutputFormat>,
105 /// Whether this harness can continue a prior session (`run --resume`). When
106 /// false, the command layer rejects `--resume` for it rather than silently
107 /// starting a fresh session. Kept as data so the capability is introspectable
108 /// via `oneharness list`.
109 pub supports_resume: bool,
110 /// Whether this harness can *fork* a session when resuming (`run --resume
111 /// <id> --fork`): branch a new session id from the resumed one, leaving the
112 /// original untouched so its cached prefix seeds independent follow-ups. Only
113 /// two CLIs expose a headless fork flag (Claude Code's `--fork-session`,
114 /// OpenCode's `--fork`); the rest resume linearly (append in place). When
115 /// false, `--fork` is a loud usage error for the harness, never a silent
116 /// linear resume. Implies `supports_resume`. Introspectable via `oneharness
117 /// list`.
118 pub supports_fork: bool,
119 /// Whether a forked run *reuses* the parent session's provider prompt-cache
120 /// prefix — so a fork-based `min-tokens` batch (warm one prompt, fork the
121 /// rest) actually *reduces* tokens. Implies `supports_fork`. This is the gate
122 /// for the fork-based batch path: when false, `min-tokens` only orders the
123 /// calls (no token saving) rather than forking. Measured by the live e2e
124 /// (`oh_batch_fork_enforce`), never guessed: **true for Claude Code** (Anthropic
125 /// prompt caching, and `--fork-session` preserves the cached session prefix);
126 /// **false for OpenCode**, whose `--fork` re-sends the branched conversation
127 /// cold (the fan-out reads nothing and re-writes the whole prefix — measured,
128 /// so forking it would *raise* tokens, not lower them). Introspectable via
129 /// `oneharness list`.
130 pub fork_reuses_cache: bool,
131 /// Where this harness reads project-scoped configuration, and how the
132 /// unified enforcement settings (`allowed_tools` / `denied_tools` /
133 /// `hooks` / `settings`) map into that file. `None` means the harness has
134 /// no project-level config file oneharness knows how to write (Codex and
135 /// Goose read only user-global config; Copilot takes permission rules as
136 /// flags, deliverable via `[harness.copilot] args`) — configuring a sync
137 /// setting for it is then a loud usage error, never a silent no-op.
138 /// Consumed by `oneharness sync`; nothing here is passed on the argv.
139 pub sync: Option<SyncSpec>,
140 /// How a normalized pre-tool [`crate::domain::hooks::HookSpec`] is installed
141 /// into this harness — the shape its config expects and where the file lands
142 /// (a shared config file, a dedicated hooks file, or a plugin). `None` for a
143 /// harness oneharness cannot wire a hook into. Consumed by `oneharness sync`
144 /// / `src/io/hooks.rs`; nothing here is passed on the argv.
145 pub hooks: Option<HookBinding>,
146 /// Where this harness reads a *user-global* hook, for an `io::hooks::install`
147 /// at [`crate::io::hooks::Scope::Global`] (the project path lives in
148 /// [`HookBinding`]). The install strategy is identical to the project one;
149 /// only the anchor moves to a `$HOME`/`$XDG_CONFIG_HOME` location. `None` for
150 /// a harness with no user-global hook location oneharness knows. Sourced from
151 /// the allowlister adapters, never guessed.
152 pub global_hook: Option<GlobalHook>,
153 /// How this harness expresses a pre-tool *deny* when its installed hook runs
154 /// `oneharness gate <id>` — the runtime counterpart to [`HookBinding`]. `None`
155 /// for a harness with no gateable pre-tool hook. Consumed by the `gate`
156 /// command (`src/commands/gate.rs`); sourced from the allowlister adapters.
157 pub gate_deny: Option<DenyShape>,
158 /// How this harness expresses a pre-tool *input rewrite* when its installed
159 /// hook runs `oneharness mock <id>` — allow the call with substituted
160 /// arguments, the mock workhorse (swap a shell command for a stub that
161 /// prints canned output). `None` for a harness whose hook protocol has no
162 /// rewrite verdict (Goose), or whose support is not yet verified through
163 /// `oneharness run` (Codex, Copilot, Cursor — pending the `explore-hooks`
164 /// probe; see `docs/mock-spy-design.md`): a rewrite rule for it is then a
165 /// loud usage error, never a silent allow. Sourced from each CLI's hook
166 /// docs; honored-live is drift-alarmed by the `oh_mock_enforce` e2e phases.
167 pub mock_rewrite: Option<RewriteShape>,
168 /// How `run --mock-rules`/`run --spy-file` delivers the mock hook for ONE
169 /// invocation (see [`MockDelivery`]): Claude Code takes it on the argv via
170 /// `--settings` (zero workspace mutation); the rest get a project-scope
171 /// install that is snapshotted and restored around the run, layering on top
172 /// of any existing config non-destructively. `None` for a harness whose
173 /// hooks cannot fire from a project-scope headless run (qwen: user scope
174 /// only; copilot: probe-refuted entirely) — the flag is then a loud usage
175 /// error, never a silently inert install.
176 pub mock_delivery: Option<MockDelivery>,
177 /// Environment variables oneharness sets when spawning this harness, so a
178 /// headless run is clean without the caller knowing the harness's quirks
179 /// (e.g. silencing a startup warning that would otherwise litter `stderr`).
180 /// Pure data: the registry declares them; the command/io layer injects them,
181 /// and an explicit `--env` always wins over a default here. Empty for most.
182 pub default_env: &'static [(&'static str, &'static str)],
183 /// How this harness accepts a JSON Schema *natively*, when it does — the
184 /// schema is delivered through its own CLI flag and the conforming value
185 /// read from a known field, rather than appended to the prompt. `None` means
186 /// structured-output runs fall back to the portable prompt-based path (which
187 /// works for every harness). Either way oneharness validates the result
188 /// itself, so a native flag the harness ignores is still caught.
189 pub native_schema: Option<NativeSchema>,
190 /// The approval modes this harness can express, each with how it behaves in
191 /// a headless run. A [`PermissionMode`] absent from this list is unsupported
192 /// for the harness — the command layer turns a request for it into a loud
193 /// usage error rather than silently downgrading. Every harness lists
194 /// [`PermissionMode::Bypass`] (the headless default) and
195 /// [`PermissionMode::Default`]. Sourced from each CLI's docs/behavior, never
196 /// guessed (see the README support matrix and `AGENTS.md`).
197 pub modes: &'static [ModeSpec],
198 /// Builds the full argv (argv[0] is the binary). Pure.
199 pub build_argv: fn(&BuildCtx) -> Vec<String>,
200}
201
202impl HarnessSpec {
203 /// The [`ModeSpec`] for `mode`, or `None` when this harness cannot express
204 /// it. The lookup the command layer uses to gate a run and to inject any
205 /// per-mode environment.
206 pub fn mode(&self, mode: PermissionMode) -> Option<&'static ModeSpec> {
207 self.modes.iter().find(|m| m.mode == mode)
208 }
209}
210
211/// One approval mode a harness supports: its headless behavior and any
212/// environment that delivers it. Most modes are expressed on the argv by
213/// `build_argv`; a few harnesses (Goose) carry the mode in the environment
214/// instead, declared here so the command layer injects it when spawning.
215pub struct ModeSpec {
216 pub mode: PermissionMode,
217 /// Whether this mode blocks on an interactive prompt headlessly.
218 pub headless: ModeHeadless,
219 /// Environment variables that select this mode (Goose's `GOOSE_MODE`,
220 /// OpenCode's `OPENCODE_CONFIG_CONTENT`). Empty when `build_argv` expresses
221 /// the mode on the argv. Injected like the harness's `default_env` (so a
222 /// config / `--env` value still wins).
223 pub env: &'static [(&'static str, &'static str)],
224 /// A per-run instruction prepended to the prompt to induce a *behavioral*
225 /// mode the harness can't express natively, paired with the enforcement that
226 /// `build_argv`/`env` provides. Used for Codex's `plan`: the read-only
227 /// sandbox enforces no-mutation and this instruction induces the planning
228 /// behavior (mirroring Codex's own interactive Plan-mode template). `None`
229 /// for modes a harness expresses natively (their own plan/agent mode already
230 /// carries the behavior). Prepended by the command layer; kept single-line.
231 pub instruction: Option<&'static str>,
232}
233
234/// Shorthand for an argv-expressed mode (no environment, no instruction): the
235/// common case.
236const fn mode(mode: PermissionMode, headless: ModeHeadless) -> ModeSpec {
237 ModeSpec {
238 mode,
239 headless,
240 env: &[],
241 instruction: None,
242 }
243}
244
245/// A harness's project-scoped config file and the key paths the unified
246/// settings merge into. All paths were sourced from each CLI's documentation —
247/// never guessed (see the README support matrix for the references).
248pub struct SyncSpec {
249 /// Project-relative path of the config file to create or merge into.
250 pub file: &'static str,
251 /// Alternative file names the harness reads with *higher* precedence;
252 /// when one exists, oneharness merges into it instead of `file` so it
253 /// never creates a second, shadowed config (e.g. crush's `.crush.json`).
254 pub alt_files: &'static [&'static str],
255 /// Key path `allowed_tools` rules land at (e.g. `permissions.allow`);
256 /// `None` rejects the unified list field for this harness (OpenCode's
257 /// `permission` is a policy map, not a list — use `settings` instead).
258 pub allow_path: Option<&'static [&'static str]>,
259 /// Key path `denied_tools` rules land at. For crush this is
260 /// `options.disabled_tools`: the tool is hidden from the agent entirely,
261 /// the strongest deny it offers.
262 pub deny_path: Option<&'static [&'static str]>,
263 /// Key path the `hooks` table lands at (Claude Code's top-level `hooks`).
264 pub hooks_path: Option<&'static [&'static str]>,
265 /// JSON merged *beneath* any top-level key the fragment touches, so a
266 /// partial write still satisfies the harness's schema. Cursor's
267 /// `.cursor/cli.json` requires `permissions.allow` and `permissions.deny`
268 /// to both exist whenever `permissions` does (its CLI rejects the file
269 /// otherwise — caught by the live e2e), so writing only an allow list
270 /// must seed an empty deny. Keys the fragment doesn't touch are never
271 /// seeded, preserving the "only keys oneharness manages" contract.
272 pub schema_seed: Option<&'static str>,
273}
274
275/// How a normalized hook reaches one harness. The [`HookShape`] (where present)
276/// is the JSON layout [`crate::domain::hooks::render`] produces; the variant is
277/// where that JSON — or, for OpenCode, a JS shim — is written. All eight
278/// harnesses gate the same pre-tool moment; only the file and the shape differ.
279pub enum HookBinding {
280 /// Merge the rendered hook under `path` in the harness's *existing* config
281 /// file (`SyncSpec.file`/`alt_files`) — hooks share the permissions file
282 /// (Claude Code, Qwen, Crush).
283 SameFile {
284 shape: HookShape,
285 path: &'static [&'static str],
286 },
287 /// Merge into a *dedicated* JSON file, created (seeded with `seed`) if
288 /// absent. `file` may contain `{name}` for the plugin identity (Copilot's
289 /// per-owner file). Codex, Cursor, Copilot.
290 File {
291 shape: HookShape,
292 file: &'static str,
293 path: &'static [&'static str],
294 seed: Option<&'static str>,
295 },
296 /// Goose plugin: a one-time `<plugins_dir>/<name>/plugin.json` manifest plus
297 /// the hook merged under `path` in `<plugins_dir>/<name>/hooks/hooks.json`.
298 GoosePlugin {
299 shape: HookShape,
300 plugins_dir: &'static str,
301 manifest: &'static str,
302 path: &'static [&'static str],
303 },
304 /// OpenCode can only block from an in-process plugin, so the hook is a JS
305 /// shim at `<plugin_dir>/<name>.js` that bridges to the command; rendered
306 /// from `template` (not a JSON shape).
307 JsPlugin {
308 plugin_dir: &'static str,
309 template: &'static str,
310 },
311}
312
313/// The base directory a user-global hook anchors under. Resolved by the I/O
314/// layer from the environment; kept abstract here so the registry stays pure.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum HookBase {
317 /// `$HOME`.
318 Home,
319 /// `$XDG_CONFIG_HOME`, falling back to `$HOME/.config` (Crush, OpenCode).
320 ConfigHome,
321}
322
323/// Where a harness reads a *user-global* hook. The install strategy is the
324/// matching [`HookBinding`] variant — only the anchor differs, because several
325/// harnesses place the global hook at a different relative path than the project
326/// one (Copilot's `.github/hooks` becomes `~/.copilot/hooks`; Crush and OpenCode
327/// move under the XDG config dir). `{name}` is the plugin identity.
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub struct GlobalHook {
330 pub base: HookBase,
331 /// Anchor relative to `base`: the settings/hooks file for the JSON-merge
332 /// strategies, the `.js` shim for OpenCode, or the plugin directory for
333 /// Goose — i.e. the same thing the [`HookBinding`] anchors at under a project.
334 pub anchor: &'static str,
335}
336
337/// The plan instruction synthesized for Codex, whose `exec` has no native plan
338/// mode: the read-only sandbox (`build_argv`) enforces no-mutation and this
339/// induces the planning behavior, together reproducing Codex's own interactive
340/// Plan mode (which is exactly a read-only sandbox + a plan template). Mirrors
341/// the semantics of that template (`codex-rs/.../templates/plan.md`). Kept on a
342/// single line — the command layer prepends it to the prompt.
343const CODEX_PLAN_INSTRUCTION: &str = "PLAN MODE: research the task and produce an implementation plan only — do not edit or create files and do not run mutating commands (reading and searching files, configs, and docs is allowed); even if asked to execute, treat it as a request to plan the execution. Reply with a short intent paragraph, explicit in-scope vs out-of-scope, then a 6-10 item ordered checklist (discovery, changes, tests, rollout). The task to plan:";
344
345/// Goose plugin manifest, `{name}` filled with the plugin identity. Written once
346/// and preserved; the merge is idempotent so re-syncing changes nothing.
347const GOOSE_MANIFEST: &str = r#"{
348 "name": "{name}",
349 "version": "0.1.0",
350 "description": "Pre-tool hook installed by oneharness."
351}"#;
352
353/// OpenCode plugin shim. `{export}` is a JS-identifier-safe plugin name,
354/// `{argv}` the command as a JSON argv array, `{name}` the display name. It
355/// spawns the command before every tool call, pipes `{tool_name, tool_input,
356/// cwd, session_id}` on stdin (the camelCase `input.sessionID` normalized to
357/// snake_case, omitted when absent), throws to block on a
358/// `{"decision":"deny"}` reply, and merges a reply's `updated_input` object
359/// into the tool's mutable `args` (OpenCode's documented pre-execution
360/// mutation point — how `oneharness mock` rewrites a call) — failing open if
361/// the command cannot run. Mirrors the known-good allowlister shim; pinned in
362/// the `io::hooks` tests.
363const OPENCODE_PLUGIN_JS: &str = r#"// {name} OpenCode plugin — installed by oneharness.
364//
365// OpenCode can block a tool call only from an in-process plugin, so this shim
366// bridges to an external command: before any tool runs it spawns the command,
367// piping the tool name and arguments as JSON on stdin, throws to block when
368// the command replies with {"decision":"deny"}, and applies a reply's
369// {"updated_input":{...}} by merging it into the tool's mutable args (an input
370// rewrite, for `oneharness mock`). Re-running sync regenerates it.
371
372export const {export} = async ({ directory }) => ({
373 "tool.execute.before": async (input, output) => {
374 const tool_name = (input && input.tool) || "";
375 if (!tool_name) return;
376 const args = (output && output.args) || {};
377 const cwd = args.workdir || directory || ".";
378 const session_id = (input && input.sessionID) || undefined;
379 const event = JSON.stringify({ tool_name, tool_input: args, cwd, session_id });
380
381 let stdout = "";
382 try {
383 const proc = Bun.spawn({argv}, {
384 cwd,
385 stdin: new TextEncoder().encode(event),
386 stdout: "pipe",
387 stderr: "ignore",
388 });
389 stdout = await new Response(proc.stdout).text();
390 await proc.exited;
391 } catch (_) {
392 return; // fail open: if the gate command cannot run, never block
393 }
394
395 const trimmed = stdout.trim();
396 if (trimmed.length === 0) return; // no objection — let it run
397 let decision;
398 try {
399 decision = JSON.parse(trimmed);
400 } catch (_) {
401 return; // unparseable output: fail open
402 }
403 if (decision && decision.decision === "deny") {
404 throw new Error(decision.reason || "Blocked by {name}");
405 }
406 if (
407 decision &&
408 decision.updated_input &&
409 typeof decision.updated_input === "object" &&
410 !Array.isArray(decision.updated_input) &&
411 output &&
412 output.args
413 ) {
414 Object.assign(output.args, decision.updated_input);
415 }
416 },
417});
418"#;
419
420/// All supported harnesses, in a stable order.
421pub fn all() -> &'static [HarnessSpec] {
422 REGISTRY
423}
424
425/// Look up a harness by its canonical id.
426pub fn by_id(id: &str) -> Option<&'static HarnessSpec> {
427 REGISTRY.iter().find(|h| h.id == id)
428}
429
430/// Comma-joined list of valid ids, for error messages and help.
431pub fn valid_ids() -> String {
432 REGISTRY.iter().map(|h| h.id).collect::<Vec<_>>().join(", ")
433}
434
435static REGISTRY: &[HarnessSpec] = &[
436 HarnessSpec {
437 id: "claude-code",
438 display: "Claude Code",
439 default_bin: "claude",
440 install_hint: "npm install -g @anthropic-ai/claude-code",
441 output_format: OutputFormat::Json,
442 // The default `json` result carries no transcript; `stream-json` emits the
443 // Anthropic content-block stream oneharness normalizes into `events`.
444 events_format: Some(OutputFormat::StreamJson),
445 supports_resume: true,
446 supports_fork: true,
447 fork_reuses_cache: true,
448 sync: Some(SyncSpec {
449 file: ".claude/settings.json",
450 alt_files: &[],
451 allow_path: Some(&["permissions", "allow"]),
452 deny_path: Some(&["permissions", "deny"]),
453 hooks_path: Some(&["hooks"]),
454 schema_seed: None,
455 }),
456 hooks: Some(HookBinding::SameFile {
457 shape: HookShape::Nested {
458 event: "PreToolUse",
459 // Claude Code's PreToolUse hook object accepts a per-hook
460 // `timeout` (sourced from the allowlister adapter, which sets
461 // one); emit it when a `[[hooks]]` entry provides one.
462 with_timeout: true,
463 },
464 path: &["hooks"],
465 }),
466 global_hook: Some(GlobalHook {
467 base: HookBase::Home,
468 anchor: ".claude/settings.json",
469 }),
470 gate_deny: Some(DenyShape::ClaudeNested),
471 // PreToolUse output: `permissionDecision: "allow"` + `updatedInput`
472 // (the documented input-rewrite verdict; docs/mock-spy-design.md).
473 mock_rewrite: Some(RewriteShape::ClaudeNested),
474 // Probe-verified: hooks load from a per-run `--settings <file>` in `-p`
475 // mode — the zero-mutation delivery (existing config still applies).
476 mock_delivery: Some(MockDelivery::SettingsFlag { flag: "--settings" }),
477 default_env: &[],
478 native_schema: Some(NativeSchema::ClaudeJsonSchema),
479 // `--permission-mode` covers the whole spectrum, all honored under `-p`.
480 // `default` maps to `dontAsk` (deny-and-continue), not `default` (which
481 // aborts on an un-allowed tool), so the ask flow never hangs headless.
482 // `read-only` is `bypassPermissions` with the mutating tools denied (deny
483 // rules win even under bypass), distinct from `plan`'s plan workflow.
484 modes: &[
485 mode(PermissionMode::ReadOnly, ModeHeadless::Clean),
486 mode(PermissionMode::Plan, ModeHeadless::Clean),
487 mode(PermissionMode::Default, ModeHeadless::Clean),
488 mode(PermissionMode::Edit, ModeHeadless::Clean),
489 mode(PermissionMode::Auto, ModeHeadless::Clean),
490 mode(PermissionMode::Bypass, ModeHeadless::Clean),
491 ],
492 build_argv: argv_claude_code,
493 },
494 HarnessSpec {
495 id: "codex",
496 display: "OpenAI Codex CLI",
497 default_bin: "codex",
498 install_hint: "npm install -g @openai/codex",
499 output_format: OutputFormat::Text,
500 // `codex exec --json` emits a JSONL event stream whose `command_execution`
501 // items oneharness normalizes into `events` (the plain default has no
502 // transcript). A non-text format maps to `--json` in `argv_codex`.
503 events_format: Some(OutputFormat::Json),
504 supports_resume: true,
505 supports_fork: false,
506 fork_reuses_cache: false,
507 sync: None,
508 hooks: Some(HookBinding::File {
509 shape: HookShape::Nested {
510 event: "PreToolUse",
511 with_timeout: false,
512 },
513 file: ".codex/hooks.json",
514 path: &["hooks"],
515 seed: None,
516 }),
517 global_hook: Some(GlobalHook {
518 base: HookBase::Home,
519 anchor: ".codex/hooks.json",
520 }),
521 gate_deny: Some(DenyShape::ClaudeNested),
522 // Probe-verified (2026-07-06, codex v0.142.5): `codex exec` DOES load
523 // project `.codex/hooks.json` and honors the claude-nested
524 // `updatedInput` rewrite — but ONLY when the invocation carries
525 // `-c features.hooks=true --dangerously-bypass-hook-trust` (the
526 // `projects.<dir>.trust_level="trusted"` config route yields zero hook
527 // events). oneharness does not add those flags itself; the caller
528 // passes them per run (config `args` / `--` passthrough), as the
529 // `oh_mock_enforce codex` phase does.
530 mock_rewrite: Some(RewriteShape::ClaudeNested),
531 // Project .codex/hooks.json, restored after the run; the opt-in flags
532 // the probe proved necessary are auto-appended to the argv.
533 mock_delivery: Some(MockDelivery::ProjectHooks {
534 extra_args: &[
535 "-c",
536 "features.hooks=true",
537 "--dangerously-bypass-hook-trust",
538 ],
539 }),
540 default_env: &[],
541 // Codex `exec` *does* have a native schema flag (`--output-schema <file>`),
542 // but it takes a schema FILE (not inline) and is reportedly ignored once
543 // the agent uses tools: https://github.com/openai/codex/issues/15451 — so
544 // structured output uses the more reliable prompt-based path for it today.
545 // To wire it up once that's resolved: add a `CodexOutputSchema` variant to
546 // `structured::NativeSchema`, set it here, add a `--output-schema` arm to
547 // `argv_codex` (the command layer must materialize the schema to a temp
548 // file and pass its path via `BuildCtx.schema`), and teach
549 // `structured::extract_value` where Codex reports the conforming value.
550 native_schema: None,
551 // `codex exec` gates by sandbox, not by op-type, and downgrades approval
552 // to `never` (it never hangs — out-of-sandbox actions fail closed and the
553 // agent continues). `read-only` is the (OS-enforced) read-only sandbox,
554 // `auto` is `workspace-write`. Codex has no *native* plan mode in `exec`
555 // (its TUI Plan mode = read-only sandbox + a plan instruction, both
556 // reproducible here), so `plan` is synthesized: same read-only sandbox
557 // plus the `instruction` below. No edit-vs-shell split, so no `edit`.
558 modes: &[
559 mode(PermissionMode::ReadOnly, ModeHeadless::Clean),
560 ModeSpec {
561 mode: PermissionMode::Plan,
562 headless: ModeHeadless::Clean,
563 env: &[],
564 instruction: Some(CODEX_PLAN_INSTRUCTION),
565 },
566 mode(PermissionMode::Default, ModeHeadless::Clean),
567 mode(PermissionMode::Auto, ModeHeadless::Clean),
568 mode(PermissionMode::Bypass, ModeHeadless::Clean),
569 ],
570 build_argv: argv_codex,
571 },
572 HarnessSpec {
573 id: "opencode",
574 display: "OpenCode",
575 default_bin: "opencode",
576 install_hint: "npm install -g opencode-ai",
577 output_format: OutputFormat::Json,
578 // Default `json` (JSONL) already carries the `tool` parts, so no upgrade.
579 events_format: None,
580 supports_resume: true,
581 supports_fork: true,
582 // OpenCode can fork, but its fork re-sends the branched conversation cold
583 // (measured: the fan-out reads no cache and re-writes the whole prefix),
584 // so a fork-based min-tokens would raise tokens, not lower them.
585 fork_reuses_cache: false,
586 sync: Some(SyncSpec {
587 file: "opencode.json",
588 alt_files: &[],
589 allow_path: None,
590 deny_path: None,
591 hooks_path: None,
592 schema_seed: None,
593 }),
594 hooks: Some(HookBinding::JsPlugin {
595 plugin_dir: ".opencode/plugin",
596 template: OPENCODE_PLUGIN_JS,
597 }),
598 global_hook: Some(GlobalHook {
599 base: HookBase::ConfigHome,
600 anchor: "opencode/plugin/{name}.js",
601 }),
602 gate_deny: Some(DenyShape::Decision("deny")),
603 // The oneharness plugin shim applies `updated_input` by merging it into
604 // the tool's mutable `args` at `tool.execute.before` (the officially
605 // documented mutation point of OpenCode's plugin API).
606 mock_rewrite: Some(RewriteShape::OpencodeShim),
607 mock_delivery: Some(MockDelivery::ProjectHooks { extra_args: &[] }),
608 default_env: &[],
609 native_schema: None,
610 // The built-in `plan` agent is read-only, so `plan` and `read-only` both
611 // map to it. `default` is clean headless: OpenCode's out-of-box default
612 // is `allow`, and even an `ask` permission *auto-rejects* (deny-and-
613 // continue) under `opencode run` rather than blocking — it never hangs.
614 // `edit` (auto-approve edits, gate bash) is delivered per-run through the
615 // inline-config env var `OPENCODE_CONFIG_CONTENT` (highest-precedence
616 // config, set without touching `opencode.json`) — no argv flag exists.
617 // Bypass auto-approves all but explicit denies. There is no classifier
618 // `auto`.
619 modes: &[
620 mode(PermissionMode::ReadOnly, ModeHeadless::Clean),
621 mode(PermissionMode::Plan, ModeHeadless::Clean),
622 mode(PermissionMode::Default, ModeHeadless::Clean),
623 ModeSpec {
624 mode: PermissionMode::Edit,
625 headless: ModeHeadless::Clean,
626 env: &[(
627 "OPENCODE_CONFIG_CONTENT",
628 r#"{"permission":{"edit":"allow","bash":"deny"}}"#,
629 )],
630 instruction: None,
631 },
632 mode(PermissionMode::Bypass, ModeHeadless::Clean),
633 ],
634 build_argv: argv_opencode,
635 },
636 HarnessSpec {
637 id: "goose",
638 display: "Goose",
639 default_bin: "goose",
640 install_hint: "see https://block.github.io/goose/docs/getting-started/installation",
641 output_format: OutputFormat::Text,
642 // Events pending investigation (see the events matrix).
643 events_format: None,
644 supports_resume: true,
645 supports_fork: false,
646 fork_reuses_cache: false,
647 sync: None,
648 hooks: Some(HookBinding::GoosePlugin {
649 shape: HookShape::Nested {
650 event: "PreToolUse",
651 with_timeout: true,
652 },
653 plugins_dir: ".agents/plugins",
654 manifest: GOOSE_MANIFEST,
655 path: &["hooks"],
656 }),
657 global_hook: Some(GlobalHook {
658 base: HookBase::Home,
659 anchor: ".agents/plugins/{name}",
660 }),
661 gate_deny: Some(DenyShape::Decision("block")),
662 // Goose hooks can only block (exit 2 / `decision: "block"`); its
663 // protocol has no input-rewrite verdict — deny is its mock ceiling.
664 mock_rewrite: None,
665 // Deny/spy rules still deliver (its project plugin hooks fire live).
666 mock_delivery: Some(MockDelivery::ProjectHooks { extra_args: &[] }),
667 default_env: &[],
668 native_schema: None,
669 // Goose has no mode flag on `goose run`; the mode is the `GOOSE_MODE`
670 // environment variable (highest precedence over its config.yaml). None of
671 // these hang headlessly: `approve`/`smart_approve` fail *closed* with an
672 // error when a tool needs approval (exit non-zero rather than block),
673 // `auto` approves everything. Goose has no headless plan workflow and no
674 // per-run read-only-with-reads (its `chat` mode disables *all* tools,
675 // reads included, so it is neither) — `plan`/`read-only`/`edit` are
676 // absent. Bypass MUST set `GOOSE_MODE=auto` explicitly: leaving it unset
677 // would inherit the user's config, which may be a fail-closed mode.
678 modes: &[
679 ModeSpec {
680 mode: PermissionMode::Default,
681 headless: ModeHeadless::Clean,
682 env: &[("GOOSE_MODE", "approve")],
683 instruction: None,
684 },
685 ModeSpec {
686 mode: PermissionMode::Auto,
687 headless: ModeHeadless::Clean,
688 env: &[("GOOSE_MODE", "smart_approve")],
689 instruction: None,
690 },
691 ModeSpec {
692 mode: PermissionMode::Bypass,
693 headless: ModeHeadless::Clean,
694 env: &[("GOOSE_MODE", "auto")],
695 instruction: None,
696 },
697 ],
698 build_argv: argv_goose,
699 },
700 HarnessSpec {
701 id: "qwen",
702 display: "Qwen Code",
703 default_bin: "qwen",
704 install_hint: "npm install -g @qwen-code/qwen-code",
705 output_format: OutputFormat::Text,
706 // Qwen's `--output-format stream-json` emits the Anthropic content-block
707 // stream oneharness normalizes into `events` (its default text has no
708 // transcript). Mapped to `--output-format stream-json` in `argv_qwen`.
709 events_format: Some(OutputFormat::StreamJson),
710 supports_resume: true,
711 supports_fork: false,
712 fork_reuses_cache: false,
713 sync: Some(SyncSpec {
714 file: ".qwen/settings.json",
715 alt_files: &[],
716 allow_path: Some(&["permissions", "allow"]),
717 deny_path: Some(&["permissions", "deny"]),
718 hooks_path: None,
719 schema_seed: None,
720 }),
721 hooks: Some(HookBinding::SameFile {
722 shape: HookShape::Nested {
723 event: "PreToolUse",
724 with_timeout: false,
725 },
726 path: &["hooks"],
727 }),
728 global_hook: Some(GlobalHook {
729 base: HookBase::Home,
730 anchor: ".qwen/settings.json",
731 }),
732 gate_deny: Some(DenyShape::ClaudeNested),
733 // Qwen's docs describe the same `updatedInput` rewrite as Claude Code,
734 // but it is NOT honored live: with the hook demonstrably firing (its
735 // gate deny passed at the same global scope) and the allow+updatedInput
736 // verdict emitted, the ORIGINAL command still ran — measured by
737 // oh_mock_enforce on all three OSes (2026-07-06, `--yolo`). Absent per
738 // the measured-not-guessed rule until the explore-hooks probe sources a
739 // shape qwen actually applies; `oneharness mock qwen` is deny-only.
740 mock_rewrite: None,
741 // Qwen fires only *user*-scoped hooks headlessly (project hooks sit
742 // behind folder trust), so a project-scope one-shot install would be
743 // silently inert — refused loudly; use `sync --global` into a
744 // redirected HOME instead (the oh_hook_enforce qwen pattern).
745 mock_delivery: None,
746 default_env: &[("QWEN_CODE_SUPPRESS_YOLO_WARNING", "1")],
747 native_schema: None,
748 // `--approval-mode` spans the whole spectrum, all clean headless: current
749 // qwen-code *deny-and-continues* a gated tool in non-interactive mode
750 // (auto-deny + the agent loop proceeds, process exits 0) rather than
751 // hanging. So `default` denies gated tools and continues; `auto-edit`
752 // genuinely auto-applies edits while denying shell; `auto` runs the
753 // classifier; none block. Qwen's only read-only mechanism is its plan
754 // mode, so `read-only` coincides with `plan` (both → `--approval-mode
755 // plan`).
756 modes: &[
757 mode(PermissionMode::ReadOnly, ModeHeadless::Clean),
758 mode(PermissionMode::Plan, ModeHeadless::Clean),
759 mode(PermissionMode::Default, ModeHeadless::Clean),
760 mode(PermissionMode::Edit, ModeHeadless::Clean),
761 mode(PermissionMode::Auto, ModeHeadless::Clean),
762 mode(PermissionMode::Bypass, ModeHeadless::Clean),
763 ],
764 build_argv: argv_qwen,
765 },
766 HarnessSpec {
767 id: "crush",
768 display: "Crush",
769 default_bin: "crush",
770 install_hint: "npm install -g @charmland/crush",
771 output_format: OutputFormat::Text,
772 // Events pending investigation (see the events matrix).
773 events_format: None,
774 supports_resume: true,
775 supports_fork: false,
776 fork_reuses_cache: false,
777 sync: Some(SyncSpec {
778 file: "crush.json",
779 alt_files: &[".crush.json"],
780 allow_path: Some(&["permissions", "allowed_tools"]),
781 deny_path: Some(&["options", "disabled_tools"]),
782 hooks_path: None,
783 schema_seed: None,
784 }),
785 hooks: Some(HookBinding::SameFile {
786 shape: HookShape::Flat {
787 event: "PreToolUse",
788 },
789 path: &["hooks"],
790 }),
791 global_hook: Some(GlobalHook {
792 base: HookBase::ConfigHome,
793 anchor: "crush/crush.json",
794 }),
795 gate_deny: Some(DenyShape::Decision("deny")),
796 // Crush's PreToolUse stdout documents `updated_input` (a shallow-merge
797 // patch of the tool input) beside `decision: "allow"`.
798 mock_rewrite: Some(RewriteShape::CrushFlat),
799 mock_delivery: Some(MockDelivery::ProjectHooks { extra_args: &[] }),
800 default_env: &[],
801 native_schema: None,
802 // `crush run` auto-approves the whole session, so it never hangs — but it
803 // also cannot gate, so `default` and `bypass` behave the same (bypass
804 // adds the explicit `--yolo`). There is no plan/edit/auto mode on `run`.
805 modes: &[
806 mode(PermissionMode::Default, ModeHeadless::Clean),
807 mode(PermissionMode::Bypass, ModeHeadless::Clean),
808 ],
809 build_argv: argv_crush,
810 },
811 HarnessSpec {
812 id: "copilot",
813 display: "GitHub Copilot CLI",
814 default_bin: "copilot",
815 install_hint: "npm install -g @github/copilot",
816 output_format: OutputFormat::Text,
817 // Events pending investigation (see the events matrix).
818 events_format: None,
819 supports_resume: true,
820 supports_fork: false,
821 fork_reuses_cache: false,
822 sync: None,
823 hooks: Some(HookBinding::File {
824 shape: HookShape::CrossShell {
825 event: "preToolUse",
826 },
827 file: ".github/hooks/{name}.json",
828 path: &["hooks"],
829 seed: Some(r#"{"version":1}"#),
830 }),
831 global_hook: Some(GlobalHook {
832 base: HookBase::Home,
833 anchor: ".copilot/hooks/{name}.json",
834 }),
835 gate_deny: Some(DenyShape::CopilotFlat),
836 // Probe-REFUTED (2026-07-06): copilot's repo `.github/hooks/*.json`
837 // hooks produced ZERO events across every headless `-p` experiment,
838 // even though the agent demonstrably used its shell tool — despite its
839 // docs demonstrating `-p` hooks. With no hook firing there is nothing
840 // to rewrite; absent until a live run shows its hooks loading at all.
841 mock_rewrite: None,
842 // No hook has ever fired headlessly (probe: zero events) — nothing any
843 // delivery could make the CLI run, so the flag is refused loudly.
844 mock_delivery: None,
845 default_env: &[],
846 native_schema: None,
847 // `--mode plan` is a real read-only plan mode; `read-only` is allow-all
848 // with `write`/`shell` denied (deny beats allow); `edit` allows
849 // `write`/`read` but not `shell`, so edits run and shell is auto-denied
850 // (the headless form of "gate shell"); bypass is the allow-all trio.
851 // Without any, `-p` auto-denies gated tools and continues (never hangs),
852 // so `default` is `Clean`. No classifier `auto`, so it is absent.
853 modes: &[
854 mode(PermissionMode::ReadOnly, ModeHeadless::Clean),
855 mode(PermissionMode::Plan, ModeHeadless::Clean),
856 mode(PermissionMode::Default, ModeHeadless::Clean),
857 mode(PermissionMode::Edit, ModeHeadless::Clean),
858 mode(PermissionMode::Bypass, ModeHeadless::Clean),
859 ],
860 build_argv: argv_copilot,
861 },
862 HarnessSpec {
863 id: "cursor",
864 display: "Cursor CLI",
865 default_bin: "cursor-agent",
866 install_hint: "see https://docs.cursor.com/en/cli/overview",
867 output_format: OutputFormat::StreamJson,
868 // Default `stream-json` already carries the tool transcript, so no upgrade.
869 events_format: None,
870 supports_resume: true,
871 supports_fork: false,
872 fork_reuses_cache: false,
873 sync: Some(SyncSpec {
874 file: ".cursor/cli.json",
875 alt_files: &[],
876 allow_path: Some(&["permissions", "allow"]),
877 deny_path: Some(&["permissions", "deny"]),
878 hooks_path: None,
879 schema_seed: Some(r#"{"permissions":{"allow":[],"deny":[]}}"#),
880 }),
881 hooks: Some(HookBinding::File {
882 shape: HookShape::CommandOnly {
883 events: &[
884 "beforeShellExecution",
885 "beforeReadFile",
886 "beforeMCPExecution",
887 // `preToolUse` is the ONLY cursor event whose reply can
888 // rewrite the tool's input (`updated_input`, probe-verified
889 // headlessly 2026-07-06); the three `before*` events are
890 // allow/deny-only. Wiring it alongside them means a shell
891 // call invokes the hook command more than once — the gate's
892 // verdicts are idempotent, and the mock's rewrite rides
893 // this event.
894 "preToolUse",
895 ],
896 },
897 file: ".cursor/hooks.json",
898 path: &["hooks"],
899 seed: Some(r#"{"version":1}"#),
900 }),
901 global_hook: Some(GlobalHook {
902 base: HookBase::Home,
903 anchor: ".cursor/hooks.json",
904 }),
905 gate_deny: Some(DenyShape::CursorPermission),
906 // Probe-verified (2026-07-06): cursor honors a `preToolUse` reply of
907 // `{"permission":"allow","updated_input":{…}}` headlessly — the
908 // original command in the preToolUse event, the rewritten one in the
909 // subsequent before/afterShellExecution events. The `preToolUse` event
910 // is wired into the hook binding above for exactly this.
911 mock_rewrite: Some(RewriteShape::CursorPermission),
912 mock_delivery: Some(MockDelivery::ProjectHooks { extra_args: &[] }),
913 default_env: &[],
914 native_schema: None,
915 // `--mode plan` is the read-only plan mode; `--mode ask` is read-only
916 // Q&A (no plan workflow) → `read-only`; `--force` is bypass. Without
917 // `--force` a gated tool stalls (Cursor proposes-not-applies, with no
918 // fail-fast deny flag), so `default` (`--trust` only) is `Hangs`.
919 // Edit/shell gating is a `permissions` config concern (synced).
920 modes: &[
921 mode(PermissionMode::ReadOnly, ModeHeadless::Clean),
922 mode(PermissionMode::Plan, ModeHeadless::Clean),
923 mode(PermissionMode::Default, ModeHeadless::Hangs),
924 mode(PermissionMode::Bypass, ModeHeadless::Clean),
925 ],
926 build_argv: argv_cursor,
927 },
928];
929
930/// Claude Code's `--permission-mode` token for each normalized mode. `Default`
931/// maps to `dontAsk` (deny any un-allowed tool and continue) rather than
932/// `default` (which *aborts* the `-p` run on an un-allowed tool): the ask flow
933/// then completes headlessly instead of failing on the first prompt. `ReadOnly`
934/// rides `bypassPermissions` (allow-all, no prompts) with the mutating tools
935/// denied separately — deny rules take precedence even under bypass.
936fn claude_permission_mode(mode: PermissionMode) -> &'static str {
937 match mode {
938 PermissionMode::Plan => "plan",
939 PermissionMode::ReadOnly => "bypassPermissions",
940 PermissionMode::Default => "dontAsk",
941 PermissionMode::Edit => "acceptEdits",
942 PermissionMode::Auto => "auto",
943 PermissionMode::Bypass => "bypassPermissions",
944 }
945}
946
947/// `claude -p <prompt> --permission-mode <mode> [--disallowedTools …] [--model M]
948/// [--append-system-prompt S] [--resume <id> [--fork-session]] --output-format json`
949/// (`--resume` continues a session by id; `--fork-session` branches a new session
950/// from it instead of appending — the session id is read from the result JSON's
951/// `session_id`).
952fn argv_claude_code(c: &BuildCtx) -> Vec<String> {
953 let mut a = vec![c.bin.into(), "-p".into(), c.prompt.into()];
954 a.push("--permission-mode".into());
955 a.push(claude_permission_mode(c.mode).into());
956 // read-only: deny the mutating tools (Bash covers destructive shell; reads
957 // still run via Read/Grep/Glob). A bare name removes the tool entirely.
958 if c.mode == PermissionMode::ReadOnly {
959 a.push("--disallowedTools".into());
960 for tool in ["Bash", "Edit", "Write", "NotebookEdit"] {
961 a.push(tool.into());
962 }
963 }
964 if let Some(m) = c.model {
965 a.push("--model".into());
966 a.push(m.into());
967 }
968 if let Some(s) = c.system {
969 a.push("--append-system-prompt".into());
970 a.push(s.into());
971 }
972 if let Some(sid) = c.resume {
973 a.push("--resume".into());
974 a.push(sid.into());
975 // Fork instead of appending: a new session id branches off `sid`, leaving
976 // the original (and its cached prefix) untouched. Only meaningful with
977 // `--resume`; the command layer only sets `fork` for this verified-capable
978 // adapter.
979 if c.fork {
980 a.push("--fork-session".into());
981 }
982 }
983 a.push("--output-format".into());
984 a.push(format_flag(c.output_format).into());
985 // Claude Code refuses `-p --output-format stream-json` without `--verbose`
986 // ("--print with --output-format=stream-json requires --verbose"). Emit it so
987 // the streaming path actually runs — it is what surfaces the Anthropic
988 // content-block transcript oneharness normalizes into `events` (the default
989 // single-document `json` result carries no transcript). Sourced from the CLI's
990 // own error, not guessed.
991 if c.output_format == OutputFormat::StreamJson {
992 a.push("--verbose".into());
993 }
994 // Native structured output: `--json-schema <inline>` makes Claude Code return
995 // the conforming value in the result document's `structured_output` field
996 // (it requires `--output-format json`, already emitted above). Sourced from
997 // the headless docs; only set when a schema run selected this adapter.
998 if let Some(schema) = c.schema {
999 a.push("--json-schema".into());
1000 a.push(schema.into());
1001 }
1002 a
1003}
1004
1005/// `codex exec [resume <id>] [--dangerously-bypass-approvals-and-sandbox]
1006/// [--model M] <prompt>`
1007///
1008/// Codex exposes no system-prompt flag, so `--system` is prepended to the prompt.
1009/// The single bypass flag replaces the older `--sandbox danger-full-access -a
1010/// never`: codex-cli >= 0.135 removed `-a`, and this flag is the supported way to
1011/// skip every approval prompt and the sandbox for a headless run.
1012///
1013/// Continuation is a *subcommand*, not a flag: `codex exec resume <SESSION_ID>
1014/// <prompt>` replays the stored thread and appends the new turn (linear; Codex's
1015/// `exec` has no headless fork — `codex fork` is TUI-only, openai/codex#11750). The
1016/// session handle is the `thread_id` Codex emits under `--json`; oneharness reads
1017/// it via [`crate::domain::signals::extract_session`].
1018fn argv_codex(c: &BuildCtx) -> Vec<String> {
1019 let mut a = vec![c.bin.into(), "exec".into()];
1020 if c.resume.is_some() {
1021 a.push("resume".into());
1022 }
1023 // The sandbox is the real control surface under `exec` (approval downgrades
1024 // to `never`). `Default` keeps the exec default (read-only). `Edit` is not a
1025 // supported mode for codex, so it is never reached.
1026 match c.mode {
1027 PermissionMode::Bypass => {
1028 a.push("--dangerously-bypass-approvals-and-sandbox".into());
1029 }
1030 // `plan` is the read-only sandbox too (enforcement half); its plan
1031 // instruction is prepended to the prompt by the command layer.
1032 PermissionMode::ReadOnly | PermissionMode::Plan => {
1033 a.push("--sandbox".into());
1034 a.push("read-only".into());
1035 }
1036 PermissionMode::Auto => {
1037 a.push("--sandbox".into());
1038 a.push("workspace-write".into());
1039 }
1040 // `default` keeps the exec default; `edit` is unsupported for codex and
1041 // never reaches here.
1042 PermissionMode::Default | PermissionMode::Edit => {}
1043 }
1044 if let Some(m) = c.model {
1045 a.push("--model".into());
1046 a.push(m.into());
1047 }
1048 // `--events`/`--stream` upgrades codex to its JSON event stream (`--json`),
1049 // whose `command_execution` items become normalized `events` and whose
1050 // `agent_message` item carries the final text. The default (`Text`) stays
1051 // plain. Codex has no `stream-json`; `--json` IS its JSONL stream, so both
1052 // non-text formats map to it. Sourced from `codex exec --help`.
1053 if c.output_format != OutputFormat::Text {
1054 a.push("--json".into());
1055 }
1056 // The resumed thread's id is the positional that precedes the prompt for
1057 // `codex exec resume <id> <prompt>` (the `resume` token was pushed above).
1058 if let Some(sid) = c.resume {
1059 a.push(sid.into());
1060 }
1061 a.push(prompt_with_system(c));
1062 a
1063}
1064
1065/// `opencode run [--dangerously-skip-permissions] --format json [-m M]
1066/// [--session SID] <prompt>` (OpenCode continues a session id with `--session`)
1067///
1068/// OpenCode's `run` has no system-prompt flag, so `--system` is prepended to the
1069/// prompt.
1070fn argv_opencode(c: &BuildCtx) -> Vec<String> {
1071 let mut a = vec![c.bin.into(), "run".into()];
1072 // `plan`/`read-only` both select the built-in read-only `plan` agent; bypass
1073 // auto-approves. Other modes are unsupported and never reach here.
1074 match c.mode {
1075 PermissionMode::Bypass => a.push("--dangerously-skip-permissions".into()),
1076 PermissionMode::Plan | PermissionMode::ReadOnly => {
1077 a.push("--agent".into());
1078 a.push("plan".into());
1079 }
1080 _ => {}
1081 }
1082 a.push("--format".into());
1083 a.push(format_flag(c.output_format).into());
1084 if let Some(m) = c.model {
1085 a.push("-m".into());
1086 a.push(m.into());
1087 }
1088 if let Some(sid) = c.resume {
1089 a.push("--session".into());
1090 a.push(sid.into());
1091 // Branch a new session from `sid` rather than appending in place, so the
1092 // original's cached prefix can seed independent follow-ups. Only set for
1093 // this verified-capable adapter, and only alongside `--session`.
1094 if c.fork {
1095 a.push("--fork".into());
1096 }
1097 }
1098 a.push(prompt_with_system(c));
1099 a
1100}
1101
1102/// `goose run --with-builtin developer [--system S] [--resume --name <name>]
1103/// -t <prompt>`
1104///
1105/// Goose selects its model from its own config, so `model` is not mapped, and the
1106/// approval mode is delivered through the `GOOSE_MODE` environment variable (the
1107/// matching [`ModeSpec::env`]), not the argv — so `c.mode` is intentionally not
1108/// read here. It does expose a native `--system` flag, so `--system` maps to it
1109/// rather than being prepended.
1110fn argv_goose(c: &BuildCtx) -> Vec<String> {
1111 let mut a = vec![
1112 c.bin.into(),
1113 "run".into(),
1114 "--with-builtin".into(),
1115 "developer".into(),
1116 ];
1117 if let Some(s) = c.system {
1118 a.push("--system".into());
1119 a.push(s.into());
1120 }
1121 // Goose emits no session id to stdout headlessly, so continuation rides a
1122 // caller-chosen *name*: `--resume --name <name>` resumes that named session
1123 // (and a fresh `--name <name>` run creates it — create-or-resume). The
1124 // `--resume` value oneharness forwards is therefore the name. `session_id`
1125 // stays null for Goose (nothing to extract); the caller owns the handle.
1126 if let Some(name) = c.resume {
1127 a.push("--resume".into());
1128 a.push("--name".into());
1129 a.push(name.into());
1130 }
1131 a.push("-t".into());
1132 a.push(c.prompt.into());
1133 a
1134}
1135
1136/// `qwen [--yolo | --approval-mode <m>] [-m M] [--resume <id>] -p <prompt>` (no
1137/// system flag, so `--system` is prepended). Bypass uses the dedicated `--yolo`;
1138/// the other modes use `--approval-mode` (only `plan` and `bypass` run cleanly
1139/// headless — see the `modes` table — but the flag is mapped for every supported
1140/// mode). `--resume <id>` continues a prior session by UUID (linear append; no
1141/// headless fork). The id is the `session_id` Qwen reports under
1142/// `--output-format json`.
1143fn argv_qwen(c: &BuildCtx) -> Vec<String> {
1144 let mut a = vec![c.bin.into()];
1145 match c.mode {
1146 PermissionMode::Bypass => a.push("--yolo".into()),
1147 PermissionMode::Plan | PermissionMode::ReadOnly => {
1148 a.push("--approval-mode".into());
1149 a.push("plan".into());
1150 }
1151 PermissionMode::Default => {
1152 a.push("--approval-mode".into());
1153 a.push("default".into());
1154 }
1155 PermissionMode::Edit => {
1156 a.push("--approval-mode".into());
1157 a.push("auto-edit".into());
1158 }
1159 PermissionMode::Auto => {
1160 a.push("--approval-mode".into());
1161 a.push("auto".into());
1162 }
1163 }
1164 if let Some(m) = c.model {
1165 a.push("-m".into());
1166 a.push(m.into());
1167 }
1168 // `--events`/`--stream` upgrades qwen to `--output-format stream-json` (its
1169 // NDJSON Anthropic content-block stream), which oneharness normalizes into
1170 // `events` and from which it recovers the final text. The default stays plain
1171 // text. Sourced from `qwen --help` (`-o, --output-format text|json|stream-json`).
1172 if c.output_format != OutputFormat::Text {
1173 a.push("--output-format".into());
1174 a.push(format_flag(c.output_format).into());
1175 }
1176 if let Some(sid) = c.resume {
1177 a.push("--resume".into());
1178 a.push(sid.into());
1179 }
1180 a.push("-p".into());
1181 a.push(prompt_with_system(c));
1182 a
1183}
1184
1185/// `crush run -q [--session <id>] [-m M] <prompt>` (`run` is non-interactive; `-q`
1186/// quiets it; no system flag, so `--system` is prepended). `crush run` already
1187/// auto-approves the whole session (verified live), so `default` and `bypass` are
1188/// identical — crush has no per-run permission flag (`--yolo` is rejected on `run`
1189/// as of v0.80.0), so the mode is not expressed on the argv. `--session <id>`
1190/// continues a stored session by id (linear append; no headless fork). The id is
1191/// the `session_id` crush reports under `--format json`.
1192fn argv_crush(c: &BuildCtx) -> Vec<String> {
1193 let mut a = vec![c.bin.into(), "run".into(), "-q".into()];
1194 if let Some(sid) = c.resume {
1195 a.push("--session".into());
1196 a.push(sid.into());
1197 }
1198 if let Some(m) = c.model {
1199 a.push("-m".into());
1200 a.push(m.into());
1201 }
1202 a.push(prompt_with_system(c));
1203 a
1204}
1205
1206/// `copilot -p <prompt> [--allow-all-tools --allow-all-paths --no-ask-user]
1207/// [--model M] [--resume <id>]` (no system flag, so `--system` is prepended to the
1208/// prompt; its `--allow-tool`/`--deny-tool` permission flags are not unified —
1209/// Copilot has no project config file to sync, so rules go via `[harness.copilot]
1210/// args`). `--resume <id>` continues a session by UUID (linear append; no headless
1211/// fork). Copilot emits no session id headlessly, and `--resume <uuid>` *creates*
1212/// the session when the id is new (create-or-resume) — so the caller mints and
1213/// reuses a UUID; `session_id` stays null (nothing to extract).
1214fn argv_copilot(c: &BuildCtx) -> Vec<String> {
1215 let mut a = vec![c.bin.into(), "-p".into(), prompt_with_system(c)];
1216 // Bypass is the allow-all trio; `plan` is the read-only plan mode;
1217 // `read-only` is allow-all with `write`/`shell` denied (deny beats allow).
1218 // Without any, `-p` auto-denies gated tools and continues. Unsupported modes
1219 // never reach here.
1220 match c.mode {
1221 PermissionMode::Bypass => {
1222 a.push("--allow-all-tools".into());
1223 a.push("--allow-all-paths".into());
1224 a.push("--no-ask-user".into());
1225 }
1226 PermissionMode::ReadOnly => {
1227 a.push("--allow-all-tools".into());
1228 a.push("--allow-all-paths".into());
1229 a.push("--deny-tool".into());
1230 a.push("shell".into());
1231 a.push("--deny-tool".into());
1232 a.push("write".into());
1233 a.push("--no-ask-user".into());
1234 }
1235 PermissionMode::Edit => {
1236 // Allow file edits and reads, but not shell — an un-allowed `shell`
1237 // is auto-denied under `-p`, so edits run and commands are gated.
1238 a.push("--allow-tool".into());
1239 a.push("write".into());
1240 a.push("--allow-tool".into());
1241 a.push("read".into());
1242 a.push("--allow-all-paths".into());
1243 a.push("--no-ask-user".into());
1244 }
1245 PermissionMode::Plan => {
1246 a.push("--mode".into());
1247 a.push("plan".into());
1248 }
1249 _ => {}
1250 }
1251 if let Some(m) = c.model {
1252 a.push("--model".into());
1253 a.push(m.into());
1254 }
1255 if let Some(sid) = c.resume {
1256 a.push("--resume".into());
1257 a.push(sid.into());
1258 }
1259 a
1260}
1261
1262/// `cursor-agent -p <prompt> [--force|--trust] [--model M] [--resume SID]
1263/// --output-format stream-json` (Cursor continues a chat id with `--resume`; no
1264/// system flag, so `--system` is prepended to the prompt)
1265fn argv_cursor(c: &BuildCtx) -> Vec<String> {
1266 let mut a = vec![c.bin.into(), "-p".into(), prompt_with_system(c)];
1267 // `--force` is bypass (it also implies trust). Otherwise a headless run still
1268 // needs `--trust` — Cursor refuses to run an untrusted workspace ("Workspace
1269 // Trust Required", observed live) — while leaving the permission system
1270 // active. `plan` additionally selects the read-only `--mode plan`.
1271 if c.mode.is_bypass() {
1272 a.push("--force".into());
1273 } else {
1274 a.push("--trust".into());
1275 match c.mode {
1276 PermissionMode::Plan => {
1277 a.push("--mode".into());
1278 a.push("plan".into());
1279 }
1280 PermissionMode::ReadOnly => {
1281 a.push("--mode".into());
1282 a.push("ask".into());
1283 }
1284 _ => {}
1285 }
1286 }
1287 if let Some(m) = c.model {
1288 a.push("--model".into());
1289 a.push(m.into());
1290 }
1291 if let Some(sid) = c.resume {
1292 a.push("--resume".into());
1293 a.push(sid.into());
1294 }
1295 a.push("--output-format".into());
1296 a.push(format_flag(c.output_format).into());
1297 a
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302 use super::*;
1303
1304 fn ctx<'a>(bin: &'a str, model: Option<&'a str>, mode: PermissionMode) -> BuildCtx<'a> {
1305 ctx_fmt(bin, model, mode, OutputFormat::Json)
1306 }
1307
1308 fn ctx_fmt<'a>(
1309 bin: &'a str,
1310 model: Option<&'a str>,
1311 mode: PermissionMode,
1312 output_format: OutputFormat,
1313 ) -> BuildCtx<'a> {
1314 BuildCtx {
1315 bin,
1316 prompt: "hi",
1317 model,
1318 system: None,
1319 resume: None,
1320 fork: false,
1321 mode,
1322 output_format,
1323 schema: None,
1324 }
1325 }
1326
1327 #[test]
1328 fn registry_ids_are_unique_and_nonempty() {
1329 let mut seen = std::collections::HashSet::new();
1330 for h in all() {
1331 assert!(!h.id.is_empty());
1332 assert!(!h.default_bin.is_empty());
1333 assert!(seen.insert(h.id), "duplicate id {}", h.id);
1334 }
1335 assert_eq!(all().len(), 8);
1336 }
1337
1338 /// Pin each harness's mock input-rewrite capability: the live-verified
1339 /// shapes for the five that honor one (oh_mock_enforce + the explore-hooks
1340 /// probe, 2026-07-06), and — as deliberately as the presences — the
1341 /// absences: Goose's protocol has no rewrite verdict; Copilot's hooks
1342 /// never fired headlessly (probe: zero events); and Qwen's documented
1343 /// `updatedInput` was live-REFUTED (verdict emitted, original command
1344 /// still ran — see its registry comment), so it stays absent despite its
1345 /// docs. A rewrite also requires an installable hook and a deny shape
1346 /// (the mock responder's other verb), so those must accompany it.
1347 #[test]
1348 fn registry_mock_rewrite_capability_is_pinned() {
1349 let shape = |id: &str| by_id(id).unwrap().mock_rewrite;
1350 assert_eq!(shape("claude-code"), Some(RewriteShape::ClaudeNested));
1351 assert_eq!(shape("codex"), Some(RewriteShape::ClaudeNested));
1352 assert_eq!(shape("crush"), Some(RewriteShape::CrushFlat));
1353 assert_eq!(shape("opencode"), Some(RewriteShape::OpencodeShim));
1354 assert_eq!(shape("cursor"), Some(RewriteShape::CursorPermission));
1355 for id in ["goose", "qwen", "copilot"] {
1356 assert_eq!(shape(id), None, "{id} must stay absent until verified");
1357 }
1358 // The one-shot delivery (`run --mock-rules`): claude rides the argv,
1359 // qwen (user-scope-only hooks) and copilot (hooks never fire) are
1360 // refused, codex auto-appends its probe-proven opt-in flags, the rest
1361 // are plain project installs.
1362 let delivery = |id: &str| by_id(id).unwrap().mock_delivery;
1363 assert_eq!(
1364 delivery("claude-code"),
1365 Some(MockDelivery::SettingsFlag { flag: "--settings" })
1366 );
1367 assert_eq!(
1368 delivery("codex"),
1369 Some(MockDelivery::ProjectHooks {
1370 extra_args: &[
1371 "-c",
1372 "features.hooks=true",
1373 "--dangerously-bypass-hook-trust",
1374 ],
1375 })
1376 );
1377 for id in ["opencode", "goose", "crush", "cursor"] {
1378 assert_eq!(
1379 delivery(id),
1380 Some(MockDelivery::ProjectHooks { extra_args: &[] }),
1381 "{id}"
1382 );
1383 }
1384 for id in ["qwen", "copilot"] {
1385 assert_eq!(delivery(id), None, "{id} must be refused loudly");
1386 }
1387 for h in all() {
1388 if h.mock_rewrite.is_some() {
1389 assert!(
1390 h.hooks.is_some() && h.gate_deny.is_some(),
1391 "{}: a rewrite shape needs an installable hook and a deny shape",
1392 h.id
1393 );
1394 }
1395 }
1396 }
1397
1398 /// The OpenCode shim must keep both verdict paths: throw-to-block on a
1399 /// deny, and merge `updated_input` into the tool's mutable args (the
1400 /// rewrite `oneharness mock` emits for `RewriteShape::OpencodeShim`).
1401 #[test]
1402 fn opencode_shim_applies_updated_input_and_still_blocks_on_deny() {
1403 assert!(OPENCODE_PLUGIN_JS.contains(r#"decision.decision === "deny""#));
1404 assert!(OPENCODE_PLUGIN_JS.contains("Object.assign(output.args, decision.updated_input)"));
1405 // The deny check runs first, so a malformed reply carrying both never
1406 // rewrites a call that should have been blocked.
1407 let deny = OPENCODE_PLUGIN_JS
1408 .find(r#"decision.decision === "deny""#)
1409 .unwrap();
1410 let rewrite = OPENCODE_PLUGIN_JS.find("decision.updated_input").unwrap();
1411 assert!(deny < rewrite, "deny must be evaluated before the rewrite");
1412 }
1413
1414 #[test]
1415 fn claude_argv_bypass_on() {
1416 let spec = by_id("claude-code").unwrap();
1417 let argv = (spec.build_argv)(&ctx("claude", None, PermissionMode::Bypass));
1418 assert_eq!(
1419 argv,
1420 vec![
1421 "claude",
1422 "-p",
1423 "hi",
1424 "--permission-mode",
1425 "bypassPermissions",
1426 "--output-format",
1427 "json"
1428 ]
1429 );
1430 }
1431
1432 #[test]
1433 fn claude_argv_default_mode_maps_to_dont_ask() {
1434 // The normalized `default` ask flow maps to `dontAsk` (deny un-allowed
1435 // tools and continue), not `--permission-mode default` (which aborts the
1436 // `-p` run on the first un-allowed tool) — so it never hangs/aborts.
1437 let spec = by_id("claude-code").unwrap();
1438 let argv = (spec.build_argv)(&ctx("claude", Some("haiku"), PermissionMode::Default));
1439 assert_eq!(
1440 argv,
1441 vec![
1442 "claude",
1443 "-p",
1444 "hi",
1445 "--permission-mode",
1446 "dontAsk",
1447 "--model",
1448 "haiku",
1449 "--output-format",
1450 "json"
1451 ]
1452 );
1453 }
1454
1455 #[test]
1456 fn claude_maps_each_mode_to_its_permission_mode_token() {
1457 let spec = by_id("claude-code").unwrap();
1458 for (mode, token) in [
1459 (PermissionMode::Plan, "plan"),
1460 (PermissionMode::ReadOnly, "bypassPermissions"),
1461 (PermissionMode::Default, "dontAsk"),
1462 (PermissionMode::Edit, "acceptEdits"),
1463 (PermissionMode::Auto, "auto"),
1464 (PermissionMode::Bypass, "bypassPermissions"),
1465 ] {
1466 let argv = (spec.build_argv)(&ctx("claude", None, mode));
1467 assert!(
1468 argv.windows(2).any(|w| w == ["--permission-mode", token]),
1469 "mode {mode:?} should emit {token}: {argv:?}"
1470 );
1471 }
1472 // read-only additionally denies the mutating tools (and only read-only
1473 // does — plan/bypass leave them available to the permission system).
1474 let ro = (spec.build_argv)(&ctx("claude", None, PermissionMode::ReadOnly));
1475 assert!(
1476 ro.windows(2).any(|w| w == ["--disallowedTools", "Bash"]),
1477 "read-only should deny Bash: {ro:?}"
1478 );
1479 for tool in ["Edit", "Write", "NotebookEdit"] {
1480 assert!(
1481 ro.iter().any(|t| t == tool),
1482 "read-only denies {tool}: {ro:?}"
1483 );
1484 }
1485 assert!(
1486 !(spec.build_argv)(&ctx("claude", None, PermissionMode::Plan))
1487 .iter()
1488 .any(|t| t == "--disallowedTools"),
1489 "plan should not deny tools"
1490 );
1491 }
1492
1493 #[test]
1494 fn mode_native_flags_per_harness() {
1495 // Pin the mode→flag mapping for the harnesses that express it on the argv
1496 // (Goose carries it in the environment, asserted via `modes` env below).
1497 let cases: &[(&str, PermissionMode, &[&str])] = &[
1498 // read-only: enforced where possible (codex sandbox, copilot/cursor
1499 // native), coinciding with plan where it's the only mechanism.
1500 (
1501 "codex",
1502 PermissionMode::ReadOnly,
1503 &["--sandbox", "read-only"],
1504 ),
1505 (
1506 "codex",
1507 PermissionMode::Auto,
1508 &["--sandbox", "workspace-write"],
1509 ),
1510 ("opencode", PermissionMode::Plan, &["--agent", "plan"]),
1511 ("opencode", PermissionMode::ReadOnly, &["--agent", "plan"]),
1512 ("qwen", PermissionMode::Plan, &["--approval-mode", "plan"]),
1513 (
1514 "qwen",
1515 PermissionMode::ReadOnly,
1516 &["--approval-mode", "plan"],
1517 ),
1518 (
1519 "qwen",
1520 PermissionMode::Edit,
1521 &["--approval-mode", "auto-edit"],
1522 ),
1523 ("qwen", PermissionMode::Auto, &["--approval-mode", "auto"]),
1524 ("copilot", PermissionMode::Plan, &["--mode", "plan"]),
1525 (
1526 "copilot",
1527 PermissionMode::ReadOnly,
1528 &["--deny-tool", "shell"],
1529 ),
1530 (
1531 "copilot",
1532 PermissionMode::ReadOnly,
1533 &["--deny-tool", "write"],
1534 ),
1535 ("copilot", PermissionMode::Edit, &["--allow-tool", "write"]),
1536 ("cursor", PermissionMode::Plan, &["--mode", "plan"]),
1537 ("cursor", PermissionMode::ReadOnly, &["--mode", "ask"]),
1538 ];
1539 for (id, mode, want) in cases {
1540 let spec = by_id(id).unwrap();
1541 let argv = (spec.build_argv)(&ctx(spec.default_bin, None, *mode));
1542 assert!(
1543 argv.windows(want.len()).any(|w| w == *want),
1544 "harness {id} mode {mode:?} should emit {want:?}; got {argv:?}"
1545 );
1546 }
1547 // copilot `edit` must NOT blanket-allow tools, or shell wouldn't be
1548 // gated — it allows only write/read, leaving shell to be auto-denied.
1549 let copilot_edit =
1550 (by_id("copilot").unwrap().build_argv)(&ctx("copilot", None, PermissionMode::Edit));
1551 assert!(
1552 !copilot_edit.iter().any(|t| t == "--allow-all-tools"),
1553 "copilot edit must gate shell, not allow-all: {copilot_edit:?}"
1554 );
1555 // Codex `plan` is synthesized: read-only sandbox (enforcement) + a plan
1556 // instruction prepended to the prompt (no native exec plan mode).
1557 let codex_plan = by_id("codex").unwrap().mode(PermissionMode::Plan);
1558 assert!(
1559 codex_plan.is_some(),
1560 "codex should support synthesized plan"
1561 );
1562 assert!(
1563 codex_plan.unwrap().instruction.is_some(),
1564 "codex plan must carry a plan instruction"
1565 );
1566 let codex_plan_argv =
1567 (by_id("codex").unwrap().build_argv)(&ctx("codex", None, PermissionMode::Plan));
1568 assert!(
1569 codex_plan_argv
1570 .windows(2)
1571 .any(|w| w == ["--sandbox", "read-only"]),
1572 "codex plan must enforce read-only: {codex_plan_argv:?}"
1573 );
1574 // Goose rejects plan (no plan workflow, and no read-only to enforce it).
1575 assert!(by_id("goose").unwrap().mode(PermissionMode::Plan).is_none());
1576 assert!(by_id("goose")
1577 .unwrap()
1578 .mode(PermissionMode::ReadOnly)
1579 .is_none());
1580 // Crush supports neither plan nor read-only, and has no per-run
1581 // permission flag (`crush run` auto-approves), so bypass == default.
1582 let crush = by_id("crush").unwrap();
1583 assert!(crush.mode(PermissionMode::Plan).is_none());
1584 assert!(crush.mode(PermissionMode::ReadOnly).is_none());
1585 let bypass = (crush.build_argv)(&ctx("crush", None, PermissionMode::Bypass));
1586 let default = (crush.build_argv)(&ctx("crush", None, PermissionMode::Default));
1587 assert_eq!(bypass, default, "crush has no per-run mode flag");
1588 assert!(!bypass.iter().any(|t| t == "--yolo"), "{bypass:?}");
1589 }
1590
1591 #[test]
1592 fn every_harness_supports_bypass_and_default_and_goose_carries_mode_env() {
1593 for h in all() {
1594 assert!(
1595 h.mode(PermissionMode::Bypass).is_some(),
1596 "harness {} must support bypass (the headless default)",
1597 h.id
1598 );
1599 assert!(
1600 h.mode(PermissionMode::Default).is_some(),
1601 "harness {} must support the default ask flow",
1602 h.id
1603 );
1604 }
1605 // Goose delivers the mode via GOOSE_MODE, so every supported mode carries
1606 // an env mapping (and no other harness does).
1607 let goose = by_id("goose").unwrap();
1608 assert_eq!(
1609 goose.mode(PermissionMode::Bypass).unwrap().env,
1610 &[("GOOSE_MODE", "auto")]
1611 );
1612 assert_eq!(
1613 goose.mode(PermissionMode::Default).unwrap().env,
1614 &[("GOOSE_MODE", "approve")]
1615 );
1616 // OpenCode delivers `edit` through the inline-config env var (no argv
1617 // flag exists for its per-tool permission map).
1618 assert_eq!(
1619 by_id("opencode")
1620 .unwrap()
1621 .mode(PermissionMode::Edit)
1622 .unwrap()
1623 .env,
1624 &[(
1625 "OPENCODE_CONFIG_CONTENT",
1626 r#"{"permission":{"edit":"allow","bash":"deny"}}"#
1627 )]
1628 );
1629 // Every other (harness, mode) expresses itself on the argv, not env.
1630 for h in all() {
1631 for m in h.modes {
1632 let env_ok = h.id == "goose"
1633 || (h.id == "opencode" && m.mode == PermissionMode::Edit)
1634 || m.env.is_empty();
1635 assert!(
1636 env_ok,
1637 "harness {} mode {:?} unexpectedly carries env",
1638 h.id, m.mode
1639 );
1640 }
1641 }
1642 }
1643
1644 #[test]
1645 fn codex_argv_uses_exec_and_bypass_flag() {
1646 // Codex's default format is Text (no transcript), so no `--json`.
1647 let spec = by_id("codex").unwrap();
1648 let argv = (spec.build_argv)(&ctx_fmt(
1649 "codex",
1650 None,
1651 PermissionMode::Bypass,
1652 OutputFormat::Text,
1653 ));
1654 assert_eq!(
1655 argv,
1656 vec![
1657 "codex",
1658 "exec",
1659 "--dangerously-bypass-approvals-and-sandbox",
1660 "hi"
1661 ]
1662 );
1663 }
1664
1665 #[test]
1666 fn codex_events_format_adds_json_flag() {
1667 // Under `--events`/`--stream` the command layer selects codex's
1668 // events_format (Json), which maps to `--json` — its JSONL event stream.
1669 let spec = by_id("codex").unwrap();
1670 assert_eq!(spec.events_format, Some(OutputFormat::Json));
1671 let argv = (spec.build_argv)(&ctx_fmt(
1672 "codex",
1673 None,
1674 PermissionMode::Bypass,
1675 OutputFormat::Json,
1676 ));
1677 assert!(argv.iter().any(|t| t == "--json"), "{argv:?}");
1678 }
1679
1680 #[test]
1681 fn qwen_events_format_adds_stream_json_flag() {
1682 // Qwen's events_format is stream-json → `--output-format stream-json`; the
1683 // default (text) emits no format flag.
1684 let spec = by_id("qwen").unwrap();
1685 assert_eq!(spec.events_format, Some(OutputFormat::StreamJson));
1686 let stream = (spec.build_argv)(&ctx_fmt(
1687 "qwen",
1688 None,
1689 PermissionMode::Bypass,
1690 OutputFormat::StreamJson,
1691 ));
1692 assert!(
1693 stream
1694 .windows(2)
1695 .any(|w| w == ["--output-format", "stream-json"]),
1696 "{stream:?}"
1697 );
1698 let text = (spec.build_argv)(&ctx_fmt(
1699 "qwen",
1700 None,
1701 PermissionMode::Bypass,
1702 OutputFormat::Text,
1703 ));
1704 assert!(
1705 !text.iter().any(|t| t == "--output-format"),
1706 "default text must not add a format flag: {text:?}"
1707 );
1708 }
1709
1710 #[test]
1711 fn goose_ignores_model_and_bypass() {
1712 let spec = by_id("goose").unwrap();
1713 let with = (spec.build_argv)(&ctx("goose", Some("gpt"), PermissionMode::Bypass));
1714 let without = (spec.build_argv)(&ctx("goose", None, PermissionMode::Default));
1715 assert_eq!(with, without);
1716 assert_eq!(
1717 with,
1718 vec!["goose", "run", "--with-builtin", "developer", "-t", "hi"]
1719 );
1720 }
1721
1722 #[test]
1723 fn output_format_override_changes_the_emitted_flag() {
1724 let spec = by_id("claude-code").unwrap();
1725 let argv = (spec.build_argv)(&ctx_fmt(
1726 "claude",
1727 None,
1728 PermissionMode::Bypass,
1729 OutputFormat::StreamJson,
1730 ));
1731 assert!(
1732 argv.windows(2)
1733 .any(|w| w == ["--output-format", "stream-json"]),
1734 "{argv:?}"
1735 );
1736 // opencode spells its flag `--format`.
1737 let oc = by_id("opencode").unwrap();
1738 let argv = (oc.build_argv)(&ctx_fmt(
1739 "opencode",
1740 None,
1741 PermissionMode::Bypass,
1742 OutputFormat::Text,
1743 ));
1744 assert!(
1745 argv.windows(2).any(|w| w == ["--format", "text"]),
1746 "{argv:?}"
1747 );
1748 }
1749
1750 #[test]
1751 fn claude_maps_system_to_append_system_prompt() {
1752 let spec = by_id("claude-code").unwrap();
1753 let ctx = BuildCtx {
1754 bin: "claude",
1755 prompt: "hi",
1756 model: None,
1757 system: Some("be terse"),
1758 resume: None,
1759 fork: false,
1760 mode: PermissionMode::Bypass,
1761 output_format: OutputFormat::Json,
1762 schema: None,
1763 };
1764 let argv = (spec.build_argv)(&ctx);
1765 assert!(
1766 argv.windows(2)
1767 .any(|w| w == ["--append-system-prompt", "be terse"]),
1768 "{argv:?}"
1769 );
1770 }
1771
1772 #[test]
1773 fn prompt_with_system_prefixes_only_when_present() {
1774 let spec = by_id("codex").unwrap();
1775 let none = BuildCtx {
1776 system: None,
1777 ..base_ctx(spec)
1778 };
1779 assert_eq!(prompt_with_system(&none), "hi");
1780 let some = BuildCtx {
1781 system: Some("rules"),
1782 ..base_ctx(spec)
1783 };
1784 assert_eq!(prompt_with_system(&some), "rules\n\nhi");
1785 // A blank system prompt is a no-op (no stray leading newlines).
1786 let empty = BuildCtx {
1787 system: Some(""),
1788 ..base_ctx(spec)
1789 };
1790 assert_eq!(prompt_with_system(&empty), "hi");
1791 }
1792
1793 #[test]
1794 fn goose_maps_system_to_its_native_flag() {
1795 let spec = by_id("goose").unwrap();
1796 let argv = (spec.build_argv)(&BuildCtx {
1797 system: Some("be terse"),
1798 ..base_ctx(spec)
1799 });
1800 assert!(
1801 argv.windows(2).any(|w| w == ["--system", "be terse"]),
1802 "{argv:?}"
1803 );
1804 // The prompt is delivered via -t and left untouched (not prepended).
1805 assert!(argv.windows(2).any(|w| w == ["-t", "hi"]), "{argv:?}");
1806 }
1807
1808 #[test]
1809 fn harnesses_without_a_system_flag_prepend_it_to_the_prompt() {
1810 // Codex/OpenCode/Qwen/Crush/Copilot/Cursor expose no system-prompt flag,
1811 // so `--system` must be prepended to the prompt — never silently dropped.
1812 for id in ["codex", "opencode", "qwen", "crush", "copilot", "cursor"] {
1813 let spec = by_id(id).unwrap();
1814 let argv = (spec.build_argv)(&BuildCtx {
1815 system: Some("be terse"),
1816 ..base_ctx(spec)
1817 });
1818 assert!(
1819 argv.iter().any(|t| t == "be terse\n\nhi"),
1820 "harness {id} should carry the prepended prompt; got {argv:?}"
1821 );
1822 // The un-prefixed prompt must not also be sent on its own.
1823 assert!(
1824 !argv.iter().any(|t| t == "hi"),
1825 "harness {id} should not also send the bare prompt; got {argv:?}"
1826 );
1827 }
1828 }
1829
1830 fn base_ctx(spec: &'static HarnessSpec) -> BuildCtx<'static> {
1831 BuildCtx {
1832 bin: spec.default_bin,
1833 prompt: "hi",
1834 model: None,
1835 system: None,
1836 resume: None,
1837 fork: false,
1838 mode: PermissionMode::Bypass,
1839 output_format: spec.output_format,
1840 schema: None,
1841 }
1842 }
1843
1844 #[test]
1845 fn claude_native_schema_appends_json_schema_flag() {
1846 // The native structured-output path: claude-code carries the inline
1847 // schema on `--json-schema`, after `--output-format json`. Only this
1848 // adapter declares native support today.
1849 let spec = by_id("claude-code").unwrap();
1850 assert_eq!(spec.native_schema, Some(NativeSchema::ClaudeJsonSchema));
1851 let argv = (spec.build_argv)(&BuildCtx {
1852 schema: Some(r#"{"type":"object"}"#),
1853 ..base_ctx(spec)
1854 });
1855 assert!(
1856 argv.windows(2)
1857 .any(|w| w == ["--json-schema", r#"{"type":"object"}"#]),
1858 "{argv:?}"
1859 );
1860 assert!(
1861 argv.windows(2).any(|w| w == ["--output-format", "json"]),
1862 "native schema requires json output: {argv:?}"
1863 );
1864 // Without a schema the flag is absent.
1865 let argv = (spec.build_argv)(&base_ctx(spec));
1866 assert!(!argv.iter().any(|t| t == "--json-schema"), "{argv:?}");
1867 }
1868
1869 #[test]
1870 fn claude_stream_json_adds_verbose_but_default_json_does_not() {
1871 // `-p --output-format stream-json` requires `--verbose` (Claude Code
1872 // errors otherwise); it is what surfaces the content-block transcript
1873 // oneharness normalizes into `events`. The default `json` result carries
1874 // no transcript and must NOT get `--verbose`.
1875 let spec = by_id("claude-code").unwrap();
1876 let stream = (spec.build_argv)(&BuildCtx {
1877 output_format: OutputFormat::StreamJson,
1878 ..base_ctx(spec)
1879 });
1880 assert!(
1881 stream
1882 .windows(2)
1883 .any(|w| w == ["--output-format", "stream-json"]),
1884 "{stream:?}"
1885 );
1886 assert!(
1887 stream.iter().any(|t| t == "--verbose"),
1888 "stream-json needs --verbose: {stream:?}"
1889 );
1890 let json = (spec.build_argv)(&base_ctx(spec));
1891 assert!(
1892 !json.iter().any(|t| t == "--verbose"),
1893 "default json must not add --verbose: {json:?}"
1894 );
1895 }
1896
1897 #[test]
1898 fn claude_maps_resume_to_resume_flag() {
1899 let spec = by_id("claude-code").unwrap();
1900 assert!(spec.supports_resume);
1901 let argv = (spec.build_argv)(&BuildCtx {
1902 resume: Some("sess-123"),
1903 ..base_ctx(spec)
1904 });
1905 assert!(
1906 argv.windows(2).any(|w| w == ["--resume", "sess-123"]),
1907 "{argv:?}"
1908 );
1909 }
1910
1911 #[test]
1912 fn every_harness_supports_resume() {
1913 // All eight CLIs expose a headless continuation flag (sourced per-adapter
1914 // from their docs); a new harness without one must flip this expectation
1915 // deliberately rather than silently start a fresh session.
1916 let unsupported: Vec<&str> = all()
1917 .iter()
1918 .filter(|h| !h.supports_resume)
1919 .map(|h| h.id)
1920 .collect();
1921 assert!(
1922 unsupported.is_empty(),
1923 "resume gaps drifted: {unsupported:?}"
1924 );
1925 }
1926
1927 #[test]
1928 fn fork_supported_set_is_claude_and_opencode() {
1929 // Only Claude Code (`--fork-session`) and OpenCode (`--fork`) expose a
1930 // headless session fork; the rest resume linearly. A drift alarm for the
1931 // capability the fork feature depends on.
1932 let supported: std::collections::HashSet<&str> = all()
1933 .iter()
1934 .filter(|h| h.supports_fork)
1935 .map(|h| h.id)
1936 .collect();
1937 assert_eq!(
1938 supported,
1939 ["claude-code", "opencode"].into_iter().collect(),
1940 "supports_fork set drifted"
1941 );
1942 // Fork implies resume: nothing forks that cannot also resume.
1943 assert!(all().iter().all(|h| !h.supports_fork || h.supports_resume));
1944 }
1945
1946 #[test]
1947 fn claude_maps_fork_to_fork_session_flag() {
1948 let spec = by_id("claude-code").unwrap();
1949 assert!(spec.supports_fork);
1950 let argv = (spec.build_argv)(&BuildCtx {
1951 resume: Some("sess-123"),
1952 fork: true,
1953 ..base_ctx(spec)
1954 });
1955 assert!(
1956 argv.windows(2).any(|w| w == ["--resume", "sess-123"]),
1957 "{argv:?}"
1958 );
1959 assert!(argv.iter().any(|t| t == "--fork-session"), "{argv:?}");
1960 // Without --fork the flag is absent (plain resume appends in place).
1961 let argv = (spec.build_argv)(&BuildCtx {
1962 resume: Some("sess-123"),
1963 ..base_ctx(spec)
1964 });
1965 assert!(!argv.iter().any(|t| t == "--fork-session"), "{argv:?}");
1966 }
1967
1968 #[test]
1969 fn opencode_maps_fork_to_fork_flag() {
1970 let spec = by_id("opencode").unwrap();
1971 assert!(spec.supports_fork);
1972 let argv = (spec.build_argv)(&BuildCtx {
1973 resume: Some("ses_abc"),
1974 fork: true,
1975 ..base_ctx(spec)
1976 });
1977 assert!(
1978 argv.windows(2).any(|w| w == ["--session", "ses_abc"]),
1979 "{argv:?}"
1980 );
1981 assert!(argv.iter().any(|t| t == "--fork"), "{argv:?}");
1982 }
1983
1984 #[test]
1985 fn codex_maps_resume_to_resume_subcommand_before_prompt() {
1986 // `codex exec resume <id> <prompt>`: the `resume` token follows `exec`,
1987 // and the id is the positional immediately before the prompt.
1988 let spec = by_id("codex").unwrap();
1989 assert!(spec.supports_resume && !spec.supports_fork);
1990 let argv = (spec.build_argv)(&BuildCtx {
1991 resume: Some("0199-thread"),
1992 ..base_ctx(spec)
1993 });
1994 assert!(
1995 argv.windows(2).any(|w| w == ["exec", "resume"]),
1996 "resume is a subcommand after exec: {argv:?}"
1997 );
1998 // id directly precedes the prompt positional.
1999 assert!(
2000 argv.windows(2).any(|w| w == ["0199-thread", "hi"]),
2001 "{argv:?}"
2002 );
2003 // No fork token for codex.
2004 assert!(!argv.iter().any(|t| t == "--fork"), "{argv:?}");
2005 }
2006
2007 #[test]
2008 fn goose_maps_resume_to_named_session() {
2009 // Goose emits no id headlessly; continuation rides a caller-chosen name:
2010 // `--resume --name <name>`.
2011 let spec = by_id("goose").unwrap();
2012 assert!(spec.supports_resume);
2013 let argv = (spec.build_argv)(&BuildCtx {
2014 resume: Some("my-session"),
2015 ..base_ctx(spec)
2016 });
2017 assert!(argv.iter().any(|t| t == "--resume"), "{argv:?}");
2018 assert!(
2019 argv.windows(2).any(|w| w == ["--name", "my-session"]),
2020 "{argv:?}"
2021 );
2022 }
2023
2024 #[test]
2025 fn qwen_maps_resume_to_resume_flag() {
2026 let spec = by_id("qwen").unwrap();
2027 assert!(spec.supports_resume);
2028 let argv = (spec.build_argv)(&BuildCtx {
2029 resume: Some("uuid-1"),
2030 ..base_ctx(spec)
2031 });
2032 assert!(
2033 argv.windows(2).any(|w| w == ["--resume", "uuid-1"]),
2034 "{argv:?}"
2035 );
2036 }
2037
2038 #[test]
2039 fn crush_maps_resume_to_session_flag() {
2040 let spec = by_id("crush").unwrap();
2041 assert!(spec.supports_resume);
2042 let argv = (spec.build_argv)(&BuildCtx {
2043 resume: Some("sess-9"),
2044 ..base_ctx(spec)
2045 });
2046 assert!(
2047 argv.windows(2).any(|w| w == ["--session", "sess-9"]),
2048 "{argv:?}"
2049 );
2050 }
2051
2052 #[test]
2053 fn copilot_maps_resume_to_resume_flag() {
2054 let spec = by_id("copilot").unwrap();
2055 assert!(spec.supports_resume);
2056 let argv = (spec.build_argv)(&BuildCtx {
2057 resume: Some("uuid-c"),
2058 ..base_ctx(spec)
2059 });
2060 assert!(
2061 argv.windows(2).any(|w| w == ["--resume", "uuid-c"]),
2062 "{argv:?}"
2063 );
2064 }
2065
2066 #[test]
2067 fn opencode_maps_resume_to_session_flag() {
2068 let spec = by_id("opencode").unwrap();
2069 assert!(spec.supports_resume);
2070 let argv = (spec.build_argv)(&BuildCtx {
2071 resume: Some("ses_abc"),
2072 ..base_ctx(spec)
2073 });
2074 assert!(
2075 argv.windows(2).any(|w| w == ["--session", "ses_abc"]),
2076 "{argv:?}"
2077 );
2078 }
2079
2080 #[test]
2081 fn cursor_maps_resume_to_resume_flag() {
2082 let spec = by_id("cursor").unwrap();
2083 assert!(spec.supports_resume);
2084 let argv = (spec.build_argv)(&BuildCtx {
2085 resume: Some("chat-9"),
2086 ..base_ctx(spec)
2087 });
2088 assert!(
2089 argv.windows(2).any(|w| w == ["--resume", "chat-9"]),
2090 "{argv:?}"
2091 );
2092 }
2093
2094 #[test]
2095 fn cursor_no_bypass_trusts_the_workspace_without_force() {
2096 let spec = by_id("cursor").unwrap();
2097 let argv = (spec.build_argv)(&BuildCtx {
2098 mode: PermissionMode::Default,
2099 ..base_ctx(spec)
2100 });
2101 assert!(argv.iter().any(|t| t == "--trust"), "{argv:?}");
2102 assert!(!argv.iter().any(|t| t == "--force"), "{argv:?}");
2103 // Bypass mode keeps the plain --force (which implies trust).
2104 let argv = (spec.build_argv)(&base_ctx(spec));
2105 assert!(argv.iter().any(|t| t == "--force"), "{argv:?}");
2106 assert!(!argv.iter().any(|t| t == "--trust"), "{argv:?}");
2107 }
2108
2109 #[test]
2110 fn qwen_alone_declares_the_yolo_suppression_default_env() {
2111 // Qwen prints a one-line YOLO/no-sandbox warning to stderr under `--yolo`;
2112 // oneharness silences it so headless `stderr` stays clean. No other
2113 // harness needs a default env today — guard that the set hasn't drifted.
2114 for h in all() {
2115 if h.id == "qwen" {
2116 assert_eq!(
2117 h.default_env,
2118 &[("QWEN_CODE_SUPPRESS_YOLO_WARNING", "1")],
2119 "qwen should suppress its YOLO warning"
2120 );
2121 } else {
2122 assert!(
2123 h.default_env.is_empty(),
2124 "harness {} unexpectedly declares default env",
2125 h.id
2126 );
2127 }
2128 }
2129 }
2130
2131 #[test]
2132 fn bin_override_lands_at_argv0_for_every_harness() {
2133 for h in all() {
2134 let argv = (h.build_argv)(&ctx("/custom/bin", None, PermissionMode::Bypass));
2135 assert_eq!(argv[0], "/custom/bin", "harness {}", h.id);
2136 }
2137 }
2138
2139 #[test]
2140 fn model_flag_is_emitted_for_every_model_aware_harness() {
2141 // Each harness that accepts a model spells the flag its own way; `--model`
2142 // must reach the child via that spelling, never be dropped. Goose is the
2143 // sole exception — it selects its model from its own config — so its argv
2144 // is identical with and without a model (asserted separately).
2145 let expected: &[(&str, &[&str])] = &[
2146 ("claude-code", &["--model", "m"]),
2147 ("codex", &["--model", "m"]),
2148 ("opencode", &["-m", "m"]),
2149 ("qwen", &["-m", "m"]),
2150 ("crush", &["-m", "m"]),
2151 ("copilot", &["--model", "m"]),
2152 ("cursor", &["--model", "m"]),
2153 ];
2154 for (id, want) in expected {
2155 let spec = by_id(id).unwrap();
2156 let argv = (spec.build_argv)(&ctx(spec.default_bin, Some("m"), PermissionMode::Bypass));
2157 assert!(
2158 argv.windows(2).any(|w| w == *want),
2159 "harness {id} should carry {want:?}; got {argv:?}"
2160 );
2161 }
2162 // Goose deliberately ignores the model: argv is unchanged when one is set.
2163 let goose = by_id("goose").unwrap();
2164 let with = (goose.build_argv)(&ctx("goose", Some("m"), PermissionMode::Bypass));
2165 let without = (goose.build_argv)(&ctx("goose", None, PermissionMode::Bypass));
2166 assert_eq!(with, without, "goose should ignore --model");
2167 }
2168}