diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806..ce9ceac8d6 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -74,7 +74,7 @@ evalexpr = { workspace = true } # Process-group kill (safe wrapper around killpg) — Unix-only; kill_process_group # has a #[cfg(not(unix))] fallback in acp.rs. [target.'cfg(unix)'.dependencies] -nix = { version = "0.31", default-features = false, features = ["signal"] } +nix = { version = "0.31", default-features = false, features = ["fs", "signal", "user"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..1e948b6964 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -135,19 +135,20 @@ Controls which authors' events the harness forwards to the agent. Events from di | Flag | Env Var | Default | Description | |------|---------|---------|-------------| -| `--respond-to` | `BUZZ_ACP_RESPOND_TO` | `owner-only` | Author gate mode: `owner-only`, `allowlist`, `anyone`, `nobody`. | -| `--respond-to-allowlist` | `BUZZ_ACP_RESPOND_TO_ALLOWLIST` | — | Comma-separated 64-char hex pubkeys (required when mode is `allowlist`). Owner is always implicitly included. | +| `--respond-to` | `BUZZ_ACP_RESPOND_TO` | `owner-only` | Author gate mode: `owner-only`, `allowlist`, `strict-allowlist`, `anyone`, `nobody`. | +| `--respond-to-allowlist` | `BUZZ_ACP_RESPOND_TO_ALLOWLIST` | — | Comma-separated 64-char hex pubkeys (required by both allowlist modes). Owner is always implicitly included. | **Modes:** | Mode | Behavior | |------|----------| -| `owner-only` | Forward only events from the agent's registered owner. If no owner is set, all events are dropped until the owner is resolved. | -| `allowlist` | Forward events from the listed pubkeys plus the owner. | +| `owner-only` | Forward events from the agent's registered owner and verified same-owner siblings. If no owner is set, all events are dropped until the owner is resolved. | +| `allowlist` | Forward events from the listed pubkeys, the owner, and verified same-owner siblings. | +| `strict-allowlist` | In channels, forward events only from the listed pubkeys and the owner. In DMs, only the owner is accepted. Same-owner siblings receive no implicit authority. | | `anyone` | Forward all events (no author filtering). | | `nobody` | Drop all inbound events. Agent only acts on heartbeat prompts. | -The gate applies to **all** inbound events — @mentions, DMs, thread replies, and any event delivered by the relay. Owner control commands are checked **before** the gate, so the owner can still manage the harness regardless of mode: +The gate applies to **all** inbound events — @mentions, DMs, thread replies, and any event delivered by the relay. DMs intentionally ignore explicit allowlist entries: the owner and verified same-owner siblings are accepted by the legacy responding modes, while `strict-allowlist` accepts only the owner. Owner control commands are checked **before** the gate, so the owner can still manage the harness regardless of mode: | Command | Effect | |---------|--------| diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 49e4d6cce0..21f633afb9 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -202,6 +202,10 @@ pub struct AcpClient { goose_usage: UsageTracker, } +fn harness_bound_agent_env(key: &str) -> bool { + matches!(key, "BUZZ_RELAY_URL" | "BUZZ_PRIVATE_KEY") +} + /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape /// collisions. When both sides have an object for the same key, the merge recurses so /// unrelated nested keys from `base` are preserved. @@ -422,6 +426,17 @@ impl AcpClient { // Ensure the child is killed when the AcpClient is dropped (best-effort). // Callers MUST still call shutdown().await for guaranteed cleanup. .kill_on_drop(true); + // Buzz signing authority is opt-in through `extra_env`; never inherit + // credentials from the harness process into an arbitrary ACP child. + for key in [ + "BUZZ_RELAY_URL", + "BUZZ_PRIVATE_KEY", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_PRIVATE_KEY_FILE", + "BUZZ_EXPECTED_PUBLIC_KEY", + ] { + cmd.env_remove(key); + } // Per-persona env vars (e.g., GOOSE_PROVIDER, BUZZ_AGENT_PROVIDER). // For most keys, operator precedence wins: skip injection if already set @@ -452,7 +467,7 @@ impl AcpClient { // Handled by build_codex_config_env; skip here to avoid double-setting. continue; } - if std::env::var(key).is_err() { + if harness_bound_agent_env(key) || std::env::var(key).is_err() { cmd.env(key, value); } } @@ -537,10 +552,23 @@ impl AcpClient { /// Must be called exactly once, before any other ACP method. /// The caller may inspect `agentCapabilities` in the returned value. pub async fn initialize(&mut self) -> Result { + self.initialize_with_timeout(Self::REQUEST_TIMEOUT).await + } + + /// Initialize with a caller-owned deadline. + /// + /// Supervisors use a longer bound for cold agent processes; probes retain + /// the normal request deadline through [`Self::initialize`]. + pub async fn initialize_with_timeout( + &mut self, + timeout: std::time::Duration, + ) -> Result { // Requesting version 2 is an intentional temporary pin — we are squatting // on ACP v2 ahead of the upstream ACP RFD. Revisit when that RFD merges. let params = build_initialize_params(); - let result = self.send_request("initialize", params).await?; + let result = self + .send_request_with_timeout("initialize", params, timeout) + .await?; tracing::debug!(target: "acp::init", "initialize response: {result}"); Ok(result) } @@ -988,6 +1016,16 @@ impl AcpClient { &mut self, method: &str, params: serde_json::Value, + ) -> Result { + self.send_request_with_timeout(method, params, Self::REQUEST_TIMEOUT) + .await + } + + async fn send_request_with_timeout( + &mut self, + method: &str, + params: serde_json::Value, + timeout: std::time::Duration, ) -> Result { let id = self.next_id; self.next_id += 1; @@ -1004,13 +1042,17 @@ impl AcpClient { // Wrap write + read in a single timeout so a hung agent can't block forever. // We cannot use an async block that borrows `self` mutably across two awaits // inside timeout(), so we sequence them with early-return on timeout. - let timeout = Self::REQUEST_TIMEOUT; + let deadline = tokio::time::Instant::now() + timeout; match tokio::time::timeout(timeout, self.write_ndjson(&msg)).await { Ok(result) => result?, Err(_) => return Err(AcpError::Timeout(timeout)), } - match tokio::time::timeout(timeout, self.read_until_response(id)).await { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(AcpError::Timeout(timeout)); + } + match tokio::time::timeout(remaining, self.read_until_response(id)).await { Ok(result) => result, Err(_) => Err(AcpError::Timeout(timeout)), } @@ -1888,7 +1930,11 @@ pub fn resolve_model_switch_method( // 1. Search stable configOptions for a "model"-category entry whose // options contain a value matching desired_model. for config_opt in extract_model_config_options(session_new_result) { - let config_id = match config_opt.get("configId").and_then(|v| v.as_str()) { + let config_id = match config_opt + .get("configId") + .or_else(|| config_opt.get("id")) + .and_then(|v| v.as_str()) + { Some(id) => id, None => continue, }; @@ -3777,4 +3823,12 @@ mod tests { "error must mention sandbox_workspace_write" ); } + + #[test] + fn harness_buzz_credentials_override_parent_environment() { + assert!(harness_bound_agent_env("BUZZ_RELAY_URL")); + assert!(harness_bound_agent_env("BUZZ_PRIVATE_KEY")); + assert!(!harness_bound_agent_env("CODEX_CONFIG")); + assert!(!harness_bound_agent_env("INITIAL_AGENT_MODE")); + } } diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 9a1b74c276..5a27a0c21d 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -4,6 +4,7 @@ //! Config file (TOML) for complex subscription rules. use std::collections::{HashMap, HashSet}; +use std::io::Read; use std::path::PathBuf; use clap::Parser; @@ -47,6 +48,54 @@ pub enum ConfigError { ConfigFile(String), } +fn read_owned_secret_file(path: &std::path::Path) -> Result { + if !path.is_absolute() { + return Err(ConfigError::ConfigFile( + "--private-key-file must be an absolute path".into(), + )); + } + + #[cfg(unix)] + let mut file = { + use nix::fcntl::{open, OFlag}; + use nix::sys::stat::Mode; + + let fd = open(path, OFlag::O_RDONLY | OFlag::O_NOFOLLOW, Mode::empty()) + .map_err(|error| ConfigError::Io(error.into()))?; + std::fs::File::from(fd) + }; + #[cfg(not(unix))] + let mut file = std::fs::OpenOptions::new().read(true).open(path)?; + + // Validate the same handle that supplies the secret so the path cannot be + // swapped between metadata inspection and the read. + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(ConfigError::ConfigFile( + "--private-key-file must be a regular, non-symlink file".into(), + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + if metadata.permissions().mode() & 0o777 != 0o600 { + return Err(ConfigError::ConfigFile( + "--private-key-file permissions must be 0600".into(), + )); + } + if metadata.uid() != nix::unistd::getuid().as_raw() { + return Err(ConfigError::ConfigFile( + "--private-key-file must be owned by the current user".into(), + )); + } + } + + let mut secret = String::new(); + file.read_to_string(&mut secret)?; + Ok(secret.trim().to_string()) +} + #[derive(Debug, Clone, PartialEq, clap::ValueEnum)] pub enum SubscribeMode { Mentions, @@ -87,8 +136,9 @@ pub enum MultipleEventHandling { /// Inbound author gate: which authors' events the harness forwards to the agent. /// -/// - `owner-only` — only the agent's registered owner (default). -/// - `allowlist` — owner + explicit pubkey list (`--respond-to-allowlist`). +/// - `owner-only` — owner + verified same-owner siblings (default). +/// - `allowlist` — owner + same-owner siblings + explicit pubkey list. +/// - `strict-allowlist` — owner + explicit pubkey list, excluding siblings. /// - `anyone` — all events forwarded (no author filtering). /// - `nobody` — all events dropped (proactive/heartbeat-only mode). #[derive(Debug, Clone, Default, PartialEq, Eq, Hash, clap::ValueEnum)] @@ -96,6 +146,7 @@ pub enum RespondTo { #[default] OwnerOnly, Allowlist, + StrictAllowlist, Anyone, Nobody, } @@ -105,6 +156,7 @@ impl std::fmt::Display for RespondTo { match self { Self::OwnerOnly => f.write_str("owner-only"), Self::Allowlist => f.write_str("allowlist"), + Self::StrictAllowlist => f.write_str("strict-allowlist"), Self::Anyone => f.write_str("anyone"), Self::Nobody => f.write_str("nobody"), } @@ -240,16 +292,57 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_RELAY_URL", default_value = "ws://localhost:3000")] pub relay_url: String, - #[arg(long, env = "BUZZ_PRIVATE_KEY")] - pub private_key: String, + #[arg( + long, + env = "BUZZ_PRIVATE_KEY", + conflicts_with = "private_key_file", + required_unless_present = "private_key_file" + )] + pub private_key: Option, + + /// Read the Nostr private key from an operator-owned regular file. + #[arg( + long, + env = "BUZZ_PRIVATE_KEY_FILE", + conflicts_with = "private_key", + required_unless_present = "private_key" + )] + pub private_key_file: Option, + + /// Expected public key derived from `--private-key-file`. + #[arg(long, env = "BUZZ_EXPECTED_PUBLIC_KEY", requires = "private_key_file")] + pub expected_public_key: Option, - /// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate. + /// Absolute workspace path sent as `cwd` in every ACP `session/new`. + /// Defaults to the harness process working directory. + #[arg(long, env = "BUZZ_ACP_SESSION_CWD")] + pub session_cwd: Option, + + /// Agent owner pubkey (64-char hex). Used by owner-aware author gates. #[arg(long, env = "BUZZ_ACP_AGENT_OWNER")] pub agent_owner: Option, #[arg(long, env = "BUZZ_ACP_AGENT_COMMAND", default_value = "goose")] pub agent_command: String, + /// Forward this harness identity to the managed agent for direct Buzz CLI use. + /// Disabled by default because it grants the child signing authority. + #[arg( + long, + env = "BUZZ_ACP_AGENT_PUBLISHER_CREDENTIALS", + default_value_t = false, + conflicts_with = "no_agent_publisher_credentials" + )] + pub agent_publisher_credentials: bool, + + /// Prevent the managed agent from inheriting this harness's Buzz signer. + #[arg( + long, + env = "BUZZ_ACP_NO_AGENT_PUBLISHER_CREDENTIALS", + default_value_t = false + )] + pub no_agent_publisher_credentials: bool, + #[arg( long, env = "BUZZ_ACP_AGENT_ARGS", @@ -444,7 +537,7 @@ pub struct CliArgs { pub permission_mode: PermissionMode, /// Inbound author gate: which authors' events the harness forwards. - /// Modes: owner-only (default), allowlist, anyone, nobody. + /// Modes: owner-only (default), allowlist, strict-allowlist, anyone, nobody. #[arg( long, env = "BUZZ_ACP_RESPOND_TO", @@ -453,14 +546,14 @@ pub struct CliArgs { )] pub respond_to: RespondTo, - /// Comma-separated 64-char hex pubkeys for allowlist mode. + /// Comma-separated 64-char hex pubkeys for allowlist modes. /// Owner pubkey is always implicitly included. #[arg(long, env = "BUZZ_ACP_RESPOND_TO_ALLOWLIST", value_delimiter = ',')] pub respond_to_allowlist: Option>, /// Comma-separated list of allowed `--respond-to` modes. /// When set, the harness rejects startup if `--respond-to` is not in this list. - /// Modes: owner-only, allowlist, anyone, nobody. + /// Modes: owner-only, allowlist, strict-allowlist, anyone, nobody. /// Default: empty (all modes allowed — no restriction). /// Example: `BUZZ_ACP_ALLOWED_RESPOND_TO=owner-only,allowlist` #[arg(long, env = "BUZZ_ACP_ALLOWED_RESPOND_TO", value_delimiter = ',')] @@ -492,7 +585,9 @@ pub struct ChannelFilter { pub struct Config { pub keys: Keys, pub relay_url: String, + pub session_cwd: PathBuf, pub agent_command: String, + pub agent_publisher_credentials: bool, pub agent_args: Vec, pub mcp_command: String, pub idle_timeout_secs: u64, @@ -535,7 +630,7 @@ pub struct Config { pub permission_mode: PermissionMode, /// Inbound author gate mode. pub respond_to: RespondTo, - /// Validated allowlist of pubkey hex strings (used when respond_to == Allowlist). + /// Validated pubkeys used by both allowlist modes. pub respond_to_allowlist: HashSet, /// Allowed `respond_to` modes. Empty = all modes allowed. pub allowed_respond_to: Vec, @@ -809,13 +904,65 @@ impl Config { /// tests can construct `CliArgs` via `CliArgs::try_parse_from` and exercise the full /// validation path without going through process args. pub fn from_args(mut args: CliArgs) -> Result { - let keys = Keys::parse(&args.private_key)?; + // Preserve only credentials that a child would already have inherited + // from the harness environment. Arg/file signers require explicit opt-in. + let inherited_publisher_credentials = std::env::var_os("BUZZ_PRIVATE_KEY").is_some() + || std::env::var_os("BUZZ_ACP_PRIVATE_KEY").is_some(); + let agent_publisher_credentials = args.agent_publisher_credentials + || (inherited_publisher_credentials && !args.no_agent_publisher_credentials); + let mut private_key = if let Some(value) = args.private_key.take() { + value + } else if let Some(path) = args.private_key_file.as_ref() { + read_owned_secret_file(path)? + } else { + return Err(ConfigError::ConfigFile( + "one of --private-key or --private-key-file is required".into(), + )); + }; + let keys = Keys::parse(&private_key)?; + if let Some(expected) = args.expected_public_key.as_deref() { + if keys.public_key().to_hex() != expected.trim().to_ascii_lowercase() { + return Err(ConfigError::ConfigFile( + "private-key file does not derive the expected public key".into(), + )); + } + } // Best-effort zeroize: overwrite the raw private key string to reduce // exposure via core dumps or heap inspection (#41). Without the `zeroize` // crate we can only clear the String — the allocator may retain copies. - args.private_key - .replace_range(.., &"0".repeat(args.private_key.len())); - args.private_key.clear(); + private_key.replace_range(.., &"0".repeat(private_key.len())); + private_key.clear(); + + let session_cwd = if let Some(path) = args.session_cwd { + if !path.is_absolute() { + return Err(ConfigError::ConfigFile( + "--session-cwd must be an absolute path".into(), + )); + } + if path.to_str().is_none() { + return Err(ConfigError::ConfigFile( + "--session-cwd must be valid UTF-8 for the ACP protocol".into(), + )); + } + let metadata = std::fs::metadata(&path).map_err(|error| { + ConfigError::ConfigFile(format!( + "--session-cwd must be an existing directory: {error}" + )) + })?; + if !metadata.is_dir() { + return Err(ConfigError::ConfigFile( + "--session-cwd must be an existing directory".into(), + )); + } + path + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")) + }; + if session_cwd.to_str().is_none() { + return Err(ConfigError::ConfigFile( + "--session-cwd must be valid UTF-8 for the ACP protocol".into(), + )); + } let system_prompt = if let Some(text) = args.system_prompt { Some(text) @@ -969,18 +1116,23 @@ impl Config { ))); } - let respond_to_allowlist = if args.respond_to == RespondTo::Allowlist { + let uses_allowlist = matches!( + args.respond_to, + RespondTo::Allowlist | RespondTo::StrictAllowlist + ); + let respond_to_allowlist = if uses_allowlist { let raw = args.respond_to_allowlist.unwrap_or_default(); if raw.is_empty() { - return Err(ConfigError::ConfigFile( - "--respond-to=allowlist requires --respond-to-allowlist with at least one pubkey".into(), - )); + return Err(ConfigError::ConfigFile(format!( + "--respond-to={} requires --respond-to-allowlist with at least one pubkey", + args.respond_to + ))); } validate_allowlist(&raw)? } else { if args.respond_to_allowlist.is_some() { tracing::warn!( - "--respond-to-allowlist is ignored when --respond-to is not 'allowlist'" + "--respond-to-allowlist is ignored when --respond-to is not an allowlist mode" ); } HashSet::new() @@ -993,7 +1145,7 @@ impl Config { RespondTo::from_str(s.trim(), true).map_err(|_| { ConfigError::ConfigFile(format!( "invalid value in BUZZ_ACP_ALLOWED_RESPOND_TO: '{s}' \ - (valid values: owner-only, allowlist, anyone, nobody)" + (valid values: owner-only, allowlist, strict-allowlist, anyone, nobody)" )) })?; } @@ -1032,7 +1184,9 @@ impl Config { let config = Config { keys, relay_url: args.relay_url, + session_cwd, agent_command, + agent_publisher_credentials, agent_args, mcp_command: args.mcp_command, idle_timeout_secs, @@ -1086,9 +1240,11 @@ impl Config { /// Human-readable summary (no secrets). pub fn summary(&self) -> String { let respond_to_detail = match &self.respond_to { - RespondTo::Allowlist => { - format!("respond_to=allowlist({})", self.respond_to_allowlist.len()) - } + RespondTo::Allowlist | RespondTo::StrictAllowlist => format!( + "respond_to={}({})", + self.respond_to, + self.respond_to_allowlist.len() + ), other => format!("respond_to={other}"), }; let allowed_respond_to_detail = if self.allowed_respond_to.is_empty() { @@ -1099,9 +1255,10 @@ impl Config { format!(" allowed_respond_to=[{}]", modes.join(",")) }; format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", + "relay={} pubkey={} session_cwd={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", self.relay_url, self.keys.public_key().to_hex(), + self.session_cwd.display(), self.agent_command, self.agent_args.join(" "), self.mcp_command, @@ -1124,6 +1281,22 @@ impl Config { allowed_respond_to_detail, ) } + + /// Build the managed agent runtime environment. Buzz publisher credentials + /// are present only for deployments that explicitly grant the child signer + /// authority; the spawn path removes inherited values in every other case. + pub fn agent_spawn_env(&self) -> Vec<(String, String)> { + let mut env = self.persona_env_vars.clone(); + env.retain(|(key, _)| !matches!(key.as_str(), "BUZZ_RELAY_URL" | "BUZZ_PRIVATE_KEY")); + if self.agent_publisher_credentials { + env.push(("BUZZ_RELAY_URL".into(), self.relay_url.clone())); + env.push(( + "BUZZ_PRIVATE_KEY".into(), + self.keys.secret_key().to_secret_hex(), + )); + } + env + } } #[derive(Debug, serde::Deserialize)] @@ -1410,7 +1583,9 @@ mod tests { Config { keys: nostr::Keys::generate(), relay_url: "ws://localhost:3000".into(), + session_cwd: PathBuf::from("."), agent_command: "goose".into(), + agent_publisher_credentials: false, agent_args: vec!["acp".into()], mcp_command: "".into(), idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS, @@ -2350,6 +2525,10 @@ channels = "ALL" fn test_respond_to_display() { assert_eq!(format!("{}", RespondTo::OwnerOnly), "owner-only"); assert_eq!(format!("{}", RespondTo::Allowlist), "allowlist"); + assert_eq!( + format!("{}", RespondTo::StrictAllowlist), + "strict-allowlist" + ); assert_eq!(format!("{}", RespondTo::Anyone), "anyone"); assert_eq!(format!("{}", RespondTo::Nobody), "nobody"); } @@ -2365,6 +2544,10 @@ channels = "ALL" RespondTo::from_str("allowlist", true).unwrap(), RespondTo::Allowlist ); + assert_eq!( + RespondTo::from_str("strict-allowlist", true).unwrap(), + RespondTo::StrictAllowlist + ); assert_eq!( RespondTo::from_str("anyone", true).unwrap(), RespondTo::Anyone @@ -2397,6 +2580,18 @@ channels = "ALL" ); } + #[test] + fn test_summary_respond_to_strict_allowlist_shows_count() { + let mut config = test_config(SubscribeMode::Mentions); + config.respond_to = RespondTo::StrictAllowlist; + config.respond_to_allowlist = HashSet::from(["ab".repeat(32), "cd".repeat(32)]); + let s = config.summary(); + assert!( + s.contains("respond_to=strict-allowlist(2)"), + "should show strict allowlist count, got: {s}" + ); + } + #[test] fn test_validate_allowlist_valid_entries() { let entries = vec!["ab".repeat(32), "cd".repeat(32)]; @@ -2555,7 +2750,7 @@ channels = "ALL" let mode = RespondTo::from_str(s.trim(), true).map_err(|_| { ConfigError::ConfigFile(format!( "invalid value in BUZZ_ACP_ALLOWED_RESPOND_TO: '{s}' \ - (valid values: owner-only, allowlist, anyone, nobody)" + (valid values: owner-only, allowlist, strict-allowlist, anyone, nobody)" )) })?; set.insert(mode); @@ -2711,6 +2906,24 @@ channels = "ALL" ); } + #[test] + fn strict_allowlist_full_path_requires_explicit_pubkeys() { + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--respond-to", + "strict-allowlist", + "--allowed-respond-to", + "strict-allowlist", + ]) + .expect("clap should parse args"); + let error = Config::from_args(args).expect_err("strict allowlist must require pubkeys"); + assert!(error + .to_string() + .contains("--respond-to=strict-allowlist requires --respond-to-allowlist")); + } + #[test] fn allowed_respond_to_full_path_unset_allows_all() { // No --allowed-respond-to flag → anyone is accepted. @@ -2783,6 +2996,17 @@ channels = "ALL" } } + #[test] + fn session_cwd_defaults_to_current_directory() { + let args = + CliArgs::try_parse_from(["buzz-acp", "--private-key", TEST_PRIVATE_KEY]).unwrap(); + let config = Config::from_args(args).unwrap(); + assert_eq!( + config.session_cwd, + std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")) + ); + } + #[test] fn sanitize_session_title_collapses_whitespace_and_strips_control_chars() { assert_eq!( @@ -2791,6 +3015,208 @@ channels = "ALL" ); } + #[test] + fn session_cwd_accepts_an_absolute_existing_directory() { + let dir = std::env::temp_dir().join(format!("buzz-acp-session-cwd-{}", Uuid::new_v4())); + std::fs::create_dir(&dir).unwrap(); + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--session-cwd", + dir.to_str().unwrap(), + ]) + .unwrap(); + let config = Config::from_args(args).unwrap(); + assert_eq!(config.session_cwd, dir); + std::fs::remove_dir_all(&config.session_cwd).unwrap(); + } + + #[test] + fn session_cwd_rejects_relative_missing_and_non_directory_paths() { + let relative = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--session-cwd", + "relative", + ]) + .unwrap(); + assert!(matches!( + Config::from_args(relative), + Err(ConfigError::ConfigFile(message)) if message.contains("absolute path") + )); + + let dir = std::env::temp_dir().join(format!("buzz-acp-session-cwd-{}", Uuid::new_v4())); + let missing = dir.join("missing"); + let missing_args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--session-cwd", + missing.to_str().unwrap(), + ]) + .unwrap(); + assert!(matches!( + Config::from_args(missing_args), + Err(ConfigError::ConfigFile(message)) if message.contains("existing directory") + )); + + std::fs::create_dir(&dir).unwrap(); + let file = dir.join("file"); + std::fs::write(&file, "not a directory").unwrap(); + let file_args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--session-cwd", + file.to_str().unwrap(), + ]) + .unwrap(); + assert!(matches!( + Config::from_args(file_args), + Err(ConfigError::ConfigFile(message)) if message.contains("existing directory") + )); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn session_cwd_rejects_non_utf8_paths() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let dir = + std::env::temp_dir().join(OsString::from_vec(b"buzz-acp-session-cwd-\xff".to_vec())); + let args = CliArgs::try_parse_from([ + OsString::from("buzz-acp"), + OsString::from("--private-key"), + OsString::from(TEST_PRIVATE_KEY), + OsString::from("--session-cwd"), + dir.clone().into_os_string(), + ]) + .unwrap(); + assert!(matches!( + Config::from_args(args), + Err(ConfigError::ConfigFile(message)) if message.contains("valid UTF-8") + )); + } + + #[cfg(unix)] + #[test] + fn private_key_file_is_owned_mode_checked_and_binds_expected_pubkey() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let dir = std::env::temp_dir().join(format!("buzz-acp-key-{}", Uuid::new_v4())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("codex-cli.sk"); + let keys = Keys::generate(); + std::fs::write(&path, keys.secret_key().to_secret_hex()).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key-file", + path.to_str().unwrap(), + "--expected-public-key", + &keys.public_key().to_hex(), + ]) + .unwrap(); + let config = Config::from_args(args).expect("valid owned signer file"); + assert_eq!(config.keys.public_key(), keys.public_key()); + assert!(!config.agent_publisher_credentials); + + let wrong_mode = dir.join("wrong-mode.sk"); + std::fs::write(&wrong_mode, keys.secret_key().to_secret_hex()).unwrap(); + std::fs::set_permissions(&wrong_mode, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(matches!( + read_owned_secret_file(&wrong_mode), + Err(ConfigError::ConfigFile(message)) if message.contains("0600") + )); + + let link = dir.join("signer-link.sk"); + symlink(&path, &link).unwrap(); + assert!(read_owned_secret_file(&link).is_err()); + + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn managed_agent_env_uses_validated_harness_buzz_identity() { + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--relay-url", + "ws://127.0.0.1:3000", + "--agent-publisher-credentials", + ]) + .unwrap(); + let mut config = Config::from_args(args).unwrap(); + config.persona_env_vars.extend([ + ("BUZZ_RELAY_URL".into(), "ws://wrong.invalid".into()), + ("BUZZ_PRIVATE_KEY".into(), "wrong-secret".into()), + ("SAFE_PERSONA_SETTING".into(), "kept".into()), + ]); + + let env = config.agent_spawn_env(); + assert_eq!( + env.iter() + .filter(|(key, _)| key == "BUZZ_RELAY_URL") + .count(), + 1 + ); + assert_eq!( + env.iter() + .find(|(key, _)| key == "BUZZ_RELAY_URL") + .map(|(_, value)| value.as_str()), + Some("ws://127.0.0.1:3000") + ); + assert_eq!( + env.iter() + .filter(|(key, _)| key == "BUZZ_PRIVATE_KEY") + .count(), + 1 + ); + assert!(env.contains(&("SAFE_PERSONA_SETTING".into(), "kept".into()))); + } + + #[test] + fn managed_agent_env_omits_buzz_identity_without_explicit_grant() { + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--relay-url", + "ws://127.0.0.1:3000", + "--no-agent-publisher-credentials", + ]) + .unwrap(); + let mut config = Config::from_args(args).unwrap(); + config.persona_env_vars.extend([ + ("BUZZ_RELAY_URL".into(), "ws://wrong.invalid".into()), + ("BUZZ_PRIVATE_KEY".into(), "wrong-secret".into()), + ]); + + let env = config.agent_spawn_env(); + assert!(!env + .iter() + .any(|(key, _)| matches!(key.as_str(), "BUZZ_RELAY_URL" | "BUZZ_PRIVATE_KEY"))); + } + + #[test] + fn direct_signer_does_not_grant_publisher_credentials_by_default() { + let args = + CliArgs::try_parse_from(["buzz-acp", "--private-key", TEST_PRIVATE_KEY]).unwrap(); + let config = Config::from_args(args).unwrap(); + + assert!(!config.agent_publisher_credentials); + assert!(!config + .agent_spawn_env() + .iter() + .any(|(key, _)| key == "BUZZ_PRIVATE_KEY")); + } + #[test] fn sanitize_session_title_returns_none_when_nothing_printable_remains() { assert_eq!(sanitize_session_title(" \n\t "), None); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b11d96d8f7..272e317fa6 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -66,6 +66,10 @@ const MODELS_TIMEOUT: Duration = Duration::from_secs(10); /// human interaction, so it must not share the short probe timeout. const AUTHENTICATE_TIMEOUT: Duration = Duration::from_secs(10 * 60); +/// Cold OpenClaw bridges can spend over a minute loading their Gateway plugin +/// surface after host restart. Keep the supervisor attached through that boot. +const AGENT_INITIALIZE_TIMEOUT: Duration = Duration::from_secs(3 * 60); + /// Publish a kind:20001 presence update event via the WebSocket connection. /// /// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence @@ -217,9 +221,9 @@ async fn is_owner_or_sibling( /// Inbound author gate decision: does this author's event fire a turn? /// -/// Coarse security policy applied before subscription rules. Both `OwnerOnly` -/// and `Allowlist` accept the owner and same-owner siblings; `Allowlist` -/// additionally accepts the explicit external pubkey list. +/// Coarse security policy applied before subscription rules. `OwnerOnly` and +/// `Allowlist` accept same-owner siblings. `StrictAllowlist` deliberately +/// excludes siblings so deployments can name every inbound principal. /// /// # DM hardening (`is_dm`) /// @@ -228,9 +232,10 @@ async fn is_owner_or_sibling( /// agent-initiated DMs (the agent can be asked to DM a third party), that /// turns `anyone`/`allowlist` modes into transitive access grants: whoever /// lands in a DM with the agent can prompt it. To close that hole, when -/// `is_dm` is true only the owner and cryptographically verified same-owner -/// siblings may fire a turn — the explicit allowlist and `anyone` mode do -/// NOT apply inside DMs. `Nobody` still drops everything. Callers must +/// `is_dm` is true, ordinary modes admit only the owner and cryptographically +/// verified same-owner siblings. `StrictAllowlist` admits only the owner. +/// Explicit allowlists and `anyone` do not apply inside DMs. `Nobody` still +/// drops everything. Callers must /// resolve `is_dm` fail-closed: unknown channel type ⇒ treat as DM. async fn author_allowed( respond_to: &RespondTo, @@ -243,6 +248,7 @@ async fn author_allowed( if is_dm { return match respond_to { RespondTo::Nobody => false, + RespondTo::StrictAllowlist => owner_cache.get() == Some(author), _ => is_owner_or_sibling(author, owner_cache, rest_client).await, }; } @@ -254,6 +260,9 @@ async fn author_allowed( allowlist.contains(author) || is_owner_or_sibling(author, owner_cache, rest_client).await } + RespondTo::StrictAllowlist => { + allowlist.contains(author) || owner_cache.get() == Some(author) + } } } @@ -1380,11 +1389,12 @@ async fn tokio_main() -> Result<()> { dropped. Set BUZZ_AUTH_TAG or --agent-owner, or use --respond-to=anyone." ); } - RespondTo::Allowlist => { + RespondTo::Allowlist | RespondTo::StrictAllowlist => { tracing::warn!( - "respond-to=allowlist but no owner is set — allowlisted pubkeys \ + "respond-to={} but no owner is set — allowlisted pubkeys \ will still be accepted, but owner-based matching is unavailable \ - until owner is resolved." + until owner is resolved.", + config.respond_to ); } _ => {} // anyone/nobody don't depend on owner @@ -1526,6 +1536,11 @@ async fn tokio_main() -> Result<()> { ); } + let session_cwd = config + .session_cwd + .to_str() + .ok_or_else(|| anyhow::anyhow!("session cwd must be valid UTF-8 for the ACP protocol"))? + .to_owned(); let base_prompt_content = config.base_prompt_content.take(); let ctx = Arc::new(PromptContext { mcp_servers: build_mcp_servers(&config), @@ -1545,10 +1560,7 @@ async fn tokio_main() -> Result<()> { Some(include_str!("base_prompt.md")) }, heartbeat_prompt: config.heartbeat_prompt.clone(), - cwd: std::env::current_dir() - .unwrap_or_else(|_| std::path::PathBuf::from("/")) - .to_string_lossy() - .to_string(), + cwd: session_cwd, rest_client: relay.rest_client(), channel_info: pool::ChannelInfoResolver::new(channel_info_map, relay.rest_client()), context_message_limit: config.context_message_limit, @@ -1760,7 +1772,7 @@ async fn tokio_main() -> Result<()> { tracing::info!(agent = idx, "slot refill: spawning background respawn"); let cmd = config.agent_command.clone(); let args = config.agent_args.clone(); - let env = config.persona_env_vars.clone(); + let env = config.agent_spawn_env(); let has_codex = config.has_generated_codex_config; let observer = observer.clone(); let guard = RespawnGuard::new(idx, respawn_tx.clone()); @@ -2138,12 +2150,15 @@ async fn tokio_main() -> Result<()> { // agent. Must be AFTER !shutdown (owner can always // shut down regardless of gate mode). // - // Both OwnerOnly and Allowlist accept events from + // OwnerOnly and Allowlist accept events from // "siblings" — pubkeys whose agent_owner_pubkey // matches this agent's owner (e.g. other bots // launched by the same human). Allowlist adds the // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. + // StrictAllowlist accepts only the owner and named + // pubkeys, so sibling bots do not inherit execution + // authority from a shared owner. { let author = buzz_event.event.pubkey.to_hex(); // DM hardening: resolve channel type (fail-closed @@ -3486,7 +3501,7 @@ fn recover_panicked_agent( slot.respawn_in_flight = true; let cmd = config.agent_command.clone(); let args = config.agent_args.clone(); - let env = config.persona_env_vars.clone(); + let env = config.agent_spawn_env(); let has_codex = config.has_generated_codex_config; let guard = RespawnGuard::new(i, respawn_tx.clone()); respawn_tasks.spawn(async move { @@ -3664,7 +3679,7 @@ fn spawn_respawn_task( // Spawn the actual work (shutdown + sleep + spawn + init) off the main loop. let cmd = config.agent_command.clone(); let args = config.agent_args.clone(); - let env = config.persona_env_vars.clone(); + let env = config.agent_spawn_env(); let has_codex = config.has_generated_codex_config; let guard = RespawnGuard::new(index, respawn_tx.clone()); respawn_tasks.spawn(async move { @@ -3731,7 +3746,7 @@ impl PoolStartup { agents: config.agents, command: config.agent_command.clone(), args: config.agent_args.clone(), - extra_env: config.persona_env_vars.clone(), + extra_env: config.agent_spawn_env(), has_generated_codex_config: config.has_generated_codex_config, model: config.model.clone(), observer, @@ -3744,7 +3759,7 @@ async fn initialize_agent_pool( mut shutdown: Option>, ) -> Result { // One agent failing to start must not kill the whole pool. - // Attempt each spawn under a 60-second timeout; a partial pool is valid. + // Attempt each spawn under the bounded cold-start timeout; a partial pool is valid. let mut agent_slots: Vec> = Vec::with_capacity(startup.agents as usize); for i in 0..startup.agents as usize { let spawn_result = AcpClient::spawn( @@ -3757,7 +3772,7 @@ async fn initialize_agent_pool( match spawn_result { Ok(mut acp) => { acp.set_observer(startup.observer.clone(), i); - let initialize = tokio::time::timeout(Duration::from_secs(60), acp.initialize()); + let initialize = acp.initialize_with_timeout(AGENT_INITIALIZE_TIMEOUT); let initialize_result = match shutdown.as_mut() { Some(shutdown) => tokio::select! { biased; @@ -3771,7 +3786,7 @@ async fn initialize_agent_pool( None => initialize.await, }; match initialize_result { - Ok(Ok(init_result)) => { + Ok(init_result) => { tracing::info!(agent = i, "agent initialized: {init_result}"); let protocol_version = init_result["protocolVersion"].as_u64().unwrap_or(1) as u32; @@ -3805,16 +3820,11 @@ async fn initialize_agent_pool( protocol_version, })); } - Ok(Err(e)) => { + Err(e) => { tracing::error!(agent = i, "agent initialize failed: {e}"); acp.shutdown().await; agent_slots.push(None); } - Err(_) => { - tracing::error!(agent = i, "agent timed out during init (60s)"); - acp.shutdown().await; - agent_slots.push(None); - } } } Err(e) => { @@ -4481,6 +4491,44 @@ mod author_gate_tests { ); } + #[tokio::test] + async fn test_strict_allowlist_rejects_unlisted_sibling() { + let cache = cache_with_sibling(); + let allowlist = HashSet::from([EXTERNAL.to_string()]); + assert!( + !author_allowed( + &RespondTo::StrictAllowlist, + &allowlist, + SIBLING, + false, + &cache, + &dummy_rest_client() + ) + .await, + "a same-owner sibling absent from a strict allowlist must be dropped" + ); + } + + #[tokio::test] + async fn test_strict_allowlist_accepts_owner_and_explicit_pubkey() { + let cache = cache_with_sibling(); + let allowlist = HashSet::from([EXTERNAL.to_string()]); + for (who, label) in [(OWNER, "owner"), (EXTERNAL, "explicit pubkey")] { + assert!( + author_allowed( + &RespondTo::StrictAllowlist, + &allowlist, + who, + false, + &cache, + &dummy_rest_client() + ) + .await, + "strict allowlist must accept the {label}" + ); + } + } + // The default `respond-to` is OwnerOnly. Under steering, "an ineligible // author must NOT steer" is enforced *here* — author_allowed drops the // event before it reaches the mode gate — not in the gate itself. These @@ -4526,7 +4574,7 @@ mod author_gate_tests { // In a DM, clients auto-p-tag every participant, and an agent can be // asked to open a DM with a third party. The gate must therefore ignore // the allowlist and `anyone` mode inside DMs: only owner + verified - // siblings fire turns. + // siblings fire turns. Strict allowlists narrow that further to owner-only. #[tokio::test] async fn test_dm_rejects_allowlisted_external_pubkey() { @@ -4588,6 +4636,35 @@ mod author_gate_tests { } } + #[tokio::test] + async fn test_dm_strict_allowlist_accepts_owner_but_rejects_sibling() { + let cache = cache_with_sibling(); + let allowlist = HashSet::from([SIBLING.to_string()]); + assert!( + author_allowed( + &RespondTo::StrictAllowlist, + &allowlist, + OWNER, + true, + &cache, + &dummy_rest_client() + ) + .await + ); + assert!( + !author_allowed( + &RespondTo::StrictAllowlist, + &allowlist, + SIBLING, + true, + &cache, + &dummy_rest_client() + ) + .await, + "strict allowlist must not grant same-owner sibling authority inside DMs" + ); + } + #[tokio::test] async fn test_dm_nobody_rejects_even_owner() { let cache = cache_with_sibling(); @@ -4960,7 +5037,9 @@ mod build_mcp_servers_tests { Config { keys: nostr::Keys::generate(), relay_url: "ws://localhost:3000".into(), + session_cwd: std::path::PathBuf::from("."), agent_command: "goose".into(), + agent_publisher_credentials: false, agent_args: vec!["acp".into()], mcp_command: "test-mcp-server".into(), idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, @@ -5178,10 +5257,12 @@ mod error_outcome_emission_tests { Config { keys: nostr::Keys::generate(), relay_url: "ws://localhost:3000".into(), + session_cwd: std::path::PathBuf::from("."), // `true` exits cleanly, so the async respawn fails fast and // harmlessly off the JoinSet — irrelevant to the synchronous // feed emission under test. agent_command: "true".into(), + agent_publisher_credentials: false, agent_args: vec![], mcp_command: "test-mcp-server".into(), idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0c51fe954f..c49e26f745 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -785,9 +785,64 @@ const CONTEXT_FETCH_TIMEOUT: Duration = Duration::from_millis(3_000); /// Delay between the first failed context fetch and the single retry. const CONTEXT_FETCH_RETRY_DELAY: Duration = Duration::from_millis(500); -/// Timeout for model-switch requests (`session/set_config_option`, `session/set_model`). +/// Default timeout for model-switch requests (`session/set_config_option`, `session/set_model`). const MODEL_SWITCH_TIMEOUT: Duration = Duration::from_secs(5); +/// Config-option model switches may hydrate provider state before acknowledging. +const CONFIG_OPTION_MODEL_SWITCH_TIMEOUT: Duration = Duration::from_secs(30); + +fn model_switch_timeout(method: &ModelSwitchMethod) -> Duration { + match method { + ModelSwitchMethod::ConfigOption { .. } => CONFIG_OPTION_MODEL_SWITCH_TIMEOUT, + ModelSwitchMethod::SetModel { .. } => MODEL_SWITCH_TIMEOUT, + } +} + +fn capture_post_switch_config( + session_new: &serde_json::Value, + applied_method: Option<&ModelSwitchMethod>, +) -> (serde_json::Value, serde_json::Value) { + let mut config_options = session_new + .get("configOptions") + .cloned() + .unwrap_or(serde_json::Value::Null); + let mut models = session_new + .get("models") + .cloned() + .unwrap_or(serde_json::Value::Null); + match applied_method { + Some(ModelSwitchMethod::ConfigOption { + config_id, + option_value, + }) => { + if let Some(options) = config_options.as_array_mut() { + for option in options { + if option + .get("configId") + .or_else(|| option.get("id")) + .and_then(|value| value.as_str()) + == Some(config_id.as_str()) + { + option["currentValue"] = + serde_json::Value::String(option_value.to_string()); + } + } + } + models = serde_json::Value::Null; + } + Some(ModelSwitchMethod::SetModel { model_id }) => { + if let Some(models) = models.as_object_mut() { + models.insert( + "currentModelId".to_string(), + serde_json::Value::String(model_id.to_string()), + ); + } + } + None => {} + } + (config_options, models) +} + /// Bounded grace window for the post-cancel drain after a control-signal /// cancellation (steer fallback, interrupt, or explicit stop). This is a /// cleanup deadline, not the turn's configured max-turn wall clock — see @@ -922,11 +977,24 @@ async fn create_session_and_apply_model( // Apply desired_model if set, matching against the fresh session/new response. // Track whether the switch succeeded so session_config_captured reflects // the post-switch state (not the pre-switch desired state). - let switch_succeeded = if let Some(ref desired) = agent.desired_model { + let applied_model_switch = if let Some(ref desired) = agent.desired_model { match resolve_model_switch_method(&resp.raw, desired) { Some(method) => { - apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await?; - true + if matches!( + apply_model_switch( + &mut agent.acp, + &resp.session_id, + desired, + &method, + model_switch_timeout(&method), + ) + .await?, + ModelSwitchApplication::Applied + ) { + Some(method) + } else { + None + } } None => { tracing::warn!( @@ -945,11 +1013,11 @@ async fn create_session_and_apply_model( "modelId": desired, }), ); - false + None } } } else { - false + None }; // Emit session config for desktop consumption (config bridge tier 1b). @@ -957,13 +1025,15 @@ async fn create_session_and_apply_model( // post-switch state. modelOverridden reflects whether the switch actually // applied — false on the unsupported arm so the panel doesn't show a // stale override badge. + let (captured_config_options, captured_models) = + capture_post_switch_config(&resp.raw, applied_model_switch.as_ref()); agent.acp.observe( "session_config_captured", serde_json::json!({ - "configOptions": resp.raw.get("configOptions").cloned().unwrap_or(serde_json::Value::Null), + "configOptions": captured_config_options, "modes": resp.raw.get("modes").cloned().unwrap_or(serde_json::Value::Null), - "models": resp.raw.get("models").cloned().unwrap_or(serde_json::Value::Null), - "modelOverridden": agent.model_overridden && switch_succeeded, + "models": captured_models, + "modelOverridden": agent.model_overridden && applied_model_switch.is_some(), // Pair identity for the desktop session-config cache, which is // keyed by (agent, relay) like the lifecycle frames. "relayUrl": ctx.relay_url, @@ -989,12 +1059,37 @@ async fn create_session_and_apply_model( /// with the agent's default model. This is intentionally non-fatal: a stale /// response from a timed-out request is safely ignored by `read_until_response` /// (non-matching JSON-RPC IDs are skipped). +#[derive(Debug, PartialEq, Eq)] +enum ModelSwitchApplication { + Applied, + Rejected { reason: String }, +} + +fn classify_model_switch_response( + response: Result, +) -> Result { + match response { + Ok(_) => Ok(ModelSwitchApplication::Applied), + Err( + error @ (AcpError::Io(_) + | AcpError::WriteTimeout(_) + | AcpError::Timeout(_) + | AcpError::Protocol(_) + | AcpError::AgentExited), + ) => Err(error), + Err(error) => Ok(ModelSwitchApplication::Rejected { + reason: error.to_string(), + }), + } +} + async fn apply_model_switch( acp: &mut AcpClient, session_id: &str, desired: &str, method: &ModelSwitchMethod, -) -> Result<(), AcpError> { + switch_timeout: Duration, +) -> Result { let method_label = match method { ModelSwitchMethod::ConfigOption { config_id, .. } => { format!("configOption (configId={config_id})") @@ -1002,7 +1097,7 @@ async fn apply_model_switch( ModelSwitchMethod::SetModel { .. } => "set_model".to_string(), }; - let result = tokio::time::timeout(MODEL_SWITCH_TIMEOUT, async { + let result = tokio::time::timeout(switch_timeout, async { match method { ModelSwitchMethod::ConfigOption { config_id, @@ -1019,43 +1114,32 @@ async fn apply_model_switch( .await; match result { - Ok(Ok(_)) => { - tracing::info!( - target: "pool::model", - "applied model {desired} via {method_label} on session {session_id}" - ); - } - // Transport-class errors may have corrupted the stdio stream — propagate - // so the caller can respawn the agent instead of reusing a poisoned one. - Ok(Err(e @ AcpError::Io(_))) - | Ok(Err(e @ AcpError::WriteTimeout(_))) - | Ok(Err(e @ AcpError::Timeout(_))) - | Ok(Err(e @ AcpError::Protocol(_))) - | Ok(Err(e @ AcpError::AgentExited)) => { - tracing::error!( - target: "pool::model", - "fatal error setting model {desired} via {method_label}: {e}" - ); - return Err(e); - } - // Application-level errors (Json, etc.) — agent is fine, just uses default model. - Ok(Err(e)) => { - tracing::warn!( - target: "pool::model", - "failed to set model {desired} via {method_label}: {e} — proceeding with agent default" - ); - } + Ok(response) => match classify_model_switch_response(response)? { + ModelSwitchApplication::Applied => { + tracing::info!( + target: "pool::model", + "applied model {desired} via {method_label} on session {session_id}" + ); + Ok(ModelSwitchApplication::Applied) + } + ModelSwitchApplication::Rejected { reason } => { + tracing::warn!( + target: "pool::model", + "failed to set model {desired} via {method_label}: {reason} — proceeding with agent default" + ); + Ok(ModelSwitchApplication::Rejected { reason }) + } + }, Err(_) => { // Outer timeout fired — the inner send_request may have left the // stream in an unknown state. Treat as transport error. tracing::error!( target: "pool::model", - "model set via {method_label} timed out ({MODEL_SWITCH_TIMEOUT:?}) — treating as fatal" + "model set via {method_label} timed out ({switch_timeout:?}) — treating as fatal" ); - return Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT)); + Err(AcpError::Timeout(switch_timeout)) } } - Ok(()) } /// Set the session permission mode via `session/set_config_option`. @@ -3716,6 +3800,128 @@ mod tests { // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. + #[test] + fn config_option_switch_gets_hydration_window_without_widening_set_model() { + let config_option = ModelSwitchMethod::ConfigOption { + config_id: "model".to_string(), + option_value: "provider-model".to_string(), + }; + let set_model = ModelSwitchMethod::SetModel { + model_id: "provider-model".to_string(), + }; + assert_eq!( + model_switch_timeout(&config_option), + CONFIG_OPTION_MODEL_SWITCH_TIMEOUT + ); + assert_eq!(model_switch_timeout(&set_model), MODEL_SWITCH_TIMEOUT); + } + + #[test] + fn cursor_id_selector_uses_config_option_hydration_window() { + let session_new = json!({ + "configOptions": [{ + "id": "model", + "category": "model", + "currentValue": "default[]", + "options": [{"value": "grok-4.5[effort=high,fast=true]"}] + }], + "models": { + "currentModelId": "default[]", + "availableModels": [{"modelId": "grok-4.5[effort=high,fast=true]"}] + } + }); + let method = resolve_model_switch_method(&session_new, "grok-4.5[effort=high,fast=true]") + .expect("Cursor's stable selector should resolve"); + assert!(matches!(method, ModelSwitchMethod::ConfigOption { .. })); + assert_eq!( + model_switch_timeout(&method), + CONFIG_OPTION_MODEL_SWITCH_TIMEOUT + ); + } + + #[test] + fn config_option_switch_updates_only_its_desktop_selector() { + let session_new = json!({ + "configOptions": [ + { + "configId": "model", + "category": "model", + "currentValue": "default[]" + }, + { + "configId": "secondary-model", + "category": "model", + "currentValue": "secondary-default" + } + ], + "models": { + "currentModelId": "default[]", + "availableModels": [{"modelId": "grok-4.5[effort=high,fast=true]"}] + } + }); + let desired = "grok-4.5[effort=high,fast=true]"; + let method = ModelSwitchMethod::ConfigOption { + config_id: "model".to_string(), + option_value: desired.to_string(), + }; + let (config_options, models) = capture_post_switch_config(&session_new, Some(&method)); + assert_eq!(config_options[0]["currentValue"], desired); + assert_eq!(config_options[1]["currentValue"], "secondary-default"); + assert_eq!(models, serde_json::Value::Null); + } + + #[test] + fn set_model_switch_updates_only_unstable_desktop_state() { + let session_new = json!({ + "configOptions": [{ + "configId": "model", + "category": "model", + "currentValue": "default[]" + }], + "models": { + "currentModelId": "default[]", + "availableModels": [{"modelId": "grok-4.5[effort=high,fast=true]"}] + } + }); + let desired = "grok-4.5[effort=high,fast=true]"; + let method = ModelSwitchMethod::SetModel { + model_id: desired.to_string(), + }; + let (config_options, models) = capture_post_switch_config(&session_new, Some(&method)); + assert_eq!(config_options, session_new["configOptions"]); + assert_eq!(models["currentModelId"], desired); + } + + #[test] + fn failed_model_switch_preserves_session_new_state() { + let session_new = json!({ + "configOptions": [{ + "id": "model", + "category": "model", + "currentValue": "default[]" + }], + "models": {"currentModelId": "default[]"} + }); + let (config_options, models) = capture_post_switch_config(&session_new, None); + assert_eq!(config_options, session_new["configOptions"]); + assert_eq!(models, session_new["models"]); + } + + #[test] + fn agent_model_switch_rejection_is_not_reported_as_applied() { + let result = classify_model_switch_response(Err(AcpError::AgentError { + code: -32602, + message: "model unavailable".to_string(), + })) + .expect("application rejection should leave the ACP connection usable"); + assert_eq!( + result, + ModelSwitchApplication::Rejected { + reason: "Agent reported error (code -32602): model unavailable".to_string(), + } + ); + } + #[test] fn test_initial_message_legacy_agent_gets_base_prepended() { // protocol_version 1 + Some(base_prompt): [Base] rides along in the diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea4..c9b9218cff 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -425,9 +425,9 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> continue; } - // Apply the same author gate as normal mode so the nudge only goes - // to authors the real agent would have answered. Same DM hardening: - // in DMs only owner/siblings get a nudge (fail-closed on unknown type). + // Apply the same author gate as normal mode so the nudge only goes to + // authors the real agent would have answered. DM handling also keeps + // strict-allowlist's sibling exclusion (fail-closed on unknown type). let author_hex = buzz_event.event.pubkey.to_hex(); let is_dm = crate::is_dm_channel(buzz_event.channel_id, &channel_info).await; let allowed = author_allowed( diff --git a/deploy/local/aeon-external-cli/README.md b/deploy/local/aeon-external-cli/README.md new file mode 100644 index 0000000000..3afc36a41d --- /dev/null +++ b/deploy/local/aeon-external-cli/README.md @@ -0,0 +1,251 @@ +# AEON external CLI workers + +This package renders separate disabled-by-default `buzz-acp` workers for the +external `codex_cli`, `claude_code`, `cursor_cli`, and `grok_cli` principals. The Claude +deploy selector, launchd label, and runtime namespace remain `claude_cli`; its +signed Buzz identity and Concilium seat remain the established `claude_code`. +The workers are separate from each other and from the six internal Aspect workers. Buzz +owns transport, presence, typing, queueing, thread context, signed replies, +observer events, `!cancel`, `!rotate`, and the managed Buzz CLI publisher. Each +ACP adapter owns coding tools in the selected workspace. + +Each worker accepts mentioned messages from Architect or any of the six +canonical Aspects in `#concilium` and their configured Aspect offices. The +workers also observe `#ops`; its narrower operational membership remains +unchanged. +External CLI seats cannot direct each other. Each worker starts one pinned ACP +process. + +The Codex worker starts `@agentclientprotocol/codex-acp@1.1.7` with +an isolated `CODEX_HOME` under the AEON Application Support runtime and +`INITIAL_AGENT_MODE=agent-full-access`. Buzz's permission mode stays `default`; +it does not attempt to translate `bypass-permissions` into a Codex mode. + +The Claude worker starts the maintained +`@agentclientprotocol/claude-agent-acp@0.62.0` adapter at source checkpoint +`53a0c36ce3b0b76929d11d8b9565e319da745608`. That adapter uses the official +Claude Agent SDK and the pinned installed Claude Code `2.1.220` executable. +Its adapter installation, runtime signer, subscription config, and logs live below +`/Users/architect/Library/Application Support/AEON/aeon-v6`, matching the +launchd-safe Data-volume layout used by the working Codex worker. Selected +workspace paths may remain below `/Volumes/AEON/Projects`. +Both services reuse the canonical shared +`/Users/architect/Library/Application Support/AEON/aeon-v6/bin/buzz-acp`; +neither worker installs or maintains a private harness executable. Both +manifests pin the release at SHA-256 +`1d260060a0b790645a0455d23c7a82ac7836193108673a76f44423c5d81be9be`. +The pinned harness supports +`--session-cwd` and requires the explicit `--agent-publisher-credentials` +grant, so the renderer cannot silently depend on legacy default forwarding. +The LaunchAgent PATH resolves the trusted +`/Users/architect/.nvm/versions/node/v24.1.0/bin/node` first. That runtime is +pinned at SHA-256 +`59450bb6448c8a40b3f3b86da45c3babb2e0503e04c47e5a715e8e137389878b`. +Runtime validation rejects symlinks, non-regular files, non-executable or +non-`0755` modes, hash drift, and version drift. The service does not use the +Data-volume Node copy, whose filesystem watcher cannot reliably watch the +selected workspace on `/Volumes`. +The Claude supervisor itself starts from the Data-volume runtime root, so +launchd and Node never resolve the process cwd through `/Volumes`. The selected +manifest workspace is passed separately as `--session-cwd`; `buzz-acp` +validates that explicit path is absolute and is an existing directory, then +uses it for ACP `session/new`. Workers that omit `--session-cwd` retain the +current process working directory. +Buzz requests ACP `bypassPermissions` through its `bypass-permissions` mode; +the adapter remains the owner of tool execution and permission enforcement. +Authentication reuses the standard Claude user login without setting +`CLAUDE_CONFIG_DIR`; relocating that directory would make Claude Code look for +credentials below the override instead of the standard `~/.claude.json`. No +API key or token is added to source, argv, or the plist. Runtime validation +rejects non-empty ambient `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` +credentials, strips both at the rendered `buzz-acp` process boundary with +`/usr/bin/env -u`, strips them again before invoking Claude Code during +validation, and requires +`authMethod=claude.ai`, `apiProvider=firstParty`, and `subscriptionType=pro` +from `claude auth status`. The adapter must not be launched with +`--hide-claude-auth`, because that mode rejects Claude subscription +authentication. + +The Cursor worker reuses the same renderer and starts the official +`/Users/architect/.local/bin/cursor-agent` at exact version +`2026.07.23-e383d2b` with native ACP arguments +`--trust acp`. It uses the same +rooms, bounded workspace selector, managed signer isolation, Data-volume +supervisor cwd, explicit `--session-cwd`, canonical Aspect inbound authority, +and `bypass-permissions` posture as the Claude worker. The manifest pins the +resolved launcher SHA-256 and a deterministic closure SHA-256 over the complete +installed Cursor version, excluding only its transient `.running` PID markers. +Runtime validation requires the existing authenticated Pro subscription and +scrubs `CURSOR_API_KEY` and `CURSOR_API_ENDPOINT` at the process boundary. + +The Grok worker uses the official Grok Build `0.2.93` native ACP server through +`grok agent stdio`, pinned to build `f00f96316d4b`, `grok-4.5`, high reasoning, +and the existing grok.com login. It shares Cursor's Data-volume supervisor and +workspace separation while retaining its own signer and signed Buzz identity. +Runtime validation pins the resolved binary digest, existing login, model +catalog, reasoning metadata, and `0600` auth file. API-key, auth-path, proxy, +OIDC, and alternate-home overrides are removed at the process boundary. + +The authenticated Cursor catalog exposes the user-facing alias +`cursor-grok-4.5-high`; Cursor ACP advertises its wire model as +`grok-4.5[effort=high,fast=true]`. The manifest records both and reports +`selectionStatus=upstream_limited_to_fast_wire_variant`; it does not claim that +Cursor ACP currently offers the requested non-fast variant. Buzz ACP applies the +advertised wire model to every new session. The +dedicated Grok CLI seat remains a separate Grok 4.5 High execution path. + +The manifest pins npm registry integrity and git-head provenance plus a +deterministic SHA-256 over the complete installed +`@agentclientprotocol/claude-agent-acp` package closure. Runtime validation +hashes sorted relative paths, file sizes, file bytes, and symlink targets, +including the adapter's nested `node_modules`. Changes to adapter siblings such +as `dist/acp-agent.js` or transitive dependency code therefore fail validation +rather than relying on the entrypoint hash alone. + +The harness reads each principal's signer from its own `0600` non-symlink file, +then forwards the validated identity and relay URL only to that managed +process. The key is never placed in prompt text, logs, argv, the manifest, or +the LaunchAgent plist. Neither worker impersonates Nexus. + +Validate and render without changing live state: + +```sh +node deploy/local/aeon-external-cli/validate.mjs +node deploy/local/aeon-external-cli/validate.mjs --worker claude_cli +node deploy/local/aeon-external-cli/validate.mjs --worker cursor_cli +node deploy/local/aeon-external-cli/validate.mjs --worker grok_cli +node deploy/local/aeon-external-cli/render-launchagent.mjs \ + --workspace aeon-v6 \ + --identity-map /Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json \ + > /tmp/org.aeon.buzz-acp.codex-cli.plist +node deploy/local/aeon-external-cli/render-launchagent.mjs \ + --worker claude_cli \ + --workspace aeon-v6 \ + --identity-map /Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json \ + > /tmp/org.aeon.buzz-acp.claude-cli.plist +node deploy/local/aeon-external-cli/render-launchagent.mjs \ + --worker cursor_cli \ + --workspace aeon-v6 \ + --identity-map /Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json \ + > /tmp/org.aeon.buzz-acp.cursor-cli.plist +node deploy/local/aeon-external-cli/render-launchagent.mjs \ + --worker grok_cli \ + --workspace aeon-v6 \ + --identity-map /Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json \ + > /tmp/org.aeon.buzz-acp.grok-cli.plist +``` + +Install the pinned adapter into the exact manifest path: + +```sh +npm install \ + --prefix '/Users/architect/Library/Application Support/AEON/aeon-v6/codex-acp/1.1.7' \ + --save-exact \ + --ignore-scripts --no-audit --no-fund \ + @agentclientprotocol/codex-acp@1.1.7 +``` + +Install the pinned Claude adapter into its exact manifest path: + +```sh +npm install \ + --prefix '/Users/architect/Library/Application Support/AEON/aeon-v6/claude-acp/0.62.0' \ + --save-exact --ignore-scripts --no-audit --no-fund \ + @agentclientprotocol/claude-agent-acp@0.62.0 +``` + +Build and install the one shared `buzz-acp` release at the path pinned by both +manifests before either runtime check. Install the checked-in subscription +config at its exact path: + +```sh +cargo build --release -p buzz-acp +install -d -m 0755 \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/bin' \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/buzz' \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/logs' +install -m 0500 \ + target/release/buzz-acp \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/bin/buzz-acp' +install -d -m 0700 \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/secrets' \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/codex-home' +install -m 0600 \ + /Users/architect/.codex/auth.json \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/codex-home/auth.json' +install -m 0600 \ + /Users/architect/.codex/config.toml \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/codex-home/config.toml' +install -m 0444 \ + deploy/local/aeon-external-cli/config/codex_cli.toml \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/codex-cli.toml' +install -m 0444 \ + deploy/local/aeon-external-cli/config/codex_cli_system.md \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/codex-cli-system.md' +install -m 0600 \ + /Volumes/AEON/Projects/buzz-data/keys/codex_cli.sk \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/secrets/codex-cli.sk' +install -m 0444 \ + deploy/local/aeon-external-cli/config/claude_cli.toml \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/claude-cli.toml' +install -m 0600 \ + /Volumes/AEON/Projects/buzz-data/keys/claude_code.sk \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/secrets/claude-code.sk' +install -m 0444 \ + deploy/local/aeon-external-cli/config/cursor_cli.toml \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/cursor-cli.toml' +install -m 0444 \ + deploy/local/aeon-external-cli/config/cursor_acp_bootstrap.cjs \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/cursor-acp-bootstrap.cjs' +install -m 0444 \ + deploy/local/aeon-external-cli/config/cursor_cli_system.md \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/cursor-cli-system.md' +install -m 0600 \ + /Volumes/AEON/Projects/buzz-data/keys/cursor_cli.sk \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/secrets/cursor-cli.sk' +install -m 0444 \ + deploy/local/aeon-external-cli/config/grok_cli.toml \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/grok-cli.toml' +install -m 0600 \ + /Volumes/AEON/Projects/buzz-data/keys/grok_cli.sk \ + '/Users/architect/Library/Application Support/AEON/aeon-v6/secrets/grok-cli.sk' +node deploy/local/aeon-external-cli/validate.mjs \ + /Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json \ + --runtime +node deploy/local/aeon-external-cli/validate.mjs \ + /Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json \ + --worker claude_cli \ + --runtime +node deploy/local/aeon-external-cli/validate.mjs \ + /Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json \ + --worker cursor_cli \ + --runtime +node deploy/local/aeon-external-cli/validate.mjs \ + /Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json \ + --worker grok_cli \ + --runtime +``` + +The signer copy command emits no key material. Canonical identity-map +membership and `secret_ref` remain unchanged; runtime validation passes the +Data-volume copy and canonical `claude_code` pubkey through the shared +`buzz-acp` safe signer reader. That reader opens with no symlink following, +requires a current-user-owned regular file with exact mode `0600`, and rejects +any key that does not derive the canonical pubkey. + +Activation is intentionally absent: the generated plist has +`RunAtLoad=false` and `KeepAlive=false`. A later operator action must install +and bootstrap that plist. + +The supported workspace selector is a manifest key, never an arbitrary path: + +```sh +node deploy/local/aeon-external-cli/render-launchagent.mjs --workspace buzz +node deploy/local/aeon-external-cli/render-launchagent.mjs --workspace codex +node deploy/local/aeon-external-cli/render-launchagent.mjs --worker claude_cli --workspace buzz +node deploy/local/aeon-external-cli/render-launchagent.mjs --worker claude_cli --workspace codex +node deploy/local/aeon-external-cli/render-launchagent.mjs --worker cursor_cli --workspace buzz +node deploy/local/aeon-external-cli/render-launchagent.mjs --worker cursor_cli --workspace codex +node deploy/local/aeon-external-cli/render-launchagent.mjs --worker grok_cli --workspace buzz +node deploy/local/aeon-external-cli/render-launchagent.mjs --worker grok_cli --workspace codex +``` diff --git a/deploy/local/aeon-external-cli/config/claude_cli.toml b/deploy/local/aeon-external-cli/config/claude_cli.toml new file mode 100644 index 0000000000..005fef53ff --- /dev/null +++ b/deploy/local/aeon-external-cli/config/claude_cli.toml @@ -0,0 +1,21 @@ +[[rules]] +name = "aeon-shared-control" +channels = [ + "3dd32f72-4272-4195-ad63-c31e6167ac55", + "4ac1cee0-2238-483f-b25b-f99ec17eec46", +] +kinds = [9, 40002] +require_mention = true + +[[rules]] +name = "aeon-aspect-offices" +channels = [ + "7e8c4840-d401-4701-afc9-7ae7174cfc4e", + "7d022bea-5e81-4d18-bd2e-176e9eda1971", + "600cb602-ff2a-422b-bdb2-6353bb81100d", + "4e9f115c-cf8d-4abe-a085-c2b2c64ef2c2", + "39560f3c-f638-41a1-945b-ed5400ec41fc", + "d00b15e9-0dbe-4f35-895e-b6d36faa299e", +] +kinds = [9, 40002] +require_mention = true diff --git a/deploy/local/aeon-external-cli/config/codex_cli.toml b/deploy/local/aeon-external-cli/config/codex_cli.toml new file mode 100644 index 0000000000..005fef53ff --- /dev/null +++ b/deploy/local/aeon-external-cli/config/codex_cli.toml @@ -0,0 +1,21 @@ +[[rules]] +name = "aeon-shared-control" +channels = [ + "3dd32f72-4272-4195-ad63-c31e6167ac55", + "4ac1cee0-2238-483f-b25b-f99ec17eec46", +] +kinds = [9, 40002] +require_mention = true + +[[rules]] +name = "aeon-aspect-offices" +channels = [ + "7e8c4840-d401-4701-afc9-7ae7174cfc4e", + "7d022bea-5e81-4d18-bd2e-176e9eda1971", + "600cb602-ff2a-422b-bdb2-6353bb81100d", + "4e9f115c-cf8d-4abe-a085-c2b2c64ef2c2", + "39560f3c-f638-41a1-945b-ed5400ec41fc", + "d00b15e9-0dbe-4f35-895e-b6d36faa299e", +] +kinds = [9, 40002] +require_mention = true diff --git a/deploy/local/aeon-external-cli/config/codex_cli_system.md b/deploy/local/aeon-external-cli/config/codex_cli_system.md new file mode 100644 index 0000000000..fd99957dd5 --- /dev/null +++ b/deploy/local/aeon-external-cli/config/codex_cli_system.md @@ -0,0 +1,19 @@ +You are codex_cli, AEON's external coding executor. + +Architect instructions are authoritative. Nexus coordinates work and owns outcomes. +Mechanon may direct coding work and supply control-plane evidence; do not impersonate +Nexus, Mechanon, or any other Aspect. + +Use Codex tools for repository work: inspect source, edit files, run focused tests, +review changes, and use git. The primary workspace is the selected AEON checkout. +Keep OpenClaw and upstream product boundaries intact. + +Buzz is the collaboration surface. You have your own signed Buzz identity and may use +the full `buzz` CLI: messages and threads, reactions, canvas, notes, repos, patches, +issues, pull requests, media, workflows, feed, and `mem`. Run `buzz --help` or a +subcommand's help when needed. Publish useful results back to the triggering Buzz +room with exact files, commit IDs, and test evidence. Use the reply destination in +the current Buzz context unless the Architect asks for a root post. + +Shared rooms require an explicit mention. Do not invent background work or create +new plans when a direct implementation or repair will produce the requested outcome. diff --git a/deploy/local/aeon-external-cli/config/cursor_acp_bootstrap.cjs b/deploy/local/aeon-external-cli/config/cursor_acp_bootstrap.cjs new file mode 100644 index 0000000000..73a13e4dfa --- /dev/null +++ b/deploy/local/aeon-external-cli/config/cursor_acp_bootstrap.cjs @@ -0,0 +1,10 @@ +const [, , workspace, entrypoint, ...args] = process.argv; + +if (!workspace?.startsWith("/") || !entrypoint?.startsWith("/")) { + throw new Error("Cursor ACP bootstrap requires absolute workspace and entrypoint paths"); +} + +process.chdir(workspace); +process.cwd = () => workspace; +process.argv = [process.argv[0], entrypoint, ...args]; +require(entrypoint); diff --git a/deploy/local/aeon-external-cli/config/cursor_cli.toml b/deploy/local/aeon-external-cli/config/cursor_cli.toml new file mode 100644 index 0000000000..005fef53ff --- /dev/null +++ b/deploy/local/aeon-external-cli/config/cursor_cli.toml @@ -0,0 +1,21 @@ +[[rules]] +name = "aeon-shared-control" +channels = [ + "3dd32f72-4272-4195-ad63-c31e6167ac55", + "4ac1cee0-2238-483f-b25b-f99ec17eec46", +] +kinds = [9, 40002] +require_mention = true + +[[rules]] +name = "aeon-aspect-offices" +channels = [ + "7e8c4840-d401-4701-afc9-7ae7174cfc4e", + "7d022bea-5e81-4d18-bd2e-176e9eda1971", + "600cb602-ff2a-422b-bdb2-6353bb81100d", + "4e9f115c-cf8d-4abe-a085-c2b2c64ef2c2", + "39560f3c-f638-41a1-945b-ed5400ec41fc", + "d00b15e9-0dbe-4f35-895e-b6d36faa299e", +] +kinds = [9, 40002] +require_mention = true diff --git a/deploy/local/aeon-external-cli/config/cursor_cli_system.md b/deploy/local/aeon-external-cli/config/cursor_cli_system.md new file mode 100644 index 0000000000..7169a8317b --- /dev/null +++ b/deploy/local/aeon-external-cli/config/cursor_cli_system.md @@ -0,0 +1,14 @@ +You are Cursor CLI, an external AEON coding seat operating through Buzz. + +Architect instructions are authoritative. Nexus coordinates outcomes. Mechanon and +the other canonical Aspects may direct repository work from their Buzz offices. +Do not impersonate them or another CLI seat. + +Use Cursor's agent tools to inspect, edit, test, and verify the selected workspace. +Complete the concrete request in the current Buzz event. When work is useful, publish +the result to the triggering room with `buzz messages send`, using the exact channel +and reply destination supplied in `[Context]`. The managed process already owns its +Buzz credentials; never print them. + +Keep responses concise and include exact paths, hashes, commits, and test evidence +when relevant. Prefer a direct implementation over new planning or framework work. diff --git a/deploy/local/aeon-external-cli/config/grok_cli.toml b/deploy/local/aeon-external-cli/config/grok_cli.toml new file mode 100644 index 0000000000..005fef53ff --- /dev/null +++ b/deploy/local/aeon-external-cli/config/grok_cli.toml @@ -0,0 +1,21 @@ +[[rules]] +name = "aeon-shared-control" +channels = [ + "3dd32f72-4272-4195-ad63-c31e6167ac55", + "4ac1cee0-2238-483f-b25b-f99ec17eec46", +] +kinds = [9, 40002] +require_mention = true + +[[rules]] +name = "aeon-aspect-offices" +channels = [ + "7e8c4840-d401-4701-afc9-7ae7174cfc4e", + "7d022bea-5e81-4d18-bd2e-176e9eda1971", + "600cb602-ff2a-422b-bdb2-6353bb81100d", + "4e9f115c-cf8d-4abe-a085-c2b2c64ef2c2", + "39560f3c-f638-41a1-945b-ed5400ec41fc", + "d00b15e9-0dbe-4f35-895e-b6d36faa299e", +] +kinds = [9, 40002] +require_mention = true diff --git a/deploy/local/aeon-external-cli/fixtures/identity-map.json b/deploy/local/aeon-external-cli/fixtures/identity-map.json new file mode 100644 index 0000000000..147c612994 --- /dev/null +++ b/deploy/local/aeon-external-cli/fixtures/identity-map.json @@ -0,0 +1,147 @@ +{ + "members": { + "architect": { + "display_name": "Architect", + "gateway_agent_id": null, + "aspect_slug": null, + "pubkey_hex": "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/architect.sk" + }, + "nexus": { + "display_name": "Nexus", + "gateway_agent_id": "main", + "aspect_slug": "nexus", + "pubkey_hex": "ca2bce90e858e12f399f7b4cd510b5fce74e4238352229ada47b8bdd1a7f3b97", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/nexus.sk" + }, + "mechanon": { + "display_name": "Mechanon", + "gateway_agent_id": "mechanon", + "aspect_slug": "mechanon", + "pubkey_hex": "44437bd6007641845f154bdb7698b1627745b0a1b1d9bdfcf4826fe82a37ca9d", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/mechanon.sk" + }, + "fontis": { + "display_name": "Fontis", + "gateway_agent_id": "fontis", + "aspect_slug": "fontis", + "pubkey_hex": "3672ca65abf6b12effaf57475ce31260a4bc96e6d3acf01b909f9c2a44542147", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/fontis.sk" + }, + "sapientis": { + "display_name": "Sapientis", + "gateway_agent_id": "sapientis", + "aspect_slug": "sapientis", + "pubkey_hex": "1b7ddc51e6e7d688cd1816047b3ec35e7460014bf146813a505fee851e8b0d89", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/sapientis.sk" + }, + "viatica": { + "display_name": "Viatica", + "gateway_agent_id": "viatica", + "aspect_slug": "viatica", + "pubkey_hex": "89a2f2679f83cd9033582f023a3b92e01ed2b5950587efae565045ae65bd03cc", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/viatica.sk" + }, + "voxis": { + "display_name": "Voxis", + "gateway_agent_id": "voxis", + "aspect_slug": "voxis", + "pubkey_hex": "e2b124c4728989a09d0f7df96cd2b1823635cc220ed9cdd4f97eaedc12a17fe4", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/voxis.sk" + }, + "codex_cli": { + "display_name": "Codex CLI", + "gateway_agent_id": null, + "aspect_slug": null, + "concilium_seat": "codex_cli", + "pubkey_hex": "7924cc1dd5389c567ea4ad2b3013b71df28c7b856247365efcccbc7763bfdb7f", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/codex_cli.sk" + }, + "claude_code": { + "display_name": "Claude Code", + "gateway_agent_id": null, + "aspect_slug": null, + "concilium_seat": "claude_code", + "pubkey_hex": "13f60f1a6b19e9f2472f16fd9d4cab9de7327cb12146d92282c34ad11b6e0a91", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/claude_code.sk" + }, + "cursor_cli": { + "display_name": "Cursor CLI", + "gateway_agent_id": null, + "aspect_slug": null, + "concilium_seat": "cursor_cli", + "pubkey_hex": "aaa16ded647fb6452166462aec5bc414116a519ac52d926f4d52cbb58dc3077f", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/cursor_cli.sk" + }, + "grok_cli": { + "display_name": "Grok CLI", + "gateway_agent_id": null, + "aspect_slug": null, + "concilium_seat": "grok_cli", + "pubkey_hex": "b95398604cefa35a0da4568961c296b908806d53a3caf0f07f2b3a93ae3bc087", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/grok_cli.sk" + } + }, + "channels": { + "ops": { + "name": "ops", + "channel_id": "3dd32f72-4272-4195-ad63-c31e6167ac55", + "members": [ + "architect", + "nexus", + "mechanon", + "codex_cli", + "claude_code", + "cursor_cli", + "grok_cli" + ] + }, + "concilium": { + "name": "concilium", + "channel_id": "4ac1cee0-2238-483f-b25b-f99ec17eec46", + "members": [ + "architect", + "nexus", + "mechanon", + "fontis", + "sapientis", + "viatica", + "voxis", + "codex_cli", + "claude_code", + "cursor_cli", + "grok_cli" + ] + }, + "aspect_nexus": { + "name": "aspect-nexus", + "channel_id": "7e8c4840-d401-4701-afc9-7ae7174cfc4e", + "members": ["architect", "nexus", "codex_cli", "claude_code", "cursor_cli", "grok_cli"] + }, + "aspect_mechanon": { + "name": "aspect-mechanon", + "channel_id": "7d022bea-5e81-4d18-bd2e-176e9eda1971", + "members": ["architect", "mechanon", "codex_cli", "claude_code", "cursor_cli", "grok_cli"] + }, + "aspect_fontis": { + "name": "aspect-fontis", + "channel_id": "600cb602-ff2a-422b-bdb2-6353bb81100d", + "members": ["architect", "fontis", "codex_cli", "claude_code", "cursor_cli", "grok_cli"] + }, + "aspect_sapientis": { + "name": "aspect-sapientis", + "channel_id": "4e9f115c-cf8d-4abe-a085-c2b2c64ef2c2", + "members": ["architect", "sapientis", "codex_cli", "claude_code", "cursor_cli", "grok_cli"] + }, + "aspect_viatica": { + "name": "aspect-viatica", + "channel_id": "39560f3c-f638-41a1-945b-ed5400ec41fc", + "members": ["architect", "viatica", "codex_cli", "claude_code", "cursor_cli", "grok_cli"] + }, + "aspect_voxis": { + "name": "aspect-voxis", + "channel_id": "d00b15e9-0dbe-4f35-895e-b6d36faa299e", + "members": ["architect", "voxis", "codex_cli", "claude_code", "cursor_cli", "grok_cli"] + } + } +} diff --git a/deploy/local/aeon-external-cli/manifest.claude_cli.json b/deploy/local/aeon-external-cli/manifest.claude_cli.json new file mode 100644 index 0000000000..12da56f5b7 --- /dev/null +++ b/deploy/local/aeon-external-cli/manifest.claude_cli.json @@ -0,0 +1,110 @@ +{ + "schema": "aeon_buzz_external_cli_worker_v1", + "enabled": false, + "identityMap": "/Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json", + "worker": { + "selector": "claude_cli", + "principal": "claude_code", + "displayName": "Claude Code", + "label": "org.aeon.buzz-acp.claude-cli", + "agents": 1 + }, + "buzz": { + "relayUrl": "ws://localhost:3000", + "owner": "architect", + "allowedInbound": [ + "architect", + "nexus", + "mechanon", + "fontis", + "sapientis", + "viatica", + "voxis" + ], + "sharedRooms": ["ops", "concilium"], + "officeRooms": [ + "aspect_nexus", + "aspect_mechanon", + "aspect_fontis", + "aspect_sapientis", + "aspect_viatica", + "aspect_voxis" + ] + }, + "runtime": { + "buzzAcpBinary": "/Users/architect/Library/Application Support/AEON/aeon-v6/bin/buzz-acp", + "buzzAcpSha256": "1d260060a0b790645a0455d23c7a82ac7836193108673a76f44423c5d81be9be", + "configPath": "/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/claude-cli.toml", + "logDir": "/Users/architect/Library/Application Support/AEON/aeon-v6/logs", + "signerPath": "/Users/architect/Library/Application Support/AEON/aeon-v6/secrets/claude-code.sk", + "supervisorWorkingDirectory": "/Users/architect/Library/Application Support/AEON/aeon-v6", + "node": { + "version": "v24.1.0", + "sha256": "59450bb6448c8a40b3f3b86da45c3babb2e0503e04c47e5a715e8e137389878b", + "mode": "0755", + "binary": "/Users/architect/.nvm/versions/node/v24.1.0/bin/node" + }, + "claudeAcp": { + "package": "@agentclientprotocol/claude-agent-acp", + "version": "0.62.0", + "integrity": "sha512-8QRNmyk5Cfy4XVREeg5KCPoCDtmYS0xALY9WqI640PfopLMpeUzMByXbzLkBLbD819zB67DBhLG5ta98uOEPKg==", + "gitHead": "53a0c36ce3b0b76929d11d8b9565e319da745608", + "entrypointSha256": "260aac90bf75f197b93640087c1de66441761d43c2784efa035fdcee60b5dacd", + "closureSha256": "ba5650a750d25811f36f4e6e91ad079d700743ddfb4f52abb90d46c9e9d86002", + "root": "/Users/architect/Library/Application Support/AEON/aeon-v6/claude-acp/0.62.0", + "binary": "/Users/architect/Library/Application Support/AEON/aeon-v6/claude-acp/0.62.0/node_modules/.bin/claude-agent-acp" + }, + "claudeCode": { + "version": "2.1.220", + "binary": "/Users/architect/.local/share/claude/versions/2.1.220", + "binarySha256": "8addc857f3fe64d5a0368af9ee50321b50afb4a6918ba3ef018ab84f5dbbe081", + "auth": { + "mode": "existing-claude-subscription", + "authMethod": "claude.ai", + "provider": "firstParty", + "subscriptionTypes": ["pro"] + } + }, + "path": [ + "/Users/architect/.nvm/versions/node/v24.1.0/bin", + "/Users/architect/Library/Application Support/AEON/aeon-v6/claude-acp/0.62.0/node_modules/.bin", + "/Users/architect/.local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin" + ] + }, + "workspaces": { + "default": "aeon-v6", + "allowed": { + "aeon-v6": "/Volumes/AEON/Projects/aeon-v6", + "buzz": "/Volumes/AEON/Projects/buzz", + "codex": "/Volumes/AEON/Projects/codex" + } + }, + "posture": { + "subscribe": "config", + "respondTo": "strict-allowlist", + "allowedRespondTo": ["strict-allowlist"], + "dedup": "queue", + "multipleEventHandling": "queue", + "presence": true, + "typing": true, + "memory": true, + "basePrompt": true, + "relayObserver": true, + "permissionMode": "bypass-permissions", + "heartbeatIntervalSecs": 0, + "turnLivenessSecs": 10, + "idleTimeoutSecs": 900, + "maxTurnDurationSecs": 7200, + "contextMessageLimit": 24, + "maxTurnsPerSession": 0 + }, + "supervisor": { + "runAtLoad": false, + "keepAlive": false, + "throttleSeconds": 10 + } +} diff --git a/deploy/local/aeon-external-cli/manifest.cursor_cli.json b/deploy/local/aeon-external-cli/manifest.cursor_cli.json new file mode 100644 index 0000000000..5ff6a50362 --- /dev/null +++ b/deploy/local/aeon-external-cli/manifest.cursor_cli.json @@ -0,0 +1,107 @@ +{ + "schema": "aeon_buzz_external_cli_worker_v1", + "enabled": false, + "identityMap": "/Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json", + "worker": { + "selector": "cursor_cli", + "principal": "cursor_cli", + "displayName": "Cursor CLI", + "label": "org.aeon.buzz-acp.cursor-cli", + "agents": 1 + }, + "buzz": { + "relayUrl": "ws://localhost:3000", + "owner": "architect", + "allowedInbound": [ + "architect", + "nexus", + "mechanon", + "fontis", + "sapientis", + "viatica", + "voxis" + ], + "sharedRooms": ["ops", "concilium"], + "officeRooms": [ + "aspect_nexus", + "aspect_mechanon", + "aspect_fontis", + "aspect_sapientis", + "aspect_viatica", + "aspect_voxis" + ] + }, + "runtime": { + "buzzAcpBinary": "/Users/architect/Library/Application Support/AEON/aeon-v6/bin/buzz-acp", + "buzzAcpSha256": "1d260060a0b790645a0455d23c7a82ac7836193108673a76f44423c5d81be9be", + "configPath": "/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/cursor-cli.toml", + "bootstrapPath": "/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/cursor-acp-bootstrap.cjs", + "bootstrapSha256": "b3f4e90e675bd0e8f0827b618203c33b9904cbd01becd2b80fc868d75b8797e8", + "systemPromptPath": "/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/cursor-cli-system.md", + "systemPromptSha256": "9cd520bc01584eb8e7eefa9e132a7b98c40766e6b978af5c629ebc8e3e091482", + "logDir": "/Users/architect/Library/Application Support/AEON/aeon-v6/logs", + "signerPath": "/Users/architect/Library/Application Support/AEON/aeon-v6/secrets/cursor-cli.sk", + "supervisorWorkingDirectory": "/Users/architect/Library/Application Support/AEON/aeon-v6", + "cursorAcp": { + "package": "cursor-agent", + "version": "2026.07.23-e383d2b", + "binary": "/Users/architect/.local/bin/cursor-agent", + "root": "/Users/architect/.local/share/cursor-agent/versions/2026.07.23-e383d2b", + "entrypointSha256": "eed61c5224668c9236334c4c68936a16aecc37374b592f59e31eb50433817831", + "closureSha256": "400227a16df5e9f7bb4273f176cf68e41ef499f06fac5e6c9c6c3556ab2cc726", + "args": [ + "--trust", + "acp" + ], + "auth": { + "mode": "existing-cursor-subscription", + "status": "authenticated", + "subscriptionTypes": ["Pro"] + }, + "model": { + "requested": "cursor-grok-4.5-high", + "effective": "grok-4.5[effort=high,fast=true]", + "selectionStatus": "upstream_limited_to_fast_wire_variant" + } + }, + "path": [ + "/Users/architect/.local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin" + ] + }, + "workspaces": { + "default": "aeon-v6", + "allowed": { + "aeon-v6": "/Volumes/AEON/Projects/aeon-v6", + "buzz": "/Volumes/AEON/Projects/buzz", + "codex": "/Volumes/AEON/Projects/codex" + } + }, + "posture": { + "subscribe": "config", + "respondTo": "strict-allowlist", + "allowedRespondTo": ["strict-allowlist"], + "dedup": "queue", + "multipleEventHandling": "queue", + "presence": true, + "typing": true, + "memory": false, + "basePrompt": false, + "relayObserver": true, + "permissionMode": "bypass-permissions", + "heartbeatIntervalSecs": 0, + "turnLivenessSecs": 10, + "idleTimeoutSecs": 900, + "maxTurnDurationSecs": 7200, + "contextMessageLimit": 0, + "maxTurnsPerSession": 0 + }, + "supervisor": { + "runAtLoad": false, + "keepAlive": false, + "throttleSeconds": 10 + } +} diff --git a/deploy/local/aeon-external-cli/manifest.grok_cli.json b/deploy/local/aeon-external-cli/manifest.grok_cli.json new file mode 100644 index 0000000000..fb6395cc52 --- /dev/null +++ b/deploy/local/aeon-external-cli/manifest.grok_cli.json @@ -0,0 +1,109 @@ +{ + "schema": "aeon_buzz_external_cli_worker_v1", + "enabled": false, + "identityMap": "/Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json", + "worker": { + "selector": "grok_cli", + "principal": "grok_cli", + "displayName": "Grok CLI", + "label": "org.aeon.buzz-acp.grok-cli", + "agents": 1 + }, + "buzz": { + "relayUrl": "ws://localhost:3000", + "owner": "architect", + "allowedInbound": [ + "architect", + "nexus", + "mechanon", + "fontis", + "sapientis", + "viatica", + "voxis" + ], + "sharedRooms": ["ops", "concilium"], + "officeRooms": [ + "aspect_nexus", + "aspect_mechanon", + "aspect_fontis", + "aspect_sapientis", + "aspect_viatica", + "aspect_voxis" + ] + }, + "runtime": { + "buzzAcpBinary": "/Users/architect/Library/Application Support/AEON/aeon-v6/bin/buzz-acp", + "buzzAcpSha256": "1d260060a0b790645a0455d23c7a82ac7836193108673a76f44423c5d81be9be", + "configPath": "/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/grok-cli.toml", + "logDir": "/Users/architect/Library/Application Support/AEON/aeon-v6/logs", + "signerPath": "/Users/architect/Library/Application Support/AEON/aeon-v6/secrets/grok-cli.sk", + "supervisorWorkingDirectory": "/Users/architect/Library/Application Support/AEON/aeon-v6", + "grokAcp": { + "package": "grok-build", + "version": "0.2.93", + "build": "f00f96316d4b", + "binary": "/Users/architect/.grok/bin/grok", + "realBinary": "/Users/architect/.grok/downloads/grok-0.2.93-macos-aarch64", + "entrypointSha256": "2a97ba675bd992aa9b981e2e83776460d94f469b510c0b8efe28b50d236d767c", + "args": [ + "agent", + "--model", + "grok-4.5", + "--reasoning-effort", + "high", + "--always-approve", + "stdio" + ], + "auth": { + "mode": "existing-grok-login", + "provider": "grok.com", + "authFile": "/Users/architect/.grok/auth.json" + }, + "model": { + "requested": "grok-4.5-high", + "effective": "grok-4.5", + "reasoningEffort": "high" + } + }, + "path": [ + "/Users/architect/.grok/bin", + "/Users/architect/.local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin" + ] + }, + "workspaces": { + "default": "aeon-v6", + "allowed": { + "aeon-v6": "/Volumes/AEON/Projects/aeon-v6", + "buzz": "/Volumes/AEON/Projects/buzz", + "codex": "/Volumes/AEON/Projects/codex" + } + }, + "posture": { + "subscribe": "config", + "respondTo": "strict-allowlist", + "allowedRespondTo": ["strict-allowlist"], + "dedup": "queue", + "multipleEventHandling": "queue", + "presence": true, + "typing": true, + "memory": true, + "basePrompt": true, + "relayObserver": true, + "permissionMode": "bypass-permissions", + "heartbeatIntervalSecs": 0, + "turnLivenessSecs": 10, + "idleTimeoutSecs": 900, + "maxTurnDurationSecs": 7200, + "contextMessageLimit": 24, + "maxTurnsPerSession": 0 + }, + "supervisor": { + "runAtLoad": false, + "keepAlive": false, + "throttleSeconds": 10 + } +} diff --git a/deploy/local/aeon-external-cli/manifest.json b/deploy/local/aeon-external-cli/manifest.json new file mode 100644 index 0000000000..0651a2093d --- /dev/null +++ b/deploy/local/aeon-external-cli/manifest.json @@ -0,0 +1,95 @@ +{ + "schema": "aeon_buzz_external_cli_worker_v1", + "enabled": false, + "identityMap": "/Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json", + "worker": { + "principal": "codex_cli", + "displayName": "Codex CLI", + "label": "org.aeon.buzz-acp.codex-cli", + "agents": 1 + }, + "buzz": { + "relayUrl": "ws://localhost:3000", + "owner": "architect", + "allowedInbound": [ + "architect", + "nexus", + "mechanon", + "fontis", + "sapientis", + "viatica", + "voxis" + ], + "sharedRooms": ["ops", "concilium"], + "officeRooms": [ + "aspect_nexus", + "aspect_mechanon", + "aspect_fontis", + "aspect_sapientis", + "aspect_viatica", + "aspect_voxis" + ] + }, + "runtime": { + "buzzAcpBinary": "/Users/architect/Library/Application Support/AEON/aeon-v6/bin/buzz-acp", + "buzzAcpSha256": "1d260060a0b790645a0455d23c7a82ac7836193108673a76f44423c5d81be9be", + "configPath": "/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/codex-cli.toml", + "logDir": "/Users/architect/Library/Application Support/AEON/aeon-v6/logs", + "signerPath": "/Users/architect/Library/Application Support/AEON/aeon-v6/secrets/codex-cli.sk", + "systemPromptPath": "/Users/architect/Library/Application Support/AEON/aeon-v6/buzz/codex-cli-system.md", + "systemPromptSha256": "77be0827d5fc5292b649659b547664659ca0f6ebb2cd2ee47bfb7718ec8fb1ed", + "supervisorWorkingDirectory": "/Users/architect/Library/Application Support/AEON/aeon-v6", + "codexAcp": { + "package": "@agentclientprotocol/codex-acp", + "version": "1.1.7", + "integrity": "sha512-bhFLbGtOMEw6+PAp33vNERb6dXlULOfV3mWbRdps4v7sY7PHha/C2T1dnlG0yVcvBu9W+NYPzL0CAupnVoFTiQ==", + "entrypointSha256": "0deb6b820dfed8804cd76b16a50210fe12202e5e339b5edaa23f6987f1742e0a", + "binary": "/Users/architect/Library/Application Support/AEON/aeon-v6/codex-acp/1.1.7/node_modules/.bin/codex-acp", + "model": "gpt-5.6-sol[medium]" + }, + "codexHome": "/Users/architect/Library/Application Support/AEON/aeon-v6/codex-home", + "initialAgentMode": "agent-full-access", + "path": [ + "/Users/architect/.nvm/versions/node/v24.1.0/bin", + "/Users/architect/Library/Application Support/AEON/aeon-v6/codex-acp/1.1.7/node_modules/.bin", + "/Volumes/AEON/Projects/buzz/target/release", + "/Volumes/AEON/runtime/aeon-v6-state/service-runtime/current/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin" + ] + }, + "workspaces": { + "default": "aeon-v6", + "allowed": { + "aeon-v6": "/Volumes/AEON/Projects/aeon-v6", + "buzz": "/Volumes/AEON/Projects/buzz", + "codex": "/Volumes/AEON/Projects/codex" + } + }, + "posture": { + "subscribe": "config", + "respondTo": "strict-allowlist", + "allowedRespondTo": ["strict-allowlist"], + "dedup": "queue", + "multipleEventHandling": "queue", + "presence": true, + "typing": true, + "memory": true, + "basePrompt": true, + "relayObserver": true, + "permissionMode": "default", + "heartbeatIntervalSecs": 0, + "turnLivenessSecs": 10, + "idleTimeoutSecs": 900, + "maxTurnDurationSecs": 7200, + "contextMessageLimit": 24, + "maxTurnsPerSession": 0 + }, + "supervisor": { + "runAtLoad": false, + "keepAlive": false, + "throttleSeconds": 10 + } +} diff --git a/deploy/local/aeon-external-cli/render-launchagent.mjs b/deploy/local/aeon-external-cli/render-launchagent.mjs new file mode 100644 index 0000000000..ab7879b7c4 --- /dev/null +++ b/deploy/local/aeon-external-cli/render-launchagent.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + loadJson, + renderDisabledLaunchAgent, + validateManifest, + validateSubscriptionProjection, +} from "./worker.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +function option(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +const workspace = option("--workspace"); +const identityPath = option("--identity-map") ?? join(here, "fixtures", "identity-map.json"); +const worker = option("--worker") ?? "codex_cli"; +const manifestName = worker === "codex_cli" ? "manifest.json" : `manifest.${worker}.json`; +if (!["codex_cli", "claude_cli", "cursor_cli", "grok_cli"].includes(worker)) { + console.error(`unsupported external CLI worker: ${worker}`); + process.exit(1); +} +const manifest = loadJson(join(here, manifestName)); +const identityMap = loadJson(identityPath); +const validation = validateManifest(manifest, identityMap); +if (!validation.ok) { + console.error(validation.errors.join("\n")); + process.exit(1); +} +const selector = manifest.worker.selector ?? manifest.worker.principal; +const configText = fs.readFileSync(join(here, "config", `${selector}.toml`), "utf8"); +const subscriptionValidation = validateSubscriptionProjection(configText, manifest, identityMap); +if (!subscriptionValidation.ok) { + console.error(subscriptionValidation.errors.join("\n")); + process.exit(1); +} + +process.stdout.write(renderDisabledLaunchAgent(manifest, identityMap, workspace).plist); diff --git a/deploy/local/aeon-external-cli/validate.mjs b/deploy/local/aeon-external-cli/validate.mjs new file mode 100644 index 0000000000..79d47c4fe4 --- /dev/null +++ b/deploy/local/aeon-external-cli/validate.mjs @@ -0,0 +1,462 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + hashPackageClosure, + hashCursorClosure, + loadJson, + renderDisabledLaunchAgent, + validateAmbientAnthropicCredentials, + validateAmbientCursorOverrides, + validateAmbientGrokOverrides, + validateClaudeSubscriptionAuth, + validateCursorSubscriptionAuth, + validateManifest, + validatePinnedNodeRuntime, + validateSubscriptionProjection, +} from "./worker.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +function option(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +const optionValues = new Set([option("--worker")].filter(Boolean)); +const positional = process.argv + .slice(2) + .filter((arg) => !arg.startsWith("--") && !optionValues.has(arg)); +const identityPath = positional[0] ?? join(here, "fixtures", "identity-map.json"); +const worker = option("--worker") ?? "codex_cli"; +if (!["codex_cli", "claude_cli", "cursor_cli", "grok_cli"].includes(worker)) { + console.error(`unsupported external CLI worker: ${worker}`); + process.exit(1); +} +const manifestName = worker === "codex_cli" ? "manifest.json" : `manifest.${worker}.json`; +const manifest = loadJson(join(here, manifestName)); +const identityMap = loadJson(identityPath); +const validation = validateManifest(manifest, identityMap); +if (!validation.ok) { + console.error(validation.errors.join("\n")); + process.exit(1); +} + +function validatePinnedPrompt(runtime) { + if (!runtime.systemPromptPath) return; + const promptStat = fs.lstatSync(runtime.systemPromptPath); + if ( + !promptStat.isFile() || + promptStat.isSymbolicLink() || + (promptStat.mode & 0o777) !== 0o444 + ) { + throw new Error("system prompt must be a regular mode-0444 file"); + } + const promptSha256 = createHash("sha256") + .update(fs.readFileSync(runtime.systemPromptPath)) + .digest("hex"); + if (promptSha256 !== runtime.systemPromptSha256) { + throw new Error("system prompt SHA-256 does not match the manifest pin"); + } +} + +const selector = manifest.worker.selector ?? manifest.worker.principal; +const configText = fs.readFileSync(join(here, "config", `${selector}.toml`), "utf8"); +const subscriptionValidation = validateSubscriptionProjection(configText, manifest, identityMap); +if (!subscriptionValidation.ok) { + console.error(subscriptionValidation.errors.join("\n")); + process.exit(1); +} + +const artifact = renderDisabledLaunchAgent(manifest, identityMap); +if (artifact.plist.includes("BUZZ_PRIVATE_KEY") || artifact.plist.includes("nsec1")) { + console.error("rendered artifact contains signer material"); + process.exit(1); +} +if (artifact.args.includes("--no-agent-publisher-credentials")) { + console.error( + `external ${manifest.worker.principal} must receive its own managed Buzz credentials`, + ); + process.exit(1); +} +if (!artifact.args.includes("--agent-publisher-credentials")) { + console.error( + `external ${manifest.worker.principal} must explicitly opt into managed Buzz credentials`, + ); + process.exit(1); +} +if ( + artifact.args.filter((arg) => arg === "--agent-publisher-credentials").length !== 1 || + artifact.args[artifact.args.indexOf("--subscribe") + 1] !== manifest.posture.subscribe || + artifact.args[artifact.args.indexOf("--config") + 1] !== manifest.runtime.configPath || + artifact.args[artifact.args.indexOf("--expected-public-key") + 1] !== + identityMap.members[manifest.worker.principal].pubkey_hex || + artifact.subscriptionRoomIds.join("\n") !== subscriptionValidation.roomIds.join("\n") +) { + console.error("rendered launch argv does not match the source projection"); + process.exit(1); +} + +const runtimeCheck = process.argv.includes("--runtime"); +if (runtimeCheck) { + const adapter = + selector === "codex_cli" + ? manifest.runtime.codexAcp + : selector === "claude_cli" + ? manifest.runtime.claudeAcp + : selector === "cursor_cli" + ? manifest.runtime.cursorAcp + : manifest.runtime.grokAcp; + fs.accessSync(manifest.runtime.buzzAcpBinary, fs.constants.X_OK); + const buzzSha256 = createHash("sha256") + .update(fs.readFileSync(manifest.runtime.buzzAcpBinary)) + .digest("hex"); + if (buzzSha256 !== manifest.runtime.buzzAcpSha256) { + throw new Error("shared buzz-acp SHA-256 does not match the manifest pin"); + } + const buzzHelp = spawnSync(manifest.runtime.buzzAcpBinary, ["--help"], { + encoding: "utf8", + env: artifact.environment, + }); + if ( + buzzHelp.status !== 0 || + !buzzHelp.stdout.includes("--agent-publisher-credentials") || + !buzzHelp.stdout.includes("--no-agent-publisher-credentials") || + !buzzHelp.stdout.includes("--session-cwd") || + !buzzHelp.stdout.includes("strict-allowlist") + ) { + throw new Error("shared buzz-acp does not advertise the required external worker contract"); + } + fs.accessSync(adapter.binary, fs.constants.X_OK); + for (const directory of artifact.requiredDirectories) { + if (!fs.statSync(directory).isDirectory()) { + throw new Error(`required runtime path is not a directory: ${directory}`); + } + } + validatePinnedPrompt(manifest.runtime); + if (selector === "claude_cli") { + const nodeValidation = validatePinnedNodeRuntime(manifest.runtime.node, artifact.environment); + if (!nodeValidation.ok) throw new Error(nodeValidation.errors.join("\n")); + } else if (selector === "codex_cli") { + const nodeBinary = manifest.runtime.path + .map((directory) => join(directory, "node")) + .find((candidate) => { + try { + fs.accessSync(candidate, fs.constants.X_OK); + return true; + } catch { + return false; + } + }); + if (!nodeBinary) { + throw new Error("rendered PATH does not contain an executable Node runtime"); + } + } + const adapterEntrypoint = fs.realpathSync(adapter.binary); + const adapterSha256 = createHash("sha256") + .update(fs.readFileSync(adapterEntrypoint)) + .digest("hex"); + if (adapterSha256 !== adapter.entrypointSha256) { + throw new Error( + `${manifest.worker.principal} ACP entrypoint SHA-256 does not match the manifest pin`, + ); + } + const signerProbe = spawnSync( + manifest.runtime.buzzAcpBinary, + [ + "--private-key-file", + artifact.signerFile, + "--expected-public-key", + artifact.expectedPublicKey, + "--heartbeat-interval", + "1", + ], + { + encoding: "utf8", + env: artifact.environment, + }, + ); + if ( + signerProbe.status === 0 || + !signerProbe.stderr.includes("heartbeat interval must be 0 (disabled)") + ) { + throw new Error(`shared buzz-acp signer validation failed: ${signerProbe.stderr.trim()}`); + } + if (selector === "codex_cli") { + fs.accessSync(manifest.runtime.codexHome, fs.constants.R_OK); + const adapterVersion = spawnSync(adapter.binary, ["--version"], { + encoding: "utf8", + env: artifact.environment, + }); + if (adapterVersion.status !== 0) { + throw new Error(`codex-acp --version failed: ${adapterVersion.stderr.trim()}`); + } + if (!adapterVersion.stdout.includes(` ${adapter.version}`)) { + throw new Error(`codex-acp version does not match ${adapter.version}`); + } + } else if (selector === "claude_cli") { + const packageRoot = dirname(dirname(adapterEntrypoint)); + const packageJson = loadJson(join(packageRoot, "package.json")); + if (packageJson.name !== adapter.package || packageJson.version !== adapter.version) { + throw new Error("claude-agent-acp package metadata does not match the manifest pin"); + } + if (hashPackageClosure(adapter.root) !== adapter.closureSha256) { + throw new Error("claude-agent-acp installed package closure does not match the manifest pin"); + } + const adapterVersion = spawnSync(adapter.binary, ["--version"], { + encoding: "utf8", + env: artifact.environment, + }); + if (adapterVersion.status !== 0 || adapterVersion.stdout.trim() !== adapter.version) { + throw new Error(`claude-agent-acp version does not match ${adapter.version}`); + } + fs.accessSync(manifest.runtime.claudeCode.binary, fs.constants.X_OK); + const claudeSha256 = createHash("sha256") + .update(fs.readFileSync(manifest.runtime.claudeCode.binary)) + .digest("hex"); + if (claudeSha256 !== manifest.runtime.claudeCode.binarySha256) { + throw new Error("Claude Code binary SHA-256 does not match the manifest pin"); + } + const ambientCredentials = validateAmbientAnthropicCredentials(process.env); + if (!ambientCredentials.ok) { + throw new Error(ambientCredentials.errors.join("\n")); + } + const standardClaudeEnvironment = { + ...process.env, + ...artifact.environment, + }; + delete standardClaudeEnvironment.CLAUDE_CONFIG_DIR; + delete standardClaudeEnvironment.ANTHROPIC_API_KEY; + delete standardClaudeEnvironment.ANTHROPIC_AUTH_TOKEN; + const claudeVersion = spawnSync(manifest.runtime.claudeCode.binary, ["--version"], { + encoding: "utf8", + env: standardClaudeEnvironment, + }); + if ( + claudeVersion.status !== 0 || + !claudeVersion.stdout.startsWith(manifest.runtime.claudeCode.version) + ) { + throw new Error(`Claude Code version does not match ${manifest.runtime.claudeCode.version}`); + } + const authStatus = spawnSync(manifest.runtime.claudeCode.binary, ["auth", "status"], { + encoding: "utf8", + env: standardClaudeEnvironment, + }); + let auth; + try { + auth = JSON.parse(authStatus.stdout); + } catch { + throw new Error("Claude Code auth status did not return JSON"); + } + if (authStatus.status !== 0) { + throw new Error("Claude Code auth status failed"); + } + const authValidation = validateClaudeSubscriptionAuth(auth, manifest.runtime.claudeCode.auth); + if (!authValidation.ok) { + throw new Error(authValidation.errors.join("\n")); + } + } else if (selector === "cursor_cli") { + const bootstrapStat = fs.lstatSync(manifest.runtime.bootstrapPath); + if ( + !bootstrapStat.isFile() || + bootstrapStat.isSymbolicLink() || + (bootstrapStat.mode & 0o777) !== 0o444 + ) { + throw new Error("Cursor ACP bootstrap must be a regular mode-0444 file"); + } + const bootstrapSha256 = createHash("sha256") + .update(fs.readFileSync(manifest.runtime.bootstrapPath)) + .digest("hex"); + if (bootstrapSha256 !== manifest.runtime.bootstrapSha256) { + throw new Error("Cursor ACP bootstrap SHA-256 does not match the manifest pin"); + } + if (hashCursorClosure(adapter.root) !== adapter.closureSha256) { + throw new Error("Cursor CLI installed closure does not match the manifest pin"); + } + const ambientOverrides = validateAmbientCursorOverrides(process.env); + if (!ambientOverrides.ok) throw new Error(ambientOverrides.errors.join("\n")); + const cursorEnvironment = { ...process.env, ...artifact.environment }; + delete cursorEnvironment.CURSOR_API_KEY; + delete cursorEnvironment.CURSOR_API_ENDPOINT; + const cursorVersion = spawnSync(adapter.binary, ["--version"], { + encoding: "utf8", + env: cursorEnvironment, + }); + if (cursorVersion.status !== 0 || cursorVersion.stdout.trim() !== adapter.version) { + throw new Error(`Cursor CLI version does not match ${adapter.version}`); + } + const status = spawnSync(adapter.binary, ["status", "--format", "json"], { + encoding: "utf8", + env: cursorEnvironment, + }); + const about = spawnSync(adapter.binary, ["about", "--format", "json"], { + encoding: "utf8", + env: cursorEnvironment, + }); + let statusJson; + let aboutJson; + try { + statusJson = JSON.parse(status.stdout); + aboutJson = JSON.parse(about.stdout); + } catch { + throw new Error("Cursor auth status did not return JSON"); + } + if (status.status !== 0 || about.status !== 0) { + throw new Error("Cursor auth status failed"); + } + const authValidation = validateCursorSubscriptionAuth(statusJson, aboutJson, adapter.auth); + if (!authValidation.ok) throw new Error(authValidation.errors.join("\n")); + const modelCatalog = spawnSync(adapter.binary, ["models"], { + encoding: "utf8", + env: cursorEnvironment, + }); + if (modelCatalog.status !== 0) { + throw new Error("Cursor model catalog query failed"); + } + const catalogModelIds = new Set( + modelCatalog.stdout + .split("\n") + .map((line) => line.match(/^(\S+) - /)?.[1]) + .filter(Boolean), + ); + if (!catalogModelIds.has(adapter.model.requested)) { + throw new Error("Cursor requested model alias is absent from its native catalog"); + } + const acpModelArgs = [ + "models", + "--agent-command", + `${adapter.root}/node`, + `--agent-args=${[ + "--use-system-ca", + manifest.runtime.bootstrapPath, + manifest.workspaces.allowed[manifest.workspaces.default], + `${adapter.root}/index.js`, + ...adapter.args, + ].join(",")}`, + "--json", + ]; + const acpModelCatalog = spawnSync(manifest.runtime.buzzAcpBinary, acpModelArgs, { + cwd: manifest.runtime.supervisorWorkingDirectory, + encoding: "utf8", + env: cursorEnvironment, + }); + let acpCatalog; + try { + acpCatalog = JSON.parse(acpModelCatalog.stdout); + } catch { + throw new Error("Cursor ACP model catalog did not return JSON"); + } + const acpModelIds = new Set( + (acpCatalog?.unstable?.availableModels ?? []).map((model) => model.modelId), + ); + if (acpModelCatalog.status !== 0 || !acpModelIds.has(adapter.model.effective)) { + throw new Error("Cursor effective ACP model is absent from its catalog"); + } + } else { + const authStat = fs.lstatSync(adapter.auth.authFile); + if (!authStat.isFile() || authStat.isSymbolicLink() || (authStat.mode & 0o777) !== 0o600) { + throw new Error("Grok auth file must be a regular non-symlink file with mode 0600"); + } + if (fs.realpathSync(adapter.binary) !== adapter.realBinary) { + throw new Error("Grok real binary does not match the manifest pin"); + } + const ambientOverrides = validateAmbientGrokOverrides(process.env); + if (!ambientOverrides.ok) { + throw new Error(ambientOverrides.errors.join("\n")); + } + const grokEnvironment = { ...process.env, ...artifact.environment }; + for (const name of [ + "XAI_API_KEY", + "GROK_CODE_XAI_API_KEY", + "GROK_AUTH", + "GROK_AUTH_PATH", + "GROK_HOME", + "GROK_AUTH_PROVIDER_COMMAND", + "GROK_OIDC_ISSUER", + "GROK_OIDC_CLIENT_ID", + "GROK_CLI_CHAT_PROXY_BASE_URL", + "XAI_API_BASE_URL", + ]) { + delete grokEnvironment[name]; + } + const grokVersion = spawnSync(adapter.binary, ["--version"], { + encoding: "utf8", + env: grokEnvironment, + }); + if ( + grokVersion.status !== 0 || + !grokVersion.stdout.startsWith(`grok ${adapter.version} (${adapter.build})`) + ) { + throw new Error(`Grok version does not match ${adapter.version} (${adapter.build})`); + } + const grokModels = spawnSync(adapter.binary, ["models"], { + encoding: "utf8", + env: grokEnvironment, + }); + if ( + grokModels.status !== 0 || + !grokModels.stdout.includes("You are logged in with grok.com.") || + !grokModels.stdout.includes(`Default model: ${adapter.model.effective}`) + ) { + throw new Error("Grok existing login or model checkpoint is unavailable"); + } + const modelArgs = [ + "models", + "--agent-command", + adapter.binary, + "--agent-args", + adapter.args.join(","), + "--json", + ]; + const modelCatalog = spawnSync(manifest.runtime.buzzAcpBinary, modelArgs, { + encoding: "utf8", + env: grokEnvironment, + }); + let catalog; + try { + catalog = JSON.parse(modelCatalog.stdout); + } catch { + throw new Error("Grok ACP model catalog did not return JSON"); + } + const available = catalog?.unstable?.availableModels ?? []; + const selected = available.find((model) => model.modelId === adapter.model.effective); + if ( + modelCatalog.status !== 0 || + catalog?.unstable?.currentModelId !== adapter.model.effective || + selected?._meta?.reasoningEffort !== adapter.model.reasoningEffort + ) { + throw new Error("Grok ACP model or reasoning checkpoint drift"); + } + } +} + +const result = { + ok: true, + enabled: false, + principal: manifest.worker.principal, + ...(selector !== manifest.worker.principal ? { worker: selector } : {}), + workspace: artifact.sessionCwd, + ...(selector === "codex_cli" + ? { agentMode: artifact.environment.INITIAL_AGENT_MODE } + : { permissionMode: manifest.posture.permissionMode }), + roomCount: subscriptionValidation.roomIds.length, + publisherCredentials: "managed", + ...(selector === "cursor_cli" + ? { + requestedModel: manifest.runtime.cursorAcp.model.requested, + effectiveModel: manifest.runtime.cursorAcp.model.effective, + modelSelectionStatus: manifest.runtime.cursorAcp.model.selectionStatus, + } + : {}), + ...(selector === "grok_cli" + ? { + requestedModel: manifest.runtime.grokAcp.model.requested, + effectiveModel: manifest.runtime.grokAcp.model.effective, + reasoningEffort: manifest.runtime.grokAcp.model.reasoningEffort, + } + : {}), + runtimeCheck, +}; +process.stdout.write(`${JSON.stringify(result)}\n`); diff --git a/deploy/local/aeon-external-cli/worker.mjs b/deploy/local/aeon-external-cli/worker.mjs new file mode 100644 index 0000000000..380c6ebb01 --- /dev/null +++ b/deploy/local/aeon-external-cli/worker.mjs @@ -0,0 +1,995 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import { createHash } from "node:crypto"; +import path from "node:path"; + +const HEX_64 = /^[0-9a-f]{64}$/; +const SAFE_LABEL = /^[a-z0-9][a-z0-9._-]*$/; +const REQUIRED_AGENT_MODE = "agent-full-access"; +const REQUIRED_CODEX_ACP_VERSION = "1.1.7"; +const REQUIRED_CLAUDE_ACP_VERSION = "0.62.0"; +const REQUIRED_CLAUDE_CODE_VERSION = "2.1.220"; +const REQUIRED_CURSOR_CLI_VERSION = "2026.07.23-e383d2b"; +const REQUIRED_CLAUDE_ACP_INTEGRITY = + "sha512-8QRNmyk5Cfy4XVREeg5KCPoCDtmYS0xALY9WqI640PfopLMpeUzMByXbzLkBLbD819zB67DBhLG5ta98uOEPKg=="; +const REQUIRED_CLAUDE_ACP_GIT_HEAD = "53a0c36ce3b0b76929d11d8b9565e319da745608"; +const REQUIRED_CLAUDE_ACP_ENTRYPOINT_SHA256 = + "260aac90bf75f197b93640087c1de66441761d43c2784efa035fdcee60b5dacd"; +const REQUIRED_CLAUDE_ACP_CLOSURE_SHA256 = + "ba5650a750d25811f36f4e6e91ad079d700743ddfb4f52abb90d46c9e9d86002"; +const REQUIRED_CLAUDE_CODE_SHA256 = + "8addc857f3fe64d5a0368af9ee50321b50afb4a6918ba3ef018ab84f5dbbe081"; +const REQUIRED_CLAUDE_RUNTIME_ROOT = "/Users/architect/Library/Application Support/AEON/aeon-v6"; +const REQUIRED_SHARED_BUZZ_ACP_BINARY = `${REQUIRED_CLAUDE_RUNTIME_ROOT}/bin/buzz-acp`; +const REQUIRED_SHARED_BUZZ_ACP_SHA256 = + "1d260060a0b790645a0455d23c7a82ac7836193108673a76f44423c5d81be9be"; +const REQUIRED_CURSOR_BOOTSTRAP_PATH = `${REQUIRED_CLAUDE_RUNTIME_ROOT}/buzz/cursor-acp-bootstrap.cjs`; +const REQUIRED_CURSOR_BOOTSTRAP_SHA256 = + "b3f4e90e675bd0e8f0827b618203c33b9904cbd01becd2b80fc868d75b8797e8"; +const REQUIRED_CLAUDE_NODE = { + version: "v24.1.0", + sha256: "59450bb6448c8a40b3f3b86da45c3babb2e0503e04c47e5a715e8e137389878b", + mode: "0755", + binary: "/Users/architect/.nvm/versions/node/v24.1.0/bin/node", +}; +const REQUIRED_CLAUDE_AUTH = { + mode: "existing-claude-subscription", + authMethod: "claude.ai", + provider: "firstParty", + subscriptionTypes: ["pro"], +}; +const ANTHROPIC_CREDENTIAL_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]; +const CURSOR_OVERRIDE_ENV = ["CURSOR_API_KEY", "CURSOR_API_ENDPOINT"]; +const REQUIRED_SHARED_ROOMS = ["ops", "concilium"]; +const REQUIRED_OFFICE_ROOMS = [ + "aspect_nexus", + "aspect_mechanon", + "aspect_fontis", + "aspect_sapientis", + "aspect_viatica", + "aspect_voxis", +]; +const REQUIRED_INBOUND_MEMBERS = [ + "architect", + "nexus", + "mechanon", + "fontis", + "sapientis", + "viatica", + "voxis", +]; +export const REQUIRED_ROOM_NAMES = [...REQUIRED_SHARED_ROOMS, ...REQUIRED_OFFICE_ROOMS]; +const REQUIRED_CURSOR_ROOT = `/Users/architect/.local/share/cursor-agent/versions/${REQUIRED_CURSOR_CLI_VERSION}`; +const REQUIRED_CURSOR_CLI = { + package: "cursor-agent", + version: REQUIRED_CURSOR_CLI_VERSION, + binary: "/Users/architect/.local/bin/cursor-agent", + root: REQUIRED_CURSOR_ROOT, + entrypointSha256: "eed61c5224668c9236334c4c68936a16aecc37374b592f59e31eb50433817831", + closureSha256: "400227a16df5e9f7bb4273f176cf68e41ef499f06fac5e6c9c6c3556ab2cc726", + args: ["--trust", "acp"], + auth: { + mode: "existing-cursor-subscription", + status: "authenticated", + subscriptionTypes: ["Pro"], + }, + model: { + requested: "cursor-grok-4.5-high", + effective: "grok-4.5[effort=high,fast=true]", + selectionStatus: "upstream_limited_to_fast_wire_variant", + }, +}; +const REQUIRED_GROK_CLI = { + package: "grok-build", + version: "0.2.93", + build: "f00f96316d4b", + binary: "/Users/architect/.grok/bin/grok", + realBinary: "/Users/architect/.grok/downloads/grok-0.2.93-macos-aarch64", + entrypointSha256: "2a97ba675bd992aa9b981e2e83776460d94f469b510c0b8efe28b50d236d767c", + args: ["agent", "--model", "grok-4.5", "--reasoning-effort", "high", "--always-approve", "stdio"], + auth: { + mode: "existing-grok-login", + provider: "grok.com", + authFile: "/Users/architect/.grok/auth.json", + }, + model: { + requested: "grok-4.5-high", + effective: "grok-4.5", + reasoningEffort: "high", + }, +}; +const GROK_OVERRIDE_ENV = [ + "XAI_API_KEY", + "GROK_CODE_XAI_API_KEY", + "GROK_AUTH", + "GROK_AUTH_PATH", + "GROK_HOME", + "GROK_AUTH_PROVIDER_COMMAND", + "GROK_OIDC_ISSUER", + "GROK_OIDC_CLIENT_ID", + "GROK_CLI_CHAT_PROXY_BASE_URL", + "XAI_API_BASE_URL", +]; +const ENV_BINARY = "/usr/bin/env"; +const WORKER_CONTRACTS = { + codex_cli: { + principal: "codex_cli", + adapterKey: "codexAcp", + adapterPackage: "@agentclientprotocol/codex-acp", + adapterVersion: REQUIRED_CODEX_ACP_VERSION, + label: "org.aeon.buzz-acp.codex-cli", + }, + claude_cli: { + principal: "claude_code", + adapterKey: "claudeAcp", + adapterPackage: "@agentclientprotocol/claude-agent-acp", + adapterVersion: REQUIRED_CLAUDE_ACP_VERSION, + label: "org.aeon.buzz-acp.claude-cli", + }, + cursor_cli: { + principal: "cursor_cli", + adapterKey: "cursorAcp", + adapterPackage: "cursor-agent", + adapterVersion: REQUIRED_CURSOR_CLI_VERSION, + label: "org.aeon.buzz-acp.cursor-cli", + native: true, + }, + grok_cli: { + principal: "grok_cli", + adapterKey: "grokAcp", + adapterPackage: "grok-build", + adapterVersion: REQUIRED_GROK_CLI.version, + label: "org.aeon.buzz-acp.grok-cli", + native: true, + }, +}; + +export function loadJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function isAbsoluteSafePath(value) { + return typeof value === "string" && path.isAbsolute(value) && !/[\0\r\n,]/.test(value); +} + +export function hashPackageClosure(root, ignoredTopLevel = []) { + if (!isAbsoluteSafePath(root)) throw new Error("package root must be an absolute safe path"); + const hash = createHash("sha256"); + const entries = []; + + function visit(directory, relativeDirectory) { + for (const name of fs.readdirSync(directory).sort()) { + if (!relativeDirectory && ignoredTopLevel.includes(name)) continue; + const absolutePath = path.join(directory, name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name; + const stat = fs.lstatSync(absolutePath); + if (stat.isDirectory()) { + visit(absolutePath, relativePath); + } else if (stat.isFile()) { + entries.push({ + kind: "file", + absolutePath, + relativePath, + size: stat.size, + }); + } else if (stat.isSymbolicLink()) { + entries.push({ + kind: "symlink", + relativePath, + target: fs.readlinkSync(absolutePath), + }); + } else { + throw new Error(`unsupported package entry: ${relativePath}`); + } + } + } + + visit(root, ""); + entries.sort((left, right) => { + if (left.relativePath === right.relativePath) return 0; + return left.relativePath < right.relativePath ? -1 : 1; + }); + for (const entry of entries) { + if (entry.kind === "symlink") { + hash.update(`l\0${entry.relativePath}\0${entry.target}\0`); + } else { + hash.update(`f\0${entry.relativePath}\0${entry.size}\0`); + hash.update(fs.readFileSync(entry.absolutePath)); + hash.update("\0"); + } + } + return hash.digest("hex"); +} + +export function hashCursorClosure(root) { + return hashPackageClosure(root, [".running"]); +} + +export function validateAmbientAnthropicCredentials(environment) { + const present = ANTHROPIC_CREDENTIAL_ENV.filter( + (name) => typeof environment?.[name] === "string" && environment[name].length > 0, + ); + return { + ok: present.length === 0, + errors: present.map((name) => `${name} must be absent for Claude subscription authentication`), + }; +} + +export function validateClaudeSubscriptionAuth(status, contract) { + const errors = []; + if (status?.loggedIn !== true) errors.push("Claude Code existing login is unavailable"); + if (status?.authMethod !== contract?.authMethod) { + errors.push(`Claude Code auth method must be ${contract?.authMethod}`); + } + if (status?.apiProvider !== contract?.provider) { + errors.push(`Claude Code API provider must be ${contract?.provider}`); + } + if (!contract?.subscriptionTypes?.includes(status?.subscriptionType)) { + errors.push( + `Claude Code subscription type must be one of: ${(contract?.subscriptionTypes ?? []).join(", ")}`, + ); + } + return { ok: errors.length === 0, errors }; +} + +export function validateCursorSubscriptionAuth(status, about, contract) { + const errors = []; + if (status?.status !== contract?.status || status?.isAuthenticated !== true) { + errors.push("Cursor existing subscription login is unavailable"); + } + if (!contract?.subscriptionTypes?.includes(about?.subscriptionTier)) { + errors.push( + `Cursor subscription type must be one of: ${(contract?.subscriptionTypes ?? []).join(", ")}`, + ); + } + return { ok: errors.length === 0, errors }; +} + +export function validateAmbientCursorOverrides(environment) { + const present = CURSOR_OVERRIDE_ENV.filter( + (name) => typeof environment?.[name] === "string" && environment[name].length > 0, + ); + return { + ok: present.length === 0, + errors: present.map((name) => `${name} must be absent for Cursor subscription authentication`), + }; +} + +export function validateAmbientGrokOverrides(environment) { + const present = GROK_OVERRIDE_ENV.filter( + (name) => typeof environment?.[name] === "string" && environment[name].length > 0, + ); + return { + ok: present.length === 0, + errors: present.map((name) => `${name} must be absent for Grok subscription authentication`), + }; +} + +export function validatePinnedNodeRuntime(node, environment) { + let stat; + try { + stat = fs.lstatSync(node?.binary); + } catch { + return { ok: false, errors: ["pinned Node runtime is missing"] }; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + return { + ok: false, + errors: ["pinned Node runtime must be a regular non-symlink file"], + }; + } + if ((stat.mode & 0o777).toString(8).padStart(4, "0") !== node.mode) { + return { + ok: false, + errors: [`pinned Node runtime mode must be ${node.mode}`], + }; + } + try { + fs.accessSync(node.binary, fs.constants.X_OK); + } catch { + return { ok: false, errors: ["pinned Node runtime must be executable"] }; + } + const sha256 = createHash("sha256").update(fs.readFileSync(node.binary)).digest("hex"); + if (sha256 !== node.sha256) { + return { + ok: false, + errors: ["pinned Node runtime SHA-256 does not match the manifest pin"], + }; + } + const version = spawnSync(node.binary, ["--version"], { + encoding: "utf8", + env: environment, + }); + if (version.status !== 0 || version.stdout.trim() !== node.version) { + return { + ok: false, + errors: [`pinned Node runtime version does not match ${node.version}`], + }; + } + return { ok: true, errors: [] }; +} + +function memberPubkey(identityMap, memberId) { + return identityMap.members?.[memberId]?.pubkey_hex; +} + +function workerSelector(manifest) { + return manifest.worker?.selector ?? manifest.worker?.principal; +} + +export function exactRoomIds(manifest, identityMap) { + return [...manifest.buzz.sharedRooms, ...manifest.buzz.officeRooms].map( + (roomName) => identityMap.channels?.[roomName]?.channel_id, + ); +} + +export function validateSubscriptionProjection(configText, manifest, identityMap) { + const errors = []; + const channelArrays = []; + const ruleNames = []; + const channelArrayPattern = /^\s*channels\s*=\s*(\[[\s\S]*?^\s*\])/gm; + const tableHeaderPattern = /^\s*\[[^\r\n]*$/gm; + const tableHeaders = [...configText.matchAll(tableHeaderPattern)]; + const preamble = configText.slice(0, tableHeaders[0]?.index ?? configText.length); + const preambleLines = preamble + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); + const validRuleHeader = (header) => /^\s*\[\[rules\]\]\s*(?:#.*)?$/.test(header[0]); + const ruleSections = tableHeaders.flatMap((header, index) => { + if (!validRuleHeader(header)) return []; + const bodyStart = header.index + header[0].length; + const bodyEnd = tableHeaders[index + 1]?.index ?? configText.length; + return [configText.slice(bodyStart, bodyEnd)]; + }); + + if (/'''|"""/.test(configText)) { + errors.push("subscription projection must not contain multiline strings"); + } + if ( + preambleLines.length !== 0 || + tableHeaders.length !== 2 || + tableHeaders.some((header) => !validRuleHeader(header)) + ) { + errors.push("subscription projection must not contain content outside the two canonical rules"); + } + if (ruleSections.length !== 2) { + errors.push("subscription projection must contain exactly two rules"); + } + + for (const rule of ruleSections) { + const channelMatches = [...rule.matchAll(channelArrayPattern)]; + if (channelMatches.length !== 1) { + errors.push("each subscription rule must contain exactly one channel array"); + continue; + } + try { + const channels = JSON.parse(channelMatches[0][1].replace(/,\s*\]$/, "]")); + if (!Array.isArray(channels) || !channels.every((id) => typeof id === "string")) { + errors.push("subscription channels must be string arrays"); + } else { + channelArrays.push(channels); + } + } catch { + errors.push("subscription channels must use deterministic string-array syntax"); + } + + const mentionMatches = [...rule.matchAll(/^\s*require_mention\s*=\s*(true|false)\s*$/gm)]; + if (mentionMatches.length !== 1 || mentionMatches[0][1] !== "true") { + errors.push("each subscription rule must require a mention"); + } + + const remainingLines = rule + .replace(channelArrayPattern, "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); + const nameCount = remainingLines.filter((line) => + /^name\s*=\s*"[a-z0-9-]+"$/.test(line), + ).length; + const kindsCount = remainingLines.filter((line) => + /^kinds\s*=\s*\[\s*9\s*,\s*40002\s*\]$/.test(line), + ).length; + const mentionCount = remainingLines.filter((line) => + /^require_mention\s*=\s*true$/.test(line), + ).length; + if (nameCount !== 1 || kindsCount !== 1 || mentionCount !== 1 || remainingLines.length !== 3) { + errors.push( + "each subscription rule must use the deterministic name, channels, kinds, and mention schema", + ); + } else { + ruleNames.push( + remainingLines + .find((line) => line.startsWith("name")) + .match(/^name\s*=\s*"([a-z0-9-]+)"$/)[1], + ); + } + } + + if ( + JSON.stringify(ruleNames) !== JSON.stringify(["aeon-shared-control", "aeon-aspect-offices"]) + ) { + errors.push("subscription rules must be the canonical shared-control and Aspect-office rules"); + } + + const expectedChannelArrays = [ + manifest.buzz.sharedRooms.map((roomName) => identityMap.channels?.[roomName]?.channel_id), + manifest.buzz.officeRooms.map((roomName) => identityMap.channels?.[roomName]?.channel_id), + ]; + const expectedRoomIds = expectedChannelArrays.flat(); + const actualRoomIds = channelArrays.flat(); + if ( + expectedRoomIds.some((id) => typeof id !== "string") || + JSON.stringify(channelArrays) !== JSON.stringify(expectedChannelArrays) + ) { + errors.push("subscription projection must contain exactly the eight canonical rooms"); + } + + return { + ok: errors.length === 0, + errors, + roomIds: actualRoomIds, + }; +} + +export function validateManifest(manifest, identityMap) { + const errors = []; + const principal = manifest.worker?.principal; + const selector = workerSelector(manifest); + const member = identityMap.members?.[principal]; + const contract = WORKER_CONTRACTS[selector]; + + if (manifest.schema !== "aeon_buzz_external_cli_worker_v1") { + errors.push("unsupported external CLI worker schema"); + } + if (manifest.enabled !== false) errors.push("external CLI worker must be disabled by default"); + if (!contract) { + errors.push("worker selector must be codex_cli, claude_cli, cursor_cli, or grok_cli"); + } + if (contract && principal !== contract.principal) { + errors.push(`${selector} worker must bind to ${contract.principal}`); + } + if (manifest.worker?.agents !== 1) errors.push("exactly one ACP subprocess is required"); + if (!SAFE_LABEL.test(manifest.worker?.label ?? "")) errors.push("invalid launchd label"); + if (contract && manifest.worker?.label !== contract.label) { + errors.push(`${principal} launchd label drift`); + } + if (!member) errors.push(`identity map is missing ${principal}`); + if (member?.gateway_agent_id !== null || member?.aspect_slug !== null) { + errors.push(`${principal} must remain an external non-Aspect principal`); + } + if (member?.concilium_seat !== principal) errors.push(`${principal} Concilium seat drift`); + if (!HEX_64.test(member?.pubkey_hex ?? "")) + errors.push(`${principal} pubkey must be 64 lowercase hex`); + if (!isAbsoluteSafePath(member?.secret_ref)) { + errors.push(`${principal} secret_ref must be an absolute safe path`); + } + + const inbound = manifest.buzz?.allowedInbound ?? []; + if (JSON.stringify(inbound) !== JSON.stringify(REQUIRED_INBOUND_MEMBERS)) { + errors.push("inbound allowlist must be exactly Architect and the six canonical Aspects"); + } + for (const memberId of inbound) { + if (!HEX_64.test(memberPubkey(identityMap, memberId) ?? "")) { + errors.push(`${memberId}: inbound identity is missing a valid pubkey`); + } + } + const authorityPrincipals = [ + ...REQUIRED_INBOUND_MEMBERS, + ...Object.values(WORKER_CONTRACTS).map((workerContract) => workerContract.principal), + ]; + const authorityPubkeys = authorityPrincipals.map((memberId) => + memberPubkey(identityMap, memberId), + ); + if (new Set(authorityPubkeys).size !== authorityPubkeys.length) { + errors.push("Architect, Aspect, and external CLI pubkeys must be unique"); + } + for (const aspectId of REQUIRED_INBOUND_MEMBERS.filter( + (memberId) => memberId !== "architect", + )) { + const officeName = `aspect_${aspectId}`; + const officeMembers = identityMap.channels?.[officeName]?.members ?? []; + if (!officeMembers.includes(aspectId)) { + errors.push(`${officeName}: ${aspectId} is not a member`); + } + const conciliumMembers = identityMap.channels?.concilium?.members ?? []; + if (!conciliumMembers.includes(aspectId)) { + errors.push(`concilium: ${aspectId} is not a member`); + } + } + if (manifest.buzz?.owner !== "architect") errors.push("Architect must own the worker"); + if (manifest.buzz?.relayUrl !== "ws://localhost:3000") errors.push("relay must remain loopback"); + if (JSON.stringify(manifest.buzz?.sharedRooms) !== JSON.stringify(REQUIRED_SHARED_ROOMS)) { + errors.push("shared rooms must be exactly ops and concilium"); + } + if (JSON.stringify(manifest.buzz?.officeRooms) !== JSON.stringify(REQUIRED_OFFICE_ROOMS)) { + errors.push("office rooms must be exactly the six canonical Aspect offices"); + } + const roomIds = exactRoomIds(manifest, identityMap); + if (roomIds.some((roomId) => typeof roomId !== "string")) + errors.push("configured room is absent from identity map"); + if (new Set(roomIds).size !== roomIds.length) errors.push("configured rooms must be unique"); + for (const roomName of [ + ...(manifest.buzz?.sharedRooms ?? []), + ...(manifest.buzz?.officeRooms ?? []), + ]) { + const members = identityMap.channels?.[roomName]?.members ?? []; + if (!members.includes(principal)) errors.push(`${roomName}: ${principal} is not a member`); + } + + const runtime = manifest.runtime; + const adapter = contract ? runtime?.[contract.adapterKey] : undefined; + if (contract && adapter?.package !== contract.adapterPackage) { + errors.push(`${principal} ACP package owner drift`); + } + if (contract && adapter?.version !== contract.adapterVersion) { + errors.push(`${principal} ACP adapter must be pinned to ${contract.adapterVersion}`); + } + if (!contract?.native && !/^sha512-[A-Za-z0-9+/]+=*$/.test(adapter?.integrity ?? "")) { + errors.push(`${principal} ACP integrity must be pinned`); + } + if (!HEX_64.test(adapter?.entrypointSha256 ?? "")) { + errors.push(`${principal} ACP entrypoint SHA-256 must be pinned`); + } + if (runtime?.buzzAcpBinary !== REQUIRED_SHARED_BUZZ_ACP_BINARY) { + errors.push("shared buzz-acp must use the canonical Data-volume path"); + } + if (runtime?.buzzAcpSha256 !== REQUIRED_SHARED_BUZZ_ACP_SHA256) { + errors.push("shared buzz-acp checkpoint drift"); + } + if (selector === "claude_cli") { + if (adapter?.integrity !== REQUIRED_CLAUDE_ACP_INTEGRITY) { + errors.push("Claude ACP package integrity drift"); + } + if (adapter?.gitHead !== REQUIRED_CLAUDE_ACP_GIT_HEAD) { + errors.push("Claude ACP source checkpoint drift"); + } + if (adapter?.entrypointSha256 !== REQUIRED_CLAUDE_ACP_ENTRYPOINT_SHA256) { + errors.push("Claude ACP entrypoint checkpoint drift"); + } + if (adapter?.closureSha256 !== REQUIRED_CLAUDE_ACP_CLOSURE_SHA256) { + errors.push("Claude ACP package closure checkpoint drift"); + } + } + if (selector === "cursor_cli") { + if (JSON.stringify(adapter) !== JSON.stringify(REQUIRED_CURSOR_CLI)) { + errors.push("Cursor CLI runtime, auth, or model contract drift"); + } + } + if (selector === "grok_cli") { + if (JSON.stringify(adapter) !== JSON.stringify(REQUIRED_GROK_CLI)) { + errors.push("Grok CLI runtime, auth, or model contract drift"); + } + } + const usesSafeSupervisor = true; + for (const [label, value] of Object.entries({ + buzzAcpBinary: runtime?.buzzAcpBinary, + configPath: runtime?.configPath, + ...(usesSafeSupervisor + ? { + logDir: runtime?.logDir, + signerPath: runtime?.signerPath, + supervisorWorkingDirectory: runtime?.supervisorWorkingDirectory, + } + : {}), + ...(selector === "claude_cli" ? { adapterRoot: adapter?.root } : {}), + ...(selector === "cursor_cli" ? { bootstrapPath: runtime?.bootstrapPath } : {}), + adapterBinary: adapter?.binary, + })) { + if (!isAbsoluteSafePath(value)) errors.push(`${label} must be an absolute safe path`); + } + if (selector === "codex_cli") { + const expectedPaths = { + configPath: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/buzz/codex-cli.toml`, + logDir: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/logs`, + signerPath: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/secrets/codex-cli.sk`, + systemPromptPath: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/buzz/codex-cli-system.md`, + supervisorWorkingDirectory: REQUIRED_CLAUDE_RUNTIME_ROOT, + adapterBinary: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/codex-acp/${REQUIRED_CODEX_ACP_VERSION}/node_modules/.bin/codex-acp`, + }; + const actualPaths = { + configPath: runtime?.configPath, + logDir: runtime?.logDir, + signerPath: runtime?.signerPath, + systemPromptPath: runtime?.systemPromptPath, + supervisorWorkingDirectory: runtime?.supervisorWorkingDirectory, + adapterBinary: adapter?.binary, + }; + for (const [label, expected] of Object.entries(expectedPaths)) { + if (actualPaths[label] !== expected) { + errors.push(`Codex ${label} must use the launchd-safe Data-volume path`); + } + } + if (!isAbsoluteSafePath(runtime?.codexHome)) + errors.push("codexHome must be an absolute safe path"); + if (runtime?.initialAgentMode !== REQUIRED_AGENT_MODE) { + errors.push(`INITIAL_AGENT_MODE must be ${REQUIRED_AGENT_MODE}`); + } + if (adapter?.model !== "gpt-5.6-sol[medium]") { + errors.push("Codex ACP model must be gpt-5.6-sol[medium]"); + } + } + if (selector === "claude_cli") { + const claudeCode = runtime?.claudeCode; + const expectedPaths = { + configPath: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/buzz/claude-cli.toml`, + logDir: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/logs`, + signerPath: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/secrets/claude-code.sk`, + supervisorWorkingDirectory: REQUIRED_CLAUDE_RUNTIME_ROOT, + adapterRoot: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/claude-acp/${REQUIRED_CLAUDE_ACP_VERSION}`, + adapterBinary: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/claude-acp/${REQUIRED_CLAUDE_ACP_VERSION}/node_modules/.bin/claude-agent-acp`, + }; + const actualPaths = { + configPath: runtime?.configPath, + logDir: runtime?.logDir, + signerPath: runtime?.signerPath, + supervisorWorkingDirectory: runtime?.supervisorWorkingDirectory, + adapterRoot: adapter?.root, + adapterBinary: adapter?.binary, + }; + for (const [label, expected] of Object.entries(expectedPaths)) { + if (actualPaths[label] !== expected) + errors.push(`Claude ${label} must use the launchd-safe Data-volume path`); + } + if (JSON.stringify(runtime?.node) !== JSON.stringify(REQUIRED_CLAUDE_NODE)) { + errors.push("Claude Node runtime checkpoint drift"); + } + if (claudeCode?.version !== REQUIRED_CLAUDE_CODE_VERSION) { + errors.push(`Claude Code must be pinned to ${REQUIRED_CLAUDE_CODE_VERSION}`); + } + if (!isAbsoluteSafePath(claudeCode?.binary)) + errors.push("Claude Code binary must be an absolute safe path"); + if (!HEX_64.test(claudeCode?.binarySha256 ?? "")) { + errors.push("Claude Code binary SHA-256 must be pinned"); + } + if (claudeCode?.binarySha256 !== REQUIRED_CLAUDE_CODE_SHA256) { + errors.push("Claude Code binary checkpoint drift"); + } + if (claudeCode?.configDir !== undefined) { + errors.push("Claude config directory override must be absent"); + } + if (JSON.stringify(claudeCode?.auth) !== JSON.stringify(REQUIRED_CLAUDE_AUTH)) { + errors.push("Claude auth must use the pinned Claude subscription login"); + } + } + if (selector === "cursor_cli") { + const expectedPaths = { + configPath: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/buzz/cursor-cli.toml`, + logDir: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/logs`, + signerPath: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/secrets/cursor-cli.sk`, + supervisorWorkingDirectory: REQUIRED_CLAUDE_RUNTIME_ROOT, + bootstrapPath: REQUIRED_CURSOR_BOOTSTRAP_PATH, + }; + for (const [label, expected] of Object.entries(expectedPaths)) { + if (runtime?.[label] !== expected) + errors.push(`Cursor ${label} must use the launchd-safe Data-volume path`); + } + if (runtime?.bootstrapSha256 !== REQUIRED_CURSOR_BOOTSTRAP_SHA256) { + errors.push("Cursor ACP bootstrap checkpoint drift"); + } + } + if (selector === "grok_cli") { + const expectedPaths = { + configPath: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/buzz/grok-cli.toml`, + logDir: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/logs`, + signerPath: `${REQUIRED_CLAUDE_RUNTIME_ROOT}/secrets/grok-cli.sk`, + supervisorWorkingDirectory: REQUIRED_CLAUDE_RUNTIME_ROOT, + }; + for (const [label, expected] of Object.entries(expectedPaths)) { + if (runtime?.[label] !== expected) { + errors.push(`Grok ${label} must use the launchd-safe Data-volume path`); + } + } + } + if (!(runtime?.path ?? []).every(isAbsoluteSafePath)) + errors.push("every PATH entry must be absolute and safe"); + if (!runtime?.path?.includes(path.dirname(adapter?.binary ?? ""))) { + errors.push("PATH must include the pinned ACP adapter bin directory"); + } + if ( + selector === "claude_cli" && + runtime?.path?.[0] !== path.dirname(runtime?.node?.binary ?? "") + ) { + errors.push("Claude PATH must resolve the pinned trusted Node runtime first"); + } + + const workspaces = manifest.workspaces; + if (!workspaces?.allowed?.[workspaces?.default]) errors.push("default workspace must be allowed"); + for (const [name, workspacePath] of Object.entries(workspaces?.allowed ?? {})) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) errors.push(`invalid workspace name: ${name}`); + if ( + !isAbsoluteSafePath(workspacePath) || + !workspacePath.startsWith("/Volumes/AEON/Projects/") + ) { + errors.push(`${name}: workspace must be a bounded AEON project path`); + } + } + + const posture = manifest.posture; + if (posture?.subscribe !== "config") errors.push("worker must use config subscriptions"); + if (posture?.respondTo !== "strict-allowlist") { + errors.push("worker must use the strict inbound allowlist"); + } + if (JSON.stringify(posture?.allowedRespondTo) !== JSON.stringify(["strict-allowlist"])) { + errors.push("worker may only use strict-allowlist response mode"); + } + if (posture?.dedup !== "queue" || posture?.multipleEventHandling !== "queue") { + errors.push("queue semantics must remain enabled"); + } + for (const field of ["presence", "typing", "relayObserver"]) { + if (posture?.[field] !== true) errors.push(`${field} must remain enabled`); + } + const expectedMemory = selector !== "cursor_cli"; + if (posture?.memory !== expectedMemory) { + errors.push(`memory must remain ${expectedMemory ? "enabled" : "disabled"}`); + } + if (posture?.basePrompt !== true && posture?.basePrompt !== false) { + errors.push("basePrompt must be a boolean"); + } + if ( + posture?.basePrompt === false && + !isAbsoluteSafePath(manifest.runtime?.systemPromptPath) + ) { + errors.push("a compact systemPromptPath is required when basePrompt is disabled"); + } + if ( + manifest.runtime?.systemPromptPath !== undefined && + !HEX_64.test(manifest.runtime?.systemPromptSha256 ?? "") + ) { + errors.push("systemPromptSha256 must pin every configured system prompt"); + } + const expectedPermissionMode = selector === "codex_cli" ? "default" : "bypass-permissions"; + if (posture?.permissionMode !== expectedPermissionMode) { + errors.push(`${principal} Buzz permission mode must be ${expectedPermissionMode}`); + } + if (posture?.heartbeatIntervalSecs !== 0) + errors.push("autonomous heartbeat prompts must remain off"); + if (manifest.supervisor?.runAtLoad !== false || manifest.supervisor?.keepAlive !== false) { + errors.push("live activation must remain off"); + } + + return { ok: errors.length === 0, errors }; +} + +export function renderWorker(manifest, identityMap, workspaceName = manifest.workspaces.default) { + const validation = validateManifest(manifest, identityMap); + if (!validation.ok) throw new Error(validation.errors.join("\n")); + + const workspace = manifest.workspaces.allowed[workspaceName]; + if (!workspace) throw new Error(`workspace is not allowed: ${workspaceName}`); + const selector = workerSelector(manifest); + const principal = identityMap.members[manifest.worker.principal]; + const contract = WORKER_CONTRACTS[selector]; + const adapter = manifest.runtime[contract.adapterKey]; + const usesSafeSupervisor = true; + const signerFile = manifest.runtime.signerPath; + const allowlist = manifest.buzz.allowedInbound + .filter((memberId) => memberId !== manifest.buzz.owner) + .map((memberId) => memberPubkey(identityMap, memberId)); + const buzzArgs = [ + "--relay-url", + manifest.buzz.relayUrl, + "--private-key-file", + signerFile, + "--expected-public-key", + principal.pubkey_hex, + "--agent-owner", + memberPubkey(identityMap, manifest.buzz.owner), + "--agent-command", + selector === "cursor_cli" ? `${adapter.root}/node` : adapter.binary, + ...((adapter.args?.length ?? 0) > 0 + ? [ + `--agent-args=${ + selector === "cursor_cli" + ? [ + "--use-system-ca", + manifest.runtime.bootstrapPath, + workspace, + `${adapter.root}/index.js`, + ...adapter.args, + ].join(",") + : adapter.args.join(",") + }`, + ] + : []), + ...(usesSafeSupervisor ? ["--session-cwd", workspace] : []), + ...(manifest.runtime.systemPromptPath + ? ["--system-prompt-file", manifest.runtime.systemPromptPath] + : []), + ...(manifest.posture.basePrompt ? [] : ["--no-base-prompt"]), + ...(manifest.posture.memory ? [] : ["--no-memory"]), + ...(selector === "codex_cli" + ? ["--model", adapter.model] + : selector === "cursor_cli" + ? ["--model", adapter.model.effective] + : []), + "--agent-publisher-credentials", + "--agents", + "1", + "--subscribe", + "config", + "--config", + manifest.runtime.configPath, + "--respond-to", + manifest.posture.respondTo, + "--respond-to-allowlist", + allowlist.join(","), + "--allowed-respond-to", + manifest.posture.allowedRespondTo.join(","), + "--dedup", + "queue", + "--multiple-event-handling", + "queue", + "--relay-observer", + "--permission-mode", + manifest.posture.permissionMode, + "--heartbeat-interval", + String(manifest.posture.heartbeatIntervalSecs), + "--turn-liveness-secs", + String(manifest.posture.turnLivenessSecs), + "--idle-timeout", + String(manifest.posture.idleTimeoutSecs), + "--max-turn-duration", + String(manifest.posture.maxTurnDurationSecs), + "--context-message-limit", + String(manifest.posture.contextMessageLimit), + "--max-turns-per-session", + String(manifest.posture.maxTurnsPerSession), + ]; + const claudeScrubPrefix = ANTHROPIC_CREDENTIAL_ENV.flatMap((name) => ["-u", name]); + const cursorScrubPrefix = CURSOR_OVERRIDE_ENV.flatMap((name) => ["-u", name]); + const grokScrubPrefix = GROK_OVERRIDE_ENV.flatMap((name) => ["-u", name]); + const scrubPrefix = + selector === "codex_cli" + ? [] + : selector === "claude_cli" + ? claudeScrubPrefix + : selector === "cursor_cli" + ? cursorScrubPrefix + : grokScrubPrefix; + return { + enabled: false, + label: manifest.worker.label, + workspaceName, + workingDirectory: manifest.runtime.supervisorWorkingDirectory, + sessionCwd: workspace, + subscriptionRoomIds: exactRoomIds(manifest, identityMap), + command: usesSafeSupervisor ? ENV_BINARY : manifest.runtime.buzzAcpBinary, + args: usesSafeSupervisor + ? [...scrubPrefix, manifest.runtime.buzzAcpBinary, ...buzzArgs] + : buzzArgs, + environment: + selector === "codex_cli" + ? { + PATH: manifest.runtime.path.join(":"), + CODEX_HOME: manifest.runtime.codexHome, + INITIAL_AGENT_MODE: manifest.runtime.initialAgentMode, + } + : selector === "claude_cli" + ? { + PATH: manifest.runtime.path.join(":"), + CLAUDE_CODE_EXECUTABLE: manifest.runtime.claudeCode.binary, + } + : { + ...(selector === "grok_cli" ? { HOME: "/Users/architect" } : {}), + PATH: manifest.runtime.path.join(":"), + }, + signerFile, + expectedPublicKey: principal.pubkey_hex, + }; +} + +function xml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +export function renderDisabledLaunchAgent(manifest, identityMap, workspaceName) { + const worker = renderWorker(manifest, identityMap, workspaceName); + const argvXml = [worker.command, ...worker.args] + .map((value) => ` ${xml(value)}`) + .join("\n"); + const envXml = Object.entries(worker.environment) + .map(([key, value]) => ` ${xml(key)}${xml(value)}`) + .join("\n"); + const selector = workerSelector(manifest); + const logRoot = + manifest.runtime.logDir ?? `/Volumes/AEON/runtime/buzz/external-cli/${selector}/logs`; + const logName = selector.replace("_", "-"); + + return { + ...worker, + requiredDirectories: [ + ...new Set([ + path.dirname(manifest.runtime.configPath), + ...(selector === "cursor_cli" + ? [ + path.dirname(manifest.runtime.bootstrapPath), + path.dirname(manifest.runtime.systemPromptPath), + ] + : []), + logRoot, + path.dirname(worker.signerFile), + worker.workingDirectory, + worker.sessionCwd, + ]), + ], + runAtLoad: false, + keepAlive: false, + rollback: ["launchctl", "bootout", `gui//${worker.label}`], + plist: ` + + + + Label${xml(worker.label)} + ProgramArguments + +${argvXml} + + WorkingDirectory${xml(worker.workingDirectory)} + EnvironmentVariables + +${envXml} + + RunAtLoad + KeepAlive + ProcessTypeBackground + StandardOutPath${logRoot}/${logName}.log + StandardErrorPath${logRoot}/${logName}.err.log + + +`, + }; +} + +function exactTag(tags, expected) { + return tags.filter( + (tag) => + tag.length === expected.length && tag.every((value, index) => value === expected[index]), + ); +} + +export function correlateVerifiedReceipt({ + requestEventId, + channelId, + replyEvent, + observerRun, + expectedPubkey, +}) { + if (!HEX_64.test(requestEventId) || !HEX_64.test(expectedPubkey)) { + throw new Error("request and signer ids must be 64 lowercase hex"); + } + if (replyEvent?.verified !== true) throw new Error("reply signature must be verified"); + if ( + replyEvent?.kind !== 9 || + replyEvent?.pubkey !== expectedPubkey || + !HEX_64.test(replyEvent?.id ?? "") + ) { + throw new Error("reply identity mismatch"); + } + if (exactTag(replyEvent.tags ?? [], ["h", channelId]).length !== 1) { + throw new Error("reply requires one exact channel tag"); + } + if (exactTag(replyEvent.tags ?? [], ["e", requestEventId, "", "reply"]).length !== 1) { + throw new Error("reply requires one exact request anchor"); + } + if ( + observerRun?.requestEventId !== requestEventId || + observerRun?.replyEventId !== replyEvent.id || + observerRun?.channelId !== channelId || + !observerRun?.sessionId || + !observerRun?.runId + ) { + throw new Error("observer run correlation mismatch"); + } + return { + requestEventId, + replyEventId: replyEvent.id, + sessionId: observerRun.sessionId, + runId: observerRun.runId, + channelId, + }; +} diff --git a/deploy/local/aeon-external-cli/worker.test.mjs b/deploy/local/aeon-external-cli/worker.test.mjs new file mode 100644 index 0000000000..98c62e4501 --- /dev/null +++ b/deploy/local/aeon-external-cli/worker.test.mjs @@ -0,0 +1,1184 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import test from "node:test"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + REQUIRED_ROOM_NAMES, + correlateVerifiedReceipt, + hashCursorClosure, + hashPackageClosure, + loadJson, + renderDisabledLaunchAgent, + renderWorker, + validateAmbientAnthropicCredentials, + validateAmbientCursorOverrides, + validateAmbientGrokOverrides, + validateClaudeSubscriptionAuth, + validateCursorSubscriptionAuth, + validateManifest, + validatePinnedNodeRuntime, + validateSubscriptionProjection, +} from "./worker.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const manifest = loadJson(join(here, "manifest.json")); +const claudeManifest = loadJson(join(here, "manifest.claude_cli.json")); +const cursorManifest = loadJson(join(here, "manifest.cursor_cli.json")); +const grokManifest = loadJson(join(here, "manifest.grok_cli.json")); +const identityMap = loadJson(join(here, "fixtures", "identity-map.json")); +const codexConfig = readFileSync(join(here, "config", "codex_cli.toml"), "utf8"); + +test("manifest binds external codex_cli identity without changing Aspect semantics", () => { + const result = validateManifest(manifest, identityMap); + assert.deepEqual(result.errors, []); + assert.equal(result.ok, true); + assert.equal(identityMap.members.codex_cli.gateway_agent_id, null); + assert.equal(identityMap.members.codex_cli.aspect_slug, null); +}); + +test("all external workers pin the same shared Data-volume buzz-acp release", () => { + const binary = "/Users/architect/Library/Application Support/AEON/aeon-v6/bin/buzz-acp"; + const sha256 = "1d260060a0b790645a0455d23c7a82ac7836193108673a76f44423c5d81be9be"; + assert.equal(manifest.runtime.buzzAcpBinary, binary); + assert.equal(claudeManifest.runtime.buzzAcpBinary, binary); + assert.equal(cursorManifest.runtime.buzzAcpBinary, binary); + assert.equal(grokManifest.runtime.buzzAcpBinary, binary); + assert.equal(manifest.runtime.buzzAcpSha256, sha256); + assert.equal(claudeManifest.runtime.buzzAcpSha256, sha256); + assert.equal(cursorManifest.runtime.buzzAcpSha256, sha256); + assert.equal(grokManifest.runtime.buzzAcpSha256, sha256); +}); + +test("claude_cli selector binds the established external claude_code identity", () => { + const result = validateManifest(claudeManifest, identityMap); + assert.deepEqual(result.errors, []); + assert.equal(result.ok, true); + assert.equal(claudeManifest.worker.selector, "claude_cli"); + assert.equal(claudeManifest.worker.principal, "claude_code"); + assert.equal(identityMap.members.claude_code.gateway_agent_id, null); + assert.equal(identityMap.members.claude_code.aspect_slug, null); + assert.notEqual( + identityMap.members.claude_code.pubkey_hex, + identityMap.members.codex_cli.pubkey_hex, + ); +}); + +test("cursor_cli selector binds the established external Cursor identity", () => { + const result = validateManifest(cursorManifest, identityMap); + assert.deepEqual(result.errors, []); + assert.equal(result.ok, true); + assert.equal(cursorManifest.worker.selector, "cursor_cli"); + assert.equal(cursorManifest.worker.principal, "cursor_cli"); + assert.equal(identityMap.members.cursor_cli.gateway_agent_id, null); + assert.equal(identityMap.members.cursor_cli.aspect_slug, null); + assert.notEqual( + identityMap.members.cursor_cli.pubkey_hex, + identityMap.members.codex_cli.pubkey_hex, + ); +}); + +test("grok_cli selector binds a distinct external Grok identity", () => { + const result = validateManifest(grokManifest, identityMap); + assert.deepEqual(result.errors, []); + assert.equal(result.ok, true); + assert.equal(grokManifest.worker.selector, "grok_cli"); + assert.equal(grokManifest.worker.principal, "grok_cli"); + assert.equal(identityMap.members.grok_cli.gateway_agent_id, null); + assert.equal(identityMap.members.grok_cli.aspect_slug, null); + assert.notEqual( + identityMap.members.grok_cli.pubkey_hex, + identityMap.members.codex_cli.pubkey_hex, + ); + assert.notEqual( + identityMap.members.grok_cli.pubkey_hex, + identityMap.members.cursor_cli.pubkey_hex, + ); +}); + +test("Codex launch argv binds publisher credentials, identity, and all eight rooms", () => { + const worker = renderWorker(manifest, identityMap); + assert.equal( + worker.environment.PATH.split(":")[0], + "/Users/architect/.nvm/versions/node/v24.1.0/bin", + ); + assert.equal(worker.args.includes("--no-agent-publisher-credentials"), false); + assert.equal(worker.args.filter((arg) => arg === "--agent-publisher-credentials").length, 1); + assert.equal(worker.args.includes("--private-key"), false); + assert.equal(worker.args.includes("--private-key-file"), true); + assert.equal(worker.args.includes("--relay-observer"), true); + assert.equal( + worker.args[worker.args.indexOf("--private-key-file") + 1], + manifest.runtime.signerPath, + ); + assert.equal( + worker.args[worker.args.indexOf("--expected-public-key") + 1], + identityMap.members.codex_cli.pubkey_hex, + ); + assert.equal(worker.args[worker.args.indexOf("--subscribe") + 1], manifest.posture.subscribe); + assert.equal(worker.args[worker.args.indexOf("--config") + 1], manifest.runtime.configPath); + assert.deepEqual( + worker.subscriptionRoomIds, + [...manifest.buzz.sharedRooms, ...manifest.buzz.officeRooms].map( + (roomName) => identityMap.channels[roomName].channel_id, + ), + ); + assert.equal(worker.subscriptionRoomIds.length, 8); + assert.equal(new Set(worker.subscriptionRoomIds).size, 8); + assert.equal(worker.environment.BUZZ_PRIVATE_KEY, undefined); + assert.equal(worker.environment.BUZZ_RELAY_URL, undefined); +}); + +test("subscription validator requires the exact source-projected room set", () => { + assert.deepEqual( + [...manifest.buzz.sharedRooms, ...manifest.buzz.officeRooms], + REQUIRED_ROOM_NAMES, + ); + const result = validateSubscriptionProjection(codexConfig, manifest, identityMap); + assert.equal(result.ok, true); + assert.deepEqual(result.errors, []); + assert.deepEqual(result.roomIds, renderWorker(manifest, identityMap).subscriptionRoomIds); + + const duplicateRoom = codexConfig.replace(result.roomIds.at(-1), result.roomIds[0]); + assert.match( + validateSubscriptionProjection(duplicateRoom, manifest, identityMap).errors.join("\n"), + /exactly the eight canonical rooms/, + ); + + const extraRoom = codexConfig.replace( + result.roomIds.at(-1), + "ffffffff-ffff-4fff-8fff-ffffffffffff", + ); + assert.match( + validateSubscriptionProjection(extraRoom, manifest, identityMap).errors.join("\n"), + /exactly the eight canonical rooms/, + ); + + const channelBlocks = [...codexConfig.matchAll(/^\s*channels\s*=\s*(\[[\s\S]*?^\s*\])/gm)].map( + (match) => match[0], + ); + const swappedArrays = codexConfig + .replace(channelBlocks[0], "__FIRST_CHANNEL_ARRAY__") + .replace(channelBlocks[1], channelBlocks[0]) + .replace("__FIRST_CHANNEL_ARRAY__", channelBlocks[1]); + assert.match( + validateSubscriptionProjection(swappedArrays, manifest, identityMap).errors.join("\n"), + /exactly the eight canonical rooms/, + ); + + const officeDrift = structuredClone(manifest); + officeDrift.buzz.officeRooms[5] = "ops"; + assert.match( + validateManifest(officeDrift, identityMap).errors.join("\n"), + /six canonical Aspect offices/, + ); + + const extraRule = `${codexConfig} +[[rules]] +name = "unrestricted" +kinds = [9] +require_mention = false +`; + assert.match( + validateSubscriptionProjection(extraRule, manifest, identityMap).errors.join("\n"), + /exactly two rules/, + ); + + const misplacedMention = `require_mention = true +${codexConfig.replace("require_mention = true", "")}`; + assert.match( + validateSubscriptionProjection(misplacedMention, manifest, identityMap).errors.join("\n"), + /each subscription rule must require a mention/, + ); + + const firstChannels = codexConfig.match(/^\s*channels\s*=\s*(\[[\s\S]*?^\s*\])/m)[0]; + const misplacedTableFields = codexConfig + .replace(firstChannels, "") + .replace("require_mention = true", `[other]\n${firstChannels}\nrequire_mention = true`); + assert.match( + validateSubscriptionProjection(misplacedTableFields, manifest, identityMap).errors.join("\n"), + /each subscription rule must contain exactly one channel array/, + ); + + const commentedTableBoundary = codexConfig.replace( + "require_mention = true", + "[other] # valid trailing comment\nrequire_mention = true", + ); + assert.match( + validateSubscriptionProjection(commentedTableBoundary, manifest, identityMap).errors.join("\n"), + /each subscription rule must require a mention/, + ); + + const multilineBypass = codexConfig + .replace(firstChannels, `channels = "all"\nignored = """\n${firstChannels}`) + .replace("require_mention = true", 'require_mention = true\n"""'); + assert.match( + validateSubscriptionProjection(multilineBypass, manifest, identityMap).errors.join("\n"), + /must not contain multiline strings/, + ); + + const duplicateName = codexConfig.replace( + 'name = "aeon-aspect-offices"', + 'name = "aeon-shared-control"', + ); + assert.match( + validateSubscriptionProjection(duplicateName, manifest, identityMap).errors.join("\n"), + /canonical shared-control and Aspect-office rules/, + ); + + const malformedHeader = codexConfig.replace("[[rules]]", "[[rules]]garbage"); + assert.match( + validateSubscriptionProjection(malformedHeader, manifest, identityMap).errors.join("\n"), + /exactly two rules/, + ); + + const unknownPreamble = `unexpected = true\n${codexConfig}`; + assert.match( + validateSubscriptionProjection(unknownPreamble, manifest, identityMap).errors.join("\n"), + /must not contain content outside the two canonical rules/, + ); + + const unknownTable = `${codexConfig}\n[unexpected]\nvalue = true\n`; + assert.match( + validateSubscriptionProjection(unknownTable, manifest, identityMap).errors.join("\n"), + /must not contain content outside the two canonical rules/, + ); +}); + +test("renderer pins one full-access codex-acp subprocess", () => { + const worker = renderWorker(manifest, identityMap); + assert.equal(worker.command, "/usr/bin/env"); + assert.equal(worker.environment.INITIAL_AGENT_MODE, "agent-full-access"); + assert.equal( + worker.environment.CODEX_HOME, + "/Users/architect/Library/Application Support/AEON/aeon-v6/codex-home", + ); + assert.equal(worker.args[worker.args.indexOf("--agents") + 1], "1"); + assert.equal(worker.args[worker.args.indexOf("--permission-mode") + 1], "default"); + assert.equal( + worker.args[worker.args.indexOf("--agent-command") + 1], + manifest.runtime.codexAcp.binary, + ); + assert.equal( + worker.args[worker.args.indexOf("--system-prompt-file") + 1], + manifest.runtime.systemPromptPath, + ); + assert.match(manifest.runtime.systemPromptSha256, /^[0-9a-f]{64}$/); + assert.equal(worker.args[worker.args.indexOf("--model") + 1], manifest.runtime.codexAcp.model); +}); + +test("renderer pins one Claude ACP subprocess and installed Claude Code", () => { + const worker = renderWorker(claudeManifest, identityMap); + assert.equal(worker.command, "/usr/bin/env"); + assert.deepEqual(worker.args.slice(0, 5), [ + "-u", + "ANTHROPIC_API_KEY", + "-u", + "ANTHROPIC_AUTH_TOKEN", + claudeManifest.runtime.buzzAcpBinary, + ]); + assert.equal(worker.args.includes("--agent-publisher-credentials"), true); + assert.equal(worker.args.includes("--no-agent-publisher-credentials"), false); + assert.equal( + worker.args[worker.args.indexOf("--agent-command") + 1], + "/Users/architect/Library/Application Support/AEON/aeon-v6/claude-acp/0.62.0/node_modules/.bin/claude-agent-acp", + ); + assert.equal(worker.args[worker.args.indexOf("--agents") + 1], "1"); + assert.equal(worker.args[worker.args.indexOf("--permission-mode") + 1], "bypass-permissions"); + assert.equal( + worker.args[worker.args.indexOf("--agent-command") + 1], + claudeManifest.runtime.claudeAcp.binary, + ); + assert.equal( + worker.signerFile, + "/Users/architect/Library/Application Support/AEON/aeon-v6/secrets/claude-code.sk", + ); + assert.equal(worker.expectedPublicKey, identityMap.members.claude_code.pubkey_hex); + assert.equal( + worker.environment.CLAUDE_CODE_EXECUTABLE, + "/Users/architect/.local/share/claude/versions/2.1.220", + ); + assert.equal(worker.environment.CLAUDE_CONFIG_DIR, undefined); + assert.equal(worker.environment.ANTHROPIC_API_KEY, undefined); + assert.equal(worker.environment.ANTHROPIC_AUTH_TOKEN, undefined); + assert.equal(worker.environment.RUST_LOG, undefined); + assert.equal( + worker.environment.PATH.split(":")[0], + "/Users/architect/.nvm/versions/node/v24.1.0/bin", + ); + assert.equal( + worker.environment.PATH.includes( + "/Volumes/AEON/runtime/aeon-v6-state/service-runtime/current/bin", + ), + false, + ); +}); + +test("renderer pins one native Cursor ACP subprocess with a proven operational model", () => { + const worker = renderWorker(cursorManifest, identityMap); + const adapter = cursorManifest.runtime.cursorAcp; + assert.equal(worker.command, "/usr/bin/env"); + assert.deepEqual(worker.args.slice(0, 5), [ + "-u", + "CURSOR_API_KEY", + "-u", + "CURSOR_API_ENDPOINT", + cursorManifest.runtime.buzzAcpBinary, + ]); + assert.equal( + worker.args[worker.args.indexOf("--agent-command") + 1], + `${adapter.root}/node`, + ); + assert.deepEqual( + worker.args.filter((value) => value.startsWith("--agent-args=")), + [ + `--agent-args=--use-system-ca,${cursorManifest.runtime.bootstrapPath},/Volumes/AEON/Projects/aeon-v6,${adapter.root}/index.js,--trust,acp`, + ], + ); + assert.equal(worker.args.includes("--agent-args"), false); + assert.equal( + worker.args[worker.args.lastIndexOf("--model") + 1], + "grok-4.5[effort=high,fast=true]", + ); + assert.equal( + worker.args[worker.args.indexOf("--system-prompt-file") + 1], + cursorManifest.runtime.systemPromptPath, + ); + assert.match(cursorManifest.runtime.systemPromptSha256, /^[0-9a-f]{64}$/); + assert.equal(worker.args.includes("--no-base-prompt"), true); + assert.equal(worker.args.includes("--no-memory"), true); + assert.equal( + worker.args[worker.args.indexOf("--context-message-limit") + 1], + "0", + ); + assert.doesNotMatch( + worker.args.find((value) => value.startsWith("--agent-args=")), + /--model/, + ); + assert.equal(adapter.model.requested, "cursor-grok-4.5-high"); + assert.equal(adapter.model.effective, "grok-4.5[effort=high,fast=true]"); + assert.equal(adapter.model.selectionStatus, "upstream_limited_to_fast_wire_variant"); + assert.equal( + worker.args[worker.args.indexOf("--session-cwd") + 1], + "/Volumes/AEON/Projects/aeon-v6", + ); + assert.equal(worker.args[worker.args.indexOf("--permission-mode") + 1], "bypass-permissions"); + assert.equal(worker.environment.CURSOR_API_KEY, undefined); + assert.equal(worker.environment.CURSOR_API_ENDPOINT, undefined); + assert.equal(worker.signerFile, cursorManifest.runtime.signerPath); + assert.equal(worker.expectedPublicKey, identityMap.members.cursor_cli.pubkey_hex); +}); + +test("Codex and Claude omit --agent-args when adapters have no child args", () => { + for (const worker of [ + renderWorker(manifest, identityMap), + renderWorker(claudeManifest, identityMap), + ]) { + assert.equal( + worker.args.some( + (value) => value === "--agent-args" || value.startsWith("--agent-args="), + ), + false, + ); + } +}); + +test("renderer pins one native Grok ACP subprocess with full coding authority", () => { + const worker = renderWorker(grokManifest, identityMap); + assert.equal(worker.command, "/usr/bin/env"); + assert.equal(worker.sessionCwd, "/Volumes/AEON/Projects/aeon-v6"); + assert.equal(worker.environment.HOME, "/Users/architect"); + assert.equal(worker.environment.PATH.startsWith("/Users/architect/.grok/bin:"), true); + assert.equal(worker.environment.PATH.includes("/Users/architect/.local/bin"), true); + const buzzPublisher = worker.environment.PATH.split(":") + .map((directory) => join(directory, "buzz")) + .find((candidate) => existsSync(candidate)); + assert.equal(buzzPublisher, "/Users/architect/.local/bin/buzz"); + assert.equal( + worker.args.find((value) => value.startsWith("--agent-args=")), + `--agent-args=${grokManifest.runtime.grokAcp.args.join(",")}`, + ); + assert.equal(worker.args.includes("--agent-args"), false); + // Regression: Grok model/reasoning stay inside the single packed --agent-args= + // token; buzz-acp must not see them as top-level flags (Cursor alone uses --model). + assert.equal( + worker.args.includes("--model") || worker.args.includes("--reasoning-effort"), + false, + ); + assert.equal(worker.args[worker.args.indexOf("--permission-mode") + 1], "bypass-permissions"); + assert.equal(worker.args.filter((arg) => arg === "--agent-publisher-credentials").length, 1); + assert.equal(worker.expectedPublicKey, identityMap.members.grok_cli.pubkey_hex); + assert.deepEqual( + worker.subscriptionRoomIds, + renderWorker(manifest, identityMap).subscriptionRoomIds, + ); +}); + +test("Claude rendered command scrubs ambient API credentials from its child", () => { + const worker = renderWorker(claudeManifest, identityMap); + const buzzBinaryIndex = worker.args.indexOf(claudeManifest.runtime.buzzAcpBinary); + const probe = spawnSync( + worker.command, + [ + ...worker.args.slice(0, buzzBinaryIndex), + process.execPath, + "-e", + "process.stdout.write(JSON.stringify({key:process.env.ANTHROPIC_API_KEY,token:process.env.ANTHROPIC_AUTH_TOKEN}))", + ], + { + encoding: "utf8", + env: { + ...process.env, + ANTHROPIC_API_KEY: "ambient-key", + ANTHROPIC_AUTH_TOKEN: "ambient-token", + }, + }, + ); + assert.equal(probe.status, 0, probe.stderr); + assert.deepEqual(JSON.parse(probe.stdout), {}); +}); + +test("renderer pins Architect and all six Aspects as inbound authority", () => { + const expectedAllowlist = [ + identityMap.members.nexus.pubkey_hex, + identityMap.members.mechanon.pubkey_hex, + identityMap.members.fontis.pubkey_hex, + identityMap.members.sapientis.pubkey_hex, + identityMap.members.viatica.pubkey_hex, + identityMap.members.voxis.pubkey_hex, + ]; + for (const workerManifest of [manifest, claudeManifest, cursorManifest, grokManifest]) { + const worker = renderWorker(workerManifest, identityMap); + const allowlist = worker.args[worker.args.indexOf("--respond-to-allowlist") + 1].split(","); + assert.deepEqual(allowlist, expectedAllowlist); + assert.equal( + worker.args[worker.args.indexOf("--agent-owner") + 1], + identityMap.members.architect.pubkey_hex, + ); + assert.equal(worker.args[worker.args.indexOf("--respond-to") + 1], "strict-allowlist"); + assert.equal( + worker.args[worker.args.indexOf("--allowed-respond-to") + 1], + "strict-allowlist", + ); + for (const externalSeat of ["codex_cli", "claude_code", "cursor_cli", "grok_cli"]) { + assert.equal(allowlist.includes(identityMap.members[externalSeat].pubkey_hex), false); + } + } +}); + +test("renderer rejects missing or external-seat inbound authority", () => { + const missingAspect = structuredClone(manifest); + missingAspect.buzz.allowedInbound = missingAspect.buzz.allowedInbound.filter( + (memberId) => memberId !== "voxis", + ); + assert.throws( + () => renderWorker(missingAspect, identityMap), + /inbound allowlist must be exactly Architect and the six canonical Aspects/, + ); + + const externalSeat = structuredClone(manifest); + externalSeat.buzz.allowedInbound = [...externalSeat.buzz.allowedInbound, "cursor_cli"]; + assert.throws( + () => renderWorker(externalSeat, identityMap), + /inbound allowlist must be exactly Architect and the six canonical Aspects/, + ); +}); + +test("renderer rejects colliding authorities and Aspects absent from their offices", () => { + const collidingIdentityMap = structuredClone(identityMap); + collidingIdentityMap.members.fontis.pubkey_hex = + collidingIdentityMap.members.cursor_cli.pubkey_hex; + assert.throws( + () => renderWorker(manifest, collidingIdentityMap), + /Architect, Aspect, and external CLI pubkeys must be unique/, + ); + + const missingOfficeMember = structuredClone(identityMap); + missingOfficeMember.channels.aspect_fontis.members = + missingOfficeMember.channels.aspect_fontis.members.filter( + (memberId) => memberId !== "fontis", + ); + assert.throws( + () => renderWorker(manifest, missingOfficeMember), + /aspect_fontis: fontis is not a member/, + ); + + const missingSharedRoomMember = structuredClone(identityMap); + missingSharedRoomMember.channels.concilium.members = + missingSharedRoomMember.channels.concilium.members.filter( + (memberId) => memberId !== "fontis", + ); + assert.throws( + () => renderWorker(manifest, missingSharedRoomMember), + /concilium: fontis is not a member/, + ); +}); + +test("workspace selection is bounded to the manifest allowlist", () => { + const codexWorker = renderWorker(manifest, identityMap, "buzz"); + assert.equal( + codexWorker.workingDirectory, + "/Users/architect/Library/Application Support/AEON/aeon-v6", + ); + assert.equal(codexWorker.sessionCwd, "/Volumes/AEON/Projects/buzz"); + assert.equal( + codexWorker.args[codexWorker.args.indexOf("--session-cwd") + 1], + "/Volumes/AEON/Projects/buzz", + ); + assert.throws(() => renderWorker(manifest, identityMap, "/tmp/escape"), /not allowed/); + const claudeWorker = renderWorker(claudeManifest, identityMap, "codex"); + assert.equal( + claudeWorker.workingDirectory, + "/Users/architect/Library/Application Support/AEON/aeon-v6", + ); + assert.equal(claudeWorker.sessionCwd, "/Volumes/AEON/Projects/codex"); + assert.equal( + claudeWorker.args[claudeWorker.args.indexOf("--session-cwd") + 1], + "/Volumes/AEON/Projects/codex", + ); + const cursorWorker = renderWorker(cursorManifest, identityMap, "buzz"); + assert.equal( + cursorWorker.workingDirectory, + "/Users/architect/Library/Application Support/AEON/aeon-v6", + ); + assert.equal(cursorWorker.sessionCwd, "/Volumes/AEON/Projects/buzz"); + assert.equal( + cursorWorker.args.find((value) => value.startsWith("--agent-args=")), + `--agent-args=--use-system-ca,${cursorManifest.runtime.bootstrapPath},/Volumes/AEON/Projects/buzz,${cursorManifest.runtime.cursorAcp.root}/index.js,--trust,acp`, + ); + assert.equal( + cursorWorker.args[cursorWorker.args.indexOf("--session-cwd") + 1], + "/Volumes/AEON/Projects/buzz", + ); + const grokWorker = renderWorker(grokManifest, identityMap, "aeon-v6"); + assert.equal( + grokWorker.workingDirectory, + "/Users/architect/Library/Application Support/AEON/aeon-v6", + ); + assert.equal( + grokWorker.args.find((value) => value.startsWith("--agent-args=")), + `--agent-args=${grokManifest.runtime.grokAcp.args.join(",")}`, + ); +}); + +test("launchd artifact remains inert and secret-free", () => { + const artifact = renderDisabledLaunchAgent(manifest, identityMap); + assert.equal(artifact.runAtLoad, false); + assert.equal(artifact.keepAlive, false); + assert.match(artifact.plist, /RunAtLoad<\/key>/); + assert.match(artifact.plist, /KeepAlive<\/key>/); + assert.match(artifact.plist, /INITIAL_AGENT_MODE<\/key>agent-full-access/); + assert.doesNotMatch(artifact.plist, /BUZZ_PRIVATE_KEY|nsec1/); + assert.deepEqual(artifact.requiredDirectories, [ + "/Users/architect/Library/Application Support/AEON/aeon-v6/buzz", + "/Users/architect/Library/Application Support/AEON/aeon-v6/logs", + "/Users/architect/Library/Application Support/AEON/aeon-v6/secrets", + "/Users/architect/Library/Application Support/AEON/aeon-v6", + "/Volumes/AEON/Projects/aeon-v6", + ]); +}); + +test("Claude launchd artifact is separate, inert, and secret-free", () => { + const artifact = renderDisabledLaunchAgent(claudeManifest, identityMap); + assert.equal(artifact.label, "org.aeon.buzz-acp.claude-cli"); + assert.equal(artifact.runAtLoad, false); + assert.equal(artifact.keepAlive, false); + assert.match(artifact.plist, /CLAUDE_CODE_EXECUTABLE/); + assert.match(artifact.plist, /-u<\/string>\s+ANTHROPIC_API_KEY<\/string>/); + assert.match(artifact.plist, /-u<\/string>\s+ANTHROPIC_AUTH_TOKEN<\/string>/); + assert.doesNotMatch( + artifact.plist, + /ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CONFIG_DIR|nsec1|sk-ant-/, + ); + assert.deepEqual(artifact.requiredDirectories, [ + "/Users/architect/Library/Application Support/AEON/aeon-v6/buzz", + "/Users/architect/Library/Application Support/AEON/aeon-v6/logs", + "/Users/architect/Library/Application Support/AEON/aeon-v6/secrets", + "/Users/architect/Library/Application Support/AEON/aeon-v6", + "/Volumes/AEON/Projects/aeon-v6", + ]); + assert.match( + artifact.plist, + /\/Users\/architect\/Library\/Application Support\/AEON\/aeon-v6\/bin\/buzz-acp/, + ); + assert.doesNotMatch(artifact.plist, /buzz-acp-claude-cli/); + assert.doesNotMatch(artifact.plist, /\/Volumes\/AEON\/runtime\/buzz\/external-cli\/claude_cli/); + assert.match( + artifact.plist, + /\/Users\/architect\/Library\/Application Support\/AEON\/aeon-v6\/secrets\/claude-code\.sk/, + ); + assert.match( + artifact.plist, + /WorkingDirectory<\/key>\/Users\/architect\/Library\/Application Support\/AEON\/aeon-v6<\/string>/, + ); + assert.match(artifact.plist, /\/Users\/architect\/\.nvm\/versions\/node\/v24\.1\.0\/bin/); + assert.match( + artifact.plist, + /--session-cwd<\/string>\s+\/Volumes\/AEON\/Projects\/aeon-v6<\/string>/, + ); + assert.doesNotMatch(artifact.plist, /RUST_LOG/); + assert.doesNotMatch( + artifact.plist, + /\/Volumes\/AEON\/Projects\/buzz-data\/keys\/claude_code\.sk/, + ); +}); + +test("Cursor launchd artifact is separate, inert, and secret-free", () => { + const artifact = renderDisabledLaunchAgent(cursorManifest, identityMap); + assert.equal(artifact.label, "org.aeon.buzz-acp.cursor-cli"); + assert.equal(artifact.runAtLoad, false); + assert.equal(artifact.keepAlive, false); + assert.match(artifact.plist, /-u<\/string>\s+CURSOR_API_KEY<\/string>/); + assert.match(artifact.plist, /-u<\/string>\s+CURSOR_API_ENDPOINT<\/string>/); + assert.doesNotMatch(artifact.plist, /CURSOR_API_KEY|CURSOR_API_ENDPOINT|nsec1/); + assert.match(artifact.plist, /grok-4\.5\[effort=high,fast=true\]/); + assert.match(artifact.plist, /cursor-cli-system\.md/); + assert.match(artifact.plist, /--no-base-prompt<\/string>/); + assert.match(artifact.plist, /--session-cwd<\/string>/); + assert.match( + artifact.plist, + /\/Users\/architect\/\.local\/share\/cursor-agent\/versions\/2026\.07\.23-e383d2b\/node/, + ); + assert.match(artifact.plist, /cursor-acp-bootstrap\.cjs/); + assert.match( + artifact.plist, + /--agent-args=--use-system-ca,\/Users\/architect\/Library\/Application Support\/AEON\/aeon-v6\/buzz\/cursor-acp-bootstrap\.cjs,\/Volumes\/AEON\/Projects\/aeon-v6,\/Users\/architect\/\.local\/share\/cursor-agent\/versions\/2026\.07\.23-e383d2b\/index\.js,--trust,acp/, + ); + assert.match( + artifact.plist, + /WorkingDirectory<\/key>\/Users\/architect\/Library\/Application Support\/AEON\/aeon-v6<\/string>/, + ); + assert.deepEqual(artifact.requiredDirectories, [ + "/Users/architect/Library/Application Support/AEON/aeon-v6/buzz", + "/Users/architect/Library/Application Support/AEON/aeon-v6/logs", + "/Users/architect/Library/Application Support/AEON/aeon-v6/secrets", + "/Users/architect/Library/Application Support/AEON/aeon-v6", + "/Volumes/AEON/Projects/aeon-v6", + ]); +}); + +test("Cursor ACP bootstrap is isolated from the other CLI seats", () => { + for (const workerManifest of [manifest, claudeManifest, grokManifest]) { + const worker = renderWorker(workerManifest, identityMap); + assert.equal(worker.command, "/usr/bin/env"); + assert.equal(worker.args.includes(cursorManifest.runtime.bootstrapPath), false); + } +}); + +test("Grok launchd artifact is separate, inert, and scrubs auth overrides", () => { + const artifact = renderDisabledLaunchAgent(grokManifest, identityMap); + assert.equal(artifact.label, "org.aeon.buzz-acp.grok-cli"); + assert.equal(artifact.runAtLoad, false); + assert.equal(artifact.keepAlive, false); + for (const name of ["XAI_API_KEY", "GROK_AUTH", "GROK_HOME", "XAI_API_BASE_URL"]) { + assert.match(artifact.plist, new RegExp(`-u<\\/string>\\s+${name}<\\/string>`)); + } + assert.doesNotMatch(artifact.plist, /XAI_API_KEY|GROK_AUTH|GROK_HOME|nsec1/); + assert.match( + artifact.plist, + /--agent-args=agent,--model,grok-4\.5,--reasoning-effort,high,--always-approve,stdio<\/string>/, + ); +}); + +test("Claude and Cursor/Grok launch projections retain their current behavior", () => { + const claude = renderWorker(claudeManifest, identityMap); + assert.deepEqual( + { + command: claude.command, + principal: claude.expectedPublicKey, + permissionMode: claude.args[claude.args.indexOf("--permission-mode") + 1], + publisherFlagCount: claude.args.filter((arg) => arg === "--agent-publisher-credentials") + .length, + rooms: claude.subscriptionRoomIds, + }, + { + command: "/usr/bin/env", + principal: identityMap.members.claude_code.pubkey_hex, + permissionMode: "bypass-permissions", + publisherFlagCount: 1, + rooms: renderWorker(manifest, identityMap).subscriptionRoomIds, + }, + ); + + const cursor = renderWorker(cursorManifest, identityMap); + assert.deepEqual( + { + command: cursor.command, + principal: cursor.expectedPublicKey, + model: cursorManifest.runtime.cursorAcp.model.effective, + permissionMode: cursor.args[cursor.args.indexOf("--permission-mode") + 1], + publisherFlagCount: cursor.args.filter((arg) => arg === "--agent-publisher-credentials") + .length, + rooms: cursor.subscriptionRoomIds, + }, + { + command: "/usr/bin/env", + principal: identityMap.members.cursor_cli.pubkey_hex, + model: "grok-4.5[effort=high,fast=true]", + permissionMode: "bypass-permissions", + publisherFlagCount: 1, + rooms: renderWorker(manifest, identityMap).subscriptionRoomIds, + }, + ); +}); + +test("Codex plist, validator summary, and validator log expose no secrets", () => { + const sentinelSecret = "nsec1-validator-secret-sentinel"; + const artifact = renderDisabledLaunchAgent(manifest, identityMap); + const validation = spawnSync( + process.execPath, + [join(here, "validate.mjs"), "--worker", "codex_cli"], + { + encoding: "utf8", + env: { + ...process.env, + BUZZ_PRIVATE_KEY: sentinelSecret, + }, + }, + ); + assert.equal(validation.status, 0, validation.stderr); + const summary = JSON.parse(validation.stdout); + assert.equal(summary.principal, "codex_cli"); + assert.equal(summary.agentMode, "agent-full-access"); + assert.equal(summary.roomCount, 8); + assert.equal(summary.publisherCredentials, "managed"); + + for (const output of [artifact.plist, validation.stdout, validation.stderr]) { + assert.doesNotMatch(output, /BUZZ_PRIVATE_KEY|nsec1/); + assert.equal(output.includes(sentinelSecret), false); + } +}); + +test("Claude authority contract rejects missing identity and mode drift", () => { + const missingIdentity = structuredClone(identityMap); + delete missingIdentity.members.claude_code; + assert.match( + validateManifest(claudeManifest, missingIdentity).errors.join("\n"), + /identity map is missing claude_code/, + ); + + const duplicateIdentity = structuredClone(claudeManifest); + duplicateIdentity.worker.principal = "claude_cli"; + assert.match( + validateManifest(duplicateIdentity, identityMap).errors.join("\n"), + /must bind to claude_code/, + ); + + const modeDrift = structuredClone(claudeManifest); + modeDrift.posture.permissionMode = "default"; + assert.match( + validateManifest(modeDrift, identityMap).errors.join("\n"), + /must be bypass-permissions/, + ); + const missingPromptPin = structuredClone(cursorManifest); + delete missingPromptPin.runtime.systemPromptSha256; + assert.match( + validateManifest(missingPromptPin, identityMap).errors.join("\n"), + /systemPromptSha256/, + ); + + const adapterDrift = structuredClone(claudeManifest); + adapterDrift.runtime.claudeAcp.integrity = "sha512-ZHJpZnQ="; + assert.match( + validateManifest(adapterDrift, identityMap).errors.join("\n"), + /package integrity drift/, + ); + + const closureDrift = structuredClone(claudeManifest); + closureDrift.runtime.claudeAcp.closureSha256 = "0".repeat(64); + assert.match( + validateManifest(closureDrift, identityMap).errors.join("\n"), + /package closure checkpoint drift/, + ); + + const configRelocation = structuredClone(claudeManifest); + configRelocation.runtime.claudeCode.configDir = "/Users/architect/.claude"; + assert.match( + validateManifest(configRelocation, identityMap).errors.join("\n"), + /config directory override must be absent/, + ); + + const volumeRuntime = structuredClone(claudeManifest); + volumeRuntime.runtime.buzzAcpBinary = "/Volumes/AEON/runtime/buzz-acp"; + assert.match( + validateManifest(volumeRuntime, identityMap).errors.join("\n"), + /canonical Data-volume path/, + ); + + const sharedHarnessDrift = structuredClone(claudeManifest); + sharedHarnessDrift.runtime.buzzAcpSha256 = "0".repeat(64); + assert.match( + validateManifest(sharedHarnessDrift, identityMap).errors.join("\n"), + /shared buzz-acp checkpoint drift/, + ); + + const signerDrift = structuredClone(claudeManifest); + signerDrift.runtime.signerPath = identityMap.members.claude_code.secret_ref; + assert.match( + validateManifest(signerDrift, identityMap).errors.join("\n"), + /launchd-safe Data-volume path/, + ); + + const nodeDrift = structuredClone(claudeManifest); + nodeDrift.runtime.node.sha256 = "0".repeat(64); + assert.match( + validateManifest(nodeDrift, identityMap).errors.join("\n"), + /Node runtime checkpoint drift/, + ); + + const nodePathFallback = structuredClone(claudeManifest); + nodePathFallback.runtime.path.reverse(); + assert.match( + validateManifest(nodePathFallback, identityMap).errors.join("\n"), + /Node runtime first/, + ); +}); + +test("Cursor contract rejects identity, runtime, auth, and model drift", () => { + const missingIdentity = structuredClone(identityMap); + delete missingIdentity.members.cursor_cli; + assert.match( + validateManifest(cursorManifest, missingIdentity).errors.join("\n"), + /identity map is missing cursor_cli/, + ); + + const modeDrift = structuredClone(cursorManifest); + modeDrift.posture.permissionMode = "default"; + assert.match( + validateManifest(modeDrift, identityMap).errors.join("\n"), + /must be bypass-permissions/, + ); + + for (const mutate of [ + (value) => (value.runtime.cursorAcp.version = "future"), + (value) => (value.runtime.cursorAcp.entrypointSha256 = "0".repeat(64)), + (value) => (value.runtime.cursorAcp.closureSha256 = "0".repeat(64)), + (value) => (value.runtime.cursorAcp.args = ["acp"]), + (value) => (value.runtime.cursorAcp.auth.subscriptionTypes = ["Free"]), + (value) => (value.runtime.cursorAcp.model.requested = "cursor-grok-4.5-high-fast"), + (value) => (value.runtime.cursorAcp.model.effective = "cursor-grok-4.5-high-fast"), + (value) => (value.runtime.signerPath = identityMap.members.cursor_cli.secret_ref), + (value) => (value.runtime.bootstrapPath = "/tmp/cursor-acp-bootstrap.cjs"), + (value) => (value.runtime.bootstrapSha256 = "0".repeat(64)), + ]) { + const drift = structuredClone(cursorManifest); + mutate(drift); + assert.match(validateManifest(drift, identityMap).errors.join("\n"), /Cursor/); + } +}); + +test("Grok contract rejects identity, runtime, auth, and model drift", () => { + const missingIdentity = structuredClone(identityMap); + delete missingIdentity.members.grok_cli; + assert.match( + validateManifest(grokManifest, missingIdentity).errors.join("\n"), + /identity map is missing grok_cli/, + ); + + for (const mutate of [ + (value) => (value.runtime.grokAcp.version = "future"), + (value) => (value.runtime.grokAcp.entrypointSha256 = "0".repeat(64)), + (value) => (value.runtime.grokAcp.args = ["agent", "stdio"]), + (value) => (value.runtime.grokAcp.auth.provider = "api-key"), + (value) => (value.runtime.grokAcp.model.effective = "grok-4-fast"), + (value) => (value.runtime.signerPath = identityMap.members.grok_cli.secret_ref), + ]) { + const drift = structuredClone(grokManifest); + mutate(drift); + assert.match(validateManifest(drift, identityMap).errors.join("\n"), /Grok/); + } +}); + +test("Grok worker rejects ambient API and auth overrides", () => { + const clean = validateAmbientGrokOverrides({}); + assert.equal(clean.ok, true); + const dirty = validateAmbientGrokOverrides({ + XAI_API_KEY: "sentinel", + GROK_HOME: "/tmp/other", + }); + assert.equal(dirty.ok, false); + assert.deepEqual(dirty.errors, [ + "XAI_API_KEY must be absent for Grok subscription authentication", + "GROK_HOME must be absent for Grok subscription authentication", + ]); +}); + +test("Codex rejects shared harness path and digest drift under the safe supervisor", () => { + const pathDrift = structuredClone(manifest); + pathDrift.runtime.buzzAcpBinary = "/Volumes/AEON/runtime/buzz-acp"; + assert.match( + validateManifest(pathDrift, identityMap).errors.join("\n"), + /canonical Data-volume path/, + ); + + const digestDrift = structuredClone(manifest); + digestDrift.runtime.buzzAcpSha256 = "0".repeat(64); + assert.match( + validateManifest(digestDrift, identityMap).errors.join("\n"), + /shared buzz-acp checkpoint drift/, + ); + + const artifact = renderDisabledLaunchAgent(manifest, identityMap, "codex"); + assert.equal( + artifact.workingDirectory, + "/Users/architect/Library/Application Support/AEON/aeon-v6", + ); + assert.equal(artifact.sessionCwd, "/Volumes/AEON/Projects/codex"); + assert.equal( + artifact.args[artifact.args.indexOf("--session-cwd") + 1], + "/Volumes/AEON/Projects/codex", + ); +}); + +test("Claude package closure digest detects adapter and dependency changes", () => { + const root = mkdtempSync(join(tmpdir(), "claude-agent-acp-closure-")); + try { + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync(join(root, "dist", "index.js"), "entrypoint\n"); + const sibling = join(root, "dist", "acp-agent.js"); + writeFileSync(sibling, "original sibling\n"); + mkdirSync(join(root, "node_modules", "dependency"), { recursive: true }); + const dependency = join(root, "node_modules", "dependency", "index.js"); + writeFileSync(dependency, "original dependency\n"); + + const initial = hashPackageClosure(root); + assert.equal(hashPackageClosure(root), initial); + writeFileSync(sibling, "modified sibling\n"); + const siblingChanged = hashPackageClosure(root); + assert.notEqual(siblingChanged, initial); + writeFileSync(sibling, "original sibling\n"); + writeFileSync(dependency, "modified dependency\n"); + assert.notEqual(hashPackageClosure(root), initial); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("Cursor closure digest excludes only transient running markers", () => { + const root = mkdtempSync(join(tmpdir(), "cursor-agent-closure-")); + try { + writeFileSync(join(root, "index.js"), "entrypoint\n"); + mkdirSync(join(root, ".running")); + writeFileSync(join(root, ".running", "first"), "pid\n"); + const initial = hashCursorClosure(root); + writeFileSync(join(root, ".running", "second"), "different pid\n"); + assert.equal(hashCursorClosure(root), initial); + writeFileSync(join(root, "index.js"), "changed\n"); + assert.notEqual(hashCursorClosure(root), initial); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("Claude runtime auth requires the pinned subscription provider and type", () => { + const contract = claudeManifest.runtime.claudeCode.auth; + const valid = { + loggedIn: true, + authMethod: "claude.ai", + apiProvider: "firstParty", + subscriptionType: "pro", + }; + assert.deepEqual(validateClaudeSubscriptionAuth(valid, contract), { + ok: true, + errors: [], + }); + + const wrongMethod = validateClaudeSubscriptionAuth({ ...valid, authMethod: "apiKey" }, contract); + assert.equal(wrongMethod.ok, false); + assert.match(wrongMethod.errors.join("\n"), /auth method/); + + const wrongProvider = validateClaudeSubscriptionAuth( + { ...valid, apiProvider: "bedrock" }, + contract, + ); + assert.equal(wrongProvider.ok, false); + assert.match(wrongProvider.errors.join("\n"), /API provider/); + + const wrongSubscription = validateClaudeSubscriptionAuth( + { ...valid, subscriptionType: "free" }, + contract, + ); + assert.equal(wrongSubscription.ok, false); + assert.match(wrongSubscription.errors.join("\n"), /subscription type/); +}); + +test("Claude runtime rejects ambient API credentials without exposing values", () => { + assert.deepEqual(validateAmbientAnthropicCredentials({}), { + ok: true, + errors: [], + }); + const result = validateAmbientAnthropicCredentials({ + ANTHROPIC_API_KEY: "secret-api-key", + ANTHROPIC_AUTH_TOKEN: "secret-auth-token", + }); + assert.equal(result.ok, false); + assert.deepEqual(result.errors, [ + "ANTHROPIC_API_KEY must be absent for Claude subscription authentication", + "ANTHROPIC_AUTH_TOKEN must be absent for Claude subscription authentication", + ]); + assert.doesNotMatch(result.errors.join("\n"), /secret-/); +}); + +test("Cursor runtime requires the pinned subscription without exposing account data", () => { + const contract = cursorManifest.runtime.cursorAcp.auth; + const validStatus = { status: "authenticated", isAuthenticated: true }; + const validAbout = { subscriptionTier: "Pro" }; + assert.deepEqual(validateCursorSubscriptionAuth(validStatus, validAbout, contract), { + ok: true, + errors: [], + }); + assert.match( + validateCursorSubscriptionAuth( + { ...validStatus, isAuthenticated: false }, + validAbout, + contract, + ).errors.join("\n"), + /unavailable/, + ); + assert.match( + validateCursorSubscriptionAuth(validStatus, { subscriptionTier: "Free" }, contract).errors.join( + "\n", + ), + /subscription type/, + ); +}); + +test("Cursor worker scrubs API and endpoint overrides", () => { + assert.deepEqual(validateAmbientCursorOverrides({}), { + ok: true, + errors: [], + }); + const result = validateAmbientCursorOverrides({ + CURSOR_API_KEY: "secret-key", + CURSOR_API_ENDPOINT: "https://example.invalid", + }); + assert.equal(result.ok, false); + assert.deepEqual(result.errors, [ + "CURSOR_API_KEY must be absent for Cursor subscription authentication", + "CURSOR_API_ENDPOINT must be absent for Cursor subscription authentication", + ]); + assert.doesNotMatch(result.errors.join("\n"), /secret-key|example\.invalid/); +}); + +test("Claude Node validation rejects mode, symlink, hash, and version drift", () => { + const root = mkdtempSync(join(tmpdir(), "claude-node-runtime-")); + try { + const binary = join(root, "node"); + writeFileSync(binary, "#!/bin/sh\nprintf 'v-test\\n'\n"); + chmodSync(binary, 0o500); + const sha256 = createHash("sha256").update("#!/bin/sh\nprintf 'v-test\\n'\n").digest("hex"); + const pin = { binary, mode: "0500", sha256, version: "v-test" }; + assert.deepEqual(validatePinnedNodeRuntime(pin, process.env), { + ok: true, + errors: [], + }); + + chmodSync(binary, 0o400); + const badMode = validatePinnedNodeRuntime(pin, process.env); + assert.equal(badMode.ok, false); + assert.match(badMode.errors.join("\n"), /mode must be 0500/); + chmodSync(binary, 0o500); + + const marker = join(root, "executed"); + chmodSync(binary, 0o700); + writeFileSync(binary, `#!/bin/sh\ntouch '${marker}'\nprintf 'v-test\\n'\n`); + chmodSync(binary, 0o500); + const badHash = validatePinnedNodeRuntime({ ...pin, sha256: "0".repeat(64) }, process.env); + assert.match(badHash.errors.join("\n"), /SHA-256/); + assert.equal(existsSync(marker), false); + chmodSync(binary, 0o700); + writeFileSync(binary, "#!/bin/sh\nprintf 'v-test\\n'\n"); + chmodSync(binary, 0o500); + assert.match( + validatePinnedNodeRuntime({ ...pin, version: "v-wrong" }, process.env).errors.join("\n"), + /version/, + ); + + const link = join(root, "node-link"); + symlinkSync(binary, link); + assert.match( + validatePinnedNodeRuntime({ ...pin, binary: link }, process.env).errors.join("\n"), + /non-symlink/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("verified receipt joins request, session, run, and signed reply", () => { + const requestEventId = "a".repeat(64); + const replyEventId = "b".repeat(64); + const channelId = identityMap.channels.ops.channel_id; + const result = correlateVerifiedReceipt({ + requestEventId, + channelId, + expectedPubkey: identityMap.members.codex_cli.pubkey_hex, + replyEvent: { + id: replyEventId, + pubkey: identityMap.members.codex_cli.pubkey_hex, + kind: 9, + verified: true, + tags: [ + ["h", channelId], + ["e", requestEventId, "", "reply"], + ], + }, + observerRun: { + requestEventId, + replyEventId, + channelId, + sessionId: "codex-acp-session", + runId: "buzz-turn-id", + }, + }); + assert.deepEqual(result, { + requestEventId, + replyEventId, + sessionId: "codex-acp-session", + runId: "buzz-turn-id", + channelId, + }); +}); + +test("receipt correlation rejects unsigned or mismatched replies", () => { + const requestEventId = "a".repeat(64); + const channelId = identityMap.channels.ops.channel_id; + const base = { + requestEventId, + channelId, + expectedPubkey: identityMap.members.codex_cli.pubkey_hex, + replyEvent: { + id: "b".repeat(64), + pubkey: identityMap.members.codex_cli.pubkey_hex, + kind: 9, + verified: false, + tags: [ + ["h", channelId], + ["e", requestEventId, "", "reply"], + ], + }, + observerRun: { + requestEventId, + replyEventId: "b".repeat(64), + channelId, + sessionId: "session", + runId: "run", + }, + }; + assert.throws(() => correlateVerifiedReceipt(base), /signature/); + base.replyEvent.verified = true; + base.observerRun.replyEventId = "c".repeat(64); + assert.throws(() => correlateVerifiedReceipt(base), /correlation/); +});