Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,30 @@
[package]
name = "nomifun-terminal"
version.workspace = true
edition.workspace = true
[dependencies]
nomifun-common.workspace = true
nomifun-db.workspace = true
nomifun-api-types.workspace = true
nomifun-realtime.workspace = true
nomifun-auth.workspace = true
nomifun-runtime.workspace = true
nomifun-knowledge.workspace = true
nomifun-file.workspace = true
axum.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
dashmap.workspace = true
async-trait.workspace = true
portable-pty.workspace = true
base64.workspace = true
libc.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros", "rt-multi-thread"] }
tempfile.workspace = true
reqwest.workspace = true
@@ -0,0 +1,162 @@
//! Shared ANSI/OSC escape stripping + incremental line scanning for consumers
//! that watch a PTY's raw output byte-stream (AutoWork completion marker,
//! IDMM stall detection). Chunks arrive split at arbitrary byte boundaries —
//! possibly mid-escape-sequence — so the state machine is incremental and the
//! line scanner buffers across `feed` calls.
#[derive(Clone, Copy)]
enum EscState {
Normal,
/// Saw ESC, awaiting the sequence introducer.
Esc,
/// Inside a CSI sequence (`ESC [ … final`).
Csi,
/// Inside an OSC sequence (`ESC ] … BEL|ST`).
Osc,
/// Saw ESC while inside OSC — the next byte (`\`) completes the ST terminator.
OscEsc,
}
/// Strip ANSI/OSC escape sequences and C0 controls (except newline) from a
/// buffer, returning lossy UTF-8 text.
pub fn strip_ansi(bytes: &[u8]) -> String {
let mut state = EscState::Normal;
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
for &b in bytes {
match state {
EscState::Normal => match b {
0x1b => state = EscState::Esc,
b'\n' => out.push(b'\n'),
b'\r' => {}
0x00..=0x08 | 0x0b..=0x1f | 0x7f => {}
_ => out.push(b),
},
EscState::Esc => {
state = match b {
b'[' => EscState::Csi,
b']' => EscState::Osc,
_ => EscState::Normal,
};
}
EscState::Csi => {
if (0x40..=0x7e).contains(&b) {
state = EscState::Normal;
}
}
EscState::Osc => match b {
0x07 => state = EscState::Normal,
0x1b => state = EscState::OscEsc,
_ => {}
},
EscState::OscEsc => state = EscState::Normal,
}
}
String::from_utf8_lossy(&out).into_owned()
}
/// Incremental, escape-stripping, line-oriented scanner. Feed raw PTY chunks;
/// get back the completed (newline-terminated) lines, already ANSI-stripped and
/// with the trailing newline removed. The in-progress final line is retained
/// across `feed` calls until its newline arrives.
pub struct AnsiLineScanner {
state: EscState,
line: Vec<u8>,
}
impl Default for AnsiLineScanner {
fn default() -> Self {
Self::new()
}
}
impl AnsiLineScanner {
pub fn new() -> Self {
Self {
state: EscState::Normal,
line: Vec::new(),
}
}
/// Feed a raw output chunk; return any completed lines found within it.
pub fn feed(&mut self, bytes: &[u8]) -> Vec<String> {
let mut lines = Vec::new();
for &b in bytes {
match self.state {
EscState::Normal => match b {
0x1b => self.state = EscState::Esc,
b'\n' => {
lines.push(String::from_utf8_lossy(&self.line).into_owned());
self.line.clear();
}
b'\r' => {}
0x00..=0x08 | 0x0b..=0x1f | 0x7f => {}
_ => self.line.push(b),
},
EscState::Esc => {
self.state = match b {
b'[' => EscState::Csi,
b']' => EscState::Osc,
_ => EscState::Normal,
};
}
EscState::Csi => {
if (0x40..=0x7e).contains(&b) {
self.state = EscState::Normal;
}
}
EscState::Osc => match b {
0x07 => self.state = EscState::Normal,
0x1b => self.state = EscState::OscEsc,
_ => {}
},
EscState::OscEsc => self.state = EscState::Normal,
}
}
lines
}
/// The current (not-yet-newline-terminated) partial line, ANSI-stripped.
pub fn partial(&self) -> String {
String::from_utf8_lossy(&self.line).into_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_csi_and_osc_sequences() {
let raw = b"\x1b[1;32mhello\x1b[0m \x1b]0;title\x07world\r\n";
assert_eq!(strip_ansi(raw), "hello world\n");
}
#[test]
fn scanner_emits_complete_lines_only() {
let mut s = AnsiLineScanner::new();
assert_eq!(s.feed(b"line one\nline t"), vec!["line one".to_string()]);
assert_eq!(s.partial(), "line t");
assert_eq!(s.feed(b"wo\n"), vec!["line two".to_string()]);
}
#[test]
fn scanner_strips_ansi_within_lines() {
let mut s = AnsiLineScanner::new();
let lines = s.feed(b"\x1b[32mgreen\x1b[0m text\n");
assert_eq!(lines, vec!["green text".to_string()]);
}
#[test]
fn scanner_handles_escape_split_across_chunks() {
let mut s = AnsiLineScanner::new();
assert!(s.feed(b"\x1b").is_empty());
assert!(s.feed(b"[32m").is_empty());
assert_eq!(s.feed(b"ok\n"), vec!["ok".to_string()]);
}
#[test]
fn scanner_drops_cr_and_c0_controls() {
let mut s = AnsiLineScanner::new();
assert_eq!(s.feed(b"a\x07b\r\n"), vec!["ab".to_string()]);
}
}
@@ -0,0 +1,71 @@
//! The capability the AutoWork orchestrator (in `nomifun-requirement`) uses to
//! drive a terminal's PTY as an execution substrate — write input, observe the
//! live output stream, check liveness, and read/write the terminal's AutoWork
//! config — without depending on this crate's internals. `TerminalService`
//! implements it (see `service.rs`).
use async_trait::async_trait;
use tokio::sync::broadcast;
use crate::error::TerminalError;
/// Lightweight terminal session metadata for AutoWork gating + ownership checks.
#[derive(Debug, Clone)]
pub struct TerminalDescription {
pub user_id: String,
/// Working directory the PTY was launched in. Consumers probe it for the
/// `.nomi/knowledge/README.md` contract file to prepend knowledge guidance.
pub cwd: String,
/// The stored launch program (the `command` column; `$SHELL` sentinel for a
/// plain shell). With `args` + `backend`, lets the AutoWork gate resolve the
/// agent family the SAME way launch injection does (`terminal_autowork_capable`).
pub command: String,
/// The stored launch argv (the parsed `args` column). Carries the wrapped CLI
/// token for wrapper launches (`stepcode claude` → `["claude", …]`).
pub args: Vec<String>,
/// Preset backend: "claude" | "codex" | "gemini" | None (plain shell / custom
/// command). Only set when a preset declared it — do NOT use it alone for
/// eligibility; resolve the family from `command`/`args`/`backend` together.
pub backend: Option<String>,
/// Permission mode label: "default" | "full-auto" | None.
pub mode: Option<String>,
/// "running" | "exited" | "error".
pub last_status: String,
}
#[async_trait]
pub trait TerminalDriver: Send + Sync {
/// Write raw bytes to the PTY stdin. `Err(NotFound)` if the session is not live.
async fn write_input(&self, id: i64, bytes: &[u8]) -> Result<(), TerminalError>;
/// Subscribe to a copy of the PTY's live output byte-stream. `None` if the
/// session is not live.
fn subscribe_output(&self, id: i64) -> Option<broadcast::Receiver<Vec<u8>>>;
/// Whether the PTY is currently live (the child process is running here).
fn is_alive(&self, id: i64) -> bool;
/// Lightweight metadata for gating + ownership. `Ok(None)` if the row is gone.
async fn describe(&self, id: i64) -> Result<Option<TerminalDescription>, TerminalError>;
/// Read the raw AutoWork config JSON blob for a terminal (`None` if unset).
async fn read_autowork(&self, id: i64) -> Result<Option<String>, TerminalError>;
/// Write (or clear with `None`) the AutoWork config JSON blob for a terminal.
async fn write_autowork(&self, id: i64, autowork: Option<&str>) -> Result<(), TerminalError>;
/// Read the raw IDMM config JSON blob for a terminal (`None` if unset).
async fn read_idmm(&self, id: i64) -> Result<Option<String>, TerminalError>;
/// Write (or clear with `None`) the IDMM config JSON blob for a terminal.
async fn write_idmm(&self, id: i64, idmm: Option<&str>) -> Result<(), TerminalError>;
/// Subscribe to this terminal's structured lifecycle events (turn-end / tool /
/// notification) from the in-process lifecycle server. `None` if lifecycle is
/// not wired or the session is unknown. Used by AutoWork to await turn-end
/// (Stop) instead of scraping the byte stream.
fn subscribe_lifecycle(
&self,
id: i64,
) -> Option<broadcast::Receiver<crate::lifecycle::TerminalLifecycleEvent>>;
}
@@ -0,0 +1,678 @@
//! 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));
}
}
@@ -0,0 +1,58 @@
use nomifun_common::AppError;
#[derive(Debug, thiserror::Error)]
pub enum TerminalError {
#[error("Terminal session not found: {0}")]
NotFound(String),
#[error("Failed to spawn terminal: {0}")]
Spawn(String),
#[error("Invalid terminal input: {0}")]
InvalidInput(String),
#[error("Terminal I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Database(#[from] nomifun_db::DbError),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
impl From<TerminalError> for AppError {
fn from(err: TerminalError) -> Self {
match err {
TerminalError::NotFound(msg) => AppError::NotFound(msg),
TerminalError::InvalidInput(msg) => AppError::BadRequest(msg),
TerminalError::Spawn(msg) => AppError::Internal(msg),
TerminalError::Io(e) => AppError::Internal(format!("terminal io: {e}")),
TerminalError::Database(db_err) => AppError::from(db_err),
TerminalError::Json(e) => AppError::Internal(format!("JSON error: {e}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn not_found_maps_to_not_found() {
let app: AppError = TerminalError::NotFound("term_1".into()).into();
assert!(matches!(app, AppError::NotFound(_)));
}
#[test]
fn invalid_input_maps_to_bad_request() {
let app: AppError = TerminalError::InvalidInput("bad base64".into()).into();
assert!(matches!(app, AppError::BadRequest(_)));
}
#[test]
fn spawn_maps_to_internal() {
let app: AppError = TerminalError::Spawn("nope".into()).into();
assert!(matches!(app, AppError::Internal(_)));
}
}
@@ -0,0 +1,52 @@
use std::sync::Arc;
use nomifun_api_types::{
TerminalExitEvent, TerminalOutputEvent, TerminalRemovedPayload, TerminalSessionResponse, WebSocketMessage,
};
use nomifun_realtime::EventBroadcaster;
use tracing::error;
/// Broadcasts terminal lifecycle + stream events over the realtime WebSocket bus.
#[derive(Clone)]
pub struct TerminalEventEmitter {
broadcaster: Arc<dyn EventBroadcaster>,
}
impl TerminalEventEmitter {
pub fn new(broadcaster: Arc<dyn EventBroadcaster>) -> Self {
Self { broadcaster }
}
/// A chunk of PTY output (base64-encoded bytes).
pub fn emit_output(&self, id: i64, data_b64: String) {
self.broadcast("terminal.output", &TerminalOutputEvent { id, data_b64 });
}
/// The child process exited.
pub fn emit_exit(&self, id: i64, exit_code: Option<i32>) {
self.broadcast("terminal.exit", &TerminalExitEvent { id, exit_code });
}
pub fn emit_created(&self, session: &TerminalSessionResponse) {
self.broadcast("terminal.created", session);
}
pub fn emit_updated(&self, session: &TerminalSessionResponse) {
self.broadcast("terminal.updated", session);
}
pub fn emit_removed(&self, id: i64) {
self.broadcast("terminal.removed", &TerminalRemovedPayload { id });
}
fn broadcast<T: serde::Serialize>(&self, event_name: &str, payload: &T) {
let value = match serde_json::to_value(payload) {
Ok(v) => v,
Err(e) => {
error!(event_name, error = %e, "Failed to serialize terminal event payload");
return;
}
};
self.broadcaster.broadcast(WebSocketMessage::new(event_name, value));
}
}
@@ -0,0 +1,26 @@
//! Terminal sessions: PTY-backed interactive sessions managed alongside
//! conversations. Owns a `portable-pty` per session, streams output over the
//! realtime WebSocket broadcaster, and persists session metadata in SQLite.
pub mod ansi;
pub mod driver;
pub mod enhance;
pub mod error;
pub mod events;
pub mod lifecycle;
pub mod pty;
pub mod routes;
pub mod service;
pub mod state;
pub mod title;
pub mod types;
pub use ansi::{AnsiLineScanner, strip_ansi};
pub use driver::{TerminalDescription, TerminalDriver};
pub use enhance::{apply_enhancement, resolve_agent_family, terminal_autowork_capable, AgentCli, LifecycleHookWiring, McpServerSpec, TerminalLaunchEnhancement};
pub use events::TerminalEventEmitter;
pub use lifecycle::{LifecycleKind, TerminalLifecycleEvent, TerminalLifecycleServer};
pub use routes::terminal_routes;
pub use service::{TerminalService, TerminalSupervisionHook};
pub use state::TerminalRouterState;
pub use title::{TerminalTitleCompleter, clamp_title, fallback_title, TITLE_MAX_CHARS};
@@ -0,0 +1,201 @@
//! Terminal lifecycle channel: the structured "events OUT" half of the terminal
//! capability design. Native CLI hooks (claude --settings hooks / codex hooks)
//! invoke `nomicore terminal-hook`, which POSTs the event here; this server
//! broadcasts a normalized `TerminalLifecycleEvent` per terminal_id. Consumers
//! (Plan 3 AutoWork completion, Plan 4 IDMM supervision) subscribe — replacing
//! the byte-stream scraping that could never see real turn boundaries.
use std::sync::Arc;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use axum::{Json, Router};
use dashmap::DashMap;
use nomifun_common::generate_id;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use tokio::sync::broadcast;
use tracing::{debug, warn};
/// Normalized lifecycle event kind (CLI-agnostic). Mapped from each CLI's hook
/// event by the `--event <kind>` arg baked into the hook command at injection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleKind {
/// The agent finished a turn (claude `Stop` / codex `Stop`).
TurnEnd,
/// A tool call completed (claude/codex `PostToolUse`) — activity signal.
ToolUse,
/// The agent is waiting / surfaced a notification (claude `Notification`).
Notification,
/// Session started (claude/codex `SessionStart`).
SessionStart,
}
impl LifecycleKind {
/// Parse the wire `kind` string used in the hook command's `--event` arg.
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"turn_end" => Some(Self::TurnEnd),
"tool_use" => Some(Self::ToolUse),
"notification" => Some(Self::Notification),
"session_start" => Some(Self::SessionStart),
_ => None,
}
}
}
/// One lifecycle event broadcast to subscribers of a terminal.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminalLifecycleEvent {
pub terminal_id: i64,
pub kind: LifecycleKind,
/// The CLI's raw hook payload (StopRequest/PostToolUse JSON), opaque to the
/// channel; consumers extract what they need (e.g. last_assistant_message).
pub payload: serde_json::Value,
}
// ---------------------------------------------------------------------------
// TerminalLifecycleServer — house-pattern in-process HTTP server
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct LifecycleState {
auth_token: String,
channels: Arc<DashMap<i64, broadcast::Sender<TerminalLifecycleEvent>>>,
}
/// In-process HTTP server that receives lifecycle hook POSTs from the
/// `nomicore terminal-hook` shim and broadcasts them per terminal_id.
pub struct TerminalLifecycleServer {
http_port: u16,
auth_token: String,
channels: Arc<DashMap<i64, broadcast::Sender<TerminalLifecycleEvent>>>,
_handle: tokio::task::JoinHandle<()>,
}
#[derive(Deserialize)]
struct HookPost {
terminal_id: i64,
kind: LifecycleKind,
#[serde(default)]
payload: serde_json::Value,
}
impl TerminalLifecycleServer {
/// Bind `127.0.0.1:0`, mint a random bearer token, and start serving
/// `POST /hook`. Mirrors `KnowledgeMcpServer::start()`.
pub async fn start() -> Result<Self, String> {
let auth_token = generate_id();
let channels: Arc<DashMap<i64, broadcast::Sender<TerminalLifecycleEvent>>> =
Arc::new(DashMap::new());
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("bind terminal lifecycle listener: {e}"))?;
let http_port = listener
.local_addr()
.map_err(|e| e.to_string())?
.port();
let state = LifecycleState {
auth_token: auth_token.clone(),
channels: channels.clone(),
};
let app = Router::new()
.route("/hook", post(handle_hook))
.with_state(state);
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
warn!(error = %e, "Terminal lifecycle server exited with error");
}
});
debug!(http_port, "Terminal lifecycle server started");
Ok(Self {
http_port,
auth_token,
channels,
_handle: handle,
})
}
pub fn http_port(&self) -> u16 {
self.http_port
}
pub fn auth_token(&self) -> &str {
&self.auth_token
}
/// Subscribe to a terminal's lifecycle events (lazily creates the channel).
pub fn subscribe(&self, terminal_id: i64) -> broadcast::Receiver<TerminalLifecycleEvent> {
self.channels
.entry(terminal_id)
.or_insert_with(|| broadcast::channel(64).0)
.subscribe()
}
}
async fn handle_hook(
State(state): State<LifecycleState>,
headers: HeaderMap,
Json(post): Json<HookPost>,
) -> StatusCode {
let token = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.unwrap_or("");
if token != state.auth_token {
return StatusCode::UNAUTHORIZED;
}
let ev = TerminalLifecycleEvent {
terminal_id: post.terminal_id,
kind: post.kind,
payload: post.payload,
};
if let Some(tx) = state.channels.get(&post.terminal_id) {
let _ = tx.send(ev);
}
// No subscriber yet → drop silently (consumer attaches on demand). 200 either way.
StatusCode::OK
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_parses_from_wire_event_string() {
assert_eq!(LifecycleKind::from_wire("turn_end"), Some(LifecycleKind::TurnEnd));
assert_eq!(LifecycleKind::from_wire("tool_use"), Some(LifecycleKind::ToolUse));
assert_eq!(LifecycleKind::from_wire("notification"), Some(LifecycleKind::Notification));
assert_eq!(LifecycleKind::from_wire("session_start"), Some(LifecycleKind::SessionStart));
assert_eq!(LifecycleKind::from_wire("bogus"), None);
}
#[tokio::test]
async fn post_hook_broadcasts_to_subscriber_and_rejects_bad_token() {
let srv = TerminalLifecycleServer::start().await.expect("start");
let mut rx = srv.subscribe(42);
let url = format!("http://127.0.0.1:{}/hook", srv.http_port());
let body = serde_json::json!({"terminal_id":42,"kind":"turn_end","payload":{"last_assistant_message":"done"}});
let client = reqwest::Client::new();
// bad token → 401
let bad = client.post(&url).json(&body).bearer_auth("wrong").send().await.unwrap();
assert_eq!(bad.status(), 401);
// good token → 200 + subscriber receives
let ok = client.post(&url).json(&body).bearer_auth(srv.auth_token()).send().await.unwrap();
assert_eq!(ok.status(), 200);
let ev = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(ev.terminal_id, 42);
assert_eq!(ev.kind, LifecycleKind::TurnEnd);
}
}
@@ -0,0 +1,443 @@
//! Low-level PTY wrapper: spawns a child in a pseudo-terminal, streams its
//! output through a callback, and keeps a bounded scrollback buffer for
//! reconnect. Built on `portable-pty` (cross-platform: macOS/Linux + Windows
//! ConPTY).
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system};
use tokio::sync::broadcast;
use crate::error::TerminalError;
/// Max bytes retained for reconnect scrollback (~256 KB).
const SCROLLBACK_CAP: usize = 256 * 1024;
/// Bounded fan-out buffer for the live output stream (in chunks). A lagging
/// subscriber (e.g. a slow AutoWork watcher) drops oldest chunks rather than
/// stalling the reader; AutoWork tolerates this (it scans for a marker/quiescence
/// on whatever it receives). The WebSocket path is unaffected — it is driven by
/// the `on_output` callback, not this channel.
const OUTPUT_BROADCAST_CAP: usize = 512;
/// Grace period after a child exits before the exit is reported, so the reader
/// thread can drain output still buffered in the PTY (notably on Windows, where
/// the ConPTY master does not reach EOF on child exit).
const EXIT_DRAIN_GRACE: Duration = Duration::from_millis(120);
/// A live PTY: master handle + writer + child + scrollback.
pub struct PtyHandle {
master: Mutex<Box<dyn MasterPty + Send>>,
writer: Mutex<Box<dyn Write + Send>>,
/// A killer split from the child (via `clone_killer`) so `kill()` can signal
/// the process while the waiter thread is parked in the blocking `wait()`.
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
scrollback: Arc<Mutex<Vec<u8>>>,
/// Set whenever new bytes land in `scrollback`, cleared by
/// [`take_dirty_scrollback`]. Lets the debounced persistence flusher skip
/// idle sessions instead of rewriting an unchanged 256 KB buffer every tick.
dirty: Arc<AtomicBool>,
/// Live output fan-out. Each PTY chunk is published here in addition to the
/// scrollback + `on_output` callback, so in-process consumers (AutoWork) can
/// observe the stream without touching the WebSocket path.
out_tx: broadcast::Sender<Vec<u8>>,
/// Direct child pid. The child is its own session/process-group leader
/// (portable-pty calls `setsid()` on the slave), so this is also the
/// process-group id used to kill the whole tree.
pid: Option<u32>,
/// Monotonic spawn generation, assigned by the service. The exit callback
/// only tears the session down if this epoch is still the live one for the
/// id — a relaunch kills the old child then immediately spawns a
/// higher-epoch replacement, so the killed predecessor's (drain-grace-
/// delayed) exit callback becomes a no-op instead of closing the fresh PTY.
epoch: u64,
}
/// Parameters for spawning a PTY.
pub struct SpawnParams {
pub program: String,
pub args: Vec<String>,
pub cwd: String,
pub env: HashMap<String, String>,
pub cols: u16,
pub rows: u16,
}
impl PtyHandle {
/// Spawn a child in a new PTY.
///
/// `on_output` is invoked (on a blocking reader thread) for every chunk of
/// bytes read from the PTY. `on_exit` is invoked once when the child exits,
/// with the child's exit code (if available) and a final snapshot of the
/// scrollback (taken after the drain grace, so it includes the tail) — the
/// caller persists this so the output survives the process even between
/// debounced flushes. `epoch` is the service-assigned spawn generation
/// stored on the handle (see the field docs); the caller uses it to ignore
/// a stale exit callback after a relaunch.
pub fn spawn<FOut, FExit>(
params: SpawnParams,
epoch: u64,
on_output: FOut,
on_exit: FExit,
) -> Result<Arc<Self>, TerminalError>
where
FOut: Fn(Vec<u8>) + Send + 'static,
FExit: FnOnce(Option<i32>, Vec<u8>) + Send + 'static,
{
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: params.rows,
cols: params.cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| TerminalError::Spawn(format!("openpty: {e}")))?;
let mut cmd = CommandBuilder::new(&params.program);
for arg in &params.args {
cmd.arg(arg);
}
if !params.cwd.is_empty() {
cmd.cwd(&params.cwd);
}
for (k, v) in &params.env {
cmd.env(k, v);
}
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| TerminalError::Spawn(format!("spawn '{}': {e}", params.program)))?;
// Drop the slave so the master sees EOF when the child exits.
drop(pair.slave);
let writer = pair
.master
.take_writer()
.map_err(|e| TerminalError::Spawn(format!("take_writer: {e}")))?;
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| TerminalError::Spawn(format!("clone_reader: {e}")))?;
let scrollback = Arc::new(Mutex::new(Vec::<u8>::new()));
let dirty = Arc::new(AtomicBool::new(false));
let pid = child.process_id();
let (out_tx, _) = broadcast::channel::<Vec<u8>>(OUTPUT_BROADCAST_CAP);
// Split a killer off the child so `kill()` can signal the process while
// the waiter thread below is parked in the blocking `child.wait()`.
let killer = child.clone_killer();
let handle = Arc::new(PtyHandle {
master: Mutex::new(pair.master),
writer: Mutex::new(writer),
killer: Mutex::new(killer),
scrollback: scrollback.clone(),
dirty: dirty.clone(),
out_tx: out_tx.clone(),
pid,
epoch,
});
// Reader thread: stream PTY output (reads are synchronous). On Windows
// the ConPTY master does NOT reach EOF when the child exits, so this
// loop can outlive the child; it ends when the master is dropped (the
// PtyHandle is released) or the read errors. Exit is reported by the
// separate waiter thread below, NOT by this loop's EOF.
let scrollback_reader = scrollback.clone();
let dirty_reader = dirty.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break, // EOF (Unix, or master dropped)
Ok(n) => {
let chunk = buf[..n].to_vec();
append_scrollback(&scrollback_reader, &chunk);
dirty_reader.store(true, Ordering::Relaxed);
// Fan out to in-process subscribers (AutoWork). Err just
// means no live receivers — harmless.
let _ = out_tx.send(chunk.clone());
on_output(chunk);
}
Err(_) => break,
}
}
});
// Waiter thread: block directly on the child and report its exit exactly
// once. This is the source of truth for exit — relying on the reader's
// EOF would never fire on Windows ConPTY (the master stays open after
// the child dies).
let scrollback_waiter = scrollback.clone();
std::thread::spawn(move || {
let mut child = child;
let code = child.wait().ok().map(|status| status.exit_code() as i32);
// Brief grace so the reader can drain output still buffered in the
// PTY before the caller tears the session down on this signal.
std::thread::sleep(EXIT_DRAIN_GRACE);
// Snapshot AFTER the grace so the persisted final scrollback includes
// the tail the reader just drained.
let final_scrollback = scrollback_waiter.lock().expect("scrollback lock").clone();
on_exit(code, final_scrollback);
});
Ok(handle)
}
/// Write bytes to the PTY (the child's stdin).
pub fn write(&self, bytes: &[u8]) -> Result<(), TerminalError> {
let mut writer = self.writer.lock().expect("pty writer lock");
writer.write_all(bytes)?;
writer.flush()?;
Ok(())
}
/// Resize the PTY window.
pub fn resize(&self, cols: u16, rows: u16) -> Result<(), TerminalError> {
self.master
.lock()
.expect("pty master lock")
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| TerminalError::Spawn(format!("resize: {e}")))
}
/// Terminate the child process **and its descendants**.
///
/// `portable-pty`'s `Child::kill()` only signals the direct child pid, which
/// can leave grandchildren (e.g. a `claude`/`vim` launched by the shell)
/// alive. The child is its own process-group leader (the slave is spawned
/// with `setsid()`), so on Unix we additionally SIGKILL the whole process
/// group via the negative pid to reap the entire tree.
pub fn kill(&self) -> Result<(), TerminalError> {
#[cfg(unix)]
if let Some(pid) = self.pid {
// Negative pid → signal the process group led by `pid`.
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
// Best-effort direct kill too (covers the non-group / Windows path).
// Uses the split killer, which works even while the waiter thread is
// blocked in `child.wait()`.
let _ = self.killer.lock().expect("pty killer lock").kill();
Ok(())
}
/// Snapshot of the current scrollback bytes (for reconnect).
pub fn scrollback(&self) -> Vec<u8> {
self.scrollback.lock().expect("scrollback lock").clone()
}
/// If new output has landed since the last call (or spawn), clear the dirty
/// flag and return a snapshot to persist; otherwise return `None`. Used by
/// the debounced flusher so an idle session is never rewritten.
///
/// Note the flag is cleared *before* the snapshot is read. A chunk arriving
/// in that window re-sets the flag, so it is caught next tick — at worst the
/// snapshot already includes it (a harmless redundant write next tick),
/// never a lost update.
pub fn take_dirty_scrollback(&self) -> Option<Vec<u8>> {
if self.dirty.swap(false, Ordering::Relaxed) {
Some(self.scrollback())
} else {
None
}
}
/// Subscribe to the live output byte-stream (in-process fan-out). Each PTY
/// chunk is delivered as a `Vec<u8>`; a lagging receiver drops oldest chunks.
pub fn subscribe_output(&self) -> broadcast::Receiver<Vec<u8>> {
self.out_tx.subscribe()
}
/// The direct child pid (also the process-group id).
pub fn pid(&self) -> Option<u32> {
self.pid
}
/// The service-assigned spawn generation (see the field docs). Used by the
/// service to ignore a stale exit callback from a relaunched-over PTY.
pub fn epoch(&self) -> u64 {
self.epoch
}
}
fn append_scrollback(scrollback: &Arc<Mutex<Vec<u8>>>, chunk: &[u8]) {
let mut sb = scrollback.lock().expect("scrollback lock");
sb.extend_from_slice(chunk);
if sb.len() > SCROLLBACK_CAP {
let overflow = sb.len() - SCROLLBACK_CAP;
sb.drain(0..overflow);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scrollback_is_bounded() {
let sb = Arc::new(Mutex::new(Vec::<u8>::new()));
let big = vec![b'x'; SCROLLBACK_CAP + 5000];
append_scrollback(&sb, &big);
assert_eq!(sb.lock().unwrap().len(), SCROLLBACK_CAP);
}
#[test]
fn scrollback_keeps_most_recent_bytes() {
let sb = Arc::new(Mutex::new(Vec::<u8>::new()));
append_scrollback(&sb, &vec![b'a'; SCROLLBACK_CAP]);
append_scrollback(&sb, b"TAIL");
let data = sb.lock().unwrap();
assert_eq!(data.len(), SCROLLBACK_CAP);
assert_eq!(&data[data.len() - 4..], b"TAIL");
}
#[test]
fn dirty_flag_set_by_output_then_cleared_by_take() {
use std::sync::atomic::{AtomicBool, Ordering};
// A child that emits known output then exits on its own.
#[cfg(windows)]
let (program, args) = (
std::env::var("ComSpec").unwrap_or_else(|_| "C:\\Windows\\System32\\cmd.exe".into()),
vec!["/c".to_owned(), "echo".to_owned(), "hello".to_owned()],
);
#[cfg(not(windows))]
let (program, args) = ("sh".to_owned(), vec!["-c".to_owned(), "printf hello".to_owned()]);
let exited = Arc::new(AtomicBool::new(false));
let exited_cb = exited.clone();
let handle = PtyHandle::spawn(
SpawnParams {
program,
args,
cwd: String::new(),
env: std::collections::HashMap::new(),
cols: 80,
rows: 24,
},
0,
|_chunk| {},
move |_code, _sb| exited_cb.store(true, Ordering::SeqCst),
)
.expect("spawn");
// Wait for exit (on_exit fires after the reader has drained final bytes).
for _ in 0..250 {
if exited.load(Ordering::SeqCst) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
// Small settle so the reader thread has appended the last chunk.
std::thread::sleep(std::time::Duration::from_millis(50));
// Output landed → dirty → first take returns the snapshot.
let snap = handle.take_dirty_scrollback().expect("dirty after output");
assert!(
String::from_utf8_lossy(&snap).contains("hello"),
"snapshot should contain the emitted output, got {:?}",
String::from_utf8_lossy(&snap)
);
// No new output since the take → second take is None (idle, skip write).
assert!(
handle.take_dirty_scrollback().is_none(),
"a session with no new output must not be re-flushed"
);
}
#[test]
fn exit_fires_when_child_exits_on_its_own() {
use std::sync::atomic::{AtomicBool, Ordering};
// A child that exits immediately on its own (no kill). On Windows the
// ConPTY master never EOFs on exit, so a reader-EOF-gated design never
// fires on_exit here; the dedicated waiter thread must.
#[cfg(windows)]
let (program, args) = (
std::env::var("ComSpec").unwrap_or_else(|_| "C:\\Windows\\System32\\cmd.exe".into()),
vec!["/c".to_owned(), "exit".to_owned(), "0".to_owned()],
);
#[cfg(not(windows))]
let (program, args) = ("sh".to_owned(), vec!["-c".to_owned(), "exit 0".to_owned()]);
let exited = Arc::new(AtomicBool::new(false));
let exited_cb = exited.clone();
let _handle = PtyHandle::spawn(
SpawnParams {
program,
args,
cwd: String::new(),
env: std::collections::HashMap::new(),
cols: 80,
rows: 24,
},
0,
|_chunk| {},
move |_code, _sb| exited_cb.store(true, Ordering::SeqCst),
)
.expect("spawn");
let mut fired = false;
for _ in 0..250 {
if exited.load(Ordering::SeqCst) {
fired = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(fired, "on_exit must fire when the child exits on its own");
}
#[cfg(unix)]
#[test]
fn kill_terminates_the_process() {
use std::sync::atomic::{AtomicBool, Ordering};
let exited = Arc::new(AtomicBool::new(false));
let exited_cb = exited.clone();
// A long-lived child: sleep 60s. kill() must terminate it promptly.
let handle = PtyHandle::spawn(
SpawnParams {
program: "sleep".into(),
args: vec!["60".into()],
cwd: String::new(),
env: std::collections::HashMap::new(),
cols: 80,
rows: 24,
},
0,
|_chunk| {},
move |_code, _sb| exited_cb.store(true, Ordering::SeqCst),
)
.expect("spawn sleep");
let pid = handle.pid().expect("pid") as i32;
// Process exists right after spawn (signal 0 = existence probe).
assert_eq!(unsafe { libc::kill(pid, 0) }, 0, "child should be alive");
handle.kill().expect("kill");
// Within a short window the reader hits EOF and on_exit fires.
let mut gone = false;
for _ in 0..200 {
if exited.load(Ordering::SeqCst) {
gone = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(gone, "kill() should terminate the child and trigger on_exit");
}
}
@@ -0,0 +1,157 @@
use axum::Router;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Extension, Json, Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use nomifun_api_types::{
ApiResponse, CreateTerminalRequest, TerminalInputRequest, TerminalResizeRequest, TerminalSessionResponse,
UpdateTerminalRequest, WorkspaceEntry,
};
use nomifun_auth::CurrentUser;
use nomifun_common::AppError;
use serde::Deserialize;
use crate::state::TerminalRouterState;
/// Query for `GET /api/terminals/{id}/workspace`. `path` (workspace-relative,
/// default the cwd root) + optional case-insensitive `search`. The root itself
/// is derived server-side from the session's cwd and is never accepted here.
#[derive(Debug, Deserialize)]
pub struct TerminalWorkspaceQuery {
#[serde(default)]
pub path: String,
pub search: Option<String>,
}
pub fn terminal_routes(state: TerminalRouterState) -> Router {
Router::new()
.route("/api/terminals", get(list_terminals).post(create_terminal))
.route(
"/api/terminals/{id}",
get(get_terminal).patch(update_terminal).delete(delete_terminal),
)
.route("/api/terminals/{id}/input", post(write_input))
.route("/api/terminals/{id}/resize", post(resize_terminal))
.route("/api/terminals/{id}/kill", post(kill_terminal))
.route("/api/terminals/{id}/relaunch", post(relaunch_terminal))
.route("/api/terminals/{id}/relaunch-shell", post(relaunch_shell_terminal))
.route("/api/terminals/{id}/workspace", get(browse_workspace))
.with_state(state)
}
async fn create_terminal(
State(state): State<TerminalRouterState>,
Extension(user): Extension<CurrentUser>,
body: Result<Json<CreateTerminalRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<ApiResponse<TerminalSessionResponse>>), AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let resp = state.terminal_service.create(&user.id, req).await?;
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
}
async fn list_terminals(
State(state): State<TerminalRouterState>,
Extension(user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<Vec<TerminalSessionResponse>>>, AppError> {
let items = state.terminal_service.list(&user.id).await?;
Ok(Json(ApiResponse::ok(items)))
}
async fn get_terminal(
State(state): State<TerminalRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<TerminalSessionResponse>>, AppError> {
let resp = state.terminal_service.get(id).await?;
Ok(Json(ApiResponse::ok(resp)))
}
async fn write_input(
State(state): State<TerminalRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
body: Result<Json<TerminalInputRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.terminal_service.input(id, &req.data_b64).await?;
Ok(Json(ApiResponse::success()))
}
async fn resize_terminal(
State(state): State<TerminalRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
body: Result<Json<TerminalResizeRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.terminal_service.resize(id, req.cols, req.rows).await?;
Ok(Json(ApiResponse::success()))
}
async fn kill_terminal(
State(state): State<TerminalRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.terminal_service.kill(id).await?;
Ok(Json(ApiResponse::success()))
}
async fn delete_terminal(
State(state): State<TerminalRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.terminal_service.delete(id).await?;
Ok(Json(ApiResponse::success()))
}
async fn relaunch_terminal(
State(state): State<TerminalRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<TerminalSessionResponse>>, AppError> {
let resp = state.terminal_service.relaunch(id).await?;
Ok(Json(ApiResponse::ok(resp)))
}
/// Fall back to a clean login shell in place: kill the (possibly wedged) agent
/// CLI and spawn the platform shell under the SAME session id. The escape hatch
/// for a garbled/unresponsive claude/codex TUI — see `relaunch_as_shell`.
async fn relaunch_shell_terminal(
State(state): State<TerminalRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<TerminalSessionResponse>>, AppError> {
let resp = state.terminal_service.relaunch_as_shell(id).await?;
Ok(Json(ApiResponse::ok(resp)))
}
async fn update_terminal(
State(state): State<TerminalRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
body: Result<Json<UpdateTerminalRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<TerminalSessionResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let resp = state.terminal_service.update_meta(id, req.name, req.pinned).await?;
Ok(Json(ApiResponse::ok(resp)))
}
/// List one directory level under the terminal session's working directory.
/// The root is the session's `cwd` (server-authoritative); the client supplies
/// only a workspace-relative `path` + optional `search`. Missing session → 404,
/// `..` traversal → 400 (both from the service / `list_workspace_level`).
async fn browse_workspace(
State(state): State<TerminalRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<i64>,
Query(query): Query<TerminalWorkspaceQuery>,
) -> Result<Json<ApiResponse<Vec<WorkspaceEntry>>>, AppError> {
let entries = state
.terminal_service
.browse_workspace(id, &query.path, query.search.as_deref())
.await?;
Ok(Json(ApiResponse::ok(entries)))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
use std::sync::Arc;
use crate::service::TerminalService;
/// Router state for the terminal module.
#[derive(Clone)]
pub struct TerminalRouterState {
pub terminal_service: Arc<TerminalService>,
}
impl TerminalRouterState {
pub fn new(terminal_service: Arc<TerminalService>) -> Self {
Self { terminal_service }
}
}
@@ -0,0 +1,108 @@
//! Terminal session auto-titling: turn the first interaction into a short,
//! work-content title. The mechanical [`default_name`](crate::service) is too
//! fixed ("Shell"/"Claude"/raw command); this lets a session's sidebar entry
//! read like what the user is actually doing.
//!
//! Two seams (wired in [`crate::service`]):
//! - agent CLIs (claude/codex): the first `TurnEnd` lifecycle event carries the
//! assistant's first message → summarized by an LLM via [`TerminalTitleCompleter`].
//! - plain shell / no model / LLM failure: the first line the user types, taken
//! verbatim and truncated by [`fallback_title`].
//!
//! Layering mirrors knowledge autogen: the trait lives here (the lower crate),
//! the provider-backed `LiveTerminalTitleCompleter` lives in `nomifun-ai-agent`,
//! and `nomifun-app` wires it. A terminal with no completer (tests / webui-only)
//! still auto-titles via the fallback — the feature never hard-depends on an LLM.
use nomifun_common::AppError;
/// Summarize terminal content into a short title. Implemented in
/// `nomifun-ai-agent` (`LiveTerminalTitleCompleter`) over the default provider/
/// model; absent in hosts without a provider layer, where the fallback is used.
#[async_trait::async_trait]
pub trait TerminalTitleCompleter: Send + Sync {
/// Return a short (≤ ~20 chars) work-content title for `content`, or an
/// error if no model is configured / the call fails (caller then falls back).
async fn summarize(&self, content: &str) -> Result<String, AppError>;
}
/// Default character cap for a generated title (CJK-aware: counts `char`s, not
/// bytes, so ~20 Chinese characters fit).
pub const TITLE_MAX_CHARS: usize = 24;
/// Build a fallback title from raw user input: strip ANSI, take the first
/// non-empty line, drop control chars, trim, and cap to `max_chars` characters.
/// Returns an empty string when there is nothing usable (caller skips the write).
pub fn fallback_title(input: &str, max_chars: usize) -> String {
let cleaned = crate::ansi::strip_ansi(input.as_bytes());
let line = cleaned
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("");
clamp_title(line, max_chars)
}
/// Normalize a candidate title (from an LLM or a raw line) into a clean,
/// single-line, length-capped string: strip control chars, collapse internal
/// whitespace runs, strip wrapping quotes/backticks, trim, and cap to
/// `max_chars` characters. Empty in → empty out.
pub fn clamp_title(raw: &str, max_chars: usize) -> String {
// Collapse all whitespace (incl. newlines) to single spaces; drop other
// control chars. Keeps multibyte (CJK) intact since we operate on `char`s.
let mut collapsed = String::with_capacity(raw.len());
let mut prev_space = false;
for c in raw.chars() {
if c.is_whitespace() {
if !prev_space {
collapsed.push(' ');
prev_space = true;
}
} else if !c.is_control() {
collapsed.push(c);
prev_space = false;
}
}
let trimmed = collapsed
.trim()
.trim_matches(|c| c == '"' || c == '\'' || c == '`')
.trim();
trimmed.chars().take(max_chars).collect::<String>().trim().to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fallback_takes_first_nonempty_line_trimmed() {
assert_eq!(fallback_title(" npm run build \n more", 40), "npm run build");
assert_eq!(fallback_title("\n\n git status\n", 40), "git status");
}
#[test]
fn fallback_strips_ansi_and_control_chars() {
// ESC[31m red ESC[0m + a stray CR — only the visible text survives.
let input = "\u{1b}[31mdeploy prod\u{1b}[0m\r";
assert_eq!(fallback_title(input, 40), "deploy prod");
}
#[test]
fn fallback_caps_to_max_chars_counting_chars_not_bytes() {
// 10 Chinese chars capped to 6 → 6 chars (not bytes).
let s = fallback_title("部署生产环境的脚本任务", 6);
assert_eq!(s.chars().count(), 6);
}
#[test]
fn fallback_empty_input_is_empty() {
assert_eq!(fallback_title(" \n\t", 40), "");
assert_eq!(fallback_title("", 40), "");
}
#[test]
fn clamp_strips_quotes_and_collapses_whitespace() {
assert_eq!(clamp_title(" \"Fix the\nlogin bug\" ", 40), "Fix the login bug");
assert_eq!(clamp_title("`build`", 40), "build");
}
}
@@ -0,0 +1,227 @@
use nomifun_api_types::TerminalSessionResponse;
use nomifun_db::TerminalSessionRow;
use std::path::Path;
/// Sentinel `command` value meaning "use the platform login shell". Resolved at
/// spawn time so the stored row stays portable across machines.
pub const SHELL_SENTINEL: &str = "$SHELL";
/// Resolve the launch (program, argv) for a session, expanding the shell
/// sentinel to the platform default shell and resolving a bare program name
/// to its absolute executable path.
pub fn resolve_command(command: &str, args: &[String]) -> (String, Vec<String>) {
let program = if command == SHELL_SENTINEL {
default_login_shell()
} else {
command.to_owned()
};
(resolve_program(&program), args.to_vec())
}
/// Resolve a bare command name to its absolute executable path so the PTY
/// backend (portable-pty) launches the intended file.
///
/// portable-pty's own Windows `PATH` search picks the extension-less npm
/// shell shim (e.g. `…\npm\claude`), which `CreateProcessW` rejects with
/// "not a valid Win32 application" (os error 193). `resolve_command_path`
/// honours `PATHEXT` plus the `.cmd / .ps1 / .bat` fallback, so `claude`
/// resolves to `…\claude.cmd` — which ConPTY runs correctly.
///
/// Inputs that already contain a path separator (an absolute path, or the
/// expanded login shell) or that don't resolve are returned unchanged.
/// Mirrors `nomifun_runtime::Builder`'s `resolve_program`.
fn resolve_program(program: &str) -> String {
if !program.is_empty()
&& !program.contains('/')
&& !program.contains('\\')
&& let Some(path) = nomifun_runtime::resolve_command_path(program)
{
return path.to_string_lossy().into_owned();
}
program.to_owned()
}
/// The platform's default interactive shell.
pub fn default_login_shell() -> String {
#[cfg(windows)]
{
std::env::var("ComSpec").unwrap_or_else(|_| "powershell.exe".to_owned())
}
#[cfg(not(windows))]
{
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned())
}
}
/// Parse the JSON args array stored on a row, tolerating malformed values.
pub fn parse_args(json: &str) -> Vec<String> {
serde_json::from_str::<Vec<String>>(json).unwrap_or_default()
}
/// Build the API response for a session row. `scrollback_b64` is filled in by
/// the caller for single-session GET only.
///
/// `work_dir` is the backend-managed default work dir; the response exposes a
/// derived `is_default_workpath` flag (cwd equals or sits under `work_dir`)
/// without storing it on the row — same pattern as conversations'
/// `is_temporary_workspace` (nomifun-conversation/src/convert.rs).
pub fn row_to_response(
row: &TerminalSessionRow,
scrollback_b64: Option<String>,
work_dir: &Path,
) -> TerminalSessionResponse {
// `Path::starts_with` already covers the `cwd == work_dir` equality case.
// Guard both sides against blanks: an empty `work_dir` would make every
// path "start with" it, and an empty cwd carries no grouping signal.
let is_default_workpath =
!row.cwd.is_empty() && !work_dir.as_os_str().is_empty() && Path::new(&row.cwd).starts_with(work_dir);
TerminalSessionResponse {
id: row.id.clone(),
name: row.name.clone(),
cwd: row.cwd.clone(),
is_default_workpath,
command: row.command.clone(),
args: parse_args(&row.args),
backend: row.backend.clone(),
mode: row.mode.clone(),
cols: row.cols as u16,
rows: row.rows as u16,
created_at: row.created_at,
updated_at: row.updated_at,
last_status: row.last_status.clone(),
exit_code: row.exit_code.map(|c| c as i32),
pinned: row.pinned,
pinned_at: row.pinned_at,
scrollback_b64,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_row() -> TerminalSessionRow {
TerminalSessionRow {
id: 1,
name: "shell".into(),
cwd: "/tmp".into(),
command: "$SHELL".into(),
args: r#"["-l"]"#.into(),
env: None,
backend: None,
mode: None,
cols: 100,
rows: 30,
created_at: 10,
updated_at: 20,
last_status: "running".into(),
exit_code: None,
user_id: "u".into(),
pinned: false,
pinned_at: None,
autowork: None,
idmm: None,
}
}
#[test]
fn resolve_command_expands_shell_sentinel() {
let (program, args) = resolve_command(SHELL_SENTINEL, &["-l".to_owned()]);
assert_ne!(program, SHELL_SENTINEL);
assert!(!program.is_empty());
assert_eq!(args, vec!["-l".to_owned()]);
}
#[test]
fn resolve_command_resolves_bare_name_to_absolute_path() {
// A bare command present on PATH must resolve to an absolute executable
// path so the PTY backend (portable-pty) launches the real file. On
// Windows this is what turns an npm `claude` shim into `claude.cmd`
// instead of the extension-less shell script CreateProcessW rejects
// (os error 193).
#[cfg(windows)]
let bare = "cmd";
#[cfg(not(windows))]
let bare = "sh";
let (program, args) = resolve_command(bare, &["--flag".to_owned()]);
assert_ne!(program, bare, "bare name should resolve to an absolute path");
assert!(
std::path::Path::new(&program).is_absolute(),
"resolved program should be absolute, got {program}"
);
#[cfg(windows)]
assert!(
program.to_ascii_lowercase().ends_with("cmd.exe"),
"expected cmd.exe, got {program}"
);
assert_eq!(args, vec!["--flag".to_owned()], "args must be preserved");
}
#[test]
fn resolve_command_passes_through_unresolvable_command() {
// A name that isn't on PATH can't be resolved; keep it verbatim so the
// spawn error surfaces the original command the user asked for.
let name = "nomifun-definitely-not-on-path-xyz-987";
let (program, args) = resolve_command(name, &["a".to_owned()]);
assert_eq!(program, name);
assert_eq!(args, vec!["a".to_owned()]);
}
#[test]
fn resolve_command_passes_through_path_like_command() {
// Inputs that already carry a path separator are used as-is — no PATH
// search, mirroring nomifun_runtime::Builder's resolve_program.
let p = if cfg!(windows) {
r"C:\tools\my agent.exe"
} else {
"/opt/tools/my-agent"
};
let (program, _args) = resolve_command(p, &[]);
assert_eq!(program, p);
}
#[test]
fn parse_args_handles_valid_and_invalid() {
assert_eq!(parse_args(r#"["a","b"]"#), vec!["a".to_owned(), "b".to_owned()]);
assert!(parse_args("not json").is_empty());
assert!(parse_args("[]").is_empty());
}
#[test]
fn row_to_response_parses_args_and_maps_fields() {
let resp = row_to_response(&sample_row(), Some("c2I=".into()), Path::new("/work"));
assert_eq!(resp.id, 1);
assert_eq!(resp.args, vec!["-l".to_owned()]);
assert_eq!((resp.cols, resp.rows), (100, 30));
assert_eq!(resp.scrollback_b64.as_deref(), Some("c2I="));
assert_eq!(resp.last_status, "running");
}
#[test]
fn row_to_response_derives_is_default_workpath() {
let work_dir = Path::new("/srv/nomi-work");
let mut row = sample_row();
// cwd equal to work_dir → default workpath (starts_with covers equality).
row.cwd = "/srv/nomi-work".into();
assert!(row_to_response(&row, None, work_dir).is_default_workpath);
// cwd under work_dir → default workpath.
row.cwd = "/srv/nomi-work/projects/demo".into();
assert!(row_to_response(&row, None, work_dir).is_default_workpath);
// cwd outside work_dir → custom workpath. A same-prefix sibling must
// not match either (component-wise, not string-prefix, semantics).
row.cwd = "/Users/alice/my-project".into();
assert!(!row_to_response(&row, None, work_dir).is_default_workpath);
row.cwd = "/srv/nomi-workspace".into();
assert!(!row_to_response(&row, None, work_dir).is_default_workpath);
// Blank guards: empty cwd, or an unset work_dir, never claim the group.
row.cwd = String::new();
assert!(!row_to_response(&row, None, work_dir).is_default_workpath);
row.cwd = "/srv/nomi-work".into();
assert!(!row_to_response(&row, None, Path::new("")).is_default_workpath);
}
}
@@ -0,0 +1,91 @@
//! Regression test for the Windows terminal spawn bug (os error 193).
//!
//! npm-installed CLIs expose an extension-less shell shim (e.g. `claude`)
//! alongside `claude.cmd`. portable-pty's own PATH search picked the
//! extension-less shim — a non-PE file `CreateProcessW` rejects with
//! "not a valid Win32 application" (os error 193). The terminal path must
//! resolve the bare name to its `.cmd` shim and run it under ConPTY.
#![cfg(windows)]
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use nomifun_terminal::pty::{PtyHandle, SpawnParams};
use nomifun_terminal::types::resolve_command;
#[test]
fn resolves_and_runs_npm_style_cmd_shim() {
let dir = tempfile::tempdir().unwrap();
// Decoy: the extension-less shell shim npm also installs. Picking this
// non-PE file is exactly what produced os error 193.
std::fs::write(dir.path().join("winagent"), b"#!/bin/sh\necho DECOY_DO_NOT_RUN\n").unwrap();
// The runnable Windows shim.
std::fs::write(dir.path().join("winagent.cmd"), b"@echo off\r\necho SHIM_RAN_OK\r\n").unwrap();
// Prepend the dir to PATH for the duration of this single-test binary.
let original = std::env::var_os("PATH");
let mut entries = vec![dir.path().to_path_buf()];
if let Some(orig) = &original {
entries.extend(std::env::split_paths(orig));
}
let joined = std::env::join_paths(&entries).unwrap();
// SAFETY: this integration test binary runs a single test; nothing else
// reads PATH concurrently.
unsafe { std::env::set_var("PATH", &joined) };
// 1. Resolution must pick the `.cmd` shim, not the extension-less decoy.
let (program, _args) = resolve_command("winagent", &[]);
assert!(
program.to_ascii_lowercase().ends_with("winagent.cmd"),
"expected the .cmd shim, got {program}"
);
// 2. The resolved shim must actually run under ConPTY (no os error 193).
let out = Arc::new(Mutex::new(Vec::<u8>::new()));
let exited = Arc::new(AtomicBool::new(false));
let out_cb = out.clone();
let exit_cb = exited.clone();
let handle = PtyHandle::spawn(
SpawnParams {
program,
args: vec![],
cwd: dir.path().to_string_lossy().into_owned(),
env: HashMap::new(),
cols: 80,
rows: 24,
},
0,
move |chunk| out_cb.lock().unwrap().extend_from_slice(&chunk),
move |_code, _sb| exit_cb.store(true, Ordering::SeqCst),
)
.expect("spawn must succeed (regression: os error 193)");
let deadline = Instant::now() + Duration::from_secs(8);
let mut ran = false;
while Instant::now() < deadline {
if String::from_utf8_lossy(&out.lock().unwrap()).contains("SHIM_RAN_OK") {
ran = true;
break;
}
std::thread::sleep(Duration::from_millis(50));
}
drop(handle);
// Restore PATH before asserting.
// SAFETY: single-test binary.
unsafe {
match original {
Some(p) => std::env::set_var("PATH", p),
None => std::env::remove_var("PATH"),
}
}
let captured = String::from_utf8_lossy(&out.lock().unwrap()).into_owned();
assert!(ran, "shim did not run; output: {captured:?}");
assert!(
!captured.contains("DECOY_DO_NOT_RUN"),
"decoy ran instead of .cmd: {captured:?}"
);
}