Skip to content

Commit 895f034

Browse files
authored
Promote supported agent adapters (#185)
* feat(adapters): ship supported agent adapters Refs #184 * fix(adapters): document Claude adapter migration Record the Claude Code adapter relocation as a breaking migration instead of a new addition, and restore adversarial agnostic-launch coverage for claude --mcp-config without broadening the production token guard to tests. Refs #184
1 parent 10a5295 commit 895f034

9 files changed

Lines changed: 257 additions & 11 deletions

File tree

ARCHITECTURE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ Three crates, Rust 2024 edition, resolver v3:
1010
- **`runa-cli`** — Thin CLI binary. Clap-based argument parsing, delegates to libagent and the session MCP surface. No domain logic.
1111
- **`runa-mcp`** — MCP server binary. In fixed-protocol mode, serves one named protocol invocation per process. In session mode, serves one scoped work-unit session with driver verbs and current-step output tools in one MCP connection. Writes produced artifacts into the workspace through the same validation path in both modes.
1212

13+
Supported runtime adapters live in top-level **`adapters/`**. They translate
14+
the runtime-agnostic `RUNA_MCP_CONFIG` payload into each runtime's MCP
15+
registration surface while keeping `runa-cli` launch logic argv-agnostic.
16+
Current adapters cover Codex and Claude Code.
17+
1318
## Data Flow
1419

1520
These are library capabilities exposed by libagent and consumed by both the CLI and the MCP server.

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ Semantic Versioning.
2020
it receives the MCP session config through `RUNA_MCP_CONFIG` like every other
2121
runtime. Operators who relied on the old auto-injection should adapt by
2222
having their agent command or wrapper consume `RUNA_MCP_CONFIG`.
23+
- Breaking migration for Claude Code adapter configs: the previously documented
24+
`./examples/agent-claude-code.sh` path is gone. Repoint `[agent].command` to
25+
`./adapters/agent-claude-code.sh`; the old `examples/` adapter path is not
26+
retained as a wrapper or symlink.
2327

2428
## [0.2.0-rc.1] — 2026-06-08
2529

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ transcript capture, and scoped forge identity. Environment variables such as
175175
`RUNA_TRANSCRIPT_DIR` and `RUNA_FORGE_*` remain per-invocation overrides;
176176
config is the project-local default.
177177

178+
Runa ships supported agent adapters for Codex and Claude Code in `adapters/`.
179+
Set `[agent].command` to `./adapters/agent-codex.sh` or
180+
`./adapters/agent-claude-code.sh`; the adapter translates `RUNA_MCP_CONFIG` for
181+
the selected runtime.
182+
178183
## Build
179184

180185
Rust 2024 edition. Runa targets Linux.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
#!/bin/sh
22
set -eu
33

4-
# Example adapter: runa always delivers the MCP session payload through
4+
# Supported adapter: runa always delivers the MCP session payload through
55
# RUNA_MCP_CONFIG. This wrapper translates that payload to Claude Code's
66
# current CLI config shape; runa does not do this translation implicitly.
77

88
if [ -z "${RUNA_MCP_CONFIG:-}" ]; then
9-
echo "agent wrapper requires RUNA_MCP_CONFIG" >&2
9+
echo "agent-claude-code requires RUNA_MCP_CONFIG" >&2
1010
exit 1
1111
fi
1212

adapters/agent-codex.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
# Supported adapter for Codex CLI. Depends on jq for JSON parsing.
5+
# Runa delivers {command,args,env} through RUNA_MCP_CONFIG; Codex consumes
6+
# stdio MCP servers through TOML config overrides on `codex exec`.
7+
8+
if [ -z "${RUNA_MCP_CONFIG:-}" ]; then
9+
echo "agent-codex requires RUNA_MCP_CONFIG" >&2
10+
exit 1
11+
fi
12+
13+
if ! command -v jq >/dev/null 2>&1; then
14+
echo "agent-codex requires jq to parse RUNA_MCP_CONFIG" >&2
15+
exit 1
16+
fi
17+
18+
command_toml="$(
19+
printf '%s' "$RUNA_MCP_CONFIG" |
20+
jq -er '.command | if type == "string" then @json else error("RUNA_MCP_CONFIG.command must be a string") end'
21+
)"
22+
args_toml="$(
23+
printf '%s' "$RUNA_MCP_CONFIG" |
24+
jq -cer '.args | if type == "array" and all(.[]; type == "string") then . else error("RUNA_MCP_CONFIG.args must be a string array") end'
25+
)"
26+
env_toml="$(
27+
printf '%s' "$RUNA_MCP_CONFIG" |
28+
jq -er '(.env // {}) | if type == "object" and all(.[]; type == "string") then to_entries | map((.key | @json) + " = " + (.value | @json)) | "{ " + join(", ") + " }" else error("RUNA_MCP_CONFIG.env must be a string object") end'
29+
)"
30+
31+
exec codex exec \
32+
-c "mcp_servers.runa.command=$command_toml" \
33+
-c "mcp_servers.runa.args=$args_toml" \
34+
-c "mcp_servers.runa.env=$env_toml" \
35+
"$@"

