Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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(¶ms.program);
|
||||
for arg in ¶ms.args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
if !params.cwd.is_empty() {
|
||||
cmd.cwd(¶ms.cwd);
|
||||
}
|
||||
for (k, v) in ¶ms.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:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user