From 0d34f822749fdd455caf73e49231740c9bb7c0b8 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Mon, 31 Aug 2026 17:46:40 +0200 Subject: [PATCH] Extract native harness support from st3 --- README.md | 16 +- .../.claude-plugin/marketplace.json | 15 + .../st2-channel/.claude-plugin/plugin.json | 13 + claude-channel/plugins/st2-channel/.mcp.json | 11 + src/claude_channel.rs | 462 ++++++++++++++++++ src/claude_session.rs | 141 ++++++ src/codex_app_server.rs | 20 +- src/driver.rs | 108 ++-- src/lib.rs | 1 + src/main.rs | 45 +- src/pretrust.rs | 46 ++ src/run.rs | 45 +- tests/claude_channel_install.rs | 31 ++ tests/driver_expansion.rs | 82 +--- tests/fixtures/driver/claude.out.kdl | 3 +- 15 files changed, 903 insertions(+), 136 deletions(-) create mode 100644 claude-channel/.claude-plugin/marketplace.json create mode 100644 claude-channel/plugins/st2-channel/.claude-plugin/plugin.json create mode 100644 claude-channel/plugins/st2-channel/.mcp.json create mode 100644 src/claude_channel.rs create mode 100644 tests/claude_channel_install.rs diff --git a/README.md b/README.md index 5f773f8a..f36f5f34 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ st2 --help pty --help st2 hooks install st2 hooks verify +st2 claude-channel install +st2 claude-channel status ``` When upgrading, deploy and activate the compatible `pty` before this version of @@ -94,6 +96,18 @@ current inbox; pre-compact preserves a recovery breadcrumb when no context was w failure hooks surface newly arrived work or a harness failure. They fail open so hook trouble does not prevent the harness from starting or stopping. +`st2 claude-channel install` publishes the Claude Code marketplace and plugin embedded in the st2 +binary. It registers the plugin for the current user and installs one machine policy fragment with +`sudo`. The policy approves the stable `st2-channel@st2` identity. The plugin starts the st2 MCP +server through the managed task's `PATH`, `CATALOG`, and `ST_AGENT`; it writes no `.mcp.json` file +into a product workspace. Re-running the command updates the marketplace and reinstalls the exact +embedded plugin. `st2 claude-channel status` verifies all four parts. `uninstall` removes only the +st2 user state and its policy fragment. Installation is optional. When the plugin is absent, the +native driver passes an inline MCP declaration and uses Claude's development channel. Claude can +show a confirmation prompt on that path, so install the plugin for unattended agents. Claude treats +the managed plugin list as an allowlist. Administrators must include any other approved channel +plugins in their managed policy. + The same immutable set also carries `pi-channel.ts`. pi has no hook mechanism of its own — an extension is where a pi session exposes that surface — so st2 ships one and `st2 driver pi-session` splices it into the launch from the set this binary verified. A declaration never names it, and a @@ -581,7 +595,7 @@ st2 service uninstall ls, up, down, validate, doctor message, ding, agents, status, context, resource, rename, describe env, pty, shell, pretrust -hooks, service, eval +hooks, service, claude-channel, eval agent digest, agent publish catalog bootstrap, catalog snapshot, catalog apply completions diff --git a/claude-channel/.claude-plugin/marketplace.json b/claude-channel/.claude-plugin/marketplace.json new file mode 100644 index 00000000..1a086ab1 --- /dev/null +++ b/claude-channel/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "st2", + "description": "Provider integration shipped with st2", + "owner": { + "name": "Compounding" + }, + "plugins": [ + { + "name": "st2-channel", + "description": "Deliver st2 messages to a running Claude Code session", + "source": "./plugins/st2-channel" + } + ] +} diff --git a/claude-channel/plugins/st2-channel/.claude-plugin/plugin.json b/claude-channel/plugins/st2-channel/.claude-plugin/plugin.json new file mode 100644 index 00000000..cb5c0561 --- /dev/null +++ b/claude-channel/plugins/st2-channel/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "st2-channel", + "description": "Deliver st2 messages to a running Claude Code session", + "version": "0.1.0", + "author": { + "name": "Compounding" + }, + "keywords": [ + "st2", + "channel", + "mcp" + ] +} diff --git a/claude-channel/plugins/st2-channel/.mcp.json b/claude-channel/plugins/st2-channel/.mcp.json new file mode 100644 index 00000000..e38bb886 --- /dev/null +++ b/claude-channel/plugins/st2-channel/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "st2": { + "command": "st2", + "args": [ + "driver", + "claude-mcp" + ] + } + } +} diff --git a/src/claude_channel.rs b/src/claude_channel.rs new file mode 100644 index 00000000..3e42f8c5 --- /dev/null +++ b/src/claude_channel.rs @@ -0,0 +1,462 @@ +//! Install the Claude Code channel plugin that is embedded in the st2 binary. +//! +//! Claude Code admits channels by plugin and marketplace identity. The plugin is user state, while +//! its allowlist is machine policy. `st2 claude-channel install` owns both steps and elevates only +//! the small policy write. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use anyhow::{Context, Result, bail}; +use serde_json::{Value, json}; + +pub const MARKETPLACE: &str = "st2"; +pub const PLUGIN: &str = "st2-channel"; +pub const CHANNEL: &str = "plugin:st2-channel@st2"; + +const MARKETPLACE_MANIFEST: &[u8] = + include_bytes!("../claude-channel/.claude-plugin/marketplace.json"); +const PLUGIN_MANIFEST: &[u8] = + include_bytes!("../claude-channel/plugins/st2-channel/.claude-plugin/plugin.json"); +const MCP_CONFIG: &[u8] = include_bytes!("../claude-channel/plugins/st2-channel/.mcp.json"); +const POLICY_FILE: &str = "50-st2-channel.json"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallPaths { + pub marketplace: PathBuf, + pub policy: PathBuf, +} + +pub fn install(no_policy: bool) -> Result { + let marketplace = marketplace_root()?; + install_marketplace_at(&marketplace)?; + install_with_claude(&marketplace)?; + let policy = policy_path()?; + if !no_policy { + ensure_policy_with_elevation(&policy)?; + } + println!("marketplace\t{}", marketplace.display()); + println!("plugin\t{PLUGIN}@{MARKETPLACE}"); + if no_policy { + println!("policy\tskipped"); + } else { + println!("policy\t{}", policy.display()); + } + Ok(InstallPaths { + marketplace, + policy, + }) +} + +pub fn status() -> Result<()> { + let marketplace = marketplace_root()?; + let policy = policy_path()?; + let assets = verify_marketplace_at(&marketplace).is_ok(); + let policy_ready = policy_is_current_at(&policy); + let marketplace_ready = marketplace_registration() + .ok() + .flatten() + .is_some_and(|entry| marketplace_entry_matches(&entry, &marketplace)); + let plugin_ready = claude_json(["plugin", "list", "--json"]) + .map(|value| json_contains(&value, &format!("{PLUGIN}@{MARKETPLACE}"))) + .unwrap_or(false); + println!("assets\t{}", state(assets)); + println!("marketplace\t{}", state(marketplace_ready)); + println!("plugin\t{}", state(plugin_ready)); + println!("policy\t{}", state(policy_ready)); + if assets && marketplace_ready && plugin_ready && policy_ready { + Ok(()) + } else { + bail!("the Claude channel installation is incomplete") + } +} + +pub fn uninstall(keep_policy: bool) -> Result<()> { + let marketplace = marketplace_root()?; + let owns_marketplace = marketplace_registration()? + .is_some_and(|entry| marketplace_entry_matches(&entry, &marketplace)); + if plugin_is_installed()? { + anyhow::ensure!( + owns_marketplace, + "refusing to uninstall {PLUGIN}@{MARKETPLACE} from another marketplace source" + ); + run_claude(&[ + "plugin", + "uninstall", + &format!("{PLUGIN}@{MARKETPLACE}"), + "--scope", + "user", + "--yes", + ])?; + } + if owns_marketplace { + run_claude(&["plugin", "marketplace", "remove", MARKETPLACE])?; + } + if marketplace.exists() { + fs::remove_dir_all(&marketplace) + .with_context(|| format!("removing {}", marketplace.display()))?; + } + let policy = policy_path()?; + if !keep_policy && policy.exists() { + remove_policy_with_elevation(&policy)?; + } + println!("uninstalled"); + Ok(()) +} + +/// The elevated half of `install`. This command must not install user-scoped plugin state. +pub fn install_policy() -> Result { + let path = policy_path()?; + install_policy_at(&path)?; + println!("policy\t{}", path.display()); + Ok(path) +} + +/// The elevated half of `uninstall`. This removes only the st2-owned policy fragment. +pub fn uninstall_policy() -> Result<()> { + let path = policy_path()?; + remove_policy_at(&path)?; + println!("policy removed\t{}", path.display()); + Ok(()) +} + +pub fn verify_installed() -> Result<()> { + let marketplace = marketplace_root()?; + verify_marketplace_at(&marketplace)?; + if !marketplace_is_registered_at(&marketplace)? { + bail!("the st2 Claude marketplace is not registered; run `st2 claude-channel install`"); + } + if !plugin_is_installed()? { + bail!("the st2 Claude channel plugin is not installed; run `st2 claude-channel install`"); + } + let policy = policy_path()?; + if !policy_is_current_at(&policy) { + bail!("the st2 Claude channel policy is not installed; run `st2 claude-channel install`"); + } + Ok(()) +} + +fn state(ready: bool) -> &'static str { + if ready { "ready" } else { "missing" } +} + +fn data_home() -> Result { + if let Some(path) = env::var_os("XDG_DATA_HOME") { + return Ok(PathBuf::from(path)); + } + let home = env::var_os("HOME").context("HOME is not set")?; + Ok(PathBuf::from(home).join(".local/share")) +} + +fn marketplace_root() -> Result { + Ok(data_home()?.join("st2/claude-channel/marketplace")) +} + +#[cfg(target_os = "linux")] +fn policy_path() -> Result { + Ok(PathBuf::from("/etc/claude-code/managed-settings.d").join(POLICY_FILE)) +} + +#[cfg(target_os = "macos")] +fn policy_path() -> Result { + Ok( + PathBuf::from("/Library/Application Support/ClaudeCode/managed-settings.d") + .join(POLICY_FILE), + ) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn policy_path() -> Result { + bail!("the Claude channel policy installer supports Linux and macOS") +} + +fn embedded_files() -> [(&'static str, &'static [u8]); 3] { + [ + (".claude-plugin/marketplace.json", MARKETPLACE_MANIFEST), + ( + "plugins/st2-channel/.claude-plugin/plugin.json", + PLUGIN_MANIFEST, + ), + ("plugins/st2-channel/.mcp.json", MCP_CONFIG), + ] +} + +pub fn install_marketplace_at(root: &Path) -> Result<()> { + for (relative, bytes) in embedded_files() { + let path = root.join(relative); + let parent = path.parent().expect("an embedded file has a parent"); + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + fs::write(&path, bytes).with_context(|| format!("writing {}", path.display()))?; + } + verify_marketplace_at(root) +} + +pub fn verify_marketplace_at(root: &Path) -> Result<()> { + for (relative, expected) in embedded_files() { + let path = root.join(relative); + let actual = fs::read(&path).with_context(|| format!("reading {}", path.display()))?; + if actual != expected { + bail!("embedded Claude channel file differs at {}", path.display()); + } + } + Ok(()) +} + +fn policy_value() -> Value { + json!({ + "channelsEnabled": true, + "allowedChannelPlugins": [{"marketplace": MARKETPLACE, "plugin": PLUGIN}] + }) +} + +fn policy_bytes() -> Vec { + let mut bytes = serde_json::to_vec_pretty(&policy_value()) + .expect("the built-in Claude channel policy is serializable"); + bytes.push(b'\n'); + bytes +} + +pub fn install_policy_at(path: &Path) -> Result<()> { + let parent = path.parent().context("the policy path has no parent")?; + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + fs::write(path, policy_bytes()).with_context(|| format!("writing {}", path.display()))?; + verify_policy_at(path) +} + +pub fn remove_policy_at(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("removing {}", path.display())), + } +} + +fn policy_is_current_at(path: &Path) -> bool { + fs::read(path).is_ok_and(|bytes| bytes == policy_bytes()) +} + +fn verify_policy_at(path: &Path) -> Result<()> { + if policy_is_current_at(path) { + Ok(()) + } else { + bail!("Claude channel policy differs at {}", path.display()) + } +} + +fn ensure_policy_with_elevation(path: &Path) -> Result<()> { + if policy_is_current_at(path) { + return Ok(()); + } + if is_root() { + return install_policy_at(path); + } + run_elevated("install-policy") +} + +fn remove_policy_with_elevation(path: &Path) -> Result<()> { + if is_root() { + return remove_policy_at(path); + } + run_elevated("uninstall-policy") +} + +#[cfg(unix)] +fn is_root() -> bool { + unsafe { libc::geteuid() == 0 } +} + +#[cfg(not(unix))] +fn is_root() -> bool { + false +} + +fn run_elevated(action: &str) -> Result<()> { + let exe = env::current_exe().context("resolving the current st2 executable")?; + let status = Command::new("sudo") + .arg(exe) + .args(["claude-channel", action]) + .status() + .with_context(|| format!("running the elevated Claude channel {action}"))?; + if !status.success() { + bail!("the elevated Claude channel {action} failed with status {status}"); + } + Ok(()) +} + +fn install_with_claude(marketplace: &Path) -> Result<()> { + match marketplace_registration()? { + Some(entry) if marketplace_entry_matches(&entry, marketplace) => { + run_claude(&["plugin", "marketplace", "update", MARKETPLACE])?; + } + Some(entry) => { + bail!( + "Claude marketplace name '{MARKETPLACE}' already belongs to another source: {entry}" + ); + } + None => { + let path = marketplace + .to_str() + .context("the Claude channel marketplace path is not UTF-8")?; + run_claude(&["plugin", "marketplace", "add", path, "--scope", "user"])?; + } + } + if plugin_is_installed()? { + run_claude(&[ + "plugin", + "uninstall", + &format!("{PLUGIN}@{MARKETPLACE}"), + "--scope", + "user", + "--yes", + "--keep-data", + ])?; + } + run_claude(&[ + "plugin", + "install", + &format!("{PLUGIN}@{MARKETPLACE}"), + "--scope", + "user", + "--yes", + ]) +} + +fn marketplace_registration() -> Result> { + let value = claude_json(["plugin", "marketplace", "list", "--json"])?; + Ok(value.as_array().and_then(|entries| { + entries + .iter() + .find(|entry| entry.get("name").and_then(Value::as_str) == Some(MARKETPLACE)) + .cloned() + })) +} + +fn marketplace_is_registered_at(root: &Path) -> Result { + Ok(marketplace_registration()?.is_some_and(|entry| marketplace_entry_matches(&entry, root))) +} + +fn marketplace_entry_matches(entry: &Value, root: &Path) -> bool { + entry.get("source").and_then(Value::as_str) == Some("directory") + && entry.get("path").and_then(Value::as_str) == root.to_str() +} + +fn plugin_is_installed() -> Result { + let value = claude_json(["plugin", "list", "--json"])?; + Ok(json_contains(&value, &format!("{PLUGIN}@{MARKETPLACE}"))) +} + +fn json_contains(value: &Value, needle: &str) -> bool { + match value { + Value::String(value) => value == needle, + Value::Array(values) => values.iter().any(|value| json_contains(value, needle)), + Value::Object(values) => values + .iter() + .any(|(key, value)| key == needle || json_contains(value, needle)), + _ => false, + } +} + +fn claude_json(args: [&str; N]) -> Result { + let output = claude_output(&args)?; + serde_json::from_slice(&output.stdout) + .with_context(|| format!("decoding `claude {}` output", args.join(" "))) +} + +fn claude_output(args: &[&str]) -> Result { + let output = Command::new("claude") + .args(args) + .output() + .with_context(|| format!("running `claude {}`", args.join(" ")))?; + if !output.status.success() { + bail!( + "`claude {}` failed with status {}: {}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(output) +} + +fn run_claude(args: &[&str]) -> Result<()> { + let status = Command::new("claude") + .args(args) + .status() + .with_context(|| format!("running `claude {}`", args.join(" ")))?; + if !status.success() { + bail!("`claude {}` failed with status {status}", args.join(" ")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_marketplace_is_complete_and_byte_exact() { + let temp = tempfile::tempdir().unwrap(); + install_marketplace_at(temp.path()).unwrap(); + verify_marketplace_at(temp.path()).unwrap(); + + let marketplace: Value = serde_json::from_slice( + &fs::read(temp.path().join(".claude-plugin/marketplace.json")).unwrap(), + ) + .unwrap(); + assert_eq!(marketplace["name"], MARKETPLACE); + assert_eq!(marketplace["plugins"][0]["name"], PLUGIN); + + let mcp: Value = serde_json::from_slice( + &fs::read(temp.path().join("plugins/st2-channel/.mcp.json")).unwrap(), + ) + .unwrap(); + assert_eq!(mcp["mcpServers"]["st2"]["command"], "st2"); + assert_eq!( + mcp["mcpServers"]["st2"]["args"], + json!(["driver", "claude-mcp"]) + ); + let plugin: Value = serde_json::from_slice( + &fs::read( + temp.path() + .join("plugins/st2-channel/.claude-plugin/plugin.json"), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(plugin["version"], env!("CARGO_PKG_VERSION")); + } + + #[test] + fn policy_fragment_approves_only_the_stable_plugin_identity() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(POLICY_FILE); + install_policy_at(&path).unwrap(); + assert!(policy_is_current_at(&path)); + let value: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(value, policy_value()); + remove_policy_at(&path).unwrap(); + remove_policy_at(&path).unwrap(); + } + + #[test] + fn recursive_json_lookup_finds_plugin_ids_without_a_cli_schema_dependency() { + let value = json!([{"id": "st2-channel@st2", "nested": {"ready": true}}]); + assert!(json_contains(&value, "st2-channel@st2")); + assert!(!json_contains(&value, "other@st2")); + } + + #[test] + fn marketplace_registration_must_point_to_the_installed_asset_root() { + let root = Path::new("/data/st2/claude-channel/marketplace"); + assert!(marketplace_entry_matches( + &json!({"name":"st2","source":"directory","path":root}), + root, + )); + assert!(!marketplace_entry_matches( + &json!({"name":"st2","source":"github","repo":"other/st2"}), + root, + )); + } +} diff --git a/src/claude_session.rs b/src/claude_session.rs index efb6592a..a817c4f6 100644 --- a/src/claude_session.rs +++ b/src/claude_session.rs @@ -36,6 +36,10 @@ pub fn run( !claude_argv.is_empty(), "Claude driver '{runtime_id}' has no provider argv" ); + let claude_argv = prepare_channel_argv(catalog_root, &identity, claude_argv)?; + let workspace = std::env::current_dir().context("reading the Claude driver workspace")?; + crate::pretrust::pretrust_claude(std::slice::from_ref(&workspace)) + .with_context(|| format!("admitting Claude driver workspace {}", workspace.display()))?; install_signal_handler(); let observer = SessionObserver::new(&agent_dir, &identity, "claude", &runtime_id)?; // The runtime ID reaches hook subprocesses through the provider environment, so their @@ -60,6 +64,84 @@ pub fn run( .with_context(|| format!("running Claude driver '{runtime_id}'")) } +fn requires_st2_channel(argv: &[String]) -> bool { + argv.windows(2) + .any(|pair| pair[0] == "--channels" && pair[1] == crate::claude_channel::CHANNEL) +} + +/// Prefer the approved plugin, but preserve an interactive development path when it is absent. +/// +/// The fallback keeps its MCP declaration in provider arguments. It does not write project state. +fn prepare_channel_argv( + catalog_root: &Path, + identity: &str, + argv: Vec, +) -> Result> { + if !requires_st2_channel(&argv) { + return Ok(argv); + } + match crate::claude_channel::verify_installed() { + Ok(()) => Ok(argv), + Err(error) => { + eprintln!( + "warning: the approved st2 Claude channel plugin is unavailable: {error:#}\n\ + warning: using Claude's interactive development channel; Claude can ask for confirmation\n\ + warning: run `st2 claude-channel install` for unattended startup" + ); + let executable = std::env::current_exe() + .context("resolving the st2 executable for the Claude development channel")?; + development_channel_argv(argv, &executable, catalog_root, identity) + } + } +} + +fn development_channel_argv( + argv: Vec, + executable: &Path, + catalog_root: &Path, + identity: &str, +) -> Result> { + let mcp = serde_json::json!({ + "mcpServers": { + "st2": { + "type": "stdio", + "command": executable, + "args": [ + "--catalog", + catalog_root, + "driver", + "claude-mcp", + "--identity", + identity + ] + } + } + }); + let mut output = Vec::with_capacity(argv.len() + 1); + let mut index = 0; + let mut replaced = false; + while index < argv.len() { + if !replaced + && argv[index] == "--channels" + && argv.get(index + 1).map(String::as_str) == Some(crate::claude_channel::CHANNEL) + { + output.extend([ + "--mcp-config".to_string(), + serde_json::to_string(&mcp) + .context("serializing the Claude development channel")?, + "--dangerously-load-development-channels=server:st2".to_string(), + ]); + replaced = true; + index += 2; + continue; + } + output.push(argv[index].clone()); + index += 1; + } + anyhow::ensure!(replaced, "the Claude plugin channel selector is missing"); + Ok(output) +} + /// Apply one Claude hook event (payload on stdin) to the agent's observed-harness-state record. /// /// Invoked per event by the fail-open `claude-observe.sh` hook, so each invocation is its own @@ -509,6 +591,65 @@ mod tests { use super::*; use crate::harness_state::harness_state_path; + #[test] + fn only_the_packaged_channel_requests_the_installation_preflight() { + assert!(requires_st2_channel(&[ + "claude".into(), + "--channels".into(), + "plugin:st2-channel@st2".into(), + ])); + assert!(!requires_st2_channel(&[ + "claude".into(), + "--channels".into(), + "plugin:other@marketplace".into(), + ])); + } + + #[test] + fn development_channel_fallback_is_inline_and_keeps_the_provider_arguments() { + let argv = vec![ + "claude".into(), + "--model".into(), + "sonnet".into(), + "--channels".into(), + "plugin:st2-channel@st2".into(), + "prompt".into(), + ]; + let output = development_channel_argv( + argv, + Path::new("/opt/st2/bin/st2"), + Path::new("/var/lib/st2/catalog"), + "host.worker", + ) + .unwrap(); + assert_eq!(&output[..3], &["claude", "--model", "sonnet"]); + assert_eq!( + output.last().map(String::as_str), + Some("prompt"), + "the user prompt remains last" + ); + let config_index = output.iter().position(|arg| arg == "--mcp-config").unwrap(); + let mcp: serde_json::Value = serde_json::from_str(&output[config_index + 1]).unwrap(); + assert_eq!(mcp["mcpServers"]["st2"]["command"], "/opt/st2/bin/st2"); + assert_eq!( + mcp["mcpServers"]["st2"]["args"], + serde_json::json!([ + "--catalog", + "/var/lib/st2/catalog", + "driver", + "claude-mcp", + "--identity", + "host.worker" + ]) + ); + assert!( + output + .iter() + .any(|arg| arg == "--dangerously-load-development-channels=server:st2") + ); + assert!(!output.iter().any(|arg| arg == "--channels")); + } + #[test] fn idle_provider_refreshes_presence_without_mcp_input() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 0fef4425..9b0fa0aa 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -3104,10 +3104,22 @@ fn ensure_supported_protocol(codex: &str) -> Result<()> { } fn codex_version(codex: &str) -> Result { - let output = Command::new(codex) - .arg("--version") - .output() - .with_context(|| format!("reading Codex version from {codex}"))?; + let mut attempt_index = 0; + let output = loop { + let attempt = Command::new(codex).arg("--version").output(); + match attempt { + Ok(output) => break output, + Err(error) if error.raw_os_error() == Some(libc::ETXTBSY) && attempt_index + 1 < 5 => { + // Some Linux filesystems briefly retain writer exclusion after a binary install. + // Retry only this transient error and keep every other launch error immediate. + attempt_index += 1; + thread::sleep(Duration::from_millis(20)); + } + Err(error) => { + return Err(error).with_context(|| format!("reading Codex version from {codex}")); + } + } + }; anyhow::ensure!( output.status.success(), "{codex} --version failed: {}", diff --git a/src/driver.rs b/src/driver.rs index 4f54cba8..b4fa9be7 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -12,7 +12,6 @@ use kdl::{KdlDocument, KdlEntry, KdlNode}; const ST2: &str = "st2"; const CATALOG: &str = "$CATALOG"; -const CLAUDE_SERVER: &str = "st2"; const DEV_CHANNELS_FLAG: &str = "--dangerously-load-development-channels"; /// What a materialized Claude channel server can actually do, given how Claude Code admits @@ -22,11 +21,9 @@ const DEV_CHANNELS_FLAG: &str = "--dangerously-load-development-channels"; /// `dev: true`, and that flag is set in exactly one place: the merge of the entries parsed from /// `--dangerously-load-development-channels`. Measured admission table: compoundingtech/st2#373. pub const CHANNEL_NOT_REGISTERED: &str = concat!( - "the st2 MCP channel server is materialized, but this seat launches without ", - "`--dangerously-load-development-channels`, so Claude Code skips the channel ", - "(`server st2 not in --channels list for this session`) and no inbox message reaches ", - "the model through it. Adding `--channels server:st2` does not change this: a `server:` ", - "entry still needs `dev: true`. See compoundingtech/st2#373", + "this Claude seat launches without the packaged st2 channel or an admitted development ", + "channel, so no inbox message reaches the model through native delivery. See ", + "compoundingtech/st2#373", ); /// The other half of a skipped channel: whether anything else carries this seat's inbox. The @@ -37,7 +34,7 @@ pub const CHANNEL_NO_INBOX_TRANSPORT: &str = concat!( "messages sent to it reach the model by no path at all", ); -/// The `dev-channels #true` half of [`CHANNEL_NOT_REGISTERED`]. +/// The explicit development-channel flag bypasses the packaged channel and requires consent. pub const CHANNEL_DEV_CONSENT_REQUIRED: &str = concat!( "this seat launches with `--dangerously-load-development-channels`, which stops startup at ", "a consent dialog: no MCP server connects and the startup prompt is not read until a human ", @@ -61,6 +58,8 @@ enum ChannelRoute { Unregistered, /// The command line carries the development-channels flag. DevConsent, + /// The typed driver selects the approved marketplace plugin. + Packaged, /// The launch is an opaque shell program, so neither can be proven. Opaque, } @@ -76,16 +75,17 @@ fn carries_dev_channels(argument: &str) -> bool { fn claude_channel_route(spec: &AgentSpec) -> Option { match (&spec.driver, spec.delivery) { - // `args` are appended to the provider launch verbatim, after the typed flag, so either - // source puts the flag on the real command line. + // An authored development flag stays explicit. The typed field selects the packaged + // plugin and writes no project MCP state. (Some(Driver::Claude(driver)), _) => Some( - if driver.dev_channels - || driver - .args - .iter() - .any(|argument| carries_dev_channels(argument)) + if driver + .args + .iter() + .any(|argument| carries_dev_channels(argument)) { ChannelRoute::DevConsent + } else if driver.dev_channels { + ChannelRoute::Packaged } else { ChannelRoute::Unregistered }, @@ -133,6 +133,7 @@ pub fn claude_channel_advisories(spec: &AgentSpec) -> Vec<&'static str> { let mut advisories = vec![match route { ChannelRoute::Unregistered => CHANNEL_NOT_REGISTERED, ChannelRoute::DevConsent => CHANNEL_DEV_CONSENT_REQUIRED, + ChannelRoute::Packaged => return Vec::new(), ChannelRoute::Opaque => CHANNEL_ROUTE_UNKNOWN, }]; // Only a proven skip justifies the stronger claim; an opaque launch may yet deliver. @@ -293,42 +294,22 @@ fn expand_opencode(driver: &OpenCodeDriver, bus_id: &str) -> KdlDocument { } fn expand_claude(driver: &ClaudeDriver, bus_id: &str) -> Result { - let mcp = serde_json::json!({ - "mcpServers": { - CLAUDE_SERVER: { - "type": "stdio", - "command": ST2, - "args": [ - "--catalog", - CATALOG, - "driver", - "claude-mcp", - "--identity", - bus_id - ] - } - } - }); - let mcp = serde_json::to_string_pretty(&mcp)?; // The same registration a hand-authored seat carries: without it a driver-declared // seat has no observed-state producer and no lifecycle hooks at all. let settings = serde_json::to_string_pretty(&crate::hooks::claude_settings_registration())?; let mut render = KdlNode::new("render"); - render.set_children(document([ - node("json-upsert", vec![".mcp.json".to_string(), mcp]), - { - // Hook arrays join whatever the workspace already declares: replacement would clobber - // user-registered hooks on every materialization, and union is idempotent. - let mut upsert = node( - "json-upsert", - vec![".claude/settings.local.json".to_string(), settings], - ); - upsert - .entries_mut() - .push(KdlEntry::new_prop("arrays", "union")); - upsert - }, - ])); + render.set_children(document([{ + // Hook arrays join whatever the workspace already declares: replacement would clobber + // user-registered hooks on every materialization, and union is idempotent. + let mut upsert = node( + "json-upsert", + vec![".claude/settings.local.json".to_string(), settings], + ); + upsert + .entries_mut() + .push(KdlEntry::new_prop("arrays", "union")); + upsert + }])); let mut provider = vec!["claude".to_string()]; if let Some(model) = &driver.model { @@ -338,7 +319,10 @@ fn expand_claude(driver: &ClaudeDriver, bus_id: &str) -> Result { provider.extend(["--effort".to_string(), effort.clone()]); } if driver.dev_channels { - provider.push(format!("{DEV_CHANNELS_FLAG}=server:{CLAUDE_SERVER}")); + provider.extend([ + "--channels".to_string(), + crate::claude_channel::CHANNEL.to_string(), + ]); } provider.extend(driver.args.iter().cloned()); provider.push(driver.prompt.clone()); @@ -462,10 +446,7 @@ mod tests { let lookalike = legacy_launch( None, - Some(&[ - "claude", - "--dangerously-load-development-channels-extra", - ]), + Some(&["claude", "--dangerously-load-development-channels-extra"]), ); assert_eq!( claude_channel_route(&lookalike), @@ -571,7 +552,7 @@ mod tests { } #[test] - fn claude_expands_to_a_channel_render_and_session_owned_launch() { + fn claude_expands_to_the_packaged_channel_and_session_owned_launch() { let output = expand_driver( &spec(Driver::Claude(ClaudeDriver { model: Some("opus".into()), @@ -593,24 +574,8 @@ mod tests { .iter() .filter(|node| node.name().value() == "json-upsert") .collect(); - assert_eq!(upserts.len(), 2); - let upsert = strings(upserts[0]); - assert_eq!(upsert[0], ".mcp.json"); - let mcp: serde_json::Value = serde_json::from_str(upsert[1]).unwrap(); - assert_eq!(mcp["mcpServers"]["st2"]["type"], "stdio"); - assert_eq!(mcp["mcpServers"]["st2"]["command"], "st2"); - assert_eq!( - mcp["mcpServers"]["st2"]["args"], - serde_json::json!([ - "--catalog", - "$CATALOG", - "driver", - "claude-mcp", - "--identity", - "host.worker" - ]) - ); - let settings = strings(upserts[1]); + assert_eq!(upserts.len(), 1); + let settings = strings(upserts[0]); assert_eq!(settings[0], ".claude/settings.local.json"); let settings: serde_json::Value = serde_json::from_str(settings[1]).unwrap(); assert_eq!(settings, crate::hooks::claude_settings_registration()); @@ -632,7 +597,8 @@ mod tests { "opus", "--effort", "xhigh", - "--dangerously-load-development-channels=server:st2", + "--channels", + "plugin:st2-channel@st2", "--model", "override", "Start work." diff --git a/src/lib.rs b/src/lib.rs index e6e5ab3f..4d273870 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod catalog; pub mod catalog_graph; pub mod catalog_lock; pub mod catalog_transaction; +pub mod claude_channel; pub mod claude_mcp; pub mod claude_session; pub mod codex_app_server; diff --git a/src/main.rs b/src/main.rs index c610fe39..cd7d829f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -89,6 +89,9 @@ enum Command { /// Subcommands: install / status / uninstall. #[command(subcommand)] Service(ServiceCmd), + /// Install and approve the embedded Claude Code channel plugin. + #[command(subcommand)] + ClaudeChannel(ClaudeChannelCmd), /// Explicit lifecycle-hook management. `up` and materialization only verify; they never install /// or refresh hooks. #[command(subcommand)] @@ -337,7 +340,7 @@ enum DriverCmd { /// Run the Claude session-owned MCP server over stdio. ClaudeMcp { #[arg(long)] - identity: String, + identity: Option, }, /// Deprecated name for the Claude MCP server. // Keep this hidden command until no rendered configuration uses the old name. @@ -658,6 +661,30 @@ enum ServiceCmd { Uninstall, } +#[derive(Subcommand)] +enum ClaudeChannelCmd { + /// Install or update the user plugin and its machine approval policy. + Install { + /// Install only the user plugin. An administrator will manage the machine policy. + #[arg(long)] + no_policy: bool, + }, + /// Verify the embedded files, Claude registration, plugin, and machine policy. + Status, + /// Remove the user plugin, marketplace, embedded files, and machine policy. + Uninstall { + /// Keep the machine approval policy in place. + #[arg(long)] + keep_policy: bool, + }, + /// Write only the machine policy. The main installer runs this through sudo. + #[command(hide = true)] + InstallPolicy, + /// Remove only the st2-owned machine policy fragment. + #[command(hide = true)] + UninstallPolicy, +} + #[derive(Subcommand)] enum HooksCmd { /// Atomically publish this binary's immutable hook set and select it with a receipt. @@ -1107,6 +1134,7 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result< Command::Context(cmd) => context_cmd(cmd), Command::Resource(cmd) => resource_cmd(cmd), Command::Service(cmd) => service_cmd(cmd), + Command::ClaudeChannel(cmd) => claude_channel_cmd(cmd), Command::Hooks(cmd) => hooks_cmd(cmd), Command::Ding { session, @@ -1155,6 +1183,9 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result< Command::Driver(DriverCmd::ClaudeMcp { identity }) => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); + let identity = identity + .or_else(|| std::env::var("ST_AGENT").ok()) + .context("--identity is required when ST_AGENT is not set")?; st2::claude_mcp::run(&catalog, &identity) } Command::Driver(DriverCmd::OmpChannel { identity }) => { @@ -3310,6 +3341,18 @@ fn service_cmd(cmd: ServiceCmd) -> Result<()> { } } +fn claude_channel_cmd(cmd: ClaudeChannelCmd) -> Result<()> { + match cmd { + ClaudeChannelCmd::Install { no_policy } => { + st2::claude_channel::install(no_policy).map(|_| ()) + } + ClaudeChannelCmd::Status => st2::claude_channel::status(), + ClaudeChannelCmd::Uninstall { keep_policy } => st2::claude_channel::uninstall(keep_policy), + ClaudeChannelCmd::InstallPolicy => st2::claude_channel::install_policy().map(|_| ()), + ClaudeChannelCmd::UninstallPolicy => st2::claude_channel::uninstall_policy(), + } +} + /// Read one agent's declared Resource bindings. Selector resolution mirrors the mediated author /// (`bus_id` first, then bare identity, unique or refuse) so `ls` and `add` always name the same /// declaration, and a malformed catalog refuses rather than silently hiding an agent. diff --git a/src/pretrust.rs b/src/pretrust.rs index 53f71582..d54e718c 100644 --- a/src/pretrust.rs +++ b/src/pretrust.rs @@ -14,6 +14,8 @@ //! writes interleaved with sibling boots lost-update each other — the multi-spawn trust race. Trusting //! every workspace in one write *before* the first agent boots closes it. +use std::fs::{File, OpenOptions}; +use std::os::fd::AsRawFd as _; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -42,6 +44,12 @@ pub fn pretrust(dirs: &[PathBuf]) -> Result { Ok(n) } +/// Admit workspaces for Claude in the provider runtime's selected config. A typed Claude driver +/// calls this after task environment selection and before it starts Claude. +pub fn pretrust_claude(dirs: &[PathBuf]) -> Result { + pretrust_at(&config_path()?, dirs) +} + /// Pre-trust workspaces for Codex only in the caller's ambient config. This remains available to /// explicit tooling, but reconciliation does not call it: a provider command may select an /// account-specific `CODEX_HOME` only after st2 launches it. @@ -115,6 +123,7 @@ fn toml_key(dir: &str) -> String { /// The core, taking the config path explicitly so it is testable without touching the real config or /// the process environment. Idempotent: re-trusting an already-trusted dir is a no-op merge. pub fn pretrust_at(config: &Path, dirs: &[PathBuf]) -> Result { + let _lock = ConfigLock::acquire(config)?; // Read the existing config, or start from an empty object if it is absent/blank. let mut root: Value = match std::fs::read_to_string(config) { Ok(s) if !s.trim().is_empty() => { @@ -147,6 +156,43 @@ pub fn pretrust_at(config: &Path, dirs: &[PathBuf]) -> Result { Ok(dirs.len()) } +struct ConfigLock(File); + +impl ConfigLock { + fn acquire(config: &Path) -> Result { + if let Some(parent) = config.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + let mut path = config.as_os_str().to_owned(); + path.push(".st2trust.lock"); + let path = PathBuf::from(path); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .with_context(|| format!("opening {}", path.display()))?; + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; + anyhow::ensure!( + result == 0, + "locking {}: {}", + path.display(), + std::io::Error::last_os_error() + ); + Ok(Self(file)) + } +} + +impl Drop for ConfigLock { + fn drop(&mut self) { + unsafe { + libc::flock(self.0.as_raw_fd(), libc::LOCK_UN); + } + } +} + /// The absolute path claude keys a project by: the canonical (symlink-resolved) path when the dir /// exists — matching `getcwd` for a booted agent whose cwd is this workspace — else the dir made /// absolute against the current directory, else the path verbatim. diff --git a/src/run.rs b/src/run.rs index f1704311..4c1898f9 100644 --- a/src/run.rs +++ b/src/run.rs @@ -12,7 +12,7 @@ use std::cell::RefCell; use std::collections::{BTreeMap, HashMap, HashSet}; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io::{Read as _, Seek as _, Write as _}; use std::os::fd::AsRawFd as _; @@ -480,6 +480,17 @@ impl PtyCli { env } + /// Expand direct arguments against the environment that the managed task receives. The task + /// overlay wins over the launcher environment, as it does after the process starts. + fn expand_managed(&self, value: &str, managed_env: &BTreeMap) -> String { + crate::expand::expand_vars(value, |key| { + managed_env + .get(OsStr::new(key)) + .map(|value| value.to_string_lossy().into_owned()) + .or_else(|| std::env::var(key).ok()) + }) + } + /// Build (but do not run) the `pty run` invocation for `target`. Split out so the exact argv + /// env can be unit-tested without spawning anything. /// @@ -570,7 +581,10 @@ impl PtyCli { // Direct mode preserves argument boundaries and introduces no shell process. TaskLaunch::Argv(argv) => { debug_assert!(!argv.is_empty()); - cmd.args(argv.iter().map(|arg| self.expand(arg))); + cmd.args( + argv.iter() + .map(|arg| self.expand_managed(arg, &managed_env)), + ); } } cmd @@ -5750,6 +5764,33 @@ mod tests { assert!(!args[sep + 1..].iter().any(|arg| arg == "sh")); } + #[test] + fn build_run_command_expands_direct_argv_with_the_managed_agent_environment() { + let cli = PtyCli::new(PathBuf::from("/eval/catalog")); + let mut t = target("local.worker", "unused"); + t.env.insert("ST_AGENT".into(), "local.worker".into()); + t.env.insert("ST_ROOT".into(), "/eval/catalog".into()); + t.launch = TaskLaunch::Argv(vec![ + "claude".into(), + "$ST_AGENT reads $ST_ROOT and $CATALOG".into(), + ]); + + let cmd = cli.build_run_command(&t, Path::new("/eval/catalog/local/worker")); + let args = cmd + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + let separator = args.iter().position(|arg| arg == "--").unwrap(); + + assert_eq!( + &args[separator + 1..], + [ + "claude", + "local.worker reads /eval/catalog and /eval/catalog" + ] + ); + } + #[test] fn build_run_command_persists_the_complete_managed_environment_before_the_command() { let cli = PtyCli::new(PathBuf::from("/my/catalog")); diff --git a/tests/claude_channel_install.rs b/tests/claude_channel_install.rs new file mode 100644 index 00000000..0a93761e --- /dev/null +++ b/tests/claude_channel_install.rs @@ -0,0 +1,31 @@ +//! CLI wiring for the embedded Claude channel installer. +//! +//! These tests do not install user or machine state. The pure file and policy behavior lives in +//! `src/claude_channel.rs` tests. + +fn st2() -> std::process::Command { + std::process::Command::new(env!("CARGO_BIN_EXE_st2")) +} + +#[test] +fn claude_channel_exposes_a_service_style_lifecycle() { + let output = st2().args(["claude-channel", "--help"]).output().unwrap(); + assert!(output.status.success()); + let help = String::from_utf8_lossy(&output.stdout); + for command in ["install", "status", "uninstall"] { + assert!(help.contains(command), "{help}"); + } + assert!(!help.contains("install-policy"), "{help}"); + assert!(!help.contains("uninstall-policy"), "{help}"); +} + +#[test] +fn install_allows_an_external_machine_policy_manager() { + let output = st2() + .args(["claude-channel", "install", "--help"]) + .output() + .unwrap(); + assert!(output.status.success()); + let help = String::from_utf8_lossy(&output.stdout); + assert!(help.contains("--no-policy"), "{help}"); +} diff --git a/tests/driver_expansion.rs b/tests/driver_expansion.rs index d91879ac..ef1fbf54 100644 --- a/tests/driver_expansion.rs +++ b/tests/driver_expansion.rs @@ -166,7 +166,6 @@ fn opaque_session_driver_materializes_without_rewriting_or_adding_launch_tasks() assert!(materialize_agent(&catalog, &spec, "h").unwrap().is_empty()); } - #[test] fn cli_prints_each_snapshot_without_changing_its_input() { let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/driver"); @@ -204,30 +203,13 @@ fn cli_prints_each_snapshot_without_changing_its_input() { } #[test] -fn claude_driver_matches_deliver_after_normalizing_the_legacy_command_namespace() { +fn claude_driver_uses_the_packaged_channel_without_project_mcp_state() { let temp = tempfile::tempdir().unwrap(); let catalog = temp.path().join("catalog"); - let legacy_workspace = temp.path().join("legacy-workspace"); let driver_workspace = temp.path().join("driver-workspace"); - fs::create_dir_all(&legacy_workspace).unwrap(); fs::create_dir_all(&driver_workspace).unwrap(); - let legacy_path = catalog.join("legacy.kdl"); let driver_path = catalog.join("driver.kdl"); fs::create_dir_all(&catalog).unwrap(); - fs::write( - &legacy_path, - format!( - r#"agent "worker" {{ - host "h" - workspace "{}" - deliver "mcp" - argv "claude" "--model" "opus" "--effort" "xhigh" "--dangerously-load-development-channels=server:st2" "--permission-mode" "bypassPermissions" "boot" -}} -"#, - legacy_workspace.display() - ), - ) - .unwrap(); fs::write( &driver_path, format!( @@ -247,9 +229,7 @@ fn claude_driver_matches_deliver_after_normalizing_the_legacy_command_namespace( ), ) .unwrap(); - let (legacy, _) = st2::discover_file(&catalog, &legacy_path).unwrap(); let (driver, _) = st2::discover_file(&catalog, &driver_path).unwrap(); - let mut legacy = legacy.into_iter().next().unwrap(); let mut driver = driver.into_iter().next().unwrap(); assert!(!st2::hooks::required_by_codex_agent(&driver, "h", &catalog)); let executable = catalog.join("bin/st2"); @@ -257,13 +237,7 @@ fn claude_driver_matches_deliver_after_normalizing_the_legacy_command_namespace( fs::write(&executable, "test binary").unwrap(); let context = TaskCompileContext::new(catalog.clone(), executable.clone()).unwrap(); - compile_generated_tasks(std::slice::from_mut(&mut legacy), "h", &context).unwrap(); compile_generated_tasks(std::slice::from_mut(&mut driver), "h", &context).unwrap(); - let legacy_task = legacy - .tasks - .iter() - .find(|task| task.name == "agent") - .unwrap(); let driver_task = driver .tasks .iter() @@ -286,29 +260,25 @@ fn claude_driver_matches_deliver_after_normalizing_the_legacy_command_namespace( "--", ] ); - assert_eq!(driver_task, legacy_task); + let argv = driver_task.argv.as_ref().unwrap(); + assert!( + argv.windows(2) + .any(|pair| pair == ["--channels", "plugin:st2-channel@st2"]) + ); + assert!(!argv.iter().any(|arg| arg == "--mcp-config")); - // The driver expansion registers `$ST_HOOKS` hooks, and materialization refuses an unverified - // hook set — install one in a scratch root, as `st2 hooks install` does on a real host. + // The driver still registers lifecycle hooks. The channel plugin is machine state, not a file + // in the product workspace. let hooks = tempfile::tempdir().unwrap().keep(); st2::hooks::install_at(&hooks, false).unwrap(); unsafe { std::env::set_var("ST_HOOKS", &hooks) }; - materialize_agent(&catalog, &legacy, "h").unwrap(); materialize_agent(&catalog, &driver, "h").unwrap(); - let legacy_mcp: serde_json::Value = - serde_json::from_slice(&fs::read(legacy_workspace.join(".mcp.json")).unwrap()).unwrap(); - let mut driver_mcp: serde_json::Value = - serde_json::from_slice(&fs::read(driver_workspace.join(".mcp.json")).unwrap()).unwrap(); - assert_eq!( - driver_mcp["mcpServers"]["st2"]["command"], - legacy_mcp["mcpServers"]["st2"]["command"] + assert!(!driver_workspace.join(".mcp.json").exists()); + assert!( + driver_workspace + .join(".claude/settings.local.json") + .exists() ); - let args = driver_mcp["mcpServers"]["st2"]["args"] - .as_array_mut() - .unwrap(); - assert_eq!(&args[2..4], ["driver", "claude-mcp"]); - args.splice(2..4, [serde_json::Value::String("claude-mcp".into())]); - assert_eq!(driver_mcp, legacy_mcp); } #[test] @@ -359,6 +329,16 @@ fn claude_mcp_is_canonical_and_claude_is_a_hidden_alias() { .unwrap(); let current_error = String::from_utf8(current.stderr).unwrap(); assert!(!current_error.contains("deprecated")); + + let implicit = Command::new(env!("CARGO_BIN_EXE_st2")) + .env("ST_AGENT", "missing") + .arg("--catalog") + .arg(temp.path()) + .args(["driver", "claude-mcp"]) + .output() + .unwrap(); + let implicit_error = String::from_utf8(implicit.stderr).unwrap(); + assert!(!implicit_error.contains("--identity is required")); } #[test] @@ -457,9 +437,7 @@ fn spec_from(catalog: &Path, body: &str) -> st2::spec::AgentSpec { specs.into_iter().next().unwrap() } -/// A Claude seat's channel state has to be read off the launch that will actually run, not off -/// the typed field alone, and the advisory must not claim a fallback the declaration does not -/// have. Both halves are the same defect this PR exists to fix. +/// A Claude seat's channel state comes from the launch that will actually run. #[test] fn claude_driver_names_the_channel_state_the_seat_is_in() { let temp = tempfile::tempdir().unwrap(); @@ -479,8 +457,6 @@ fn claude_driver_names_the_channel_state_the_seat_is_in() { vec![CHANNEL_NOT_REGISTERED, CHANNEL_NO_INBOX_TRANSPORT] ); - - let dev = spec_from( &temp.path().join("dev"), r#"agent "worker" { @@ -490,13 +466,9 @@ fn claude_driver_names_the_channel_state_the_seat_is_in() { } "#, ); - assert_eq!( - claude_channel_advisories(&dev), - vec![CHANNEL_DEV_CONSENT_REQUIRED] - ); + assert!(claude_channel_advisories(&dev).is_empty()); - // `args` reach the provider launch verbatim, so the flag can arrive without the typed field. - // Reading only `dev-channels` here would report the exact opposite of what the seat runs. + // An explicit development flag still reaches the provider launch verbatim. let dev_via_args = spec_from( &temp.path().join("dev-via-args"), r#"agent "worker" { diff --git a/tests/fixtures/driver/claude.out.kdl b/tests/fixtures/driver/claude.out.kdl index 7f4d69a5..9e15de44 100644 --- a/tests/fixtures/driver/claude.out.kdl +++ b/tests/fixtures/driver/claude.out.kdl @@ -1,5 +1,4 @@ render { - json-upsert .mcp.json "{\n \"mcpServers\": {\n \"st2\": {\n \"args\": [\n \"--catalog\",\n \"$CATALOG\",\n \"driver\",\n \"claude-mcp\",\n \"--identity\",\n \"Silber.fabric\"\n ],\n \"command\": \"st2\",\n \"type\": \"stdio\"\n }\n }\n}" json-upsert ".claude/settings.local.json" "{\n \"$schema\": \"https://json.schemastore.org/claude-code-settings.json\",\n \"hooks\": {\n \"PermissionRequest\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" PermissionRequest\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"PostCompact\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" PostCompact\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"PostToolUse\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" PostToolUse\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"PreCompact\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-pre-compact.sh\\\"\",\n \"type\": \"command\"\n },\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" PreCompact\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"PreToolUse\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" PreToolUse\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"SessionStart\": [\n {\n \"hooks\": [\n {\n \"async\": true,\n \"asyncRewake\": true,\n \"command\": \"\\\"$ST_HOOKS/claude-session-start.sh\\\"\",\n \"type\": \"command\"\n },\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" SessionStart\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"Stop\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" Stop\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"StopFailure\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-stop-failure.sh\\\"\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"UserPromptSubmit\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" UserPromptSubmit\",\n \"type\": \"command\"\n }\n ]\n }\n ]\n },\n \"statusLine\": {\n \"command\": \"\\\"$ST_HOOKS/claude-statusline.sh\\\"\",\n \"padding\": 0,\n \"refreshInterval\": 5,\n \"type\": \"command\"\n }\n}" arrays=union } -argv st2 --catalog $CATALOG driver claude-session --identity Silber.fabric --runtime-id Silber.fabric -- claude --model opus --effort xhigh "--dangerously-load-development-channels=server:st2" --permission-mode bypassPermissions --model override "Start the assigned work." +argv st2 --catalog $CATALOG driver claude-session --identity Silber.fabric --runtime-id Silber.fabric -- claude --model opus --effort xhigh --channels plugin:st2-channel@st2 --permission-mode bypassPermissions --model override "Start the assigned work."