docs/cli-reference.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,16 @@ invocation may override it with `--agent-command -- <argv tokens>`:
6767

6868
```toml
6969
[agent]
70-
command = ["./examples/agent-claude-code.sh", "-p", "--dangerously-skip-permissions"]
70+
command = ["./adapters/agent-codex.sh", "--model", "gpt-5-codex"]
71+
```
72+
73+
```toml
74+
[agent]
75+
command = ["./adapters/agent-claude-code.sh", "-p", "--dangerously-skip-permissions"]
7176
```
7277

7378
```bash
74-
runa run --agent-command -- ./examples/agent-claude-code.sh -p --dangerously-skip-permissions
79+
runa run --agent-command -- ./adapters/agent-codex.sh --model gpt-5-codex
7580
```
7681

7782
Runa executes the configured argv unmodified in the project root with stdout
@@ -83,6 +88,16 @@ payload containing the resolved `runa-mcp` command, arguments, and environment
8388
child process. Runtime-specific translation, such as wrapping that payload in a
8489
client-specific config file, belongs to the runtime or adapter, not to runa.
8590

91+
The supported runtime adapters live in `adapters/`: `agent-codex.sh` for Codex
92+
and `agent-claude-code.sh` for Claude Code. Point `[agent].command` at one of
93+
those scripts and pass runtime-specific options after the script path. The
94+
Codex adapter requires `jq` because Codex accepts external MCP servers through
95+
`-c mcp_servers.<name>.*` TOML overrides rather than a JSON config file.
96+
Migration note: older documentation used
97+
`./examples/agent-claude-code.sh` for Claude Code. That path no longer exists;
98+
repoint existing `[agent].command` values to
99+
`./adapters/agent-claude-code.sh`.
100+
86101
When transcript capture is enabled through `[transcript].dir` or
87102
`RUNA_TRANSCRIPT_DIR`, live execution appends JSON Lines transcript events to
88103
`events.jsonl` in the resolved directory. Events include protocol prompts,

libagent/src/project.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -625,14 +625,14 @@ filter = "info"
625625
methodology_path = "/tmp/methodology.toml"
626626
627627
[agent]
628-
command = ["codex", "exec"]
628+
command = ["agent-runtime", "exec"]
629629
"#,
630630
)
631631
.unwrap();
632632

633633
assert_eq!(
634634
config.agent.command,
635-
Some(vec!["codex".to_string(), "exec".to_string()])
635+
Some(vec!["agent-runtime".to_string(), "exec".to_string()])
636636
);
637637
}
638638

