use crate::domain::gate::DenyShape;
use crate::domain::hooks::HookShape;
use crate::domain::mode::{ModeHeadless, PermissionMode};
use crate::domain::report::OutputFormat;
use crate::domain::structured::NativeSchema;
pub struct BuildCtx<'a> {
pub bin: &'a str,
pub prompt: &'a str,
pub model: Option<&'a str>,
pub system: Option<&'a str>,
pub resume: Option<&'a str>,
pub fork: bool,
pub mode: PermissionMode,
pub output_format: OutputFormat,
pub schema: Option<&'a str>,
}
fn format_flag(format: OutputFormat) -> &'static str {
match format {
OutputFormat::Text => "text",
OutputFormat::Json => "json",
OutputFormat::StreamJson => "stream-json",
}
}
fn prompt_with_system(c: &BuildCtx) -> String {
match c.system {
Some(s) if !s.is_empty() => format!("{s}\n\n{}", c.prompt),
_ => c.prompt.to_string(),
}
}
pub struct HarnessSpec {
pub id: &'static str,
pub display: &'static str,
pub default_bin: &'static str,
pub install_hint: &'static str,
pub output_format: OutputFormat,
pub supports_resume: bool,
pub supports_fork: bool,
pub fork_reuses_cache: bool,
pub sync: Option<SyncSpec>,
pub hooks: Option<HookBinding>,
pub global_hook: Option<GlobalHook>,
pub gate_deny: Option<DenyShape>,
pub default_env: &'static [(&'static str, &'static str)],
pub native_schema: Option<NativeSchema>,
pub modes: &'static [ModeSpec],
pub build_argv: fn(&BuildCtx) -> Vec<String>,
}
impl HarnessSpec {
pub fn mode(&self, mode: PermissionMode) -> Option<&'static ModeSpec> {
self.modes.iter().find(|m| m.mode == mode)
}
}
pub struct ModeSpec {
pub mode: PermissionMode,
pub headless: ModeHeadless,
pub env: &'static [(&'static str, &'static str)],
pub instruction: Option<&'static str>,
}
const fn mode(mode: PermissionMode, headless: ModeHeadless) -> ModeSpec {
ModeSpec {
mode,
headless,
env: &[],
instruction: None,
}
}
pub struct SyncSpec {
pub file: &'static str,
pub alt_files: &'static [&'static str],
pub allow_path: Option<&'static [&'static str]>,
pub deny_path: Option<&'static [&'static str]>,
pub hooks_path: Option<&'static [&'static str]>,
pub schema_seed: Option<&'static str>,
}
pub enum HookBinding {
SameFile {
shape: HookShape,
path: &'static [&'static str],
},
File {
shape: HookShape,
file: &'static str,
path: &'static [&'static str],
seed: Option<&'static str>,
},
GoosePlugin {
shape: HookShape,
plugins_dir: &'static str,
manifest: &'static str,
path: &'static [&'static str],
},
JsPlugin {
plugin_dir: &'static str,
template: &'static str,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookBase {
Home,
ConfigHome,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GlobalHook {
pub base: HookBase,
pub anchor: &'static str,
}
const 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:";
const GOOSE_MANIFEST: &str = r#"{
"name": "{name}",
"version": "0.1.0",
"description": "Pre-tool hook installed by oneharness."
}"#;
const OPENCODE_PLUGIN_JS: &str = r#"// {name} OpenCode plugin — installed by oneharness.
//
// OpenCode can block a tool call only from an in-process plugin, so this shim
// bridges to an external command: before any tool runs it spawns the command,
// piping the tool name and arguments as JSON on stdin, and throws to block when
// the command replies with {"decision":"deny"}. Re-running sync regenerates it.
export const {export} = async ({ directory }) => ({
"tool.execute.before": async (input, output) => {
const tool_name = (input && input.tool) || "";
if (!tool_name) return;
const args = (output && output.args) || {};
const cwd = args.workdir || directory || ".";
const session_id = (input && input.sessionID) || undefined;
const event = JSON.stringify({ tool_name, tool_input: args, cwd, session_id });
let stdout = "";
try {
const proc = Bun.spawn({argv}, {
cwd,
stdin: new TextEncoder().encode(event),
stdout: "pipe",
stderr: "ignore",
});
stdout = await new Response(proc.stdout).text();
await proc.exited;
} catch (_) {
return; // fail open: if the gate command cannot run, never block
}
const trimmed = stdout.trim();
if (trimmed.length === 0) return; // no objection — let it run
let decision;
try {
decision = JSON.parse(trimmed);
} catch (_) {
return; // unparseable output: fail open
}
if (decision && decision.decision === "deny") {
throw new Error(decision.reason || "Blocked by {name}");
}
},
});
"#;
pub fn all() -> &'static [HarnessSpec] {
REGISTRY
}
pub fn by_id(id: &str) -> Option<&'static HarnessSpec> {
REGISTRY.iter().find(|h| h.id == id)
}
pub fn valid_ids() -> String {
REGISTRY.iter().map(|h| h.id).collect::<Vec<_>>().join(", ")
}
static REGISTRY: &[HarnessSpec] = &[
HarnessSpec {
id: "claude-code",
display: "Claude Code",
default_bin: "claude",
install_hint: "npm install -g @anthropic-ai/claude-code",
output_format: OutputFormat::Json,
supports_resume: true,
supports_fork: true,
fork_reuses_cache: true,
sync: Some(SyncSpec {
file: ".claude/settings.json",
alt_files: &[],
allow_path: Some(&["permissions", "allow"]),
deny_path: Some(&["permissions", "deny"]),
hooks_path: Some(&["hooks"]),
schema_seed: None,
}),
hooks: Some(HookBinding::SameFile {
shape: HookShape::Nested {
event: "PreToolUse",
with_timeout: true,
},
path: &["hooks"],
}),
global_hook: Some(GlobalHook {
base: HookBase::Home,
anchor: ".claude/settings.json",
}),
gate_deny: Some(DenyShape::ClaudeNested),
default_env: &[],
native_schema: Some(NativeSchema::ClaudeJsonSchema),
modes: &[
mode(PermissionMode::ReadOnly, ModeHeadless::Clean),
mode(PermissionMode::Plan, ModeHeadless::Clean),
mode(PermissionMode::Default, ModeHeadless::Clean),
mode(PermissionMode::Edit, ModeHeadless::Clean),
mode(PermissionMode::Auto, ModeHeadless::Clean),
mode(PermissionMode::Bypass, ModeHeadless::Clean),
],
build_argv: argv_claude_code,
},
HarnessSpec {
id: "codex",
display: "OpenAI Codex CLI",
default_bin: "codex",
install_hint: "npm install -g @openai/codex",
output_format: OutputFormat::Text,
supports_resume: true,
supports_fork: false,
fork_reuses_cache: false,
sync: None,
hooks: Some(HookBinding::File {
shape: HookShape::Nested {
event: "PreToolUse",
with_timeout: false,
},
file: ".codex/hooks.json",
path: &["hooks"],
seed: None,
}),
global_hook: Some(GlobalHook {
base: HookBase::Home,
anchor: ".codex/hooks.json",
}),
gate_deny: Some(DenyShape::ClaudeNested),
default_env: &[],
native_schema: None,
modes: &[
mode(PermissionMode::ReadOnly, ModeHeadless::Clean),
ModeSpec {
mode: PermissionMode::Plan,
headless: ModeHeadless::Clean,
env: &[],
instruction: Some(CODEX_PLAN_INSTRUCTION),
},
mode(PermissionMode::Default, ModeHeadless::Clean),
mode(PermissionMode::Auto, ModeHeadless::Clean),
mode(PermissionMode::Bypass, ModeHeadless::Clean),
],
build_argv: argv_codex,
},
HarnessSpec {
id: "opencode",
display: "OpenCode",
default_bin: "opencode",
install_hint: "npm install -g opencode-ai",
output_format: OutputFormat::Json,
supports_resume: true,
supports_fork: true,
fork_reuses_cache: false,
sync: Some(SyncSpec {
file: "opencode.json",
alt_files: &[],
allow_path: None,
deny_path: None,
hooks_path: None,
schema_seed: None,
}),
hooks: Some(HookBinding::JsPlugin {
plugin_dir: ".opencode/plugin",
template: OPENCODE_PLUGIN_JS,
}),
global_hook: Some(GlobalHook {
base: HookBase::ConfigHome,
anchor: "opencode/plugin/{name}.js",
}),
gate_deny: Some(DenyShape::Decision("deny")),
default_env: &[],
native_schema: None,
modes: &[
mode(PermissionMode::ReadOnly, ModeHeadless::Clean),
mode(PermissionMode::Plan, ModeHeadless::Clean),
mode(PermissionMode::Default, ModeHeadless::Clean),
ModeSpec {
mode: PermissionMode::Edit,
headless: ModeHeadless::Clean,
env: &[(
"OPENCODE_CONFIG_CONTENT",
r#"{"permission":{"edit":"allow","bash":"deny"}}"#,
)],
instruction: None,
},
mode(PermissionMode::Bypass, ModeHeadless::Clean),
],
build_argv: argv_opencode,
},
HarnessSpec {
id: "goose",
display: "Goose",
default_bin: "goose",
install_hint: "see https://block.github.io/goose/docs/getting-started/installation",
output_format: OutputFormat::Text,
supports_resume: true,
supports_fork: false,
fork_reuses_cache: false,
sync: None,
hooks: Some(HookBinding::GoosePlugin {
shape: HookShape::Nested {
event: "PreToolUse",
with_timeout: true,
},
plugins_dir: ".agents/plugins",
manifest: GOOSE_MANIFEST,
path: &["hooks"],
}),
global_hook: Some(GlobalHook {
base: HookBase::Home,
anchor: ".agents/plugins/{name}",
}),
gate_deny: Some(DenyShape::Decision("block")),
default_env: &[],
native_schema: None,
modes: &[
ModeSpec {
mode: PermissionMode::Default,
headless: ModeHeadless::Clean,
env: &[("GOOSE_MODE", "approve")],
instruction: None,
},
ModeSpec {
mode: PermissionMode::Auto,
headless: ModeHeadless::Clean,
env: &[("GOOSE_MODE", "smart_approve")],
instruction: None,
},
ModeSpec {
mode: PermissionMode::Bypass,
headless: ModeHeadless::Clean,
env: &[("GOOSE_MODE", "auto")],
instruction: None,
},
],
build_argv: argv_goose,
},
HarnessSpec {
id: "qwen",
display: "Qwen Code",
default_bin: "qwen",
install_hint: "npm install -g @qwen-code/qwen-code",
output_format: OutputFormat::Text,
supports_resume: true,
supports_fork: false,
fork_reuses_cache: false,
sync: Some(SyncSpec {
file: ".qwen/settings.json",
alt_files: &[],
allow_path: Some(&["permissions", "allow"]),
deny_path: Some(&["permissions", "deny"]),
hooks_path: None,
schema_seed: None,
}),
hooks: Some(HookBinding::SameFile {
shape: HookShape::Nested {
event: "PreToolUse",
with_timeout: false,
},
path: &["hooks"],
}),
global_hook: Some(GlobalHook {
base: HookBase::Home,
anchor: ".qwen/settings.json",
}),
gate_deny: Some(DenyShape::ClaudeNested),
default_env: &[("QWEN_CODE_SUPPRESS_YOLO_WARNING", "1")],
native_schema: None,
modes: &[
mode(PermissionMode::ReadOnly, ModeHeadless::Clean),
mode(PermissionMode::Plan, ModeHeadless::Clean),
mode(PermissionMode::Default, ModeHeadless::Clean),
mode(PermissionMode::Edit, ModeHeadless::Clean),
mode(PermissionMode::Auto, ModeHeadless::Clean),
mode(PermissionMode::Bypass, ModeHeadless::Clean),
],
build_argv: argv_qwen,
},
HarnessSpec {
id: "crush",
display: "Crush",
default_bin: "crush",
install_hint: "npm install -g @charmland/crush",
output_format: OutputFormat::Text,
supports_resume: true,
supports_fork: false,
fork_reuses_cache: false,
sync: Some(SyncSpec {
file: "crush.json",
alt_files: &[".crush.json"],
allow_path: Some(&["permissions", "allowed_tools"]),
deny_path: Some(&["options", "disabled_tools"]),
hooks_path: None,
schema_seed: None,
}),
hooks: Some(HookBinding::SameFile {
shape: HookShape::Flat {
event: "PreToolUse",
},
path: &["hooks"],
}),
global_hook: Some(GlobalHook {
base: HookBase::ConfigHome,
anchor: "crush/crush.json",
}),
gate_deny: Some(DenyShape::Decision("deny")),
default_env: &[],
native_schema: None,
modes: &[
mode(PermissionMode::Default, ModeHeadless::Clean),
mode(PermissionMode::Bypass, ModeHeadless::Clean),
],
build_argv: argv_crush,
},
HarnessSpec {
id: "copilot",
display: "GitHub Copilot CLI",
default_bin: "copilot",
install_hint: "npm install -g @github/copilot",
output_format: OutputFormat::Text,
supports_resume: true,
supports_fork: false,
fork_reuses_cache: false,
sync: None,
hooks: Some(HookBinding::File {
shape: HookShape::CrossShell {
event: "preToolUse",
},
file: ".github/hooks/{name}.json",
path: &["hooks"],
seed: Some(r#"{"version":1}"#),
}),
global_hook: Some(GlobalHook {
base: HookBase::Home,
anchor: ".copilot/hooks/{name}.json",
}),
gate_deny: Some(DenyShape::CopilotFlat),
default_env: &[],
native_schema: None,
modes: &[
mode(PermissionMode::ReadOnly, ModeHeadless::Clean),
mode(PermissionMode::Plan, ModeHeadless::Clean),
mode(PermissionMode::Default, ModeHeadless::Clean),
mode(PermissionMode::Edit, ModeHeadless::Clean),
mode(PermissionMode::Bypass, ModeHeadless::Clean),
],
build_argv: argv_copilot,
},
HarnessSpec {
id: "cursor",
display: "Cursor CLI",
default_bin: "cursor-agent",
install_hint: "see https://docs.cursor.com/en/cli/overview",
output_format: OutputFormat::StreamJson,
supports_resume: true,
supports_fork: false,
fork_reuses_cache: false,
sync: Some(SyncSpec {
file: ".cursor/cli.json",
alt_files: &[],
allow_path: Some(&["permissions", "allow"]),
deny_path: Some(&["permissions", "deny"]),
hooks_path: None,
schema_seed: Some(r#"{"permissions":{"allow":[],"deny":[]}}"#),
}),
hooks: Some(HookBinding::File {
shape: HookShape::CommandOnly {
events: &[
"beforeShellExecution",
"beforeReadFile",
"beforeMCPExecution",
],
},
file: ".cursor/hooks.json",
path: &["hooks"],
seed: Some(r#"{"version":1}"#),
}),
global_hook: Some(GlobalHook {
base: HookBase::Home,
anchor: ".cursor/hooks.json",
}),
gate_deny: Some(DenyShape::CursorPermission),
default_env: &[],
native_schema: None,
modes: &[
mode(PermissionMode::ReadOnly, ModeHeadless::Clean),
mode(PermissionMode::Plan, ModeHeadless::Clean),
mode(PermissionMode::Default, ModeHeadless::Hangs),
mode(PermissionMode::Bypass, ModeHeadless::Clean),
],
build_argv: argv_cursor,
},
];
fn claude_permission_mode(mode: PermissionMode) -> &'static str {
match mode {
PermissionMode::Plan => "plan",
PermissionMode::ReadOnly => "bypassPermissions",
PermissionMode::Default => "dontAsk",
PermissionMode::Edit => "acceptEdits",
PermissionMode::Auto => "auto",
PermissionMode::Bypass => "bypassPermissions",
}
}
fn argv_claude_code(c: &BuildCtx) -> Vec<String> {
let mut a = vec![c.bin.into(), "-p".into(), c.prompt.into()];
a.push("--permission-mode".into());
a.push(claude_permission_mode(c.mode).into());
if c.mode == PermissionMode::ReadOnly {
a.push("--disallowedTools".into());
for tool in ["Bash", "Edit", "Write", "NotebookEdit"] {
a.push(tool.into());
}
}
if let Some(m) = c.model {
a.push("--model".into());
a.push(m.into());
}
if let Some(s) = c.system {
a.push("--append-system-prompt".into());
a.push(s.into());
}
if let Some(sid) = c.resume {
a.push("--resume".into());
a.push(sid.into());
if c.fork {
a.push("--fork-session".into());
}
}
a.push("--output-format".into());
a.push(format_flag(c.output_format).into());
if let Some(schema) = c.schema {
a.push("--json-schema".into());
a.push(schema.into());
}
a
}
fn argv_codex(c: &BuildCtx) -> Vec<String> {
let mut a = vec![c.bin.into(), "exec".into()];
if c.resume.is_some() {
a.push("resume".into());
}
match c.mode {
PermissionMode::Bypass => {
a.push("--dangerously-bypass-approvals-and-sandbox".into());
}
PermissionMode::ReadOnly | PermissionMode::Plan => {
a.push("--sandbox".into());
a.push("read-only".into());
}
PermissionMode::Auto => {
a.push("--sandbox".into());
a.push("workspace-write".into());
}
PermissionMode::Default | PermissionMode::Edit => {}
}
if let Some(m) = c.model {
a.push("--model".into());
a.push(m.into());
}
if let Some(sid) = c.resume {
a.push(sid.into());
}
a.push(prompt_with_system(c));
a
}
fn argv_opencode(c: &BuildCtx) -> Vec<String> {
let mut a = vec![c.bin.into(), "run".into()];
match c.mode {
PermissionMode::Bypass => a.push("--dangerously-skip-permissions".into()),
PermissionMode::Plan | PermissionMode::ReadOnly => {
a.push("--agent".into());
a.push("plan".into());
}
_ => {}
}
a.push("--format".into());
a.push(format_flag(c.output_format).into());
if let Some(m) = c.model {
a.push("-m".into());
a.push(m.into());
}
if let Some(sid) = c.resume {
a.push("--session".into());
a.push(sid.into());
if c.fork {
a.push("--fork".into());
}
}
a.push(prompt_with_system(c));
a
}
fn argv_goose(c: &BuildCtx) -> Vec<String> {
let mut a = vec![
c.bin.into(),
"run".into(),
"--with-builtin".into(),
"developer".into(),
];
if let Some(s) = c.system {
a.push("--system".into());
a.push(s.into());
}
if let Some(name) = c.resume {
a.push("--resume".into());
a.push("--name".into());
a.push(name.into());
}
a.push("-t".into());
a.push(c.prompt.into());
a
}
fn argv_qwen(c: &BuildCtx) -> Vec<String> {
let mut a = vec![c.bin.into()];
match c.mode {
PermissionMode::Bypass => a.push("--yolo".into()),
PermissionMode::Plan | PermissionMode::ReadOnly => {
a.push("--approval-mode".into());
a.push("plan".into());
}
PermissionMode::Default => {
a.push("--approval-mode".into());
a.push("default".into());
}
PermissionMode::Edit => {
a.push("--approval-mode".into());
a.push("auto-edit".into());
}
PermissionMode::Auto => {
a.push("--approval-mode".into());
a.push("auto".into());
}
}
if let Some(m) = c.model {
a.push("-m".into());
a.push(m.into());
}
if let Some(sid) = c.resume {
a.push("--resume".into());
a.push(sid.into());
}
a.push("-p".into());
a.push(prompt_with_system(c));
a
}
fn argv_crush(c: &BuildCtx) -> Vec<String> {
let mut a = vec![c.bin.into(), "run".into(), "-q".into()];
if let Some(sid) = c.resume {
a.push("--session".into());
a.push(sid.into());
}
if let Some(m) = c.model {
a.push("-m".into());
a.push(m.into());
}
a.push(prompt_with_system(c));
a
}
fn argv_copilot(c: &BuildCtx) -> Vec<String> {
let mut a = vec![c.bin.into(), "-p".into(), prompt_with_system(c)];
match c.mode {
PermissionMode::Bypass => {
a.push("--allow-all-tools".into());
a.push("--allow-all-paths".into());
a.push("--no-ask-user".into());
}
PermissionMode::ReadOnly => {
a.push("--allow-all-tools".into());
a.push("--allow-all-paths".into());
a.push("--deny-tool".into());
a.push("shell".into());
a.push("--deny-tool".into());
a.push("write".into());
a.push("--no-ask-user".into());
}
PermissionMode::Edit => {
a.push("--allow-tool".into());
a.push("write".into());
a.push("--allow-tool".into());
a.push("read".into());
a.push("--allow-all-paths".into());
a.push("--no-ask-user".into());
}
PermissionMode::Plan => {
a.push("--mode".into());
a.push("plan".into());
}
_ => {}
}
if let Some(m) = c.model {
a.push("--model".into());
a.push(m.into());
}
if let Some(sid) = c.resume {
a.push("--resume".into());
a.push(sid.into());
}
a
}
fn argv_cursor(c: &BuildCtx) -> Vec<String> {
let mut a = vec![c.bin.into(), "-p".into(), prompt_with_system(c)];
if c.mode.is_bypass() {
a.push("--force".into());
} else {
a.push("--trust".into());
match c.mode {
PermissionMode::Plan => {
a.push("--mode".into());
a.push("plan".into());
}
PermissionMode::ReadOnly => {
a.push("--mode".into());
a.push("ask".into());
}
_ => {}
}
}
if let Some(m) = c.model {
a.push("--model".into());
a.push(m.into());
}
if let Some(sid) = c.resume {
a.push("--resume".into());
a.push(sid.into());
}
a.push("--output-format".into());
a.push(format_flag(c.output_format).into());
a
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx<'a>(bin: &'a str, model: Option<&'a str>, mode: PermissionMode) -> BuildCtx<'a> {
ctx_fmt(bin, model, mode, OutputFormat::Json)
}
fn ctx_fmt<'a>(
bin: &'a str,
model: Option<&'a str>,
mode: PermissionMode,
output_format: OutputFormat,
) -> BuildCtx<'a> {
BuildCtx {
bin,
prompt: "hi",
model,
system: None,
resume: None,
fork: false,
mode,
output_format,
schema: None,
}
}
#[test]
fn registry_ids_are_unique_and_nonempty() {
let mut seen = std::collections::HashSet::new();
for h in all() {
assert!(!h.id.is_empty());
assert!(!h.default_bin.is_empty());
assert!(seen.insert(h.id), "duplicate id {}", h.id);
}
assert_eq!(all().len(), 8);
}
#[test]
fn claude_argv_bypass_on() {
let spec = by_id("claude-code").unwrap();
let argv = (spec.build_argv)(&ctx("claude", None, PermissionMode::Bypass));
assert_eq!(
argv,
vec![
"claude",
"-p",
"hi",
"--permission-mode",
"bypassPermissions",
"--output-format",
"json"
]
);
}
#[test]
fn claude_argv_default_mode_maps_to_dont_ask() {
let spec = by_id("claude-code").unwrap();
let argv = (spec.build_argv)(&ctx("claude", Some("haiku"), PermissionMode::Default));
assert_eq!(
argv,
vec![
"claude",
"-p",
"hi",
"--permission-mode",
"dontAsk",
"--model",
"haiku",
"--output-format",
"json"
]
);
}
#[test]
fn claude_maps_each_mode_to_its_permission_mode_token() {
let spec = by_id("claude-code").unwrap();
for (mode, token) in [
(PermissionMode::Plan, "plan"),
(PermissionMode::ReadOnly, "bypassPermissions"),
(PermissionMode::Default, "dontAsk"),
(PermissionMode::Edit, "acceptEdits"),
(PermissionMode::Auto, "auto"),
(PermissionMode::Bypass, "bypassPermissions"),
] {
let argv = (spec.build_argv)(&ctx("claude", None, mode));
assert!(
argv.windows(2).any(|w| w == ["--permission-mode", token]),
"mode {mode:?} should emit {token}: {argv:?}"
);
}
let ro = (spec.build_argv)(&ctx("claude", None, PermissionMode::ReadOnly));
assert!(
ro.windows(2).any(|w| w == ["--disallowedTools", "Bash"]),
"read-only should deny Bash: {ro:?}"
);
for tool in ["Edit", "Write", "NotebookEdit"] {
assert!(
ro.iter().any(|t| t == tool),
"read-only denies {tool}: {ro:?}"
);
}
assert!(
!(spec.build_argv)(&ctx("claude", None, PermissionMode::Plan))
.iter()
.any(|t| t == "--disallowedTools"),
"plan should not deny tools"
);
}
#[test]
fn mode_native_flags_per_harness() {
let cases: &[(&str, PermissionMode, &[&str])] = &[
(
"codex",
PermissionMode::ReadOnly,
&["--sandbox", "read-only"],
),
(
"codex",
PermissionMode::Auto,
&["--sandbox", "workspace-write"],
),
("opencode", PermissionMode::Plan, &["--agent", "plan"]),
("opencode", PermissionMode::ReadOnly, &["--agent", "plan"]),
("qwen", PermissionMode::Plan, &["--approval-mode", "plan"]),
(
"qwen",
PermissionMode::ReadOnly,
&["--approval-mode", "plan"],
),
(
"qwen",
PermissionMode::Edit,
&["--approval-mode", "auto-edit"],
),
("qwen", PermissionMode::Auto, &["--approval-mode", "auto"]),
("copilot", PermissionMode::Plan, &["--mode", "plan"]),
(
"copilot",
PermissionMode::ReadOnly,
&["--deny-tool", "shell"],
),
(
"copilot",
PermissionMode::ReadOnly,
&["--deny-tool", "write"],
),
("copilot", PermissionMode::Edit, &["--allow-tool", "write"]),
("cursor", PermissionMode::Plan, &["--mode", "plan"]),
("cursor", PermissionMode::ReadOnly, &["--mode", "ask"]),
];
for (id, mode, want) in cases {
let spec = by_id(id).unwrap();
let argv = (spec.build_argv)(&ctx(spec.default_bin, None, *mode));
assert!(
argv.windows(want.len()).any(|w| w == *want),
"harness {id} mode {mode:?} should emit {want:?}; got {argv:?}"
);
}
let copilot_edit =
(by_id("copilot").unwrap().build_argv)(&ctx("copilot", None, PermissionMode::Edit));
assert!(
!copilot_edit.iter().any(|t| t == "--allow-all-tools"),
"copilot edit must gate shell, not allow-all: {copilot_edit:?}"
);
let codex_plan = by_id("codex").unwrap().mode(PermissionMode::Plan);
assert!(
codex_plan.is_some(),
"codex should support synthesized plan"
);
assert!(
codex_plan.unwrap().instruction.is_some(),
"codex plan must carry a plan instruction"
);
let codex_plan_argv =
(by_id("codex").unwrap().build_argv)(&ctx("codex", None, PermissionMode::Plan));
assert!(
codex_plan_argv
.windows(2)
.any(|w| w == ["--sandbox", "read-only"]),
"codex plan must enforce read-only: {codex_plan_argv:?}"
);
assert!(by_id("goose").unwrap().mode(PermissionMode::Plan).is_none());
assert!(by_id("goose")
.unwrap()
.mode(PermissionMode::ReadOnly)
.is_none());
let crush = by_id("crush").unwrap();
assert!(crush.mode(PermissionMode::Plan).is_none());
assert!(crush.mode(PermissionMode::ReadOnly).is_none());
let bypass = (crush.build_argv)(&ctx("crush", None, PermissionMode::Bypass));
let default = (crush.build_argv)(&ctx("crush", None, PermissionMode::Default));
assert_eq!(bypass, default, "crush has no per-run mode flag");
assert!(!bypass.iter().any(|t| t == "--yolo"), "{bypass:?}");
}
#[test]
fn every_harness_supports_bypass_and_default_and_goose_carries_mode_env() {
for h in all() {
assert!(
h.mode(PermissionMode::Bypass).is_some(),
"harness {} must support bypass (the headless default)",
h.id
);
assert!(
h.mode(PermissionMode::Default).is_some(),
"harness {} must support the default ask flow",
h.id
);
}
let goose = by_id("goose").unwrap();
assert_eq!(
goose.mode(PermissionMode::Bypass).unwrap().env,
&[("GOOSE_MODE", "auto")]
);
assert_eq!(
goose.mode(PermissionMode::Default).unwrap().env,
&[("GOOSE_MODE", "approve")]
);
assert_eq!(
by_id("opencode")
.unwrap()
.mode(PermissionMode::Edit)
.unwrap()
.env,
&[(
"OPENCODE_CONFIG_CONTENT",
r#"{"permission":{"edit":"allow","bash":"deny"}}"#
)]
);
for h in all() {
for m in h.modes {
let env_ok = h.id == "goose"
|| (h.id == "opencode" && m.mode == PermissionMode::Edit)
|| m.env.is_empty();
assert!(
env_ok,
"harness {} mode {:?} unexpectedly carries env",
h.id, m.mode
);
}
}
}
#[test]
fn codex_argv_uses_exec_and_bypass_flag() {
let spec = by_id("codex").unwrap();
let argv = (spec.build_argv)(&ctx("codex", None, PermissionMode::Bypass));
assert_eq!(
argv,
vec![
"codex",
"exec",
"--dangerously-bypass-approvals-and-sandbox",
"hi"
]
);
}
#[test]
fn goose_ignores_model_and_bypass() {
let spec = by_id("goose").unwrap();
let with = (spec.build_argv)(&ctx("goose", Some("gpt"), PermissionMode::Bypass));
let without = (spec.build_argv)(&ctx("goose", None, PermissionMode::Default));
assert_eq!(with, without);
assert_eq!(
with,
vec!["goose", "run", "--with-builtin", "developer", "-t", "hi"]
);
}
#[test]
fn output_format_override_changes_the_emitted_flag() {
let spec = by_id("claude-code").unwrap();
let argv = (spec.build_argv)(&ctx_fmt(
"claude",
None,
PermissionMode::Bypass,
OutputFormat::StreamJson,
));
assert!(
argv.windows(2)
.any(|w| w == ["--output-format", "stream-json"]),
"{argv:?}"
);
let oc = by_id("opencode").unwrap();
let argv = (oc.build_argv)(&ctx_fmt(
"opencode",
None,
PermissionMode::Bypass,
OutputFormat::Text,
));
assert!(
argv.windows(2).any(|w| w == ["--format", "text"]),
"{argv:?}"
);
}
#[test]
fn claude_maps_system_to_append_system_prompt() {
let spec = by_id("claude-code").unwrap();
let ctx = BuildCtx {
bin: "claude",
prompt: "hi",
model: None,
system: Some("be terse"),
resume: None,
fork: false,
mode: PermissionMode::Bypass,
output_format: OutputFormat::Json,
schema: None,
};
let argv = (spec.build_argv)(&ctx);
assert!(
argv.windows(2)
.any(|w| w == ["--append-system-prompt", "be terse"]),
"{argv:?}"
);
}
#[test]
fn prompt_with_system_prefixes_only_when_present() {
let spec = by_id("codex").unwrap();
let none = BuildCtx {
system: None,
..base_ctx(spec)
};
assert_eq!(prompt_with_system(&none), "hi");
let some = BuildCtx {
system: Some("rules"),
..base_ctx(spec)
};
assert_eq!(prompt_with_system(&some), "rules\n\nhi");
let empty = BuildCtx {
system: Some(""),
..base_ctx(spec)
};
assert_eq!(prompt_with_system(&empty), "hi");
}
#[test]
fn goose_maps_system_to_its_native_flag() {
let spec = by_id("goose").unwrap();
let argv = (spec.build_argv)(&BuildCtx {
system: Some("be terse"),
..base_ctx(spec)
});
assert!(
argv.windows(2).any(|w| w == ["--system", "be terse"]),
"{argv:?}"
);
assert!(argv.windows(2).any(|w| w == ["-t", "hi"]), "{argv:?}");
}
#[test]
fn harnesses_without_a_system_flag_prepend_it_to_the_prompt() {
for id in ["codex", "opencode", "qwen", "crush", "copilot", "cursor"] {
let spec = by_id(id).unwrap();
let argv = (spec.build_argv)(&BuildCtx {
system: Some("be terse"),
..base_ctx(spec)
});
assert!(
argv.iter().any(|t| t == "be terse\n\nhi"),
"harness {id} should carry the prepended prompt; got {argv:?}"
);
assert!(
!argv.iter().any(|t| t == "hi"),
"harness {id} should not also send the bare prompt; got {argv:?}"
);
}
}
fn base_ctx(spec: &'static HarnessSpec) -> BuildCtx<'static> {
BuildCtx {
bin: spec.default_bin,
prompt: "hi",
model: None,
system: None,
resume: None,
fork: false,
mode: PermissionMode::Bypass,
output_format: spec.output_format,
schema: None,
}
}
#[test]
fn claude_native_schema_appends_json_schema_flag() {
let spec = by_id("claude-code").unwrap();
assert_eq!(spec.native_schema, Some(NativeSchema::ClaudeJsonSchema));
let argv = (spec.build_argv)(&BuildCtx {
schema: Some(r#"{"type":"object"}"#),
..base_ctx(spec)
});
assert!(
argv.windows(2)
.any(|w| w == ["--json-schema", r#"{"type":"object"}"#]),
"{argv:?}"
);
assert!(
argv.windows(2).any(|w| w == ["--output-format", "json"]),
"native schema requires json output: {argv:?}"
);
let argv = (spec.build_argv)(&base_ctx(spec));
assert!(!argv.iter().any(|t| t == "--json-schema"), "{argv:?}");
}
#[test]
fn claude_maps_resume_to_resume_flag() {
let spec = by_id("claude-code").unwrap();
assert!(spec.supports_resume);
let argv = (spec.build_argv)(&BuildCtx {
resume: Some("sess-123"),
..base_ctx(spec)
});
assert!(
argv.windows(2).any(|w| w == ["--resume", "sess-123"]),
"{argv:?}"
);
}
#[test]
fn every_harness_supports_resume() {
let unsupported: Vec<&str> = all()
.iter()
.filter(|h| !h.supports_resume)
.map(|h| h.id)
.collect();
assert!(
unsupported.is_empty(),
"resume gaps drifted: {unsupported:?}"
);
}
#[test]
fn fork_supported_set_is_claude_and_opencode() {
let supported: std::collections::HashSet<&str> = all()
.iter()
.filter(|h| h.supports_fork)
.map(|h| h.id)
.collect();
assert_eq!(
supported,
["claude-code", "opencode"].into_iter().collect(),
"supports_fork set drifted"
);
assert!(all().iter().all(|h| !h.supports_fork || h.supports_resume));
}
#[test]
fn claude_maps_fork_to_fork_session_flag() {
let spec = by_id("claude-code").unwrap();
assert!(spec.supports_fork);
let argv = (spec.build_argv)(&BuildCtx {
resume: Some("sess-123"),
fork: true,
..base_ctx(spec)
});
assert!(
argv.windows(2).any(|w| w == ["--resume", "sess-123"]),
"{argv:?}"
);
assert!(argv.iter().any(|t| t == "--fork-session"), "{argv:?}");
let argv = (spec.build_argv)(&BuildCtx {
resume: Some("sess-123"),
..base_ctx(spec)
});
assert!(!argv.iter().any(|t| t == "--fork-session"), "{argv:?}");
}
#[test]
fn opencode_maps_fork_to_fork_flag() {
let spec = by_id("opencode").unwrap();
assert!(spec.supports_fork);
let argv = (spec.build_argv)(&BuildCtx {
resume: Some("ses_abc"),
fork: true,
..base_ctx(spec)
});
assert!(
argv.windows(2).any(|w| w == ["--session", "ses_abc"]),
"{argv:?}"
);
assert!(argv.iter().any(|t| t == "--fork"), "{argv:?}");
}
#[test]
fn codex_maps_resume_to_resume_subcommand_before_prompt() {
let spec = by_id("codex").unwrap();
assert!(spec.supports_resume && !spec.supports_fork);
let argv = (spec.build_argv)(&BuildCtx {
resume: Some("0199-thread"),
..base_ctx(spec)
});
assert!(
argv.windows(2).any(|w| w == ["exec", "resume"]),
"resume is a subcommand after exec: {argv:?}"
);
assert!(
argv.windows(2).any(|w| w == ["0199-thread", "hi"]),
"{argv:?}"
);
assert!(!argv.iter().any(|t| t == "--fork"), "{argv:?}");
}
#[test]
fn goose_maps_resume_to_named_session() {
let spec = by_id("goose").unwrap();
assert!(spec.supports_resume);
let argv = (spec.build_argv)(&BuildCtx {
resume: Some("my-session"),
..base_ctx(spec)
});
assert!(argv.iter().any(|t| t == "--resume"), "{argv:?}");
assert!(
argv.windows(2).any(|w| w == ["--name", "my-session"]),
"{argv:?}"
);
}
#[test]
fn qwen_maps_resume_to_resume_flag() {
let spec = by_id("qwen").unwrap();
assert!(spec.supports_resume);
let argv = (spec.build_argv)(&BuildCtx {
resume: Some("uuid-1"),
..base_ctx(spec)
});
assert!(
argv.windows(2).any(|w| w == ["--resume", "uuid-1"]),
"{argv:?}"
);
}
#[test]
fn crush_maps_resume_to_session_flag() {
let spec = by_id("crush").unwrap();
assert!(spec.supports_resume);
let argv = (spec.build_argv)(&BuildCtx {
resume: Some("sess-9"),
..base_ctx(spec)
});
assert!(
argv.windows(2).any(|w| w == ["--session", "sess-9"]),
"{argv:?}"
);
}
#[test]
fn copilot_maps_resume_to_resume_flag() {
let spec = by_id("copilot").unwrap();
assert!(spec.supports_resume);
let argv = (spec.build_argv)(&BuildCtx {
resume: Some("uuid-c"),
..base_ctx(spec)
});
assert!(
argv.windows(2).any(|w| w == ["--resume", "uuid-c"]),
"{argv:?}"
);
}
#[test]
fn opencode_maps_resume_to_session_flag() {
let spec = by_id("opencode").unwrap();
assert!(spec.supports_resume);
let argv = (spec.build_argv)(&BuildCtx {
resume: Some("ses_abc"),
..base_ctx(spec)
});
assert!(
argv.windows(2).any(|w| w == ["--session", "ses_abc"]),
"{argv:?}"
);
}
#[test]
fn cursor_maps_resume_to_resume_flag() {
let spec = by_id("cursor").unwrap();
assert!(spec.supports_resume);
let argv = (spec.build_argv)(&BuildCtx {
resume: Some("chat-9"),
..base_ctx(spec)
});
assert!(
argv.windows(2).any(|w| w == ["--resume", "chat-9"]),
"{argv:?}"
);
}
#[test]
fn cursor_no_bypass_trusts_the_workspace_without_force() {
let spec = by_id("cursor").unwrap();
let argv = (spec.build_argv)(&BuildCtx {
mode: PermissionMode::Default,
..base_ctx(spec)
});
assert!(argv.iter().any(|t| t == "--trust"), "{argv:?}");
assert!(!argv.iter().any(|t| t == "--force"), "{argv:?}");
let argv = (spec.build_argv)(&base_ctx(spec));
assert!(argv.iter().any(|t| t == "--force"), "{argv:?}");
assert!(!argv.iter().any(|t| t == "--trust"), "{argv:?}");
}
#[test]
fn qwen_alone_declares_the_yolo_suppression_default_env() {
for h in all() {
if h.id == "qwen" {
assert_eq!(
h.default_env,
&[("QWEN_CODE_SUPPRESS_YOLO_WARNING", "1")],
"qwen should suppress its YOLO warning"
);
} else {
assert!(
h.default_env.is_empty(),
"harness {} unexpectedly declares default env",
h.id
);
}
}
}
#[test]
fn bin_override_lands_at_argv0_for_every_harness() {
for h in all() {
let argv = (h.build_argv)(&ctx("/custom/bin", None, PermissionMode::Bypass));
assert_eq!(argv[0], "/custom/bin", "harness {}", h.id);
}
}
#[test]
fn model_flag_is_emitted_for_every_model_aware_harness() {
let expected: &[(&str, &[&str])] = &[
("claude-code", &["--model", "m"]),
("codex", &["--model", "m"]),
("opencode", &["-m", "m"]),
("qwen", &["-m", "m"]),
("crush", &["-m", "m"]),
("copilot", &["--model", "m"]),
("cursor", &["--model", "m"]),
];
for (id, want) in expected {
let spec = by_id(id).unwrap();
let argv = (spec.build_argv)(&ctx(spec.default_bin, Some("m"), PermissionMode::Bypass));
assert!(
argv.windows(2).any(|w| w == *want),
"harness {id} should carry {want:?}; got {argv:?}"
);
}
let goose = by_id("goose").unwrap();
let with = (goose.build_argv)(&ctx("goose", Some("m"), PermissionMode::Bypass));
let without = (goose.build_argv)(&ctx("goose", None, PermissionMode::Bypass));
assert_eq!(with, without, "goose should ignore --model");
}
}