Skip to main content

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}