runa-cli/src/commands/step.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,7 +1401,8 @@ cat >/dev/null
14011401
temp.path(),
14021402
&[
14031403
command_name.to_string(),
1404-
"--mcp-config=operator-config.json".to_string(),
1404+
"--mcp-config".to_string(),
1405+
"operator-mcp.json".to_string(),
14051406
"-p".to_string(),
14061407
],
14071408
&entry,
@@ -1410,7 +1411,7 @@ cat >/dev/null
14101411
.unwrap_or_else(|err| panic!("{command_name} should launch unmodified: {err}"));
14111412
}
14121413

1413-
let expected_argv = "--mcp-config=operator-config.json\n-p\n";
1414+
let expected_argv = "--mcp-config\noperator-mcp.json\n-p\n";
14141415
let claude_argv = fs::read_to_string(temp.path().join("claude.argv")).unwrap();
14151416
let neutral_argv = fs::read_to_string(temp.path().join("neutral-agent.argv")).unwrap();
14161417
assert_eq!(claude_argv, expected_argv);
@@ -1432,6 +1433,22 @@ cat >/dev/null
14321433
assert_eq!(neutral_config, expected_config);
14331434
}
14341435

1436+
#[test]
1437+
fn production_launch_logic_has_no_claude_specific_core_path_tokens() {
1438+
let source = include_str!("step.rs");
1439+
let production_source = source
1440+
.split("\n#[cfg(test)]\nmod tests")
1441+
.next()
1442+
.expect("production source should precede tests module");
1443+
1444+
for token in ["claude", "--mcp-config", "--strict-mcp-config"] {
1445+
assert!(
1446+
!production_source.contains(token),
1447+
"production launch logic must not special-case {token}"
1448+
);
1449+
}
1450+
}
1451+
14351452
#[test]
14361453
fn execute_entry_writes_redacted_agent_transcript_events_when_enabled() {
14371454
let _lock = transcript_env_lock()

runa-cli/tests/step.rs

Lines changed: 168 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,18 @@ fn write_fake_claude(dir: &Path) -> std::path::PathBuf {
591591
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
592592
script_path
593593
}
594+
fn write_fake_codex(dir: &Path) -> std::path::PathBuf {
595+
use std::os::unix::fs::PermissionsExt;
596+
597+
let script_path = dir.join("codex");
598+
fs::write(
599+
&script_path,
600+
"#!/bin/sh\nset -eu\n: > \"$FAKE_CODEX_ARGV_CAPTURE\"\nfor arg in \"$@\"; do\n printf '%s\\0' \"$arg\" >> \"$FAKE_CODEX_ARGV_CAPTURE\"\ndone\ncat > \"$FAKE_CODEX_STDIN_CAPTURE\"\n",
601+
)
602+
.unwrap();
603+
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
604+
script_path
605+
}
594606
fn write_producing_fake_claude(dir: &Path) -> std::path::PathBuf {
595607
use std::os::unix::fs::PermissionsExt;
596608

@@ -1788,15 +1800,27 @@ fn step_without_dry_run_absolutizes_relative_config_override_and_path_entry() {
17881800
serde_json::Value::String(project_dir.to_string_lossy().into_owned())
17891801
);
17901802
}
1803+
fn read_nul_separated_args(path: &Path) -> Vec<String> {
1804+
fs::read(path)
1805+
.unwrap()
1806+
.split(|byte| *byte == 0)
1807+
.filter(|part| !part.is_empty())
1808+
.map(|part| String::from_utf8(part.to_vec()).unwrap())
1809+
.collect()
1810+
}
1811+
1812+
fn adapter_path(name: &str) -> PathBuf {
1813+
Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../adapters/{name}"))
1814+
}
1815+
17911816
#[test]
1792-
fn claude_wrapper_wraps_runa_mcp_config_under_mcp_servers() {
1817+
fn claude_code_adapter_wraps_runa_mcp_config_under_mcp_servers() {
17931818
let dir = tempfile::tempdir().unwrap();
17941819
let bin_dir = dir.path().join("bin");
17951820
fs::create_dir(&bin_dir).unwrap();
17961821
let fake_claude = write_fake_claude(&bin_dir);
17971822
let capture_path = dir.path().join("captured-claude-config.json");
1798-
let wrapper_path =
1799-
Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples/agent-claude-code.sh");
1823+
let wrapper_path = adapter_path("agent-claude-code.sh");
18001824

18011825
let output = Command::new(&wrapper_path)
18021826
.arg("--print")
@@ -1838,6 +1862,147 @@ fn claude_wrapper_wraps_runa_mcp_config_under_mcp_servers() {
18381862
})
18391863
);
18401864
}
1865+
1866+
#[test]
1867+
fn claude_code_adapter_requires_runa_mcp_config() {
1868+
let output = Command::new(adapter_path("agent-claude-code.sh"))
1869+
.env_remove("RUNA_MCP_CONFIG")
1870+
.output()
1871+
.unwrap();
1872+
1873+
assert!(!output.status.success(), "{output:?}");
1874+
let stderr = String::from_utf8_lossy(&output.stderr);
1875+
assert!(
1876+
stderr.contains("agent-claude-code requires RUNA_MCP_CONFIG"),
1877+
"stderr: {stderr}"
1878+
);
1879+
}
1880+
1881+
#[test]
1882+
fn codex_adapter_requires_runa_mcp_config() {
1883+
let output = Command::new(adapter_path("agent-codex.sh"))
1884+
.env_remove("RUNA_MCP_CONFIG")
1885+
.output()
1886+
.unwrap();
1887+
1888+
assert!(!output.status.success(), "{output:?}");
1889+
let stderr = String::from_utf8_lossy(&output.stderr);
1890+
assert!(
1891+
stderr.contains("agent-codex requires RUNA_MCP_CONFIG"),
1892+
"stderr: {stderr}"
1893+
);
1894+
}
1895+
1896+
#[test]
1897+
fn codex_adapter_requires_jq_for_json_translation() {
1898+
let dir = tempfile::tempdir().unwrap();
1899+
let empty_path = dir.path().join("empty-path");
1900+
fs::create_dir(&empty_path).unwrap();
1901+
1902+
let output = Command::new(adapter_path("agent-codex.sh"))
1903+
.env(
1904+
"RUNA_MCP_CONFIG",
1905+
r#"{"command":"/tmp/runa-mcp","args":[],"env":{}}"#,
1906+
)
1907+
.env("PATH", &empty_path)
1908+
.output()
1909+
.unwrap();
1910+
1911+
assert!(!output.status.success(), "{output:?}");
1912+
let stderr = String::from_utf8_lossy(&output.stderr);
1913+
assert!(
1914+
stderr.contains("agent-codex requires jq to parse RUNA_MCP_CONFIG"),
1915+
"stderr: {stderr}"
1916+
);
1917+
}
1918+
1919+
#[test]
1920+
fn codex_adapter_translates_runa_mcp_config_to_mcp_server_overrides() {
1921+
let dir = tempfile::tempdir().unwrap();
1922+
let bin_dir = dir.path().join("bin");
1923+
fs::create_dir(&bin_dir).unwrap();
1924+
let fake_codex = write_fake_codex(&bin_dir);
1925+
let argv_capture = dir.path().join("codex.argv");
1926+
let stdin_capture = dir.path().join("codex.stdin");
1927+
let path = format!(
1928+
"{}:{}",
1929+
bin_dir.display(),
1930+
std::env::var("PATH").unwrap_or_default()
1931+
);
1932+
let prompt = "advance one runa session tick\nwith stdin only";
1933+
1934+
let output = Command::new(adapter_path("agent-codex.sh"))
1935+
.arg("--model")
1936+
.arg("gpt-test")
1937+
.env(
1938+
"RUNA_MCP_CONFIG",
1939+
r#"{"command":"/tmp/runa mcp","args":["--session","--work-unit","wu-a","--sentinel=kept"],"env":{"RUNA_CONFIG":"/tmp/config.toml","RUNA_WORKING_DIR":"/tmp/project dir","EMPTY_VALUE":"","RUNA_FORGE_OWNER":"tesserine"}}"#,
1940+
)
1941+
.env("PATH", &path)
1942+
.env("FAKE_CODEX_ARGV_CAPTURE", &argv_capture)
1943+
.env("FAKE_CODEX_STDIN_CAPTURE", &stdin_capture)
1944+
.stdin(std::process::Stdio::piped())
1945+
.spawn()
1946+
.and_then(|mut child| {
1947+
use std::io::Write;
1948+
1949+
child.stdin.as_mut().unwrap().write_all(prompt.as_bytes())?;
1950+
child.wait_with_output()
1951+
})
1952+
.unwrap();
1953+
1954+
assert!(
1955+
output.status.success(),
1956+
"stderr: {}",
1957+
String::from_utf8_lossy(&output.stderr)
1958+
);
1959+
assert_eq!(fake_codex, bin_dir.join("codex"));
1960+
1961+
let argv = read_nul_separated_args(&argv_capture);
1962+
assert_eq!(argv[0], "exec");
1963+
assert_eq!(argv[1], "-c");
1964+
assert!(argv[2].starts_with("mcp_servers.runa.command="));
1965+
assert_eq!(argv[3], "-c");
1966+
assert!(argv[4].starts_with("mcp_servers.runa.args="));
1967+
assert_eq!(argv[5], "-c");
1968+
assert!(argv[6].starts_with("mcp_servers.runa.env="));
1969+
assert_eq!(argv[7..], ["--model", "gpt-test"]);
1970+
1971+
let command_toml = argv[2].strip_prefix("mcp_servers.runa.command=").unwrap();
1972+
let args_toml = argv[4].strip_prefix("mcp_servers.runa.args=").unwrap();
1973+
let env_toml = argv[6].strip_prefix("mcp_servers.runa.env=").unwrap();
1974+
let command_value: toml::Value = toml::from_str(&format!("value = {command_toml}")).unwrap();
1975+
let args_value: toml::Value = toml::from_str(&format!("value = {args_toml}")).unwrap();
1976+
let env_value: toml::Value = toml::from_str(&format!("value = {env_toml}")).unwrap();
1977+
1978+
assert_eq!(command_value["value"].as_str(), Some("/tmp/runa mcp"));
1979+
assert_eq!(
1980+
args_value["value"].as_array().unwrap(),
1981+
&vec![
1982+
toml::Value::String("--session".to_string()),
1983+
toml::Value::String("--work-unit".to_string()),
1984+
toml::Value::String("wu-a".to_string()),
1985+
toml::Value::String("--sentinel=kept".to_string()),
1986+
]
1987+
);
1988+
let env_table = env_value["value"].as_table().unwrap();
1989+
assert_eq!(
1990+
env_table.get("RUNA_CONFIG").unwrap().as_str(),
1991+
Some("/tmp/config.toml")
1992+
);
1993+
assert_eq!(
1994+
env_table.get("RUNA_WORKING_DIR").unwrap().as_str(),
1995+
Some("/tmp/project dir")
1996+
);
1997+
assert_eq!(env_table.get("EMPTY_VALUE").unwrap().as_str(), Some(""));
1998+
assert_eq!(
1999+
env_table.get("RUNA_FORGE_OWNER").unwrap().as_str(),
2000+
Some("tesserine")
2001+
);
2002+
assert_eq!(env_table.len(), 4);
2003+
assert_eq!(fs::read_to_string(&stdin_capture).unwrap(), prompt);
2004+
assert!(!argv.iter().any(|arg| arg.contains(prompt)), "{argv:?}");
2005+
}
18412006
#[test]
18422007
fn step_without_dry_run_reports_missing_runa_mcp_after_sibling_and_path_lookup() {
18432008
let dir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)