Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-terminal/src/enhance.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

679 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Terminal launch enhancement: the single PTY-spawn seam that renders
//! platform capabilities (today: MCP servers) into each agent CLI's NATIVE
//! launch config. Per-CLI knowledge is isolated into `AgentCli` + renderers;
//! unknown CLIs get nothing (honest — no pretense, no pollution).
use std::collections::HashMap;
use std::path::Path;
/// One MCP server to inject into a terminal-launched CLI. Backend-agnostic; a
/// per-CLI renderer turns this into that CLI's native MCP config.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpServerSpec {
/// Wire-level server name (e.g. "nomifun-knowledge"). Must be a bare-key-safe
/// identifier (ASCII alphanumeric, `-`, `_`) for codex dotted-key rendering.
pub name: String,
/// Program that launches the stdio bridge (the backend's own executable).
pub command: String,
/// Bridge subcommand args (e.g. ["mcp-knowledge-stdio"]).
pub args: Vec<String>,
/// Env baked into the bridge process (port/token/scope).
pub env: HashMap<String, String>,
}
/// Lifecycle hook wiring baked into a terminal spawn so the CLI's native hooks
/// can call back to the in-process `TerminalLifecycleServer`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleHookWiring {
pub port: u16,
pub token: String,
pub terminal_id: i64,
/// Absolute path to the backend binary (`nomicore`); used as the hook command
/// prefix (`<bin> terminal-hook --event <kind>`).
pub binary_path: String,
}
/// Everything the platform injects into one terminal launch: MCP servers +
/// lifecycle hook wiring.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TerminalLaunchEnhancement {
pub mcp_servers: Vec<McpServerSpec>,
pub lifecycle: Option<LifecycleHookWiring>,
}
impl TerminalLaunchEnhancement {
pub fn is_empty(&self) -> bool {
self.mcp_servers.is_empty() && self.lifecycle.is_none()
}
}
/// Which agent CLI a launch program is, for capability injection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentCli {
Claude,
Codex,
Gemini,
}
/// Map a stem (file name without extension, lowercased) to a known agent family.
fn family_from_stem(s: &str) -> Option<AgentCli> {
let stem = Path::new(s)
.file_stem()
.and_then(|x| x.to_str())?
.to_ascii_lowercase();
match stem.as_str() {
"claude" => Some(AgentCli::Claude),
"codex" => Some(AgentCli::Codex),
"gemini" => Some(AgentCli::Gemini),
_ => None,
}
}
/// Map a launch to its agent family for platform-MCP injection/registration.
/// Resolution order: explicitly DECLARED backend (preset/user) → the program's
/// own stem → a known family token among the args (wrapper/launcher like
/// `stepcode claude`, `npx codex`) → None (honest: unknown CLI).
pub fn resolve_agent_family(
program: &str,
args: &[String],
declared_backend: Option<&str>,
) -> Option<AgentCli> {
// 1. Declared backend wins (user/preset explicitly said "this is codex").
if let Some(b) = declared_backend.and_then(family_from_stem) {
return Some(b);
}
// 2. Program stem.
if let Some(p) = family_from_stem(program) {
return Some(p);
}
// 3. Wrapper: scan args for the FIRST token whose stem is a known family.
args.iter().find_map(|a| family_from_stem(a))
}
/// Resolve a launch program (absolute path or bare name) to a known agent CLI
/// by its lowercased file stem. Thin wrapper over `resolve_agent_family` for
/// call sites that only have the program (no args / no declared backend).
pub fn detect_agent_cli(program: &str) -> Option<AgentCli> {
resolve_agent_family(program, &[], None)
}
impl AgentCli {
/// Whether this CLI family has a lifecycle-hook renderer (Stop → TurnEnd)
/// in `apply_enhancement`. Terminal AutoWork requires this: it is the ONLY
/// structured turn-end signal (see `nomifun-requirement`'s orchestrator —
/// no quiescence fallback). Claude/Codex have launch-flag renderers; Gemini
/// has no launch-time injection mechanism, so it is NOT autowork-capable.
pub fn supports_lifecycle_hooks(self) -> bool {
matches!(self, AgentCli::Claude | AgentCli::Codex)
}
}
/// Whether a terminal launch is eligible for AutoWork: it resolves to an agent
/// CLI family that has a lifecycle-hook renderer, so the platform can detect
/// turn boundaries. This MIRRORS exactly what `apply_enhancement` will inject
/// for the same `(command, args, declared_backend)` — the single source of
/// truth for "AutoWork can drive this terminal". Covers wrappers (`stepcode
/// claude`, `npx codex`), bare/custom commands, and explicitly declared
/// backends, so the eligibility gate never rejects a launch the injector would
/// have hooked.
pub fn terminal_autowork_capable(command: &str, args: &[String], declared_backend: Option<&str>) -> bool {
let (program, prog_args) = crate::types::resolve_command(command, args);
resolve_agent_family(&program, &prog_args, declared_backend).is_some_and(AgentCli::supports_lifecycle_hooks)
}
/// Render the enhancement as a claude `--mcp-config` JSON file in `session_dir`
/// and return the EXTRA argv to append. Additive (no `--strict-mcp-config`) so
/// the user's own project/user `.mcp.json` servers are preserved; ours is added
/// alongside. Collision risk is negligible (our server name is the reserved
/// `nomifun-knowledge`). The file lives in the platform's session-private dir,
/// NEVER the user's cwd (no git pollution). claude auth (keychain/~/.claude) is
/// untouched.
fn claude_mcp_argv(enh: &TerminalLaunchEnhancement, session_dir: &Path) -> std::io::Result<Vec<String>> {
let servers: serde_json::Map<String, serde_json::Value> = enh
.mcp_servers
.iter()
.map(|s| {
(
s.name.clone(),
serde_json::json!({ "command": s.command, "args": s.args, "env": s.env }),
)
})
.collect();
let doc = serde_json::json!({ "mcpServers": servers });
std::fs::create_dir_all(session_dir)?;
let path = session_dir.join("mcp.json");
let bytes = serde_json::to_vec_pretty(&doc)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&path, bytes)?;
Ok(vec![
"--mcp-config".to_owned(),
path.to_string_lossy().into_owned(),
])
}
/// TOML bare-key-safe server name (so `mcp_servers.<name>.x` is a valid dotted key).
fn is_bare_key_safe(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
/// Render the enhancement as codex `-c mcp_servers.*` config overrides appended
/// to argv. Uses `-c` (NOT `CODEX_HOME`) so the user's `~/.codex/auth.json` and
/// base config stay the source of truth — relocating CODEX_HOME would strand
/// the login. Each value is TOML (strings quoted, arrays as TOML arrays); codex
/// parses dotted-path `-c` values as TOML (`codex --help`).
fn codex_mcp_argv(enh: &TerminalLaunchEnhancement) -> Vec<String> {
let mut argv = Vec::new();
for s in &enh.mcp_servers {
if !is_bare_key_safe(&s.name) {
tracing::warn!(name = %s.name, "skipping codex MCP server with non-bare-key-safe name");
continue;
}
let base = format!("mcp_servers.{}", s.name);
argv.push("-c".to_owned());
argv.push(format!("{base}.command={}", toml_str(&s.command)));
argv.push("-c".to_owned());
argv.push(format!("{base}.args={}", toml_str_array(&s.args)));
// Deterministic env order so the rendered argv is testable.
let mut keys: Vec<&String> = s.env.keys().collect();
keys.sort();
for k in keys {
argv.push("-c".to_owned());
argv.push(format!("{base}.env.{k}={}", toml_str(&s.env[k])));
}
}
argv
}
/// TOML basic-string literal: wrap in quotes, escape `\`, `"`, and control chars.
fn toml_str(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\r' => out.push_str("\\r"),
c if c.is_control() => { let _ = write!(out, "\\u{:04X}", c as u32); }
c => out.push(c),
}
}
out.push('"');
out
}
/// TOML inline array of basic strings.
fn toml_str_array(items: &[String]) -> String {
let inner: Vec<String> = items.iter().map(|i| toml_str(i)).collect();
format!("[{}]", inner.join(","))
}
/// Double-quote a path for use inside a CLI hook's shell `command` string
/// (handles spaces; escapes backslash and quote). Used by both claude & codex
/// hook command rendering.
fn shell_quote_arg(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
/// Render lifecycle hook commands for claude: writes a `settings.json` in
/// `session_dir` containing hook definitions for Stop/PostToolUse/Notification,
/// and returns the `--settings <path>` argv + env additions.
fn claude_lifecycle_argv(
lc: &LifecycleHookWiring,
session_dir: &Path,
) -> std::io::Result<(Vec<String>, Vec<(String, String)>)> {
// Shell command strings — quote the binary path (may contain spaces).
let quoted_bin = shell_quote_arg(&lc.binary_path);
let cmd_turn_end = format!("{} terminal-hook --event turn_end", quoted_bin);
let cmd_tool_use = format!("{} terminal-hook --event tool_use", quoted_bin);
let cmd_notification = format!("{} terminal-hook --event notification", quoted_bin);
let doc = serde_json::json!({
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": cmd_turn_end}]}],
"PostToolUse": [{"hooks": [{"type": "command", "command": cmd_tool_use}]}],
"Notification": [{"hooks": [{"type": "command", "command": cmd_notification}]}],
}
});
std::fs::create_dir_all(session_dir)?;
let path = session_dir.join("settings.json");
let bytes = serde_json::to_vec_pretty(&doc)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&path, bytes)?;
let argv = vec!["--settings".to_owned(), path.to_string_lossy().into_owned()];
let env = lifecycle_env(lc);
Ok((argv, env))
}
/// Render lifecycle hook commands for codex: appends `-c hooks.*` TOML overrides
/// + `--dangerously-bypass-hook-trust` + env. Coexists with Plan 1 MCP `-c`
/// overrides (codex handles multiple `-c` flags additively).
fn codex_lifecycle_argv(lc: &LifecycleHookWiring) -> (Vec<String>, Vec<(String, String)>) {
let quoted_bin = shell_quote_arg(&lc.binary_path);
let cmd_turn_end = format!("{} terminal-hook --event turn_end", quoted_bin);
let cmd_tool_use = format!("{} terminal-hook --event tool_use", quoted_bin);
let cmd_session_start = format!("{} terminal-hook --event session_start", quoted_bin);
let mut argv = vec!["--dangerously-bypass-hook-trust".to_owned()];
// Stop
argv.push("-c".to_owned());
argv.push(format!(
"hooks.Stop=[{{hooks=[{{type=\"command\",command={}}}]}}]",
toml_str(&cmd_turn_end)
));
// PostToolUse
argv.push("-c".to_owned());
argv.push(format!(
"hooks.PostToolUse=[{{hooks=[{{type=\"command\",command={}}}]}}]",
toml_str(&cmd_tool_use)
));
// SessionStart
argv.push("-c".to_owned());
argv.push(format!(
"hooks.SessionStart=[{{hooks=[{{type=\"command\",command={}}}]}}]",
toml_str(&cmd_session_start)
));
let env = lifecycle_env(lc);
(argv, env)
}
/// Env vars baked into the PTY so the `terminal-hook` shim can reach the
/// in-process TerminalLifecycleServer.
fn lifecycle_env(lc: &LifecycleHookWiring) -> Vec<(String, String)> {
vec![
("NOMI_TERM_HOOK_PORT".to_owned(), lc.port.to_string()),
("NOMI_TERM_HOOK_TOKEN".to_owned(), lc.token.clone()),
("NOMI_TERM_HOOK_ID".to_owned(), lc.terminal_id.to_string()),
]
}
/// Apply platform enhancement to a resolved launch argv. Dispatches on the
/// resolved agent family (declared backend > stem > wrapper arg token); unknown
/// programs are returned UNCHANGED (honest no-op, no pretense). A failed claude
/// config write degrades to "launch without the tool" (warn), never blocks the
/// PTY. `session_dir` is a platform-private dir (NEVER the user's cwd).
///
/// Returns `(args, env_additions)` — the caller merges `env_additions` into
/// the PTY spawn env.
pub fn apply_enhancement(
program: &str,
mut args: Vec<String>,
enh: &TerminalLaunchEnhancement,
session_dir: &Path,
declared_backend: Option<&str>,
) -> (Vec<String>, Vec<(String, String)>) {
if enh.is_empty() {
return (args, Vec::new());
}
let mut env_additions: Vec<(String, String)> = Vec::new();
// Resolve family BEFORE any args.extend (borrows &args).
let family = resolve_agent_family(program, &args, declared_backend);
match family {
Some(AgentCli::Claude) => {
// MCP injection
if !enh.mcp_servers.is_empty() {
match claude_mcp_argv(enh, session_dir) {
Ok(extra) => args.extend(extra),
Err(e) => tracing::warn!(error = %e, "claude MCP config write failed; launching without knowledge tool"),
}
}
// Lifecycle hooks
if let Some(lc) = &enh.lifecycle {
match claude_lifecycle_argv(lc, session_dir) {
Ok((extra_args, extra_env)) => {
args.extend(extra_args);
env_additions.extend(extra_env);
}
Err(e) => tracing::warn!(error = %e, "claude lifecycle settings write failed; launching without hooks"),
}
}
}
Some(AgentCli::Codex) => {
// MCP injection
if !enh.mcp_servers.is_empty() {
args.extend(codex_mcp_argv(enh));
}
// Lifecycle hooks
if let Some(lc) = &enh.lifecycle {
let (extra_args, extra_env) = codex_lifecycle_argv(lc);
args.extend(extra_args);
env_additions.extend(extra_env);
}
}
Some(AgentCli::Gemini) => {
// Gemini has no launch-flag injection mechanism — it uses cwd-scoped
// `.gemini/settings.json` written by the one-click registration (Task 3).
// Treat as no-op for launch-time enhancement (honest: no pretense).
}
None => {} // unknown CLI: no injection (honest)
}
(args, env_additions)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn sample_kb_server() -> McpServerSpec {
McpServerSpec {
name: "nomifun-knowledge".into(),
command: "/opt/nomi/nomicore".into(),
args: vec!["mcp-knowledge-stdio".into()],
env: HashMap::from([
("NOMI_KB_MCP_PORT".into(), "51123".into()),
("NOMI_KB_MCP_TOKEN".into(), "tok-abc".into()),
]),
}
}
#[test]
fn enhancement_empty_when_no_servers_and_no_lifecycle() {
assert!(TerminalLaunchEnhancement::default().is_empty());
let e = TerminalLaunchEnhancement { mcp_servers: vec![sample_kb_server()], lifecycle: None };
assert!(!e.is_empty());
let e2 = TerminalLaunchEnhancement {
mcp_servers: vec![],
lifecycle: Some(LifecycleHookWiring { port: 1, token: "t".into(), terminal_id: 1, binary_path: "/bin".into() }),
};
assert!(!e2.is_empty());
}
#[test]
fn detect_agent_cli_by_stem_case_and_path_insensitive() {
assert_eq!(detect_agent_cli("claude"), Some(AgentCli::Claude));
assert_eq!(detect_agent_cli("/usr/local/bin/claude"), Some(AgentCli::Claude));
assert_eq!(detect_agent_cli("codex"), Some(AgentCli::Codex));
assert_eq!(detect_agent_cli("/Users/u/.bun/bin/Codex"), Some(AgentCli::Codex));
assert_eq!(detect_agent_cli("gemini"), Some(AgentCli::Gemini));
// Unknown / shells / near-misses → None (honest: no injection).
assert_eq!(detect_agent_cli("/bin/bash"), None);
assert_eq!(detect_agent_cli("claude-helper"), None);
assert_eq!(detect_agent_cli(""), None);
}
#[test]
fn claude_renderer_writes_mcp_json_outside_cwd_and_returns_additive_argv() {
let dir = tempfile::TempDir::new().unwrap();
let enh = TerminalLaunchEnhancement { mcp_servers: vec![sample_kb_server()], lifecycle: None };
let argv = claude_mcp_argv(&enh, dir.path()).expect("write ok");
// argv 形如 ["--mcp-config", "<dir>/mcp.json"] — additive, no --strict-mcp-config
assert_eq!(argv.len(), 2);
assert_eq!(argv[0], "--mcp-config");
assert!(argv[1].ends_with("mcp.json"));
assert!(std::path::Path::new(&argv[1]).starts_with(dir.path())); // 不在用户 cwd
// 文件内容是合法 claude .mcp.json,含我们的 server + env
let doc: serde_json::Value =
serde_json::from_slice(&std::fs::read(&argv[1]).unwrap()).unwrap();
let srv = &doc["mcpServers"]["nomifun-knowledge"];
assert_eq!(srv["command"], "/opt/nomi/nomicore");
assert_eq!(srv["args"][0], "mcp-knowledge-stdio");
assert_eq!(srv["env"]["NOMI_KB_MCP_TOKEN"], "tok-abc");
}
#[test]
fn codex_renderer_emits_c_overrides_preserving_user_config() {
let enh = TerminalLaunchEnhancement { mcp_servers: vec![sample_kb_server()], lifecycle: None };
let argv = codex_mcp_argv(&enh);
// 形如 -c mcp_servers.nomifun-knowledge.command="..." -c ...args=[...] -c ...env.K="V"
let joined = argv.join(" ");
assert!(joined.contains(r#"-c mcp_servers.nomifun-knowledge.command="/opt/nomi/nomicore""#));
assert!(joined.contains(r#"mcp_servers.nomifun-knowledge.args=["mcp-knowledge-stdio"]"#));
assert!(joined.contains(r#"mcp_servers.nomifun-knowledge.env.NOMI_KB_MCP_TOKEN="tok-abc""#));
// 每个 override 前都有独立的 -c (command + args + 2 env = 4)
assert_eq!(argv.iter().filter(|a| *a == "-c").count(), 4);
// 不含 CODEX_HOME(那会丢用户 auth.json
assert!(!joined.contains("CODEX_HOME"));
// ENV_KB_IDS must NOT appear (runtime cwd scope)
assert!(!joined.contains("KB_MCP_KB_IDS"), "kb_ids must not be baked");
}
#[test]
fn toml_str_escapes_quotes_and_backslashes() {
assert_eq!(toml_str(r#"a"b\c"#), r#""a\"b\\c""#);
}
#[test]
fn apply_enhancement_dispatches_by_cli_and_noops_unknown() {
let dir = tempfile::TempDir::new().unwrap();
let enh = TerminalLaunchEnhancement { mcp_servers: vec![sample_kb_server()], lifecycle: None };
// claude → 追加 --mcp-config (additive, 不含 --strict-mcp-config)
let (out, env) = apply_enhancement("claude", vec!["--dangerously-skip-permissions".into()], &enh, dir.path(), None);
assert_eq!(out[0], "--dangerously-skip-permissions");
assert!(out.iter().any(|a| a == "--mcp-config"));
assert!(env.is_empty());
// codex → 追加 -c mcp_servers...
let (out, env) = apply_enhancement("codex", vec![], &enh, dir.path(), None);
assert!(out.iter().any(|a| a == "-c"));
assert!(out.iter().any(|a| a.starts_with("mcp_servers.nomifun-knowledge")));
assert!(env.is_empty());
// 未知 CLI → 原样(诚实不注入)
let (out, env) = apply_enhancement("/bin/bash", vec!["-l".into()], &enh, dir.path(), None);
assert_eq!(out, vec!["-l".to_owned()]);
assert!(env.is_empty());
// 空 enhancement → 原样(任何 CLI
let (out, env) = apply_enhancement("claude", vec!["-x".into()], &TerminalLaunchEnhancement::default(), dir.path(), None);
assert_eq!(out, vec!["-x".to_owned()]);
assert!(env.is_empty());
}
#[test]
fn toml_str_escapes_control_chars() {
// Named escapes for \n \t
assert_eq!(toml_str("a\nb\tc"), r#""a\nb\tc""#);
// Raw control char U+0001 → 
assert_eq!(toml_str("\u{1}"), "\"\\u0001\"");
}
#[test]
fn codex_empty_args_renders_empty_array() {
let server = McpServerSpec {
name: "simple".into(),
command: "/bin/echo".into(),
args: vec![],
env: HashMap::new(),
};
let enh = TerminalLaunchEnhancement { mcp_servers: vec![server], lifecycle: None };
let argv = codex_mcp_argv(&enh);
let joined = argv.join(" ");
assert!(joined.contains("mcp_servers.simple.args=[]"));
}
#[test]
fn codex_skips_non_bare_key_safe_name_and_emits_safe_ones() {
let bad = McpServerSpec {
name: "bad.name".into(),
command: "/bin/x".into(),
args: vec![],
env: HashMap::new(),
};
let good = McpServerSpec {
name: "nomifun-knowledge".into(),
command: "/opt/nomi/nomicore".into(),
args: vec!["mcp-knowledge-stdio".into()],
env: HashMap::new(),
};
let enh = TerminalLaunchEnhancement { mcp_servers: vec![bad, good], lifecycle: None };
let argv = codex_mcp_argv(&enh);
let joined = argv.join(" ");
// bad.name must NOT appear in output
assert!(!joined.contains("bad.name"));
// good name emitted normally
assert!(joined.contains("mcp_servers.nomifun-knowledge.command="));
}
#[test]
fn apply_enhancement_with_lifecycle_renders_hooks_and_env() {
let dir = tempfile::TempDir::new().unwrap();
let enh = TerminalLaunchEnhancement {
mcp_servers: vec![],
lifecycle: Some(LifecycleHookWiring {
port: 5151,
token: "htok".into(),
terminal_id: 42,
binary_path: "/opt/nomi/nomicore".into(),
}),
};
// claude: --settings file written with Stop/PostToolUse/Notification hooks; env carries hook wiring
let (args, env) = apply_enhancement("claude", vec![], &enh, dir.path(), None);
assert!(args.iter().any(|a| a == "--settings"));
let env_map: HashMap<String, String> = env.into_iter().collect();
assert_eq!(env_map.get("NOMI_TERM_HOOK_PORT").map(String::as_str), Some("5151"));
assert_eq!(env_map.get("NOMI_TERM_HOOK_TOKEN").map(String::as_str), Some("htok"));
assert_eq!(env_map.get("NOMI_TERM_HOOK_ID").map(String::as_str), Some("42"));
// settings file contains Stop/PostToolUse/Notification hooks calling `terminal-hook`
let settings_path = args.iter().position(|a| a == "--settings").map(|i| args[i + 1].clone()).unwrap();
let doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap();
assert!(doc["hooks"]["Stop"][0]["hooks"][0]["command"].as_str().unwrap().contains("terminal-hook --event turn_end"));
assert!(doc["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str().unwrap().contains("terminal-hook --event tool_use"));
assert!(doc["hooks"]["Notification"][0]["hooks"][0]["command"].as_str().unwrap().contains("terminal-hook --event notification"));
// codex: hook overrides + bypass-trust + same env
let (cargs, cenv) = apply_enhancement("codex", vec![], &enh, dir.path(), None);
assert!(cargs.iter().any(|a| a == "--dangerously-bypass-hook-trust"));
let cenv_map: HashMap<String, String> = cenv.into_iter().collect();
assert_eq!(cenv_map.get("NOMI_TERM_HOOK_PORT").map(String::as_str), Some("5151"));
assert_eq!(cenv_map.get("NOMI_TERM_HOOK_TOKEN").map(String::as_str), Some("htok"));
assert_eq!(cenv_map.get("NOMI_TERM_HOOK_ID").map(String::as_str), Some("42"));
// codex hooks: Stop, PostToolUse, SessionStart (no Notification)
let joined = cargs.join(" ");
assert!(joined.contains("hooks.Stop="));
assert!(joined.contains("hooks.PostToolUse="));
assert!(joined.contains("hooks.SessionStart="));
assert!(!joined.contains("Notification"));
// Each hook `-c` value contains `terminal-hook --event`
assert!(joined.contains("terminal-hook --event turn_end"));
assert!(joined.contains("terminal-hook --event tool_use"));
assert!(joined.contains("terminal-hook --event session_start"));
// unknown CLI → no hook args, no hook env (honest)
let (uargs, uenv) = apply_enhancement("/bin/bash", vec![], &enh, dir.path(), None);
assert!(uargs.is_empty() && uenv.is_empty());
}
#[test]
fn lifecycle_hooks_shell_quote_binary_path_with_spaces() {
let dir = tempfile::TempDir::new().unwrap();
let enh = TerminalLaunchEnhancement {
mcp_servers: vec![],
lifecycle: Some(LifecycleHookWiring {
port: 5151,
token: "htok".into(),
terminal_id: 42,
binary_path: "/Users/John Doe/bin/nomicore".into(),
}),
};
// claude: the settings.json hook commands must contain the quoted binary
let (args, _env) = apply_enhancement("claude", vec![], &enh, dir.path(), None);
let settings_path = args.iter().position(|a| a == "--settings").map(|i| args[i + 1].clone()).unwrap();
let doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap();
let stop_cmd = doc["hooks"]["Stop"][0]["hooks"][0]["command"].as_str().unwrap();
assert!(
stop_cmd.contains(r#""/Users/John Doe/bin/nomicore""#),
"claude hook command must shell-quote the binary path, got: {stop_cmd}"
);
// codex: the `-c` TOML hook values must contain the shell-quoted binary
// (the inner quotes get TOML-escaped inside the TOML string value)
let (cargs, _cenv) = apply_enhancement("codex", vec![], &enh, dir.path(), None);
let joined = cargs.join(" ");
// Inside the TOML string, the shell double-quotes become escaped: \"
// The command inside TOML looks like: \""/Users/John Doe/bin/nomicore\" terminal-hook ...\"
// When joined in the argv the literal chars are: \"/Users/John Doe/bin/nomicore\"
assert!(
joined.contains(r#"\"/Users/John Doe/bin/nomicore\""#),
"codex hook command must shell-quote the binary path (TOML-escaped), got: {joined}"
);
}
#[test]
fn resolve_agent_family_prefers_declared_then_stem_then_wrapped_token() {
use AgentCli::*;
// declared backend wins
assert_eq!(resolve_agent_family("stepcode", &["claude".into()], Some("codex")), Some(Codex));
// program stem
assert_eq!(resolve_agent_family("/usr/bin/claude", &[], None), Some(Claude));
assert_eq!(resolve_agent_family("codex", &[], None), Some(Codex));
assert_eq!(resolve_agent_family("gemini", &[], None), Some(Gemini));
// wrapper: program is unknown, a known family appears as an arg token
assert_eq!(resolve_agent_family("stepcode", &["claude".into(), "--yolo".into()], None), Some(Claude));
assert_eq!(resolve_agent_family("npx", &["codex".into()], None), Some(Codex));
// none: unknown program, no known token, no declared backend
assert_eq!(resolve_agent_family("/bin/bash", &["-l".into()], None), None);
assert_eq!(resolve_agent_family("stepcode", &["frobnicate".into()], None), None);
}
#[test]
fn apply_enhancement_wrapper_resolves_family_via_declared_and_args() {
let dir = tempfile::TempDir::new().unwrap();
let enh = TerminalLaunchEnhancement { mcp_servers: vec![sample_kb_server()], lifecycle: None };
// Wrapper `stepcode claude` with no declared backend → resolves to Claude via arg token
let (out, _env) = apply_enhancement("stepcode", vec!["claude".into()], &enh, dir.path(), None);
assert!(out.iter().any(|a| a == "--mcp-config"), "wrapper 'stepcode claude' must render claude --mcp-config");
// Declared backend overrides: program is stepcode, arg is claude, but declared is codex → codex
let (out, _env) = apply_enhancement("stepcode", vec!["claude".into()], &enh, dir.path(), Some("codex"));
assert!(out.iter().any(|a| a == "-c"), "declared codex must render codex -c overrides");
assert!(out.iter().any(|a| a.starts_with("mcp_servers.nomifun-knowledge")));
// Unknown wrapper with no known arg token → no injection (honest)
let (out, _env) = apply_enhancement("stepcode", vec!["frob".into()], &enh, dir.path(), None);
assert_eq!(out, vec!["frob".to_owned()], "unknown wrapper must not inject");
// Gemini via declared → no launch injection (honest: no flag renderer)
let (out, _env) = apply_enhancement("stepcode", vec!["claude".into()], &enh, dir.path(), Some("gemini"));
// Gemini = no-op for launch injection, args are unchanged
assert_eq!(out, vec!["claude".to_owned()], "gemini declared must not inject launch flags");
}
#[test]
fn supports_lifecycle_hooks_only_claude_and_codex() {
assert!(AgentCli::Claude.supports_lifecycle_hooks());
assert!(AgentCli::Codex.supports_lifecycle_hooks());
// Gemini has no launch-time lifecycle renderer → not autowork-capable.
assert!(!AgentCli::Gemini.supports_lifecycle_hooks());
}
#[test]
fn terminal_autowork_capable_matches_what_apply_enhancement_hooks() {
let no_args: Vec<String> = vec![];
// Bare / direct agent CLIs are capable.
assert!(terminal_autowork_capable("claude", &no_args, None));
assert!(terminal_autowork_capable("codex", &no_args, None));
// Wrappers resolve via the arg token (no declared backend) — exactly the
// case the old backend-string gate wrongly rejected.
assert!(terminal_autowork_capable("stepcode", &["claude".to_owned()], None));
assert!(terminal_autowork_capable("npx", &["codex".to_owned()], None));
// Declared backend wins.
assert!(terminal_autowork_capable("stepcode", &["claude".to_owned()], Some("codex")));
// Gemini resolves to a family but has no lifecycle renderer → NOT capable.
assert!(!terminal_autowork_capable("gemini", &no_args, None));
assert!(!terminal_autowork_capable("stepcode", &["gemini".to_owned()], None));
// Plain shell / unknown CLI → not capable.
assert!(!terminal_autowork_capable(crate::types::SHELL_SENTINEL, &no_args, None));
assert!(!terminal_autowork_capable("/bin/bash", &["-l".to_owned()], None));
assert!(!terminal_autowork_capable("stepcode", &["frobnicate".to_owned()], None));
}
}