diff --git a/.changeset/remote-work-launch-flow.md b/.changeset/remote-work-launch-flow.md new file mode 100644 index 000000000..c03273049 --- /dev/null +++ b/.changeset/remote-work-launch-flow.md @@ -0,0 +1,6 @@ +--- +"@internal/server": patch +"@internal/desktop-server": patch +--- + +Choose an SSH host when creating a subshell or workspace pane, reuse existing connections, and reconnect from remote panes. Keep connection management secondary to starting work. Allow administrators to connect through the server machine's SSH account without enrolling another node, and deliver initial prompts to remote agents. diff --git a/.changeset/settings-navigation.md b/.changeset/settings-navigation.md new file mode 100644 index 000000000..e715e48b3 --- /dev/null +++ b/.changeset/settings-navigation.md @@ -0,0 +1,6 @@ +--- +"@internal/server": patch +"@internal/desktop-server": patch +--- + +Move Nodes, Presets, and Prompts into the Settings navigation group. diff --git a/.changeset/ssh-connections-label.md b/.changeset/ssh-connections-label.md new file mode 100644 index 000000000..55a9347c2 --- /dev/null +++ b/.changeset/ssh-connections-label.md @@ -0,0 +1,8 @@ +--- +"@internal/server": patch +"@internal/desktop-server": patch +"@internal/desktop-client": patch +"@internal/docs": patch +--- + +Rename the personal Connections navigation item to SSH Connections and update setup instructions to match. diff --git a/.changeset/ssh-friendly-copy.md b/.changeset/ssh-friendly-copy.md new file mode 100644 index 000000000..2b170e3f5 --- /dev/null +++ b/.changeset/ssh-friendly-copy.md @@ -0,0 +1,6 @@ +--- +"@internal/server": patch +"@internal/desktop-server": patch +--- + +Make SSH setup, pairing, and connection errors clearer and more conversational, with plain-language next steps. diff --git a/.changeset/ssh-personal-connections.md b/.changeset/ssh-personal-connections.md new file mode 100644 index 000000000..d5a1f8b36 --- /dev/null +++ b/.changeset/ssh-personal-connections.md @@ -0,0 +1,9 @@ +--- +"@internal/server": minor +"@internal/desktop-server": minor +"@internal/node": minor +"@internal/desktop-client": minor +"@internal/docs": patch +--- + +Add personal SSH connections from Subshell Client without node enrollment, saved remote folders, actionable host setup, and in-place pane reconnection. Keep browser connections through owned nodes and authorized server accounts. diff --git a/.changeset/ssh-setup-guidance.md b/.changeset/ssh-setup-guidance.md new file mode 100644 index 000000000..7fca45ccd --- /dev/null +++ b/.changeset/ssh-setup-guidance.md @@ -0,0 +1,6 @@ +--- +"@internal/server": patch +"@internal/desktop-server": patch +--- + +Show SSH setup help only when needed, with copyable configuration and recovery commands. Keep launch actions visible while forms scroll and hide launch buttons until SSH is connected. diff --git a/.changeset/ssh-support-docs.md b/.changeset/ssh-support-docs.md new file mode 100644 index 000000000..ed7d9be56 --- /dev/null +++ b/.changeset/ssh-support-docs.md @@ -0,0 +1,5 @@ +--- +"@internal/docs": minor +--- + +A new SSH connections section: human setup on a node, the per-pane grant model, route identity, and the honest no-replay, takeover-controls-input-not-secrecy, and output-is-data rules, plus what v1 does not do. The MCP tool reference gained the five SSH tools and get_terminal_execution, the exec_in_terminal entry teaches the durable executionId and its late-result recovery, terminal sessions and the MCP overview point at the managed SSH loop, and the security model documents the SSH controls as section 4b. diff --git a/.changeset/ssh-support.md b/.changeset/ssh-support.md new file mode 100644 index 000000000..e7b4c283c --- /dev/null +++ b/.changeset/ssh-support.md @@ -0,0 +1,6 @@ +--- +"@internal/server": minor +"@internal/node": minor +--- + +Agents can work on plain SSH machines: a human saves an SSH connection through an enrolled node (aliases discovered from the node's config, destination resolved and tested, nothing but an approved normalized snapshot stored), grants it to individual running panes, and the granted pane can run structured commands with bounded incremental output and honest lifecycle, cancel them, and open managed interactive SSH terminals whose input and output ride the ordinary pane tools. The five MCP SSH tools plus get_terminal_execution and the /api/ssh routes carry it; machine credentials cannot configure anything, every generic pane surface asks the same SSH policy, and sharing managed panes is refused in v1. Nothing is ever replayed after an ambiguous disconnect (a lost answer stays `accepted`, reconciliation asks, never restarts), exit 255 is never claimed as a confirmed remote result, and cancellation confirms only the local process stopped. Commands run under a mandatory every-hop policy (strict host checking, no forwarding, no escape characters, no control sockets, key/certificate auth only); credentials, config contents, command text, and output never reach the server, its logs, or the audit trail. The node protocol becomes 16 to carry the nine ssh_* commands, so update the server before the nodes: a lagging agent is held and crosses the bump on the update command. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9a841b00a..a88fc67e1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -928,15 +928,24 @@ jobs: run: git config --global --add safe.directory "$GITHUB_WORKSPACE" # The session-lifecycle spec spawns real tmux sessions (stub harness). - - name: Ensure tmux and jq + # Spec 21's real-sshd composition needs ssh, ssh-keygen and sshd: the + # builder image carries none of them, so WITHOUT these the SSH specs + # loud-skip on every CI run - an honest skip, but a flagship suite that + # silently never runs is what e2e/AGENTS.md promises against. The + # fixture daemon's privilege-separation dir (/run/sshd) is NOT created + # by the postinst in this non-systemd container (measured on the exact + # image); the fixture itself ensures it before spawning. + - name: Ensure tmux, jq, and OpenSSH run: | set -euo pipefail missing=() for tool in tmux jq; do command -v "$tool" >/dev/null || missing+=("$tool"); done + { command -v ssh >/dev/null && command -v ssh-keygen >/dev/null; } || missing+=(openssh-client) + command -v sshd >/dev/null || [ -x /usr/sbin/sshd ] || missing+=(openssh-server) if [ ${#missing[@]} -gt 0 ]; then apt-get update && apt-get install -y --no-install-recommends "${missing[@]}" fi - tmux -V && jq --version + tmux -V && jq --version && ssh -V && ls -x /usr/sbin/sshd # tsdown/rolldown run under NODE, not bun — their bin shebang is # `#!/usr/bin/env node` — so the repo has always had a Node requirement diff --git a/apps/client/desktop/AGENTS.md b/apps/client/desktop/AGENTS.md index c9c8fa09e..fa9ab94d1 100644 --- a/apps/client/desktop/AGENTS.md +++ b/apps/client/desktop/AGENTS.md @@ -430,7 +430,9 @@ plan parsed from the node's own `status --json` `paths` block AT PRESS TIME with `config.json` deleted LAST so a half-run stays resumable (`deletion_order` is a tested pure function), the shared guards in `subshell_desktop_core::reset_guards`, and `Err` only for refusals before -the first mutation. `planeUrl` is KEPT. **Working on reset: read +the first mutation. Saved control planes are cleared. Desktop SSH children +are stopped and their saved credentials and connection metadata are removed +before deleting node config; failed cleanup remains retryable. **Working on reset: read `apps/client/desktop/docs/reset.md` first.** ## Things that will bite @@ -536,3 +538,19 @@ the first mutation. `planeUrl` is KEPT. **Working on reset: read plum here, near-black for the server), which is also why the tray icon is NOT `icon_as_template(true)` on macOS: a template icon is drawn from the alpha channel alone and both would collapse to the same filled square. + +## Desktop SSH connections + +The bundled assistant's **SSH Connections** screen pairs this computer with a +signed-in server account without node enrollment. A first-run SSH choice reaches +it directly. The server-loaded main window still has exactly one command; +`node_ssh_*` permissions belong only to `capabilities/node.json`. + +`src-tauri/src/ssh.rs` starts the bundled `subshell ssh-broker` child (resolved +CLI fallback in development), passes pairing codes over stdin, and retains that +pipe for the child's lifetime. EOF or a stop line drains the broker's SSH children. +The CLI holds private, origin-bound credentials; native metadata stores only the +server, broker id, and display name. Pairing also remembers the server bookmark, +so an SSH-only client remains configured after restart. Disconnected connections +are resumed explicitly from the bundled screen. See the public procedure at +`apps/docs/content/docs/ssh/index.mdx`. diff --git a/apps/client/desktop/docs/reset.md b/apps/client/desktop/docs/reset.md index f82234f8d..0e139da35 100644 --- a/apps/client/desktop/docs/reset.md +++ b/apps/client/desktop/docs/reset.md @@ -38,9 +38,16 @@ binary where its own data lived. manage window and a zero-window moment quits it; resetting a node here invalidates neither of this app's windows, and the page's own re-probe lands it on Enroll. -- **`planeUrl` is KEPT.** The control plane this person watches is not what they - reset; making them retype its address to get their dashboard back would be - the reset reaching past what it promised. +- **Saved control planes are cleared**, including the last-open memory, so a + successful reset returns to first run (operator ruling 2026-09-22). +- **Desktop SSH access is removed before node config deletion.** Reset stops + every broker child, calls the CLI's idempotent `ssh-broker --forget` for each + remembered connection, and clears native connection metadata. A failure + keeps the metadata and node config so Retry can complete. The connection + lock remains held until reset returns, and concurrent pairing refuses. + Remote panes stay running; SSH keys and config are untouched. An SSH-only + client has no node delete plan: the dialog directs it to SSH Connections → + Disconnect → Forget instead. - **Channel discipline:** `Err` only for refusals BEFORE the first mutation. A half-run is `Ok(ActionResult { ok: false })` with the verbatim log and the plan still stashed, so a Retry converges. diff --git a/apps/client/desktop/src-tauri/capabilities/node.json b/apps/client/desktop/src-tauri/capabilities/node.json index 4999c6983..6ecea063b 100644 --- a/apps/client/desktop/src-tauri/capabilities/node.json +++ b/apps/client/desktop/src-tauri/capabilities/node.json @@ -28,6 +28,10 @@ "allow-node-about", "allow-node-open-web", "allow-node-check-app-update", - "allow-node-install-app-update" + "allow-node-install-app-update", + "allow-node-ssh-connections", + "allow-node-ssh-connect", + "allow-node-ssh-disconnect", + "allow-node-ssh-forget" ] } diff --git a/apps/client/desktop/src-tauri/permissions/desktop.toml b/apps/client/desktop/src-tauri/permissions/desktop.toml index 067a3056d..4a8dbaf20 100644 --- a/apps/client/desktop/src-tauri/permissions/desktop.toml +++ b/apps/client/desktop/src-tauri/permissions/desktop.toml @@ -129,3 +129,24 @@ commands.allow = ["node_install_app_update"] identifier = "allow-desktop-open-in-browser" description = "Open one page of the control plane this window is already on, in the system browser. `desktop_`, not `node_`, because it is the one permission in this file that is NOT a node verb: it is granted to `main` (the plane's own page) and to nothing else, which is the opposite of every rule above it. It takes a PATH and nothing else (no scheme, no protocol-relative '//host', no backslash, no whitespace or control characters; see crates/desktop-core's browser::browser_url) and joins it onto the origin windows.rs's PlanePin holds, so the page names the route and this side names the host. The tray and menu-bar routes to the same act need no permission at all: they run in Rust." commands.allow = ["desktop_open_in_browser"] + + +[[permission]] +identifier = "allow-node-ssh-connections" +description = "Bundled page only: read this computer's SSH connections." +commands.allow = ["node_ssh_connections"] + +[[permission]] +identifier = "allow-node-ssh-connect" +description = "Bundled page only: connect this computer to a reviewed server for SSH." +commands.allow = ["node_ssh_connect"] + +[[permission]] +identifier = "allow-node-ssh-disconnect" +description = "Bundled page only: disconnect this computer's SSH transport." +commands.allow = ["node_ssh_disconnect"] + +[[permission]] +identifier = "allow-node-ssh-forget" +description = "Bundled page only: remove a local SSH connection credential." +commands.allow = ["node_ssh_forget"] diff --git a/apps/client/desktop/src-tauri/src/lib.rs b/apps/client/desktop/src-tauri/src/lib.rs index d068fede0..e25f42e42 100644 --- a/apps/client/desktop/src-tauri/src/lib.rs +++ b/apps/client/desktop/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ mod app_update; mod control; mod node_bin; mod reset; +mod ssh; // macOS only: a GTK menu bar is per-window chrome rather than a system bar, so // Linux has none — and a module compiled there would be entirely dead code. // Gated at the MODULE, never at the call site, so `cargo clippy` on Linux sees @@ -162,7 +163,12 @@ pub fn run() { .manage(windows::PlanePin::new()) .manage(windows::PendingScreen::new()) .manage(reset::Stash::default()) + .manage(ssh::SshState::default()) .invoke_handler(tauri::generate_handler![ + ssh::node_ssh_connections, + ssh::node_ssh_connect, + ssh::node_ssh_disconnect, + ssh::node_ssh_forget, control::node_probe, control::node_install_cli, control::node_install_tmux, @@ -265,6 +271,7 @@ pub fn run() { // from the Dock, only from the tray. #[cfg(target_os = "macos")] tauri::RunEvent::Reopen { .. } => windows::focus_any(app), + tauri::RunEvent::Exit => ssh::shutdown(app), _ => {} }); } diff --git a/apps/client/desktop/src-tauri/src/reset.rs b/apps/client/desktop/src-tauri/src/reset.rs index 139105bc9..92ab5cdc0 100644 --- a/apps/client/desktop/src-tauri/src/reset.rs +++ b/apps/client/desktop/src-tauri/src/reset.rs @@ -235,6 +235,20 @@ pub fn node_reset(app: AppHandle, settings: State<'_, SettingsState>, typed: Str ), }); } + // Desktop SSH is independent of node enrollment. Remove its authority + // before config.json, preserving the staged node plan for a failed retry. + // The guard prevents a new pairing until the entire reset has returned. + let _ssh_reset = match crate::ssh::reset_access(&app) { + Ok(guard) => guard, + Err(stderr) => { + return Ok(ActionResult { + ok: false, + stdout: log, + stderr, + }) + } + }; + log.push_str("disconnected desktop SSH and removed saved SSH credentials\n"); // 4. Delete, absence = done, config.json last. Every deletion's failure // ends the chain: a surviving node key announced as a completed reset is // worse than a refusal, because the hostname was typed against the promise diff --git a/apps/client/desktop/src-tauri/src/ssh.rs b/apps/client/desktop/src-tauri/src/ssh.rs new file mode 100644 index 000000000..91b5ebe3b --- /dev/null +++ b/apps/client/desktop/src-tauri/src/ssh.rs @@ -0,0 +1,440 @@ +//! Desktop-owned SSH transport. Only the bundled page can start this child. +//! Pairing credentials go over stdin and remain in the CLI's private store. +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::{mpsc, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; +use subshell_desktop_core::{settings::SettingsState, shell_env::login_path, sidecar}; +use tauri::{AppHandle, Manager, State}; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Connection { + id: String, + server: String, + name: String, + #[serde(default, skip_deserializing)] + connected: bool, +} + +#[derive(Default)] +pub struct SshState(Mutex); +#[derive(Default)] +struct Inner { + loaded: bool, + connections: Vec, + children: HashMap, +} +fn key(server: &str, id: &str) -> String { + format!("{server}|{id}") +} +fn metadata_path() -> Result { + crate::SETTINGS_PATHS + .file() + .and_then(|p| p.parent().map(|d| d.join("ssh-connections.json"))) + .ok_or_else(|| "This computer has no home directory.".into()) +} +fn load(inner: &mut Inner) { + if inner.loaded { + return; + } + inner.loaded = true; + if let Ok(path) = metadata_path() { + if let Ok(file) = std::fs::File::open(path) { + let mut bytes = Vec::new(); + if file.take(65537).read_to_end(&mut bytes).is_ok() && bytes.len() <= 65536 { + inner.connections = serde_json::from_slice::>(&bytes) + .unwrap_or_default() + .into_iter() + .filter(|c| valid_id(&c.id) && server_origin(&c.server).is_ok()) + .take(16) + .collect(); + } + } + } +} +fn save(inner: &Inner) -> Result<(), String> { + let path = metadata_path()?; + std::fs::create_dir_all(path.parent().ok_or("No settings directory")?).map_err(|e| e.to_string())?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec(&inner.connections).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + std::fs::rename(tmp, path).map_err(|e| e.to_string()) +} +fn valid_id(id: &str) -> bool { + id.strip_prefix("desktop:") + .is_some_and(|v| v.len() == 36 && v.chars().all(|c| c.is_ascii_hexdigit() || c == '-')) +} +fn server_origin(raw: &str) -> Result { + let url = tauri::Url::parse(raw.trim()).map_err(|_| "Enter a valid server address.")?; + let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]" | "::1")); + if (url.scheme() != "https" && !(url.scheme() == "http" && loopback)) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + { + return Err("Use the server's HTTPS origin, or HTTP on localhost, without a path or credentials.".into()); + } + Ok(url.origin().ascii_serialization()) +} +fn stop_child(child: &mut Child) { + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(b"{\"type\":\"stop\"}\n"); + } + let until = Instant::now() + Duration::from_secs(40); + while Instant::now() < until { + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } + std::thread::sleep(Duration::from_millis(25)); + } + let _ = child.kill(); + let _ = child.wait(); +} + +#[tauri::command(async)] +pub fn node_ssh_connections(state: State<'_, SshState>) -> Vec { + let mut inner = state.0.lock().unwrap_or_else(|e| e.into_inner()); + load(&mut inner); + inner.children.retain(|_, child| matches!(child.try_wait(), Ok(None))); + inner + .connections + .iter() + .map(|c| Connection { + connected: inner.children.contains_key(&key(&c.server, &c.id)), + ..c.clone() + }) + .collect() +} + +#[tauri::command] +pub async fn node_ssh_connect( + app: AppHandle, + server: String, + pairing_token: Option, + broker_id: Option, + confirm: bool, +) -> Result { + if !confirm { + return Err("Confirm that you trust this server with SSH access from this computer.".into()); + } + let server = server_origin(&server)?; + if broker_id.as_deref().is_some_and(|id| !valid_id(id)) { + return Err("Invalid saved connection.".into()); + } + let token = pairing_token.unwrap_or_default(); + if token.len() > 512 || token.chars().any(char::is_control) || (token.is_empty() && broker_id.is_none()) { + return Err("Paste a pairing code from Settings > Connections on your server.".into()); + } + tauri::async_runtime::spawn_blocking(move || start(&app, server, token, broker_id)) + .await + .map_err(|e| e.to_string())? +} +fn start(app: &AppHandle, server: String, token: String, broker_id: Option) -> Result { + let state = app.state::(); + let mut inner = state + .0 + .try_lock() + .map_err(|_| "Another SSH connection operation is in progress. Retry when it finishes.")?; + load(&mut inner); + inner.children.retain(|_, child| matches!(child.try_wait(), Ok(None))); + if inner.connections.len() >= 16 && !token.is_empty() { + return Err("This computer already remembers 16 connections. Remove an unused connection first.".into()); + } + if inner.children.len() >= 8 { + return Err("Disconnect a server before adding another connection.".into()); + } + if broker_id + .as_ref() + .is_some_and(|id| inner.children.contains_key(&key(&server, id))) + { + return Err("This computer is already connected to that server.".into()); + } + let argv = sidecar::bundled_path(&crate::node_bin::NODE_SIDECAR) + .map(|p| vec![p.to_string_lossy().into_owned()]) + .or_else(|| crate::node_bin::resolve(app.state::().get().binary_path.as_deref()).map(|b| b.argv)) + .ok_or("Update Subshell Client to install its bundled SSH support.")?; + let (program, args) = argv.split_first().ok_or("No Subshell binary")?; + let mut cmd = Command::new(program); + cmd.args(args) + .args(["ssh-broker", "--server", &server]) + .env("PATH", login_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + if let Some(id) = &broker_id { + cmd.args(["--broker-id", id]); + } + let mut child = cmd.spawn().map_err(|e| format!("Could not start SSH support: {e}"))?; + let input = serde_json::json!({"pairingToken":token}).to_string() + "\n"; + if child + .stdin + .as_mut() + .ok_or("Missing child input")? + .write_all(input.as_bytes()) + .is_err() + { + stop_child(&mut child); + return Err("Could not pass the pairing code to SSH support.".into()); + } + let stdout = child.stdout.take().ok_or("Missing child output")?; + let (tx, rx) = mpsc::sync_channel(1); + std::thread::spawn(move || { + let mut line = String::new(); + let result = BufReader::new(stdout.take(16385)).read_line(&mut line).map(|_| line); + let _ = tx.send(result); + }); + let response = rx + .recv_timeout(Duration::from_secs(20)) + .ok() + .and_then(Result::ok) + .filter(|line| line.len() <= 16384) + .and_then(|line| serde_json::from_str::(&line).ok()); + let Some(response) = response.filter(|v| v["type"] == "attached") else { + stop_child(&mut child); + return Err("Could not connect. Check the server address and pairing code, or update Subshell Client. Saved connections may need pairing again after access is revoked.".into()); + }; + let id = response["id"].as_str().unwrap_or_default().to_owned(); + if !valid_id(&id) || broker_id.as_ref().is_some_and(|expected| expected != &id) { + stop_child(&mut child); + return Err("The server returned an unexpected connection.".into()); + } + let connection = Connection { + id, + server, + name: response["name"] + .as_str() + .unwrap_or("This computer") + .chars() + .take(80) + .collect(), + connected: true, + }; + inner + .connections + .retain(|c| key(&c.server, &c.id) != key(&connection.server, &connection.id)); + inner.connections.push(connection.clone()); + if let Err(e) = save(&inner) { + stop_child(&mut child); + return Err(e); + } + // Pairing also remembers the server so an SSH-only client skips first run next time. + let settings = app.state::(); + if let Err(error) = settings.update(|s| { + if !s.planes.contains(&connection.server) { + s.planes.push(connection.server.clone()); + } + }) { + stop_child(&mut child); + return Err(error); + } + inner.children.insert(key(&connection.server, &connection.id), child); + Ok(connection) +} +#[tauri::command] +pub async fn node_ssh_disconnect(app: AppHandle, server: String, id: String) -> Result<(), String> { + let server = server_origin(&server)?; + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + let mut inner = state.0.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(mut child) = inner.children.remove(&key(&server, &id)) { + stop_child(&mut child); + } + }) + .await + .map_err(|e| e.to_string()) +} +#[tauri::command] +pub async fn node_ssh_forget(app: AppHandle, server: String, id: String) -> Result<(), String> { + let server = server_origin(&server)?; + if !valid_id(&id) { + return Err("Invalid saved connection.".into()); + } + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + let mut inner = state.0.lock().unwrap_or_else(|e| e.into_inner()); + load(&mut inner); + if let Some(mut child) = inner.children.remove(&key(&server, &id)) { + stop_child(&mut child); + } + let mut argv = sidecar::bundled_path(&crate::node_bin::NODE_SIDECAR) + .map(|p| vec![p.to_string_lossy().into_owned()]) + .or_else(|| { + crate::node_bin::resolve(app.state::().get().binary_path.as_deref()).map(|b| b.argv) + }) + .ok_or("Update Subshell Client to remove this connection's local credential.")?; + argv.extend([ + "ssh-broker".into(), + "--server".into(), + server.clone(), + "--broker-id".into(), + id.clone(), + "--forget".into(), + ]); + let outcome = subshell_desktop_core::proc::run(&argv, Duration::from_secs(10)); + if !outcome.ok() { + return Err("Could not remove the local connection credential. Update Subshell Client and retry.".into()); + } + inner.connections.retain(|c| key(&c.server, &c.id) != key(&server, &id)); + save(&inner) + }) + .await + .map_err(|e| e.to_string())? +} +pub fn shutdown(app: &AppHandle) { + let state = app.state::(); + let mut inner = state.0.lock().unwrap_or_else(|e| e.into_inner()); + for (_, mut child) in inner.children.drain() { + stop_child(&mut child); + } +} + +/// Hold the connection lock through the rest of reset: a concurrent pairing +/// must not create fresh authority after the reset has removed credentials. +pub struct ResetGuard<'a> { + _inner: MutexGuard<'a, Inner>, +} + +pub fn reset_access(app: &AppHandle) -> Result, String> { + let state = app.state::(); + let mut inner = state.inner().0.lock().unwrap_or_else(|e| e.into_inner()); + load(&mut inner); + reset_connections(&mut inner, |connection| forget_credential(app, connection), save)?; + Ok(ResetGuard { _inner: inner }) +} + +fn forget_credential(app: &AppHandle, connection: &Connection) -> Result<(), String> { + let mut argv = sidecar::bundled_path(&crate::node_bin::NODE_SIDECAR) + .map(|p| vec![p.to_string_lossy().into_owned()]) + .or_else(|| crate::node_bin::resolve(app.state::().get().binary_path.as_deref()).map(|b| b.argv)) + .ok_or("Update Subshell Client to remove its saved SSH credentials.")?; + argv.extend([ + "ssh-broker".into(), + "--server".into(), + connection.server.clone(), + "--broker-id".into(), + connection.id.clone(), + "--forget".into(), + ]); + if !subshell_desktop_core::proc::run(&argv, Duration::from_secs(10)).ok() { + return Err("Could not remove a saved SSH credential. Update Subshell Client and retry reset.".into()); + } + Ok(()) +} + +fn reset_connections( + inner: &mut Inner, + mut forget: impl FnMut(&Connection) -> Result<(), String>, + persist: impl FnOnce(&Inner) -> Result<(), String>, +) -> Result<(), String> { + // Close all live access before deleting any credentials, even if a later + // removal fails. Keep the complete list until every idempotent delete and + // metadata write succeeds so Retry can finish a partial reset. + for (_, mut child) in inner.children.drain() { + stop_child(&mut child); + } + for connection in &inner.connections { + forget(connection)?; + } + let previous = std::mem::take(&mut inner.connections); + if let Err(error) = persist(inner) { + inner.connections = previous; + return Err(error); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn saved_connection() -> Connection { + Connection { + id: "desktop:12345678-1234-1234-1234-123456789abc".into(), + server: "https://example.test".into(), + name: "Laptop".into(), + connected: false, + } + } + + #[test] + fn reset_stops_live_access_and_retains_metadata_until_credential_removal_succeeds() { + let child = Command::new("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let pid = child.id(); + let mut inner = Inner { + connections: vec![saved_connection()], + ..Inner::default() + }; + inner.children.insert("connection".into(), child); + let result = reset_connections( + &mut inner, + |_| Err("credential removal failed".into()), + |_| panic!("must not erase retry metadata"), + ); + assert!(result.is_err()); + assert!(inner.children.is_empty()); + assert!( + !Command::new("kill") + .args(["-0", &pid.to_string()]) + .stderr(Stdio::null()) + .status() + .unwrap() + .success(), + "broker child must be gone" + ); + assert_eq!(inner.connections.len(), 1); + reset_connections( + &mut inner, + |_| Ok(()), + |state| { + assert!(state.connections.is_empty()); + Ok(()) + }, + ) + .unwrap(); + assert!(inner.connections.is_empty()); + } + + #[test] + fn reset_metadata_write_failure_keeps_connection_list_retryable() { + let mut inner = Inner { + connections: vec![saved_connection()], + ..Inner::default() + }; + assert!(reset_connections(&mut inner, |_| Ok(()), |_| Err("disk full".into())).is_err()); + assert_eq!(inner.connections.len(), 1); + reset_connections(&mut inner, |_| Ok(()), |_| Ok(())).unwrap(); + assert!(inner.connections.is_empty()); + } + + #[test] + fn pairing_is_bound_to_a_secure_origin() { + assert_eq!( + server_origin("https://example.test/"), + Ok("https://example.test".into()) + ); + assert!(server_origin("http://localhost:3080").is_ok()); + for value in [ + "http://example.test", + "https://u:p@example.test", + "https://example.test/path", + "https://example.test/?token=x", + "file:///etc/passwd", + ] { + assert!(server_origin(value).is_err(), "{value}"); + } + } + #[test] + fn only_broker_ids_can_resume() { + assert!(valid_id("desktop:12345678-1234-1234-1234-123456789abc")); + assert!(!valid_id("local")); + assert!(!valid_id("desktop:../../file")); + } +} diff --git a/apps/client/desktop/ui/src/__tests__/app.test.tsx b/apps/client/desktop/ui/src/__tests__/app.test.tsx index a234d25cd..85537aa72 100644 --- a/apps/client/desktop/ui/src/__tests__/app.test.tsx +++ b/apps/client/desktop/ui/src/__tests__/app.test.tsx @@ -258,6 +258,7 @@ describe("the assistant frame", () => { const rail = screen.getByRole("navigation", { name: "Main" }); expect([...rail.querySelectorAll("button")].map((b) => b.textContent)).toEqual([ "Control Plane", + "SSH Connections", "Status", "Service", "Update", diff --git a/apps/client/desktop/ui/src/__tests__/choice-screen.test.tsx b/apps/client/desktop/ui/src/__tests__/choice-screen.test.tsx index 8eaf7cc56..bc16ef7cb 100644 --- a/apps/client/desktop/ui/src/__tests__/choice-screen.test.tsx +++ b/apps/client/desktop/ui/src/__tests__/choice-screen.test.tsx @@ -25,14 +25,20 @@ function renderChoice(busy = false) { } describe("the Choice screen", () => { - it("offers exactly the two use cases, and nothing in the bar to press after", () => { + it("offers all three use cases, and nothing in the bar to press after", () => { renderChoice(); const buttons = screen.getAllByRole("button"); - expect(buttons.length).toBe(2); + expect(buttons.length).toBe(3); expect(screen.getByRole("button", { name: /Run subshells on this machine/ })).toBeTruthy(); expect(screen.getByRole("button", { name: /Connect to a server/ })).toBeTruthy(); }); + it("offers SSH without entering enrollment", () => { + const chosen = renderChoice(); + fireEvent.click(screen.getByRole("button", { name: /Work on a machine over SSH/ })); + expect(chosen).toEqual(["ssh"]); + }); + it("reports 'node' for the machine option", () => { const chosen = renderChoice(); fireEvent.click(screen.getByRole("button", { name: /Run subshells on this machine/ })); diff --git a/apps/client/desktop/ui/src/__tests__/client-flow.test.ts b/apps/client/desktop/ui/src/__tests__/client-flow.test.ts index 5c59e6e2c..206488286 100644 --- a/apps/client/desktop/ui/src/__tests__/client-flow.test.ts +++ b/apps/client/desktop/ui/src/__tests__/client-flow.test.ts @@ -294,7 +294,7 @@ describe("registerSteps", () => { * machine's journey, not about who asked. */ describe("railFor", () => { - it("answers the six standing sections for a settled machine on a standing screen", () => { + it("answers the standing sections for a settled machine on a standing screen", () => { // Service and Control Plane joined by operator ruling 2026-09-22 (live // screenshots): the node's machinery and the plane address's home move // out of the status screen, and the rail carries them. @@ -303,11 +303,11 @@ describe("railFor", () => { expect( sections?.map((s) => s.id), screen, - ).toEqual(["plane", "status", "service", "update", "about", "reset"]); + ).toEqual(["plane", "ssh", "status", "service", "update", "about", "reset"]); expect( sections?.map((s) => s.label), screen, - ).toEqual(["Control Plane", "Status", "Service", "Update", "About", "Reset"]); + ).toEqual(["Control Plane", "SSH Connections", "Status", "Service", "Update", "About", "Reset"]); } expect(railFor("status", true)?.find((s) => s.id === "reset")?.danger).toBe(true); }); @@ -327,7 +327,7 @@ describe("railFor", () => { // Reset is not a section (dialog ruling 2026-09-22): the rail's sixth // item is a door that opens the confirmation over whatever stands. const sections = railFor("plane", true); - expect(sections?.map((s) => s.id)).toEqual(["plane", "status", "service", "update", "about", "reset"]); + expect(sections?.map((s) => s.id)).toEqual(["plane", "ssh", "status", "service", "update", "about", "reset"]); expect(sections?.find((s) => s.id === "reset")?.danger).toBe(true); }); diff --git a/apps/client/desktop/ui/src/__tests__/reset-dialog.test.tsx b/apps/client/desktop/ui/src/__tests__/reset-dialog.test.tsx index 5e09152fe..b85ca9b45 100644 --- a/apps/client/desktop/ui/src/__tests__/reset-dialog.test.tsx +++ b/apps/client/desktop/ui/src/__tests__/reset-dialog.test.tsx @@ -54,6 +54,7 @@ describe("the reset dialog", () => { expect(screen.getByText(/pane logs/)).toBeTruthy(); expect(screen.getByText(/Subshell Server on this same machine is not touched/)).toBeTruthy(); expect(screen.getByText(/installed subshell binary stays/)).toBeTruthy(); + expect(screen.getByText(/SSH connections are disconnected and their saved credentials are removed/)).toBeTruthy(); expect(screen.getAllByText(/permanent/).length).toBeGreaterThan(0); }); @@ -141,6 +142,9 @@ describe("the reset dialog", () => { it("offers no reset at all on a machine that is not enrolled", async () => { await openReset({ handlers: { node_arm_reset: () => false } }); await screen.findByText(/not registered with a control plane/); + expect( + screen.getByText(/open SSH Connections, disconnect each computer connection, then choose Forget/), + ).toBeTruthy(); expect(screen.queryByText("Reset Everything")).toBeNull(); // The modal's way out is its own: a Close button, and Escape works when // nothing is running. diff --git a/apps/client/desktop/ui/src/__tests__/ssh-screen.test.tsx b/apps/client/desktop/ui/src/__tests__/ssh-screen.test.tsx new file mode 100644 index 000000000..7f6b8c676 --- /dev/null +++ b/apps/client/desktop/ui/src/__tests__/ssh-screen.test.tsx @@ -0,0 +1,44 @@ +import { afterEach, expect, it } from "bun:test"; +import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; +import { SshScreen } from "@/components/assistant/ssh-screen"; +import { type FakeIpc, installFakeIpc, renderApp } from "./harness"; + +let ipc: FakeIpc | undefined; +afterEach(() => { + cleanup(); + ipc?.restore(); +}); +it("requires explicit server trust and clears the one-use pairing code after connecting", async () => { + ipc = installFakeIpc({ + handlers: { node_ssh_connections: () => [], node_ssh_connect: () => ({ id: "desktop:one" }) }, + }); + renderApp( {}} initialServer="https://plane.test" />); + fireEvent.change(screen.getByLabelText("Pairing code"), { target: { value: "dsp_secret" } }); + expect((screen.getByRole("button", { name: "Connect this computer" }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button", { name: "Connect this computer" })); + await waitFor(() => + expect(ipc?.callsTo("node_ssh_connect")).toEqual([ + { server: "https://plane.test", pairingToken: "dsp_secret", brokerId: null, confirm: true }, + ]), + ); + await waitFor(() => expect((screen.getByLabelText("Pairing code") as HTMLInputElement).value).toBe("")); + expect(ipc.callsTo("node_enroll")).toEqual([]); +}); +it("reconnects a saved origin without asking for another pairing code", async () => { + ipc = installFakeIpc({ + handlers: { + node_ssh_connections: () => [ + { id: "desktop:one", server: "https://plane.test", name: "Laptop", connected: false }, + ], + node_ssh_connect: () => ({ id: "desktop:one" }), + }, + }); + renderApp( {}} initialServer="" />); + fireEvent.click(await screen.findByRole("button", { name: "Reconnect" })); + await waitFor(() => + expect(ipc?.callsTo("node_ssh_connect")).toEqual([ + { server: "https://plane.test", pairingToken: null, brokerId: "desktop:one", confirm: true }, + ]), + ); +}); diff --git a/apps/client/desktop/ui/src/app.tsx b/apps/client/desktop/ui/src/app.tsx index dcb38b30a..33ead9cd6 100644 --- a/apps/client/desktop/ui/src/app.tsx +++ b/apps/client/desktop/ui/src/app.tsx @@ -36,6 +36,7 @@ import { ProgressScreen } from "@/components/assistant/progress-screen"; import { RegisterScreen } from "@/components/assistant/register-screen"; import { ResetDialog } from "@/components/assistant/reset-dialog"; import { ServiceScreen } from "@/components/assistant/service-screen"; +import { SshScreen } from "@/components/assistant/ssh-screen"; import { StartupScreen } from "@/components/assistant/startup-screen"; import { StatusScreen } from "@/components/assistant/status-screen"; import { subtitleFor } from "@/components/assistant/subtitles"; @@ -534,6 +535,15 @@ export function App() { onEdit={failedAct === null || failedAct === "start" ? undefined : () => setStep("node")} /> ); + case "ssh": + return ( + (configured(settings, probe) ? setOverride("plane") : setStep("choice"))} + initialServer={settings?.planes[0] ?? ""} + /> + ); case "connect": return ; case "status": diff --git a/apps/client/desktop/ui/src/components/assistant/choice-screen.tsx b/apps/client/desktop/ui/src/components/assistant/choice-screen.tsx index 87836f75e..ed41cb2ab 100644 --- a/apps/client/desktop/ui/src/components/assistant/choice-screen.tsx +++ b/apps/client/desktop/ui/src/components/assistant/choice-screen.tsx @@ -29,7 +29,7 @@ import { Frame, type FrameShell } from "@/components/assistant/frame"; import { Button } from "@/components/ui/button"; /** What the two options answer with. `watch` connects; `node` registers. */ -export type FirstRunChoice = "node" | "watch"; +export type FirstRunChoice = "ssh" | "node" | "watch"; export function ChoiceScreen(props: { shell: FrameShell; onChoose: (choice: FirstRunChoice) => void; busy: boolean }) { const { shell, onChoose, busy } = props; @@ -44,6 +44,13 @@ export function ChoiceScreen(props: { shell: FrameShell; onChoose: (choice: Firs * that can drift out of step with it. */}
+ } + label="Work on a machine over SSH" + detail="Use this computer's SSH configuration to open agents and terminals on a remote host." + disabled={busy} + onSelect={() => onChoose("ssh")} + /> } label="Run subshells on this machine" diff --git a/apps/client/desktop/ui/src/components/assistant/reset-dialog.tsx b/apps/client/desktop/ui/src/components/assistant/reset-dialog.tsx index 73954bb4b..fd2214bc3 100644 --- a/apps/client/desktop/ui/src/components/assistant/reset-dialog.tsx +++ b/apps/client/desktop/ui/src/components/assistant/reset-dialog.tsx @@ -34,6 +34,7 @@ const DISCLOSURES: readonly string[] = [ "A Subshell Server on this same machine is not touched. Reset it from its own app.", "The installed subshell binary stays.", "This app's saved control planes are cleared too, and it starts at the beginning again.", + "SSH connections are disconnected and their saved credentials are removed. Remote panes keep running; your SSH keys and configuration stay.", "Everything above is permanent.", ]; @@ -107,6 +108,9 @@ export function ResetDialog(props: {

This machine is not registered with a control plane.

+

+ To remove saved SSH access, open SSH Connections, disconnect each computer connection, then choose Forget. +

+ } + > +
+ {(connections.data ?? []).map((connection) => ( +
+

{connection.name}

+

+ {connection.server} · {connection.connected ? "Connected" : "Disconnected"} +

+
+ + {!connection.connected && ( + + )} + +
+

+ In the dashboard, choose New subshell → SSH host, then this computer. Disconnecting leaves remote panes + running; reconnect to return to them. +

+
+ ))} +
+

Connect this computer

+

+ Open your Subshell server's Settings → SSH Connections, choose Add computer, and copy its pairing code here. + This does not register this computer as a node. +

+ + { + setServer(event.target.value); + setTrust(false); + }} + /> + + setToken(event.target.value)} + /> + + +

+ Keep Subshell Client open while working remotely. Saved connections can reconnect without another code. + Revoke access in the server's Connections settings. +

+
+
+ + ); +} diff --git a/apps/client/desktop/ui/src/lib/client-flow.ts b/apps/client/desktop/ui/src/lib/client-flow.ts index 944155752..b76423baf 100644 --- a/apps/client/desktop/ui/src/lib/client-flow.ts +++ b/apps/client/desktop/ui/src/lib/client-flow.ts @@ -55,7 +55,7 @@ import type { NodeScreenId, NodeUserScreen } from "@/lib/node-assistant-state"; * walk's precedence over {@link configured} is load-bearing — see * {@link clientScreen}. */ -export type FteStep = "intro" | "choice" | "node" | "startup" | "registering" | "watch"; +export type FteStep = "ssh" | "intro" | "choice" | "node" | "startup" | "registering" | "watch"; /** Everything the screen decision is made from. */ export interface FlowInput { @@ -140,6 +140,8 @@ function walkScreen(step: FteStep, probe: Probe | undefined): NodeScreenId { switch (step) { case "choice": return "choice"; + case "ssh": + return "ssh"; case "watch": // The watch path's one screen: an address, and nothing about this // machine. No node, no key, nothing installed. @@ -327,6 +329,7 @@ export const CLIENT_RAIL_SECTIONS: RailSection[] = [ // the plane relationship is this app's subject, and the section is also // the landing (see {@link clientScreen} rule 4). { id: "plane", label: "Control Plane" }, + { id: "ssh", label: "SSH Connections" }, { id: "status", label: "Status" }, // The node's own machinery (operator ruling 2026-09-22, live screenshots): // when the node is not installed, the install offer lives here; when it @@ -359,6 +362,7 @@ export const CLIENT_RAIL_SECTIONS: RailSection[] = [ export function railFor(screen: NodeScreenId | null, settled: boolean): RailSection[] | null { if (!settled) return null; switch (screen) { + case "ssh": case "status": case "service": case "plane": @@ -373,6 +377,8 @@ export function railFor(screen: NodeScreenId | null, settled: boolean): RailSect /** The rail section THIS standing screen has active, or null when there is no rail. */ export function railActive(screen: NodeScreenId | null): string | null { switch (screen) { + case "ssh": + return "ssh"; case "status": return "status"; case "service": diff --git a/apps/client/desktop/ui/src/lib/ipc.ts b/apps/client/desktop/ui/src/lib/ipc.ts index 52767f0f8..596ef08c3 100644 --- a/apps/client/desktop/ui/src/lib/ipc.ts +++ b/apps/client/desktop/ui/src/lib/ipc.ts @@ -684,3 +684,28 @@ export function nodeCheckAppUpdate(): Promise { export function nodeInstallAppUpdate(installNode: boolean): Promise { return invoke("node_install_app_update", { installNode }); } + +/** Only the bundled page can access this computer's SSH transport. */ +export interface DesktopSshConnection { + id: string; + server: string; + name: string; + connected: boolean; +} +export function nodeSshConnections(): Promise { + return invoke("node_ssh_connections"); +} +export function nodeSshConnect( + server: string, + pairingToken: string | null, + brokerId: string | null, +): Promise { + return invoke("node_ssh_connect", { server, pairingToken, brokerId, confirm: true }); +} +export function nodeSshDisconnect(server: string, id: string): Promise { + return invoke("node_ssh_disconnect", { server, id }); +} + +export function nodeSshForget(server: string, id: string): Promise { + return invoke("node_ssh_forget", { server, id }); +} diff --git a/apps/client/desktop/ui/src/lib/node-assistant-state.ts b/apps/client/desktop/ui/src/lib/node-assistant-state.ts index 588812115..5156e21ed 100644 --- a/apps/client/desktop/ui/src/lib/node-assistant-state.ts +++ b/apps/client/desktop/ui/src/lib/node-assistant-state.ts @@ -48,6 +48,7 @@ export type NodeScreenId = | "status" | "service" | "plane" + | "ssh" | "connect" | "about" | "update"; @@ -67,6 +68,7 @@ export const NODE_SCREEN_IDS: readonly NodeScreenId[] = [ "status", "service", "plane", + "ssh", "connect", "about", "update", @@ -92,7 +94,7 @@ export const NODE_SCREEN_IDS: readonly NodeScreenId[] = [ * consented act with a half still outstanding, and `app.tsx` opens this screen * once per launch when the probe reports one (spec 2026-09-18 § 4.2). */ -export type NodeUserScreen = "about" | "update" | "service" | "plane" | "status"; +export type NodeUserScreen = "ssh" | "about" | "update" | "service" | "plane" | "status"; /** * ONE word for where you are, on both platforms (operator's call, 2026-09-12). @@ -163,6 +165,8 @@ export function screenTitle(screen: NodeScreenId): string { // Same: the Control Plane section's screen. One word for the thing both // addresses name — the server this app and this node talk to. return "Control Plane"; + case "ssh": + return "SSH Connections"; case "connect": return "Connect to a Server"; case "about": diff --git a/apps/docs/content/docs/mcp/index.mdx b/apps/docs/content/docs/mcp/index.mdx index ae9abb076..9decd787a 100644 --- a/apps/docs/content/docs/mcp/index.mdx +++ b/apps/docs/content/docs/mcp/index.mdx @@ -11,7 +11,7 @@ Each agent pane has its own MCP identity and bearer credential. Tools let it ins Start with [MCP configuration](/mcp/configure), then follow [Coordinate agents](/mcp/coordinate) for a worked helper workflow. -[Inspect and control subshells](/mcp/subshells) explains live status, pane output, and input. [Channels](/mcp/channels) covers encrypted posts and cursors. [Nudge an agent](/mcp/nudge) explains how to wake an idle peer without typing message contents into its terminal. +[Inspect and control subshells](/mcp/subshells) explains live status, pane output, and input. [Channels](/mcp/channels) covers encrypted posts and cursors. [Nudge an agent](/mcp/nudge) explains how to wake an idle peer without typing message contents into its terminal. [Connect over SSH](/ssh) covers sessions on machines reached through SSH; their panes are ordinary panes and need no special tools. ## Lifecycle and permissions diff --git a/apps/docs/content/docs/mcp/terminals.mdx b/apps/docs/content/docs/mcp/terminals.mdx index 947a57701..22fd50d2d 100644 --- a/apps/docs/content/docs/mcp/terminals.mdx +++ b/apps/docs/content/docs/mcp/terminals.mdx @@ -55,6 +55,10 @@ the exit code once the command completes, refusing with nothing typed while the pane is busy. Keep the manual loop for multi-command sessions and for programs that wait for input. See the [MCP tool reference](/mcp/tools). +A pane launched through an SSH session (see [Connect over SSH](/ssh)) is an +ordinary pane in this loop: the same input, log, and exec doors, with no +separate SSH policy in the way. + ## What it is not - Not a one-shot `exec` by itself: this loop has no exit code and no @@ -63,7 +67,7 @@ that wait for input. See the [MCP tool reference](/mcp/tools). append something like `echo "exit: $?"` if the status matters. - Not private: the session is a normal pane of the owner's account, visible in the panes list and to anyone the owner shares panes with. It files under - "Cross-agent comms" and its notification bell starts off. + "Agent-created" and its notification bell starts off. - Not short-lived: it persists after the caller finishes, until `terminate_subshell` closes it or the machine dies. The pane that opened it owns the cleanup, as for every helper pane; see diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 1ca0fd23d..01c8e3ab0 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -9,6 +9,7 @@ "prompts", "agents", "nodes", + "ssh", "administration", "networking", "mcp", diff --git a/apps/docs/content/docs/ssh/index.mdx b/apps/docs/content/docs/ssh/index.mdx new file mode 100644 index 000000000..7cf17014c --- /dev/null +++ b/apps/docs/content/docs/ssh/index.mdx @@ -0,0 +1,74 @@ +--- +title: Work over SSH +description: Open agents and terminals in remote folders using SSH from Subshell Client or a machine you own. +--- + +Subshell can run agents and terminals in a remote folder through an SSH connection. + +The destination runs the `subshell` runtime and tmux. It does not need node enrollment, a Subshell service, or a network connection back to your server. Your SSH keys stay on the computer that makes the connection. + +## Prepare the remote host + +On the computer that will connect, add a concrete host alias to `~/.ssh/config`: + +```sshconfig +Host devbox + HostName devbox.example.com + User you + IdentityFile ~/.ssh/id_ed25519 +``` + +Verify `ssh devbox` works from that account. Complete host-key verification there and unlock your SSH key if needed. Subshell does not answer interactive SSH password or MFA prompts. + +On the destination, install tmux and the `subshell` CLI binary for its operating system and architecture. Use the CLI node assets on the [Subshell releases page](https://github.com/subshell-ai/subshell/releases). Make the binary executable and put it on the destination account's login-shell `PATH`. You do not need to run `subshell setup` or enroll the destination. + +Install and sign in to any agent CLI you want to use on that destination. A terminal needs no agent installation. + +## Connect from your computer with Subshell Client + +1. In your server's dashboard, open **Settings → SSH Connections**. Any signed-in user can manage their own connections. +2. Give your computer a recognizable name and select **Add computer**. +3. Copy the server address and pairing code into **SSH Connections** in Subshell Client. A fresh install also offers **Work on a machine over SSH**. +4. Check the server address and confirm that you trust the server and the account that created the code to use your computer's SSH access. Select **Connect this computer**. +5. Open the dashboard. Choose **New subshell → SSH host**, then choose your computer under **Connect from**. + +Pairing codes expire after five minutes and work once. After pairing, Subshell Client remembers a scoped connection credential locally, so **Reconnect** does not require another code. Keep the app open while using the connection. **Revoke access** in the server's Connections settings invalidates that credential. + +The computer is not enrolled as a node by this procedure. + +## Connect through an existing node in the browser + +In **New subshell → SSH host**, choose a connecting machine you own. Subshell uses the SSH configuration, keys, and account on that machine, which may differ from the computer running your browser. + +Shared nodes and other users' nodes cannot supply your SSH connection. Admins can also choose the server account when server launching is enabled and the server machine is not in maintenance. + +## Open a remote folder + +1. Choose a host alias. If none appears, follow the configuration example on the connecting machine and select **Refresh hosts**. +2. Review the destination address, destination account, and connecting account, then connect. A failed connection explains what to fix, including a missing remote runtime. +3. Enter or browse to the remote folder. Choose an installed agent or terminal, optionally apply a preset and prompts, then start the subshell. + +The same form is available when creating a workspace or adding a pane. Existing connections appear directly in the host picker. + +Use **Remember this host and folder** to save the host and folder in your account. A saved location reuses a matching live connection or reconnects through the same connecting machine. If its SSH alias now points to a different host, port, or account, Subshell refuses to silently switch destinations; connect to the new destination and save a new location deliberately. + +## Return to remote work + +Remote panes appear alongside your other subshells, labelled with their SSH host. They use ordinary pane permissions, logs, sharing, and MCP access. + +If a connection drops, the pane shows it as unavailable. **Reconnect** opens recovery in place, preserving the pane or workspace you were viewing. A new connection reconciles the destination's surviving panes. Disconnecting SSH leaves remote tmux panes running, but Subshell cannot establish their current state until it reconnects. Requests whose results were lost are not replayed automatically. + +Use **Settings → SSH Connections** for connection history and disconnect controls. If Subshell Client itself disconnected, reconnect it in its **SSH Connections** screen before reconnecting the remote host. + +## Limits + +- Destinations must run Linux or macOS with a POSIX shell. +- SSH authentication must work without interactive prompts. Configurations requiring `ProxyCommand` or port forwarding are refused; bounded `ProxyJump` chains are supported. +- The destination runtime is installed manually. Subshell does not install software on a destination automatically. +- Desktop pairing requires HTTPS, except for a server reached through a loopback HTTP address. +- Moving an existing agent conversation and Git state between different hosts is not supported. + +## See also + +- [Nodes](/nodes) +- [Terminal sessions](/mcp/terminals) diff --git a/apps/docs/content/docs/ssh/meta.json b/apps/docs/content/docs/ssh/meta.json new file mode 100644 index 000000000..adcad1358 --- /dev/null +++ b/apps/docs/content/docs/ssh/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Connect over SSH", + "pages": ["index"] +} diff --git a/apps/docs/lib/navigation.ts b/apps/docs/lib/navigation.ts index 3de1496c4..13d93f127 100644 --- a/apps/docs/lib/navigation.ts +++ b/apps/docs/lib/navigation.ts @@ -8,6 +8,7 @@ export const DOC_SECTIONS = [ { slug: "prompts", title: "Reusable Prompts" }, { slug: "agents", title: "Agents" }, { slug: "nodes", title: "Manage nodes" }, + { slug: "ssh", title: "SSH connections" }, { slug: "administration", title: "Server administration" }, { slug: "networking", title: "Networking" }, { slug: "mcp", title: "MCP and agent communication" }, diff --git a/apps/node/agent/package.json b/apps/node/agent/package.json index d2b6b8e53..d90f40703 100644 --- a/apps/node/agent/package.json +++ b/apps/node/agent/package.json @@ -1,7 +1,7 @@ { "name": "@internal/node", "description": "subshell — node daemon: enrolls with the control plane and executes signed commands", - "version": "1.4.3", + "version": "1.5.0", "license": "Apache-2.0", "type": "module", "private": true, diff --git a/apps/node/agent/src/__tests__/commands-prompt-tail.test.ts b/apps/node/agent/src/__tests__/commands-prompt-tail.test.ts index bd5d7c253..fe53508b3 100644 --- a/apps/node/agent/src/__tests__/commands-prompt-tail.test.ts +++ b/apps/node/agent/src/__tests__/commands-prompt-tail.test.ts @@ -3,6 +3,7 @@ import { appendFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFile import { tmpdir } from "node:os"; import { join } from "node:path"; import { + encodeSshSessionFrame, type NodeCommandBody, type NodeEvent, type NodeLogReadResult, @@ -132,7 +133,7 @@ const live: CommandContext[] = []; function makeCtx( dataDir: string, tmux: CommandContext["tmux"], - opts: { nowMs?: () => number; ws?: FakeWs } = {}, + opts: { nowMs?: () => number; ws?: FakeWs; outputChunkCeilingBytes?: number } = {}, ): { ctx: CommandContext; ws: FakeWs } { const ws = opts.ws ?? fakeWs(); const config: NodeConfig = { @@ -154,6 +155,7 @@ function makeCtx( uploads: new Map(), runtime: null, requestRestart: () => {}, + ...(opts.outputChunkCeilingBytes !== undefined ? { outputChunkCeilingBytes: opts.outputChunkCeilingBytes } : {}), }; live.push(ctx); return { ctx, ws }; @@ -446,6 +448,46 @@ describe("tail executors (spec §3.1/§3.4)", () => { expect(Buffer.concat(evs.map((e) => Buffer.from(e.data_b64, "base64"))).equals(blob)).toBe(true); }); + /** + * The C1b fix at its source: the runtime serve loop sets + * `ctx.outputChunkCeilingBytes` to the session transfer budget, and the + * PUMP chunks by min(TAIL_CHUNK_BYTES, ceiling) - a 192 KiB node-link + * chunk base64s to exactly the 262 KiB session cap, so every event on a + * runtime pane must be one the session codec can encode. The `last`/cursor + * bookkeeping still advances per successful send (a smaller ceiling means + * MORE frames in the same window, never fewer bytes). + */ + it("session ceiling: ctx.outputChunkCeilingBytes shrinks the chunks; offsets stay contiguous and every event encodes", async () => { + const ceiling = 8 * 1024; + const { ctx, ws } = makeCtx(freshDataDir("tail-session-ceiling"), (() => {}) as never, { + outputChunkCeilingBytes: ceiling, + }); + const blob = Buffer.alloc(30 * 1024, 0x42); + seedLog(ctx, S1, blob); + expect(await dispatchCommand(ctx, tailStartCmd())).toEqual({ ok: true }); + await waitFor(() => outputs(ws.events).length >= 4, "four chunked events under the ceiling"); + let cursor = 0; + for (const ev of outputs(ws.events)) { + const raw = Buffer.from(ev.data_b64, "base64"); + expect(raw.byteLength).toBeLessThanOrEqual(ceiling); // the CEILING caps the slice, not TAIL_CHUNK_BYTES + expect(ev.fromByte).toBe(cursor); + expect(ev.toByte).toBe(cursor + raw.byteLength); + expect(() => encodeSshSessionFrame(ev)).not.toThrow(); // the runtime's own writer must fit every event + cursor = ev.toByte; + } + expect(cursor).toBe(30 * 1024); // `last` advanced across every sub-chunk (no window replay) + expect(Buffer.concat(outputs(ws.events).map((e) => Buffer.from(e.data_b64, "base64"))).equals(blob)).toBe(true); + }); + + it("no ceiling (the node daemon's ctx) keeps the 192 KiB node-link chunk (the ceiling never shrinks a plain node)", async () => { + const { ctx, ws } = makeCtx(freshDataDir("tail-no-ceiling"), (() => {}) as never); + const blob = Buffer.alloc(TAIL_CHUNK_BYTES + 1024, 0x43); + seedLog(ctx, S1, blob); + expect(await dispatchCommand(ctx, tailStartCmd())).toEqual({ ok: true }); + await waitFor(() => outputs(ws.events).length >= 2, "two node-link-sized chunks"); + expect(Buffer.from(outputs(ws.events)[0].data_b64, "base64").byteLength).toBe(TAIL_CHUNK_BYTES); + }); + it("backpressure: a stuffed socket delays delivery; the RESULT still resolves; bytes arrive when it drains", async () => { const { ctx, ws } = makeCtx(freshDataDir("tail-backpressure"), (() => {}) as never); const file = seedLog(ctx, S1, "abc"); diff --git a/apps/node/agent/src/__tests__/commands-ssh.test.ts b/apps/node/agent/src/__tests__/commands-ssh.test.ts new file mode 100644 index 000000000..05327e603 --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh.test.ts @@ -0,0 +1,130 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { JsonValue, NodeCommandBody } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; + +/** + * The two SSH read arms that outlived the destination product (design + * 2026-10-05 §7), driven through `dispatchCommand` exactly as the daemon + * drives them (posture copied from commands-archive-create.test.ts: real temp + * dirs, every answer run through the PROTOCOL validator, refusals asserted by + * `ok:false` + exact message). The ssh binary is the same recording shim the + * runtime suite uses — the arms are thin, so these tests pin the THIN things: + * the names-only discovery answer, the `ssh -G` resolve outcome shape, and + * the binary-missing failure class the review flow keys on. The run/terminal/ + * test arms retired with the product; their `type` strings no longer parse on + * the wire, so a test naming one could not even be constructed. + */ + +let base: string; +let savedSshPath: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-ssh-"))); + savedSshPath = process.env.SUBSHELL_SSH_PATH; +}); + +afterAll(() => { + if (savedSshPath === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = savedSshPath; + rmSync(base, { recursive: true, force: true }); +}); + +/** Recording shim (mirrors the runtime test helper's shape, baked constants). */ +function writeShim(dir: string, opts: { dashG?: string } = {}): { bin: string; log: string } { + mkdirSync(dir, { recursive: true }); + const log = join(dir, "shim.log"); + const lines = ["#!/bin/sh", `LOG='${log}'`, `printf 'ARGS:%s\\n' "$*" >> "$LOG"`]; + if (opts.dashG !== undefined) { + lines.push(`for a in "$@"; do if [ "$a" = "-G" ]; then cat <<'GEOF'`); + lines.push(opts.dashG); + lines.push("GEOF\nexit 0\nfi; done"); + } + lines.push("exit 0"); + const bin = join(dir, "ssh"); + writeFileSync(bin, `${lines.join("\n")}\n`, { mode: 0o755 }); + return { bin, log }; +} + +/** Points the operator override at `sshBin` and seeds a data dir for the arm. */ +function setup(tag: string, sshBin: string): { dataDir: string } { + const dataDir = join(base, tag, "data"); + mkdirSync(dataDir, { recursive: true }); + process.env.SUBSHELL_SSH_PATH = sshBin; // the operator override the arm's ladder honors + return { dataDir }; +} + +describe("ssh_discover_aliases / ssh_resolve_config arms", () => { + it("discovery answers through the validator with NAMES only from the account config", async () => { + const shim = writeShim(join(base, "shim-disc")); + setup("disc", shim.bin); + const home = join(base, "disc-home"); + mkdirSync(join(home, ".ssh"), { recursive: true }); + writeFileSync(join(home, ".ssh", "config"), "Host alpha\n HostName a.example\nHost beta\n User x\n"); + const savedHome = process.env.HOME; + const savedUserProfile = process.env.LOGNAME; + process.env.HOME = home; + try { + const res = await dispatchCommand( + { config: { dataDir: join(base, "disc", "data") } } as unknown as CommandContext, + { type: "ssh_discover_aliases" } as NodeCommandBody, + ); + expect(res.ok).toBe(true); + const data = (res as { data?: JsonValue }).data as { + aliases: string[]; + includeCycle: boolean; + truncated: boolean; + }; + expect(data.aliases).toEqual(["alpha", "beta"]); + } finally { + process.env.HOME = savedHome; + if (savedUserProfile !== undefined) process.env.LOGNAME = savedUserProfile; + } + }); + + it("resolve runs ssh -G and returns the accepted outcome shape", async () => { + const gOut = [ + "host resolved.example", + "hostname resolved.example", + "user deploy", + "port 22", + "userknownhostsfile /home/deploy/.ssh/known_hosts", + ].join("\n"); + const shim = writeShim(join(base, "shim-res"), { dashG: gOut }); + setup("res", shim.bin); + const home = join(base, "res-home"); + mkdirSync(join(home, ".ssh"), { recursive: true }); + writeFileSync(join(home, ".ssh", "config"), "Host app02\n HostName resolved.example\n"); + const savedHome = process.env.HOME; + process.env.HOME = home; + try { + const res = await dispatchCommand( + { config: { dataDir: join(base, "res", "data") } } as unknown as CommandContext, + { type: "ssh_resolve_config", alias: "app02" } as NodeCommandBody, + ); + expect(res.ok).toBe(true); + const data = (res as { data?: { accepted: boolean; snapshot?: { host?: string } } }).data; + expect(data?.accepted).toBe(true); + expect(data?.snapshot?.host).toBe("resolved.example"); + } finally { + process.env.HOME = savedHome; + } + }); + + // Rides `ssh_resolve_config` now that the destination product's + // `ssh_test_connection` arm is retired: resolution is the surviving arm + // that refuses this way, and the plane's review flow still keys on the + // binary-missing class. + it("a missing ssh binary is a command failure with the binary-missing class", async () => { + const { dataDir } = setup("no-ssh", "/nonexistent/ssh-binary"); + const res = await dispatchCommand( + { config: { dataDir } } as unknown as CommandContext, + { type: "ssh_resolve_config", alias: "app02" } as NodeCommandBody, + ); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("binary missing"); + }); +}); diff --git a/apps/node/agent/src/__tests__/daemon.test.ts b/apps/node/agent/src/__tests__/daemon.test.ts index a3911a48f..b0c25d928 100644 --- a/apps/node/agent/src/__tests__/daemon.test.ts +++ b/apps/node/agent/src/__tests__/daemon.test.ts @@ -13,9 +13,10 @@ import { } from "node:fs"; import { homedir, hostname, tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import type { TmuxRunner } from "@internal/pane-runtime"; +import { peekSshSessionSupervisor, type TmuxRunner } from "@internal/pane-runtime"; import { type ControlKeyPair, + encodeSshSessionFrame, generateControlKeys, HARNESS_BINARY_PLACEHOLDER, NODE_CLOSE_HANDSHAKE_REQUIRED, @@ -26,6 +27,7 @@ import { type NodeEvent, type NodeRuntimeReport, parseNodeEvent, + SSH_RUNTIME_PROTOCOL, signCommand, } from "@internal/subshell-protocol"; import { @@ -2180,6 +2182,438 @@ test("socket close stops every live tail: no output into the dead ws, none resur } }); +/* ------------------------------------------------------------------ */ +/* Review M1: a link flap reclaims the brokered SSH children */ +/* ------------------------------------------------------------------ */ + +test("link close drains the brokered-session supervisor: the child goes, no loss spam, a later open lands", async () => { + // The shape the e2e cannot drive: the AGENT SURVIVES and only the link + // drops. Plane-side that close is terminal for every session the node + // brokered (`markSessionsLostForNode` - design §6: a lost session is never + // resumable), so a brokered child that outlives it is an orphan: it holds + // a supervisor quota slot and the destination-deterministic callback door + // that every future open to that `host:port:user` needs. The daemon's + // link-close path therefore drains the SSH-session supervisor exactly like + // it stops the tails (the plane-side child-death test lives in e2e 22; + // the destination's tmux and panes stay up by the serve's own shutdown, + // which is the adoption contract §6 re-adopts from). + const dataDir = mkdtempSync(join(tmpdir(), "subshell-daemon-drain-")); + const root = mkdtempSync(join(tmpdir(), "subshell-daemon-drain-fake-")); + const helloFile = join(root, "hello.bin"); + writeFileSync( + helloFile, + encodeSshSessionFrame({ + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "9.9.9-drain", + os: "linux", + arch: "x64", + capabilities: ["ssh-runtime", "callback-sock"], + homeDir: "/home/dst", + dataDir: "/home/dst/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-drain00000", + paneCount: 0, + }), + ); + const pidsLog = join(root, "serve-pids.log"); + const fakeSsh = join(root, "ssh"); + writeFileSync( + fakeSsh, + [ + "#!/bin/sh", + `case "$*" in`, + ` *"command -v"*) printf '/usr/bin/subshell\\n'; exit 0 ;;`, + `esac`, + `case "$*" in`, + ` *"runtime-serve"*)`, + ` printf "%s\\n" "$$" >> '${pidsLog}'`, + ` cat '${helloFile}'`, + ` exec sleep 300`, + ` ;;`, + `esac`, + `exit 1`, + "", + ].join("\n"), + { mode: 0o755 }, + ); + chmodSync(fakeSsh, 0o755); + const prevSsh = process.env.SUBSHELL_SSH_PATH; + const prevHome = process.env.HOME; + process.env.SUBSHELL_SSH_PATH = fakeSsh; // the operator seam the executor's ladder reads first + process.env.HOME = join(root, "home"); // the connecting home, away from the developer's + const target = { alias: "drain", host: "127.0.0.1", port: 22, user: null, identityFile: null } as const; + const pidAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + const servePids = (): number[] => { + try { + return readFileSync(pidsLog, "utf8") + .split("\n") + .filter((l) => l !== "") + .map(Number); + } catch { + return []; + } + }; + try { + const h = await startDaemon({ config: { dataDir } }); + const ref = crypto.randomUUID(); + await signAndSend(h, { type: "ssh_session_open", ref, target }, { jti: "drain-1", seq: 1 }); + // The node-link `result` correlates by the command's JTI (the RPC id), + // not the session ref inside the body - the same posture the plane's own + // open RPC rides. + const opened = await waitFor>( + h, + (e) => e.type === "result" && e.ref === "drain-1", + "session open result", + 10_000, + ); + expect(opened.ok).toBe(true); + await waitUntil(() => servePids().length === 1, "the fake ssh child to record its pid"); + const childPid = servePids()[0] as number; + expect(Number.isInteger(childPid)).toBe(true); // the recorded pid is the serve's own + expect(pidAlive(childPid), "the brokered child lives under the open link").toBe(true); + expect(peekSshSessionSupervisor(dataDir)?.liveRefs()).toEqual([ref]); + + // THE FLAP: the socket dies, the agent does not (the tail test above is + // the same gesture; the SSH supervisor never had it). + closeAllSockets(h.plane, 1001, "server restart"); + + // The drain: the child's GROUP is gone and the supervisor's live map + // freed it, with the reconnect already underway. + await waitUntil(() => !pidAlive(childPid), "the brokered child to die with the link (review M1)"); + await waitUntil(() => (peekSshSessionSupervisor(dataDir)?.liveRefs().length ?? 0) === 0, "live map to drain"); + + // ...and the reconnect's `ready` proves the loop is back: + await waitFor(h, () => h.plane.events.filter((e) => e.type === "ready").length >= 2, "reconnect ready"); + // No loss spam: the drain closes (`stopping` latch), it does not report. + expect(eventsAs(h, "error").filter((e) => e.code === "ssh_session_lost")).toEqual([]); + + // A LATER open to the same destination lands over the reclaimed + // supervisor - the quota slot and the destination door are free (the + // destination-side door release itself is e2e 22's reopen cell). + const ref2 = crypto.randomUUID(); + await signAndSend(h, { type: "ssh_session_open", ref: ref2, target }, { jti: "drain-2", seq: 1 }); + const opened2 = await waitFor>( + h, + (e) => e.type === "result" && e.ref === "drain-2", + "second session open result", + 10_000, + ); + expect(opened2.ok).toBe(true); + expect(peekSshSessionSupervisor(dataDir)?.liveRefs()).toEqual([ref2]); + peekSshSessionSupervisor(dataDir)?.close(ref2); + await waitUntil(() => !pidAlive((servePids()[1] as number) ?? -1), "the second child to die on close", 4000); + } finally { + // Leave no fake child behind regardless of how the asserts landed. + for (const pid of servePids()) { + try { + process.kill(-pid, "SIGKILL"); // the whole fake-serve group, if it is still up + } catch { + /* already gone */ + } + } + if (prevSsh === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = prevSsh; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(dataDir, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +}, 60_000); + +/* ------------------------------------------------------------------ */ +/* Review m-A/m-B: the drain cannot stall the loop; a late-born */ +/* child of a dead link is reclaimed */ +/* ------------------------------------------------------------------ */ + +/** + * The M1 test's fake ssh, parameterized: the probe branch answers + * `/usr/bin/subshell`; the serve branch logs its own pid, optionally waits + * `helloDelayMs` (so an open can sit inside its hello gate across a flap, + * m-B), then emits the prepared hello and sleeps. Returns the ssh path and + * the serve-pid log. + */ +function fakeServeSsh(root: string, helloFile: string, helloDelayMs = 0): { sshPath: string; pidsLog: string } { + const pidsLog = join(root, "serve-pids.log"); + const sshPath = join(root, "ssh"); + writeFileSync( + sshPath, + [ + "#!/bin/sh", + `case "$*" in`, + ` *"command -v"*) printf '/usr/bin/subshell\\n'; exit 0 ;;`, + `esac`, + `case "$*" in`, + ` *"runtime-serve"*)`, + ` printf "%s\\n" "$$" >> '${pidsLog}'`, + ...(helloDelayMs > 0 ? [` sleep ${Math.ceil(helloDelayMs / 1000)}`] : []), + ` cat '${helloFile}'`, + ` exec sleep 300`, + ` ;;`, + `esac`, + `exit 1`, + "", + ].join("\n"), + { mode: 0o755 }, + ); + chmodSync(sshPath, 0o755); + return { sshPath, pidsLog }; +} + +/** The hello frame one of these fake serves emits (a per-test file, per-test socket name). */ +function fakeServeHello(root: string, name: string): string { + const helloFile = join(root, "hello.bin"); + writeFileSync( + helloFile, + encodeSshSessionFrame({ + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "9.9.9-drain", + os: "linux", + arch: "x64", + capabilities: ["ssh-runtime", "callback-sock"], + homeDir: "/home/dst", + dataDir: "/home/dst/.local/share/subshell/runtime", + tmuxSocket: `subshell-ssh-${name}`, + paneCount: 0, + }), + ); + return helloFile; +} + +const mbPidAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const mbServePids = (pidsLog: string): number[] => { + try { + return readFileSync(pidsLog, "utf8") + .split("\n") + .filter((l) => l !== "") + .map(Number); + } catch { + return []; + } +}; + +/** Open one brokered session through the daemon and await its `result`. */ +async function mbOpenSession(h: Harness, ref: string, target: Record, seq: number): Promise { + const jti = `open-${ref.slice(0, 8)}`; + await signAndSend(h, { type: "ssh_session_open", ref, target } as never, { jti, seq }); + const opened = await waitFor>( + h, + (e) => e.type === "result" && e.ref === jti, + "session open result", + 10_000, + ); + expect(opened.ok).toBe(true); +} + +test("m-A: a supervisor close that throws cannot stall the reconnect loop, and the remaining refs are still drained", async () => { + // The daemon's own rule (a sibling of the upload sweep's "never throws by + // contract - a broken sweep must not cost the node its connection", and + // the negotiator's swallow-its-own-failures posture): the link-close drain + // runs ON the reconnect path, so a `sup.close` throw (the record write is + // a writeFileSync/chmod: ENOSPC/EROFS/EACCES are ordinary) must neither + // abort the remaining refs nor skip the `resolve(close)` that lets the + // loop dial again. Before the guard, the throw escaped `finish`, the + // connection promise never settled, and the node never redialed. + const dataDir = mkdtempSync(join(tmpdir(), "subshell-daemon-mA-")); + const root = mkdtempSync(join(tmpdir(), "subshell-daemon-mA-fake-")); + const helloFile = fakeServeHello(root, "mA0000000001"); + const { sshPath, pidsLog } = fakeServeSsh(root, helloFile); + const prevSsh = process.env.SUBSHELL_SSH_PATH; + const prevHome = process.env.HOME; + process.env.SUBSHELL_SSH_PATH = sshPath; + process.env.HOME = join(root, "home"); + const target = { alias: "mA", host: "127.0.0.1", port: 22, user: null, identityFile: null } as const; + try { + const h = await startDaemon({ config: { dataDir } }); + const ref1 = crypto.randomUUID(); + const ref2 = crypto.randomUUID(); + await mbOpenSession(h, ref1, target, 1); + await mbOpenSession(h, ref2, target, 2); + await waitUntil(() => mbServePids(pidsLog).length === 2, "two fake serves to record their pids"); + const [pid1, pid2] = mbServePids(pidsLog); + const sup = peekSshSessionSupervisor(dataDir); + if (!sup) throw new Error("the daemon built its supervisor on the first open"); + const attempted: string[] = []; + const origClose = sup.close.bind(sup); + sup.close = (ref: string) => { + attempted.push(ref); + if (attempted.length === 1) throw new Error("simulated record-write throw"); + return origClose(ref); + }; + + closeAllSockets(h.plane, 1001, "flap"); + + // The loop is alive: the reconnect's `ready` lands even though the + // FIRST drain close threw (red: no second ready, ever). + await waitFor(h, () => count(h, (e) => e.type === "ready") >= 2, "reconnect ready after a throwing drain", 10_000); + // Every remaining ref was attempted: one throw cost one close, not the + // drain (red: `attempted` held only the first ref). + expect(attempted).toEqual([ref1, ref2]); + // The throwing close died BEFORE its kill (so that child lives, the + // honest reading of a half-run close); the next ref was reclaimed. + expect(mbPidAlive(pid1 as number)).toBe(true); + await waitUntil(() => !mbPidAlive(pid2 as number), "the second child reclaimed past the throw"); + // And no loss spam rode the reconnect (the real close's `stopping` latch). + expect(eventsAs(h, "error").filter((e) => e.code === "ssh_session_lost")).toEqual([]); + } finally { + for (const pid of mbServePids(pidsLog)) { + try { + process.kill(-pid, "SIGKILL"); + } catch { + /* already gone */ + } + } + if (prevSsh === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = prevSsh; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(dataDir, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +}, 60_000); + +test("m-A: a drain that throws before its first ref still settles the close, so the loop redials", async () => { + // The OUTER belt: the per-ref guard cannot see a throw from taking the + // snapshot itself. Whatever the drain's first line does to the disk or + // the map, `finish` must still resolve the connection. + const dataDir = mkdtempSync(join(tmpdir(), "subshell-daemon-mAb-")); + const root = mkdtempSync(join(tmpdir(), "subshell-daemon-mAb-fake-")); + const helloFile = fakeServeHello(root, "mAb000000001"); + const { sshPath, pidsLog } = fakeServeSsh(root, helloFile); + const prevSsh = process.env.SUBSHELL_SSH_PATH; + const prevHome = process.env.HOME; + process.env.SUBSHELL_SSH_PATH = sshPath; + process.env.HOME = join(root, "home"); + const target = { alias: "mAb", host: "127.0.0.1", port: 22, user: null, identityFile: null } as const; + try { + const h = await startDaemon({ config: { dataDir } }); + const ref = crypto.randomUUID(); + await mbOpenSession(h, ref, target, 1); + await waitUntil(() => mbServePids(pidsLog).length === 1, "the fake serve to record its pid"); + const childPid = mbServePids(pidsLog)[0] as number; + const sup = peekSshSessionSupervisor(dataDir); + if (!sup) throw new Error("the daemon built its supervisor on the first open"); + sup.liveRefs = (): string[] => { + throw new Error("simulated snapshot throw"); + }; + + closeAllSockets(h.plane, 1001, "flap"); + await waitFor( + h, + () => count(h, (e) => e.type === "ready") >= 2, + "reconnect ready after a first-line drain throw", + 10_000, + ); + // Nothing was reclaimed (the drain never got a ref), and that is the + // honest shape: the guard protects the LOOP, not a sweep it cannot run. + expect(mbPidAlive(childPid)).toBe(true); + } finally { + for (const pid of mbServePids(pidsLog)) { + try { + process.kill(-pid, "SIGKILL"); + } catch { + /* already gone */ + } + } + if (prevSsh === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = prevSsh; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(dataDir, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +}, 60_000); + +test("m-B: an open whose link died inside the hello gate reclaims the child its hello registers", async () => { + // The drain snapshots `liveRefs()` at close time; a ref only ENTERS the + // live map after its hello (probe up to 30 s + hello up to 30 s), so an + // `ssh_session_open` in flight across the flap re-appears AFTER the drain + // as a brand-new orphan: quota slot held, destination door bound, and + // (post-m2) every later open there refused `session_in_use` naming a + // session the plane already unrolled. The open flow stays untouched (the + // supervisor does not know about sockets); the executor checks AFTER the + // open returned `open` whether the link that CARRIED the command is still + // the live one, and reclaims through the supervisor's own close when it + // is not. + const dataDir = mkdtempSync(join(tmpdir(), "subshell-daemon-mB-")); + const root = mkdtempSync(join(tmpdir(), "subshell-daemon-mB-fake-")); + const helloFile = fakeServeHello(root, "mB0000000001"); + const { sshPath, pidsLog } = fakeServeSsh(root, helloFile, 2_000); // hello lands after the flap + redial + const prevSsh = process.env.SUBSHELL_SSH_PATH; + const prevHome = process.env.HOME; + process.env.SUBSHELL_SSH_PATH = sshPath; + process.env.HOME = join(root, "home"); + const target = { alias: "mB", host: "127.0.0.1", port: 22, user: null, identityFile: null } as const; + try { + const h = await startDaemon({ config: { dataDir } }); + const ref = crypto.randomUUID(); + await signAndSend(h, { type: "ssh_session_open", ref, target } as never, { jti: "mB-1", seq: 1 }); + // The child is spawned and inside its hello gate; the result has NOT + // landed (the hello is two seconds out). + await waitUntil(() => mbServePids(pidsLog).length === 1, "the serve child to spawn before its hello"); + const childPid = mbServePids(pidsLog)[0] as number; + expect(mbPidAlive(childPid)).toBe(true); + + closeAllSockets(h.plane, 1001, "flap"); + await waitFor( + h, + () => count(h, (e) => e.type === "ready") >= 2, + "reconnect ready while the open is mid-gate", + 10_000, + ); + + // The late hello registers the child, the open resolves, and the + // executor's liveness check says: this command rode a dead link. + await waitUntil(() => !mbPidAlive(childPid), "the late-born child reclaimed (m-B)", 10_000); + expect(peekSshSessionSupervisor(dataDir)?.liveRefs()).toEqual([]); + // The reclaim is a close, not a loss: the new link never hears about a + // session it never carried. + expect(eventsAs(h, "error").filter((e) => e.code === "ssh_session_lost")).toEqual([]); + + // A later open to the same destination lands over the reclaimed map + // (the quota slot the orphan held is free; the door is the destination + // half, and the child's group going with it is what releases it). + const ref2 = crypto.randomUUID(); + await mbOpenSession(h, ref2, target, 1); // the new connection's tracker restarted at open + expect(peekSshSessionSupervisor(dataDir)?.liveRefs()).toEqual([ref2]); + peekSshSessionSupervisor(dataDir)?.close(ref2); + await waitUntil( + () => !mbPidAlive((mbServePids(pidsLog)[1] as number) ?? -1), + "the second child to die on close", + 4_000, + ); + } finally { + for (const pid of mbServePids(pidsLog)) { + try { + process.kill(-pid, "SIGKILL"); + } catch { + /* already gone */ + } + } + if (prevSsh === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = prevSsh; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(dataDir, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +}, 90_000); + describe("4406 refusal message", () => { // The floor exists so an operator can ACT on the refusal, and this line is // the only place they read it. It used to be hardcoded to "rejected protocol diff --git a/apps/node/agent/src/__tests__/input-generation-fence.test.ts b/apps/node/agent/src/__tests__/input-generation-fence.test.ts new file mode 100644 index 000000000..433e5f37a --- /dev/null +++ b/apps/node/agent/src/__tests__/input-generation-fence.test.ts @@ -0,0 +1,271 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + NODE_RESULT_SSH_GENERATION_STALE, + type NodeCommandBody, + type NodeEvent, + parseNodeCommandBody, +} from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import type { NodeConfig } from "../config.js"; +import { getInputGenerationStore, resetInputGenerationStoresForTests } from "../input-generation.js"; +import { SubshellMetaStore } from "../subshell-meta.js"; + +/** + * The SSH input-generation fence (SSH-SUPPORT.md §3, task-C brief deliverable + * 2): the store's monotonic mirror, and the EXACT refusal every writer gets. + * Two invariants the tests pin: + * + * - The wire spelling is the frozen constant BY EQUALITY - the plane matches + * `NodeRpcError.detail` against `NODE_RESULT_SSH_GENERATION_STALE`; a + * reworded refusal silently un-maps every takeover fence into a generic + * "failed". The literal is asserted too, so a change to either side fails. + * - A refused write touches nothing: no tmux call, no settle wait. And an + * ordinary pane (no record) behaves byte-identically to before the feature. + */ + +const S1 = "11111111-1111-4111-8111-111111111111"; +const S2 = "22222222-2222-4222-8222-222222222222"; + +let base: string; +let dataDir: string; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-ingenfence-"))); + dataDir = join(base, "data"); + mkdirSync(dataDir, { recursive: true }); +}); +afterAll(() => rmSync(base, { recursive: true, force: true })); +afterEach(() => { + // Every test starts from an empty mirror AND an empty file: the store is + // process-wide per dataDir, and persistence is the feature - so isolation + // means dropping both halves between tests. + resetInputGenerationStoresForTests(); + try { + rmSync(join(dataDir, "ssh-input-generations.json"), { force: true }); + } catch { + /* not written yet */ + } +}); + +interface FakeCtx { + ctx: CommandContext; + calls: Array<{ method: string; args: unknown[] }>; +} + +function makeCtx(spec: { + sendInput?: (...args: unknown[]) => unknown; + capturePane?: () => string; + pressEnter?: () => void; +}): FakeCtx { + const calls: Array<{ method: string; args: unknown[] }> = []; + const method = + (name: string, impl?: (...args: unknown[]) => unknown) => + (...args: unknown[]) => { + calls.push({ method: name, args }); + if (!impl) throw new Error(`fake tmux: unstubbed call ${name}`); + return impl(...args); + }; + const raw = { + sendInput: method("sendInput", spec.sendInput), + capturePane: method("capturePane", spec.capturePane), + pressEnter: method("pressEnter", spec.pressEnter), + }; + const config: NodeConfig = { + serverUrl: "http://localhost:1", + nodeId: "node-1", + nodeKey: "k", + controlPublicKey: "{}", + dataDir, + name: "test-node", + }; + const ctx: CommandContext = { + config, + tmux: raw as unknown as CommandContext["tmux"], + meta: new SubshellMetaStore(dataDir), + nowMs: () => 1_700_000_000_000, + ws: { send: (_ev: NodeEvent) => {} }, + watchers: new Map(), + tails: new Map(), + uploads: new Map(), + runtime: null, + requestRestart: () => {}, + }; + return { ctx, calls }; +} + +/** Feed one raw command through the REAL wire parser, then dispatch it. */ +async function dispatchRaw(ctx: CommandContext, body: Record) { + const cmd: NodeCommandBody | null = parseNodeCommandBody(body); + if (!cmd) throw new Error(`fixture is not a valid command: ${JSON.stringify(body)}`); + return await dispatchCommand(ctx, cmd); +} + +describe("InputGenerationStore", () => { + it("no record = ordinary pane: check passes even a missing or stale-looking generation", () => { + const store = getInputGenerationStore(dataDir); + expect(store.current(S1)).toBeNull(); + expect(store.check(S1, undefined)).toBe("ok"); + expect(store.check(S1, 0)).toBe("ok"); + }); + + it("record establishes the pane as managed; below refuses, equal-or-above passes", () => { + const store = getInputGenerationStore(dataDir); + store.record(S1, 3); + expect(store.current(S1)).toBe(3); + expect(store.check(S1, undefined)).toBe("stale"); + expect(store.check(S1, 2)).toBe("stale"); + expect(store.check(S1, 3)).toBe("ok"); + expect(store.check(S1, 4)).toBe("ok"); + }); + + it("record is monotonic: a replayed lower transition cannot un-fence, and answers the still-current value", () => { + const store = getInputGenerationStore(dataDir); + store.record(S1, 5); + expect(store.record(S1, 4)).toBe(5); + expect(store.current(S1)).toBe(5); + expect(store.record(S1, 5)).toBe(5); + expect(store.record(S1, 6)).toBe(6); + }); + + it("forgets a pane: the fence lifts and the persisted file follows", () => { + const store = getInputGenerationStore(dataDir); + store.record(S1, 2); + store.forget(S1); + expect(store.current(S1)).toBeNull(); + const persisted = JSON.parse(readFileSync(join(dataDir, "ssh-input-generations.json"), "utf8")); + expect(persisted.generations[S1]).toBeUndefined(); + }); + + it("refuses a bad id on record (the store-throwing-id rule) and never looks it up as managed", () => { + const store = getInputGenerationStore(dataDir); + expect(() => store.record("../../etc/passwd", 1)).toThrow("invalid subshell id"); + expect(store.current("../../etc/passwd")).toBeNull(); + store.forget("not an id"); // total on the cleanup path + }); + + it("survives an agent restart: a fresh store re-reads the file and a stale write is STILL refused", () => { + getInputGenerationStore(dataDir).record(S1, 7); + resetInputGenerationStoresForTests(); + const reopened = getInputGenerationStore(dataDir); + expect(reopened.current(S1)).toBe(7); + expect(reopened.check(S1, 6)).toBe("stale"); + }); + + it("a corrupt file reads as no managed panes (never a crash, never a bogus fence)", () => { + writeFileSync(join(dataDir, "ssh-input-generations.json"), "{ not json"); + const store = getInputGenerationStore(dataDir); + expect(store.current(S1)).toBeNull(); + expect(store.check(S1, 1)).toBe("ok"); + }); +}); + +describe("input command generation fence", () => { + it("refuses a managed pane's write below the mirror, touching nothing", async () => { + getInputGenerationStore(dataDir).record(S1, 4); + const { ctx, calls } = makeCtx({ sendInput: () => {} }); + const result = await dispatchRaw(ctx, { type: "input", subshellId: S1, data: "ls\r", inputGeneration: 3 }); + expect(result).toEqual({ ok: false, error: NODE_RESULT_SSH_GENERATION_STALE }); + expect(calls).toEqual([]); + }); + + it("refuses a managed pane's write with NO generation (the contract requires it there)", async () => { + getInputGenerationStore(dataDir).record(S1, 1); + const { ctx, calls } = makeCtx({ sendInput: () => {} }); + const result = await dispatchRaw(ctx, { type: "input", subshellId: S1, data: "x" }); + expect(result).toEqual({ ok: false, error: NODE_RESULT_SSH_GENERATION_STALE }); + expect(calls).toEqual([]); + }); + + it("accepts equal and higher generations", async () => { + const store = getInputGenerationStore(dataDir); + store.record(S1, 2); + const { ctx, calls } = makeCtx({ sendInput: () => {} }); + expect(await dispatchRaw(ctx, { type: "input", subshellId: S1, data: "a", inputGeneration: 2 })).toEqual({ + ok: true, + }); + expect(await dispatchRaw(ctx, { type: "input", subshellId: S1, data: "b", inputGeneration: 3 })).toEqual({ + ok: true, + }); + expect(calls.filter((c) => c.method === "sendInput")).toHaveLength(2); + }); + + it("an ordinary pane (no record) writes untouched, generation present or not", async () => { + const { ctx, calls } = makeCtx({ sendInput: () => {} }); + expect(await dispatchRaw(ctx, { type: "input", subshellId: S2, data: "a" })).toEqual({ ok: true }); + expect(await dispatchRaw(ctx, { type: "input", subshellId: S2, data: "b", inputGeneration: 9 })).toEqual({ + ok: true, + }); + }); +}); + +describe("prompt_deliver generation fence", () => { + it("refuses before the settle wait: a fenced prompt never captures, never types", async () => { + getInputGenerationStore(dataDir).record(S1, 2); + const { ctx, calls } = makeCtx({ capturePane: () => "screen", sendInput: () => {}, pressEnter: () => {} }); + const result = await dispatchRaw(ctx, { + type: "prompt_deliver", + subshellId: S1, + text: "hello", + settleTimeoutMs: 50, + pollMs: 10, + inputGeneration: 1, + }); + expect(result).toEqual({ ok: false, error: NODE_RESULT_SSH_GENERATION_STALE }); + expect(calls).toEqual([]); + }); + + it("refuses a managed pane's prompt carrying no generation", async () => { + getInputGenerationStore(dataDir).record(S1, 1); + const { ctx, calls } = makeCtx({ capturePane: () => "screen", sendInput: () => {}, pressEnter: () => {} }); + const result = await dispatchRaw(ctx, { + type: "prompt_deliver", + subshellId: S1, + text: "hello", + settleTimeoutMs: 50, + pollMs: 10, + }); + expect(result).toEqual({ ok: false, error: NODE_RESULT_SSH_GENERATION_STALE }); + expect(calls).toEqual([]); + }); + + it("a current generation still settles and types (ordinary behavior preserved for managed panes at the right generation)", async () => { + const store = getInputGenerationStore(dataDir); + store.record(S1, 3); + const typed: string[] = []; + const { ctx, calls } = makeCtx({ + capturePane: () => "screen", + sendInput: (_s: unknown, _i: unknown, data: unknown) => { + typed.push(String(data)); + }, + pressEnter: () => {}, + }); + const result = await dispatchRaw(ctx, { + type: "prompt_deliver", + subshellId: S1, + text: "hello", + settleTimeoutMs: 200, + pollMs: 10, + inputGeneration: 3, + }); + expect(result).toEqual({ ok: true, data: { promptDelivered: true } }); + expect(typed).toEqual(["hello"]); + }); +}); + +describe("the frozen refusal spelling", () => { + beforeEach(() => { + expect(NODE_RESULT_SSH_GENERATION_STALE).toBe("stale input generation"); + }); + + it("answers every fenced write with that spelling verbatim", async () => { + getInputGenerationStore(dataDir).record(S1, 5); + const { ctx, calls } = makeCtx({ sendInput: () => {} }); + const result = await dispatchRaw(ctx, { type: "input", subshellId: S1, data: "x", inputGeneration: 4 }); + if (result.ok) throw new Error("expected refusal"); + expect(result.error).toBe("stale input generation"); + }); +}); diff --git a/apps/node/agent/src/__tests__/ssh-broker-dispatch.test.ts b/apps/node/agent/src/__tests__/ssh-broker-dispatch.test.ts new file mode 100644 index 000000000..881541425 --- /dev/null +++ b/apps/node/agent/src/__tests__/ssh-broker-dispatch.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from "bun:test"; +import { SshBrokerDispatcher } from "../ssh-broker-dispatch.js"; + +test("paused open cannot block an existing session send or close, and a second open refuses immediately", async () => { + const dispatcher = new SshBrokerDispatcher(); + const events: string[] = []; + let resume!: () => void; + const paused = new Promise((resolve) => { + resume = resolve; + }); + const error = () => { + throw new Error("Dispatch should not fail"); + }; + expect( + dispatcher.dispatch( + { type: "ssh_session_open", ref: "new" }, + async () => { + events.push("opening"); + await paused; + events.push("opened"); + }, + error, + ), + ).toBe("accepted"); + expect( + dispatcher.dispatch( + { type: "ssh_session_open", ref: "other" }, + async () => { + events.push("unwanted second open"); + }, + error, + ), + ).toBe("open_busy"); + expect( + dispatcher.dispatch( + { type: "ssh_session_send", ref: "existing" }, + async () => { + events.push("sent"); + }, + error, + ), + ).toBe("accepted"); + expect( + dispatcher.dispatch( + { type: "ssh_session_close", ref: "existing" }, + async () => { + events.push("closed"); + }, + error, + ), + ).toBe("accepted"); + await Bun.sleep(1); + expect(events).toEqual(["opening", "sent", "closed"]); + let drained = false; + const draining = dispatcher.drain().then(() => { + drained = true; + }); + await Bun.sleep(1); + expect(drained).toBe(false); + resume(); + await draining; + expect(events).toEqual(["opening", "sent", "closed", "opened"]); + expect(dispatcher.size).toBe(0); +}); + +test("dispatch bounds pending commands and preserves ordering per session without blocking unrelated refs", async () => { + const dispatcher = new SshBrokerDispatcher(); + let resume!: () => void; + const pause = new Promise((resolve) => { + resume = resolve; + }); + const events: string[] = []; + const error = () => {}; + dispatcher.dispatch( + { type: "ssh_session_send", ref: "first" }, + async () => { + await pause; + events.push("first send"); + }, + error, + ); + dispatcher.dispatch( + { type: "ssh_session_close", ref: "first" }, + async () => { + events.push("first close"); + }, + error, + ); + dispatcher.dispatch( + { type: "ssh_session_send", ref: "second" }, + async () => { + events.push("second send"); + }, + error, + ); + await Bun.sleep(1); + expect(events).toEqual(["second send"]); + for (let i = dispatcher.size; i < 32; i++) + expect( + dispatcher.dispatch( + { type: "ssh_resolve_config" }, + async () => { + await pause; + }, + error, + ), + ).toBe("accepted"); + expect(dispatcher.dispatch({ type: "ssh_discover_aliases" }, async () => {}, error)).toBe("full"); + resume(); + await dispatcher.drain(); + expect(events).toEqual(["second send", "first send", "first close"]); + expect(dispatcher.size).toBe(0); +}); diff --git a/apps/node/agent/src/__tests__/ssh-broker.test.ts b/apps/node/agent/src/__tests__/ssh-broker.test.ts new file mode 100644 index 000000000..34e6a3179 --- /dev/null +++ b/apps/node/agent/src/__tests__/ssh-broker.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { parseArgs, run } from "../cli.js"; +import { brokerCredentialPath, brokerServerOrigin } from "../ssh-broker.js"; + +test("desktop transport requires HTTPS except literal loopback and refuses ambiguous origins", () => { + for (const url of [ + "http://example.com", + "https://user:password@example.com", + "https://example.com/path", + "https://example.com/?secret=x", + "https://example.com/#fragment", + "file:///tmp/config", + ]) + expect(() => brokerServerOrigin(url)).toThrow(); + expect(brokerServerOrigin("https://example.com/")).toBe("https://example.com"); + expect(brokerServerOrigin("http://127.0.0.1:3199")).toBe("http://127.0.0.1:3199"); +}); +test("credentials are scoped by server origin and stable desktop id; secrets cannot be argv", () => { + const id = `desktop:${crypto.randomUUID()}`; + expect(brokerCredentialPath("https://one.example", id)).not.toBe(brokerCredentialPath("https://two.example", id)); + expect(() => brokerCredentialPath("https://one.example", "../../key")).toThrow(); + expect(parseArgs(["ssh-broker", "--server", "https://one.example", "--broker-id", id]).flags.brokerId).toBe(id); + expect(() => parseArgs(["ssh-broker", "--server", "https://one.example", "--key", "private"])).toThrow(); +}); + +test("CLI rejects null frames and replayed RPC ids through its cleanup path", async () => { + for (const attack of ["null", "replay"]) { + const root = mkdtempSync(join(tmpdir(), "desktop-wire-")); + const id = `desktop:${crypto.randomUUID()}`; + let results = 0; + const request = { type: "command", requestId: "1", command: { type: "ssh_discover_aliases" } }; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: (req, srv) => (srv.upgrade(req) ? undefined : new Response("Refused", { status: 401 })), + websocket: { + open: (ws) => { + ws.send(JSON.stringify({ type: "attached", id, name: "Fixture" })); + ws.send(JSON.stringify(request)); + }, + message: (ws, raw) => { + const frame = JSON.parse(String(raw)); + if (frame.type === "result") { + results++; + ws.send(attack === "null" ? "null" : JSON.stringify(request)); + } + }, + }, + }); + const child = Bun.spawn( + [process.execPath, "src/main.ts", "ssh-broker", "--server", `http://127.0.0.1:${server.port}`], + { + cwd: resolve(import.meta.dir, "../.."), + env: { ...process.env, HOME: root, SUBSHELL_CONFIG_HOME: join(root, "config") }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }, + ); + child.stdin.write(`${JSON.stringify({ pairingToken: `dsp_${"a".repeat(43)}` })}\n`); + try { + expect(await child.exited).toBe(0); + expect(results).toBe(1); + expect(await new Response(child.stderr).text()).toBe(""); + expect(readdirSync(join(root, "config", "ssh-broker-runtime"))).toEqual([]); + } finally { + if (child.exitCode === null) { + child.kill(); + await child.exited; + } + server.stop(true); + rmSync(root, { recursive: true, force: true }); + } + } +}, 20_000); + +test("forget removes only the selected private origin credential without stdin or network", async () => { + const id = `desktop:${crypto.randomUUID()}`; + const first = brokerCredentialPath("https://first.example", id); + const other = brokerCredentialPath("https://other.example", id); + mkdirSync(join(first, ".."), { recursive: true, mode: 0o700 }); + writeFileSync(first, "private", { mode: 0o600 }); + writeFileSync(other, "other", { mode: 0o600 }); + try { + const result = await run(["ssh-broker", "--server", "https://first.example", "--broker-id", id, "--forget"]); + expect(result).toEqual({ code: 0, out: "", err: "" }); + expect(existsSync(first)).toBe(false); + expect(existsSync(other)).toBe(true); + } finally { + rmSync(first, { force: true }); + rmSync(other, { force: true }); + } +}); diff --git a/apps/node/agent/src/__tests__/ssh-session-pump-chunks.test.ts b/apps/node/agent/src/__tests__/ssh-session-pump-chunks.test.ts new file mode 100644 index 000000000..7884ea7d7 --- /dev/null +++ b/apps/node/agent/src/__tests__/ssh-session-pump-chunks.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { SSH_SESSION_PUMP_CHUNK_BYTES } from "@internal/subshell-protocol"; +import { pumpChunks } from "../commands/ssh-session.js"; + +/** + * The `session_frame` pump bound (node-frames' own claim: raw stdout in + * ≤ 192 KiB pieces). The pump may only ever cut the byte stream, never + * reorder or lose it: pieces at the bound, offsets exact, concatenation + * byte-identical. The session's codec reassembles frames across pushes, so + * a cut at an arbitrary byte is legal by design (review Minor, folded with + * C1's framing family). + */ +describe("pumpChunks (session_frame bound)", () => { + test("a read at or under the bound passes through as ONE piece", () => { + const small = new Uint8Array(1024).fill(0xab); + expect(pumpChunks(small, SSH_SESSION_PUMP_CHUNK_BYTES)).toEqual([small]); + const at = new Uint8Array(SSH_SESSION_PUMP_CHUNK_BYTES).fill(0xcd); + expect(pumpChunks(at, SSH_SESSION_PUMP_CHUNK_BYTES)).toEqual([at]); + }); + + test("a longer read splits at the bound, pieces are ≤ bound, and the concat is byte-identical", () => { + const big = new Uint8Array(SSH_SESSION_PUMP_CHUNK_BYTES * 2 + 777).fill(0); + for (let i = 0; i < big.byteLength; i++) big[i] = i % 251; // any deterministic non-flat pattern + const parts = pumpChunks(big, SSH_SESSION_PUMP_CHUNK_BYTES); + expect(parts.length).toBe(3); + for (const p of parts) expect(p.byteLength).toBeLessThanOrEqual(SSH_SESSION_PUMP_CHUNK_BYTES); + const joined = new Uint8Array(big.byteLength); + let off = 0; + for (const p of parts) { + joined.set(p, off); + off += p.byteLength; + } + expect(off).toBe(big.byteLength); + expect(Buffer.from(joined).equals(Buffer.from(big))).toBe(true); + }); + + test("an empty read is one empty piece (the pump's no-op stays a no-op, not zero pieces of nothing)", () => { + expect(pumpChunks(new Uint8Array(0), SSH_SESSION_PUMP_CHUNK_BYTES)).toEqual([new Uint8Array(0)]); + }); +}); diff --git a/apps/node/agent/src/cli.ts b/apps/node/agent/src/cli.ts index f76734164..d47db67de 100644 --- a/apps/node/agent/src/cli.ts +++ b/apps/node/agent/src/cli.ts @@ -19,6 +19,7 @@ import { } from "./maintenance-cli.js"; import { runNodeMcp } from "./mcp/main.js"; import { defaultNodeResetDeps, type NodeResetDeps, runNodeReset } from "./reset-cli.js"; +import { runRuntimeServe } from "./runtime/serve.js"; import { controlService, DEFAULT_DEPS, @@ -40,6 +41,7 @@ import { promptYesNo, runSetup, } from "./setup.js"; +import { forgetBrokerCredential, runSshBroker } from "./ssh-broker.js"; import { defaultUnenrollDeps, runUnenroll, type UnenrollDeps } from "./unenroll-cli.js"; import { applyUpdate, @@ -157,6 +159,15 @@ usage: subshell report exit (the pane died; run by tmux's own hook) subshell pane-log --file (append stdin to a pane log, flushing each read; run by tmux's own pipe-pane, not by hand) + subshell ssh-broker --server [--broker-id ] [--forget] + trusted desktop SSH broker; initialization on stdin + subshell runtime-serve --session --tmux-socket + the session-scoped runtime mode (INTERNAL, composed + by a brokered SSH session's remote command line, + never typed): drives tmux on THIS machine for one + plane session over stdio frames, enlists nothing, + binds no network listener. Panes survive it; a + close frame exits 0. `; /** Malformed invocation → usage text, exit 2. */ @@ -173,6 +184,8 @@ const COMMANDS = new Set([ "report", "reset", "run", + "runtime-serve", + "ssh-broker", "service", "setup", "status", @@ -316,6 +329,17 @@ const FLAGS: Record = { // per-start. Exists for the cli-e2e scenario (a suite must never bind the // port a developer's node owns) as much as for an operator. "--dashboard-port": true, + // The session-runtime verbs (`subshell runtime-serve --session + // --tmux-socket `, design 2026-10-05): composed by the broker's + // remote command line, never typed. `--session` is the plane-minted ref + // the broker passes through unchanged; `--tmux-socket` is the deterministic + // per-destination socket the broker computed. No data-dir flag: the runtime + // namespace is `SUBSHELL_RUNTIME_DATA_DIR` or the default, never argv - + // argv-carried roots are how a path gets chosen by the wrong party. + "--forget": false, + "--broker-id": true, + "--session": true, + "--tmux-socket": true, }; /** Every flag any subtoken of `command` accepts — the union {@link SUBCOMMAND_FLAGS} narrows. */ const subcommandFlagUnion = (command: string): string[] => [ @@ -344,6 +368,11 @@ const COMMAND_FLAGS: Record = { reset: ["--yes", "--confirm"], report: [], // same pane-env contract; a hook's command line is built by the control plane, never typed run: ["--dashboard-port"], + // The session-scoped runtime mode (design 2026-10-05 §1): both flags are + // REQUIRED and checked in the case block, because a runtime that serves + // without its ref or its socket would create panes nowhere. + "runtime-serve": ["--session", "--tmux-socket"], + "ssh-broker": ["--server", "--broker-id", "--forget"], // Derived, never hand-listed: the command-level check is the union and the // per-subtoken check below is what actually decides. service: subcommandFlagUnion("service"), @@ -627,6 +656,37 @@ export async function run(argv: string[], deps: RunDeps = {}): Promise")); + if (parsed.flags.forget) { + if (!parsed.flags.brokerId) return fail(2, new Error("ssh-broker --forget requires --broker-id")); + forgetBrokerCredential(parsed.flags.server, parsed.flags.brokerId); + return { code: 0, out: "", err: "" }; + } + const code = await runSshBroker(parsed.flags.server, parsed.flags.brokerId); + return { code, out: "", err: "" }; + } + case "runtime-serve": { + // The session-scoped runtime mode (design 2026-10-05 §1): a pre-boot + // verb like `pane-log` and `report` - NO config, no lock, no daemon. + // A destination machine has no enrollment and must not need one; the + // runtime's whole authority is the SSH child's stdio it was born on. + // Both flags are required BY REFUSAL: a runtime that served panes + // without its session ref or its destination socket would be + // guessing at both, so a missing flag is the usage exit before any + // stdout byte (the pane-log rule for a bad --file, same reason). + const session = parsed.flags.session; + const socket = parsed.flags.tmuxSocket; + if (session === undefined || session === "" || socket === undefined || socket === "") { + return fail(2, new Error("runtime-serve requires --session and --tmux-socket ")); + } + // The serve loop holds the process open (stdin reader + tail timers); + // when it resolves the session is over and the exit code rides out + // normally - no keepAlive, unlike `mcp` whose transport must survive + // the connect promise. + const code = await runRuntimeServe({ session, tmuxSocket: socket }); + return { code, out: "", err: "" }; + } case "dashboard": { // The standalone dashboard (spec 2026-09-19): the SAME surface // `case "run"` starts, without the daemon. Read endpoints and the diff --git a/apps/node/agent/src/commands/basics.ts b/apps/node/agent/src/commands/basics.ts index 236e1a45e..8463cde41 100644 --- a/apps/node/agent/src/commands/basics.ts +++ b/apps/node/agent/src/commands/basics.ts @@ -5,10 +5,12 @@ import { type DetectResultWire, type JsonValue, NODE_MAX_FRAME_BYTES, + NODE_RESULT_SSH_GENERATION_STALE, type NodeProbeEntry, } from "@internal/subshell-protocol"; import { writeAllowedDirs } from "../allowed-dirs.js"; import { hostEnvAnswers } from "../host-env.js"; +import { getInputGenerationStore } from "../input-generation.js"; import { buildInventoryEvent } from "../inventory.js"; import { pathAllowed } from "../path-policy.js"; import { isSubshellId } from "../subshell-meta.js"; @@ -75,8 +77,22 @@ export async function execKill(ctx: CommandContext, cmd: Cmd<"kill">): Promise): Promise { + if (getInputGenerationStore(ctx.config.dataDir).check(cmd.subshellId, cmd.inputGeneration) === "stale") { + return { ok: false, error: NODE_RESULT_SSH_GENERATION_STALE }; + } const socket = await resolveSocket(ctx, cmd.subshellId); await ctx.tmux.sendInput(socket, cmd.subshellId, cmd.data); return { ok: true }; @@ -138,14 +154,22 @@ export async function execCapture(ctx: CommandContext, cmd: Cmd<"capture">): Pro * said so, and the entry shape has no third state to say anything else with. */ export async function execProbe(ctx: CommandContext, cmd: Cmd<"probe">): Promise { - // Same id-format gate as resolveSocket (probe never touches the store, but - // a hostile id must not reach tmux either): one bad id fails the batch. + // One bad id fails the batch. for (const subshellId of cmd.subshellIds) { if (!isSubshellId(subshellId)) throw new Error("invalid subshell id"); } const entries: NodeProbeEntry[] = []; for (const subshellId of cmd.subshellIds) { - const socket = tmuxSocketFor(subshellId); // same derivation the launcher uses — no stored state needed + // The socket the PANE is actually on, resolved by the shared rule (every + // other pane executor uses it): the record written at launch wins, and + // `tmuxSocketFor` is the orphan fallback. The bare derivation was wrong + // for SSH-runtime panes - the runtime launches them on its one + // destination-wide socket (design 2026-10-05 §6), so deriving the + // per-subshell name probed a socket the pane never lived on and answered + // `alive: false` for a running pane (the live-attach probe that surfaced + // it closed every runtime attach at 4004 "subshell not running"). On the + // node link the record EQUALS the derivation, so nothing else shifts. + const socket = await resolveSocket(ctx, subshellId); if (!(await ctx.tmux.hasSubshell(socket, subshellId))) { entries.push({ subshellId, alive: false, exitCode: await ctx.tmux.paneExitCode(socket, subshellId) }); continue; diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index 201aa481f..a5dbfccf5 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -43,6 +43,20 @@ export interface CommandWs { readonly bufferedAmount?: number; } +/** + * Per-command liveness gate (review m-B): is the socket that DELIVERED this + * command still the daemon's live connection? `dispatchCommand` hands it to + * the one executor whose effect outlives the command's own RPC - the + * brokered-session open, whose probe + hello gate (up to a minute) can span + * a link flap - so a child registered after its link died is reclaimed + * instead of orphaned. Absent on transports with no socket to compare + * against (unit fakes, the runtime's own dispatch): the guard does not run + * rather than fabricate liveness. + */ +export interface CommandLinkGate { + isCurrent(): boolean; +} + /** * One in-flight `write_file` stream (spec §3.4), keyed in * {@link CommandContext.uploads} by the RESOLVED final path. The chunk @@ -174,6 +188,18 @@ export interface CommandContext { * and a restart that never answered would read as a timeout on the plane. */ requestRestart: () => void; + /** + * Session-aware ceiling on the RAW bytes one `output` event may carry + * (review C1b). The plain node daemon leaves it UNSET and the pump chunks + * at `TAIL_CHUNK_BYTES` (192 KiB, sized for the 1 MiB node-link frame); + * `runtime/serve.ts` sets it to {@link SSH_SESSION_LOG_WINDOW_BYTES} + * because the runtime's `output` frames ride the 262 KiB session codec, + * where a 192 KiB raw chunk base64s to exactly the cap and the envelope + * overflows it. The pump honors `min(TAIL_CHUNK_BYTES, ceiling)` at the + * SOURCE - splitting inside the writer would desync the fromByte/toByte + * bookkeeping the retry cursor depends on. + */ + outputChunkCeilingBytes?: number; /** * The service manager seam the `service` command drives. * diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 8ff435dca..56e7d4feb 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -18,7 +18,7 @@ import { execStatDir, execTerminate, } from "./basics.js"; -import type { CommandContext, CommandResult } from "./context.js"; +import type { CommandContext, CommandLinkGate, CommandResult } from "./context.js"; import { execFileRead } from "./file-read.js"; import { execFsLs } from "./fs-ls.js"; import { execLaunch } from "./launch.js"; @@ -27,6 +27,8 @@ import { execService } from "./service.js"; import { execSetLogLevel } from "./set-log-level.js"; import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; +import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; +import { execSshSessionClose, execSshSessionOpen, execSshSessionSend } from "./ssh-session.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; import { execTransferWrite } from "./transfer-write.js"; import { execTreeManifest } from "./tree-manifest.js"; @@ -47,7 +49,9 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * the one command whose wire shape is frozen, because the plane sends it to * agents whose protocol it does not share), and the five archive-transfer * commands `archive_create`, `file_read`, `transfer_write`, `archive_extract` - * and `tree_manifest` (spec 2026-10-01 §4, protocol 15). Any + * and `tree_manifest` (spec 2026-10-01 §4, protocol 15), and the two + * pre-session SSH reads `ssh_discover_aliases` and `ssh_resolve_config` + * (grammar frozen in `ssh-frames.ts`, runtime in `@internal/pane-runtime`). Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. * @@ -65,9 +69,16 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * * @param ctx - the per-daemon execution context (config, tmux, meta, ws seam) * @param cmd - the verified `cmd` claim of a signed command + * @param link - liveness gate for the delivering socket (review m-B); only + * the brokered-session open consults it, everything else is bounded well + * inside one connection's life * @returns the result body for the `result{ref: jti}` frame */ -export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody): Promise { +export async function dispatchCommand( + ctx: CommandContext, + cmd: NodeCommandBody, + link?: CommandLinkGate, +): Promise { try { switch (cmd.type) { case "ping": @@ -136,6 +147,27 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execArchiveExtract(ctx, cmd); case "tree_manifest": return await execTreeManifest(ctx, cmd); + // The two pre-session SSH reads (grammar frozen in the protocol's + // `ssh-frames.ts`, runtime in `@internal/pane-runtime`): short RPCs + // against the connecting account's own config. The destination + // product's run/terminal/test arms retired with it (design + // 2026-10-05 §7); their `type` strings no longer parse on the wire. + case "ssh_discover_aliases": + return await execSshDiscoverAliases(); + case "ssh_resolve_config": + return await execSshResolveConfig(cmd); + // The brokered-session family (design 2026-10-05 §3; grammar frozen in + // the protocol's `ssh-session-frames.ts`, runtime in + // `@internal/pane-runtime`). `open` is the ONE ssh arm that holds its + // RPC until a real fact lands (the hello), bounded at the protocol's + // own open deadline and matched by the plane's per-command timeout - + // every other frame of the session streams as events after. + case "ssh_session_open": + return await execSshSessionOpen(ctx, cmd, link); + case "ssh_session_send": + return await execSshSessionSend(ctx, cmd); + case "ssh_session_close": + return await execSshSessionClose(ctx, cmd); default: return { ok: false, error: "unsupported" }; } diff --git a/apps/node/agent/src/commands/launch.ts b/apps/node/agent/src/commands/launch.ts index 3f2a3c664..02e968807 100644 --- a/apps/node/agent/src/commands/launch.ts +++ b/apps/node/agent/src/commands/launch.ts @@ -7,6 +7,7 @@ import { findBinary, type PresetDefinition, paneEnvFor, + seedLogFromHistory, } from "@internal/pane-runtime"; import { HARNESS_BINARY_PLACEHOLDER, NODE_RESULT_MAINTENANCE } from "@internal/subshell-protocol"; import { DIR_REFUSED_MESSAGE, launchDirAllowed, readAllowedDirs } from "../allowed-dirs.js"; @@ -204,6 +205,13 @@ export async function execLaunch(ctx: CommandContext, cmd: Cmd<"launch">): Promi await attachLog(); // STRICT (createSubshell parity): a throw fails the launch } + // The pre-attach race backfill (pane-runtime's seedLogFromHistory): AFTER + // the branch on purpose, mirroring LocalLauncher's twin - a best-effort + // attach whose dir/pipe failed still gets whatever history holds, and the + // never-throws property keeps the strict path strict-but-safe. + const seeded = await seedLogFromHistory(ctx.tmux, cmd.socket, cmd.subshellId, logFile); + if (seeded !== null) log(`launch ${cmd.subshellId}: ${seeded}`); + // (8) Initial geometry — cosmetic: a refused resize must not fail a live pane. if (cmd.cols !== undefined && cmd.rows !== undefined) { try { diff --git a/apps/node/agent/src/commands/prompt.ts b/apps/node/agent/src/commands/prompt.ts index 6ece87619..d04d9ac7d 100644 --- a/apps/node/agent/src/commands/prompt.ts +++ b/apps/node/agent/src/commands/prompt.ts @@ -1,5 +1,10 @@ import { stripAnsi } from "@internal/backend-errors"; -import type { JsonValue, NodePromptDeliverResult } from "@internal/subshell-protocol"; +import { + type JsonValue, + NODE_RESULT_SSH_GENERATION_STALE, + type NodePromptDeliverResult, +} from "@internal/subshell-protocol"; +import { getInputGenerationStore } from "../input-generation.js"; import { resolveSocket } from "./basics.js"; import type { Cmd, CommandContext, CommandResult } from "./context.js"; @@ -26,6 +31,14 @@ import type { Cmd, CommandContext, CommandResult } from "./context.js"; * enforced here so the promise itself never rejects). */ export async function execPromptDeliver(ctx: CommandContext, cmd: Cmd<"prompt_deliver">): Promise { + // SSH feature - the same generation fence as `input`, BEFORE the settle + // wait: a restart prompt or a queued delivery that a takeover or a + // revocation has fenced types nothing at all, not even after the pane + // settles (prompts are writers too - SSH-SUPPORT.md §3). Ordinary panes + // carry no record and pass untouched. + if (getInputGenerationStore(ctx.config.dataDir).check(cmd.subshellId, cmd.inputGeneration) === "stale") { + return { ok: false, error: NODE_RESULT_SSH_GENERATION_STALE }; + } let socket: string; try { socket = await resolveSocket(ctx, cmd.subshellId); // id gate FIRST (store throws on bad ids) diff --git a/apps/node/agent/src/commands/ssh-aliases.ts b/apps/node/agent/src/commands/ssh-aliases.ts new file mode 100644 index 000000000..9436cb4d7 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-aliases.ts @@ -0,0 +1,51 @@ +import { defaultSshConfigPath, discoverSshAliases, resolveSshAliasConfig } from "@internal/pane-runtime"; +import { type JsonValue, parseNodeSshAliasList, parseNodeSshResolveOutcome } from "@internal/subshell-protocol"; +import type { Cmd, CommandResult } from "./context.js"; +import { connectingHomeDir, resolveSshBin } from "./ssh-shared.js"; + +/** + * The two human-config SSH executors that touch no connection state: + * + * - `ssh_discover_aliases` — bounded config parse answering NAMES only + * (§2: "Discovery returns names, not config file contents"). The answer is + * run through the frozen wire validator on the way out. + * - `ssh_resolve_config` — `ssh -G` evaluation of one alias through + * pane-runtime's `resolveSshAliasConfig`, plus this machine's connecting + * account name for the §3 review card ("review resolved destination AND + * connecting OS account"). A refusal is a SUCCESSFUL answer (the outcome + * rides `accepted:false` in the data) so the human reads WHICH setting + * blocked; only a missing ssh binary is a command failure. + * + * The disclosure §2 demands before this runs — that a trusted `Match exec` + * can execute locally during resolution — belongs to the human-facing surface + * that dispatches it (the SPA settings flow), not to the node. + */ + +/** Execute `ssh_discover_aliases`. */ +export async function execSshDiscoverAliases(): Promise { + const home = connectingHomeDir(); + const found = discoverSshAliases({ homeDir: home, configPath: defaultSshConfigPath(home) }); + const validated = parseNodeSshAliasList({ + aliases: found.aliases, + includeCycle: found.includeCycle, + truncated: found.truncated, + }); + if (validated === null) return { ok: false, error: "malformed alias discovery" }; + // The seam cast: JSON-safe by construction, contract owned by `node-results.ts`. + return { ok: true, data: validated as unknown as JsonValue }; +} + +/** Execute `ssh_resolve_config` for one alias. */ +export async function execSshResolveConfig(cmd: Cmd<"ssh_resolve_config">): Promise { + const sshBin = await resolveSshBin(); + if (sshBin === null) return { ok: false, error: "ssh binary missing: ssh" }; + const home = connectingHomeDir(); + const outcome = await resolveSshAliasConfig(cmd.alias, { + sshBin, + homeDir: home, + configPath: defaultSshConfigPath(home), + }); + const validated = parseNodeSshResolveOutcome(outcome); + if (validated === null) return { ok: false, error: "malformed resolve outcome" }; + return { ok: true, data: validated as unknown as JsonValue }; +} diff --git a/apps/node/agent/src/commands/ssh-session.ts b/apps/node/agent/src/commands/ssh-session.ts new file mode 100644 index 000000000..dda91c359 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-session.ts @@ -0,0 +1,207 @@ +import { getSshSessionSupervisor, peekSshSessionSupervisor } from "@internal/pane-runtime"; +import { + isSshSessionRef, + type JsonValue, + parseNodeSshSessionOpenResult, + SSH_SESSION_PUMP_CHUNK_BYTES, + SSH_SESSION_UNKNOWN, + type SshSessionOpenResultWire, +} from "@internal/subshell-protocol"; +import { log } from "../log.js"; +import type { Cmd, CommandContext, CommandLinkGate, CommandResult } from "./context.js"; +import { connectingHomeDir, resolveSshBin } from "./ssh-shared.js"; +import { TAIL_BACKPRESSURE_BYTES, TAIL_BACKPRESSURE_POLL_MS } from "./tail.js"; + +/** + * The three brokered-session executors (design 2026-10-05 §3). + * + * Thin by the same contract the run family follows: every rule lives in + * `@internal/pane-runtime`'s `SshSessionSupervisor` (policy render, probe, + * hello gate, pump bounds, group-kill, boot reconcile) and these functions + * only (a) reach the supervisor for this daemon's data dir, (b) hand it the + * transport's pump seam, and (c) answer the frozen wire shapes - the open + * result re-validated on the way out, the equality-mapped refusal codes + * verbatim in `result{error}`. + * + * The pump seam is the one piece with real logic here, and it is a port of + * tail.ts's backpressure: await `bufferedAmount` back under the threshold + * before sealing each chunk into a `session_frame` event. The runtime's + * stdout is the destination program's output and can outrun the socket; a + * broker that buffered without bound would turn a chatty pane into the + * daemon's memory exhaustion, which is the bounded-pump promise in §2 made + * concrete. + * + * Refusals the plane maps by EQUALITY ride the bare codes (`runtime_missing`, + * `session_quota`, `session_protocol`, `run_conflict`, `session_unknown`, and + * the classified transport codes); an ssh binary missing entirely answers + * like every other ssh arm ("ssh binary missing: ssh"). + */ + +/** + * Split one stdout read into `≤ maxBytes` raw pieces for the `session_frame` + * pump (the node-frames arm's own claim: "raw stdout content in ≤ 192 KiB + * pieces"). A read at or under the bound passes through as ONE piece, byte- + * identical; longer reads are cut on exact offsets, and concatenating the + * pieces reproduces the input (pinned by test). This is the bound the node- + * link's frame-size discipline and the plane's ingest accounting were sized + * for; before it, one 64 KiB high-water-mark read spliced with a saturated + * pipe could ship a larger single event than the claim promised. + */ +export function pumpChunks(chunk: Uint8Array, maxBytes: number): Uint8Array[] { + if (chunk.byteLength <= maxBytes) return [chunk]; + const parts: Uint8Array[] = []; + for (let off = 0; off < chunk.byteLength; off += maxBytes) { + parts.push(chunk.subarray(off, Math.min(off + maxBytes, chunk.byteLength))); + } + return parts; +} + +/** The supervisor for this daemon's data dir, or the named absence. */ +async function supervisor(ctx: CommandContext) { + const sshBin = await resolveSshBin(); + if (sshBin === null) return { missing: true } as const; + return getSshSessionSupervisor({ + dataDir: ctx.config.dataDir, + homeDir: connectingHomeDir(), + sshBin, + nowMs: ctx.nowMs, + }); +} + +/** + * Execute `ssh_session_open`: probe the runtime, spawn the child, wait for + * the hello, answer the parsed open facts. The daemon's serial command chain + * makes one open-at-a-time per node the enforced shape; the plane's RPC + * timeout must outlast {@link SSH_SESSION_OPEN_DEADLINE_MS} (the ssh-node + * client's open sends 45 s for exactly that). + */ +export async function execSshSessionOpen( + ctx: CommandContext, + cmd: Cmd<"ssh_session_open">, + link?: CommandLinkGate, +): Promise { + if (!isSshSessionRef(cmd.ref)) return { ok: false, error: "run_unknown" }; + const sup = await supervisor(ctx); + if ("missing" in sup) return { ok: false, error: "ssh binary missing: ssh" }; + const outcome = await sup.open( + { ref: cmd.ref, target: cmd.target, runtimeCommand: cmd.runtimeCommand ?? "subshell" }, + { + emitBytes: async (chunk): Promise => { + // Backpressure mirrors the tail pump: throttle BEFORE sealing each + // chunk, and a dead socket (send throws) stops the session the same + // way it stops a tail - the child's group goes with it. One stdout + // read can return more than the node-frames promise ("in ≤ 192 KiB + // pieces"), so the read is SPLIT here before sealing: the session's + // codec reassembles frames across pushes, so a chunk boundary costs + // the stream nothing but an event, and the plane's ingest never sees + // one fat event at a time it must buffer. + for (const part of pumpChunks(chunk, SSH_SESSION_PUMP_CHUNK_BYTES)) { + let attempts = 0; + while ((ctx.ws.bufferedAmount ?? 0) > TAIL_BACKPRESSURE_BYTES && attempts < 600) { + await Bun.sleep(TAIL_BACKPRESSURE_POLL_MS); + attempts += 1; + } + try { + ctx.ws.send({ type: "session_frame", ref: cmd.ref, data_b64: Buffer.from(part).toString("base64") }); + } catch { + sup.close(cmd.ref); + return; // the session is over mid-read; the rest of the bytes go with it + } + } + }, + emitDiag: (line): void => { + // The child's stderr to the daemon's OWN log, uninterpreted (design + // §2): the session record for why an open or run died is here. + log(`ssh-session ${cmd.ref.slice(0, 8)} stderr: ${line}`); + }, + onLost: ({ exitCode }): void => { + // The close report (design §6 Disconnect): the plane marks the + // session `lost` and its panes unavailable from this one event. + ctx.ws.send({ + type: "error", + code: "ssh_session_lost", + message: JSON.stringify({ ref: cmd.ref, exitCode }), + }); + }, + }, + ); + if (outcome.kind === "refused") return { ok: false, error: outcome.code }; + // The late-open reclaim (review m-B): this command's probe + hello gate + // can outlast its own link. A ref enters the supervisor's live map only + // at hello, so the link-close drain's snapshot could not have included + // it - answering `open` now would hand the plane a session whose RPC died + // with the old socket (its row already unrolled), an orphan holding a + // quota slot and the destination's door for every future open there. + // Reclaim through the supervisor's own close (group-kill, `closed` + // recorded, the `stopping` latch keeps the death quiet) and refuse. + if (link && !link.isCurrent()) { + log(`ssh-session ${cmd.ref.slice(0, 8)} opened after its link died: reclaimed, refused`); + sup.close(cmd.ref); + return { ok: false, error: "connection_failed" }; + } + const validated = parseNodeSshSessionOpenResult(outcome.result as unknown as SshSessionOpenResultWire); + if (validated === null) return { ok: false, error: "malformed session open result" }; + return { ok: true, data: validated as unknown as JsonValue }; +} + +/** + * Drain every brokered session this process supervises for `dataDir` + * (review M1). The daemon's link-close path calls it, and the reason is the + * PLANE's semantics, not a leak cleanup: a link close is terminal for the + * sessions riding it (`markSessionsLostForNode` - design §6: a lost session + * is never resumable), so a child that outlives the socket is an orphan + * holding a quota slot AND the destination-deterministic callback door every + * future open to that `host:port:user` needs. Each ref goes through the + * supervisor's own `close`: the GROUP dies first (the record write is bare + * sync fs and a disk fault must never spare the kill - round-4 review + * MINOR2), and the death on the way down is then never reported as a loss + * through a socket that could not deliver it anyway (the `stopping` latch + * is what keeps it quiet, not the record; the `emitBytes` catch is the only + * self-heal this transport had, and `commandWs.send` swallows into a log + * line while no socket is attached, so an idle session could never fire + * it); the `closed` record is best-effort after. The destination's tmux + * server and its panes are untouched (the + * runtime-serve's own shutdown stops tails and doors only, design §6); the + * door paths free with the serve, so the next open can bind. Never BUILDS a + * supervisor: a node that never brokered pays nothing here. + * + * @returns how many sessions were drained (0 is the common no-SSH answer) + */ +export function drainBrokeredSessions(dataDir: string): number { + const sup = peekSshSessionSupervisor(dataDir); + if (sup === undefined) return 0; + let drained = 0; + // ONE ref's throw costs ONE close, never the loop (review m-A). Since the + // round-4 reorder `close()` kills first and writes the record best-effort, + // it has no reachable disk throw left - so this guard is belt-and-beyond: + // it stands because the drain runs on the daemon's reconnect path, where an + // unexpected throw mid-loop would strand every remaining child AND (with the + // caller's outer guard) the node's link. Cheap insurance against a future + // close() that can fail, not against a fault it can no longer have. + for (const ref of sup.liveRefs()) { + try { + sup.close(ref); + drained += 1; + } catch (err) { + log(`ssh-session ${ref.slice(0, 8)} drain close failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + return drained; +} + +/** Execute `ssh_session_send`: one base64 write to the child's stdin. */ +export async function execSshSessionSend(ctx: CommandContext, cmd: Cmd<"ssh_session_send">): Promise { + const sup = await supervisor(ctx); + if ("missing" in sup) return { ok: false, error: "ssh binary missing: ssh" }; + const bytes = Buffer.from(cmd.data_b64, "base64"); + const answer = sup.send(cmd.ref, new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)); + return answer === "ok" ? { ok: true } : { ok: false, error: SSH_SESSION_UNKNOWN }; +} + +/** Execute `ssh_session_close`: bounded-grace group stop, `closed` recorded. */ +export async function execSshSessionClose(ctx: CommandContext, cmd: Cmd<"ssh_session_close">): Promise { + const sup = await supervisor(ctx); + if ("missing" in sup) return { ok: false, error: "ssh binary missing: ssh" }; + const answer = sup.close(cmd.ref); + return answer === "ok" ? { ok: true } : { ok: false, error: SSH_SESSION_UNKNOWN }; +} diff --git a/apps/node/agent/src/commands/ssh-shared.ts b/apps/node/agent/src/commands/ssh-shared.ts new file mode 100644 index 000000000..745a44ae1 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-shared.ts @@ -0,0 +1,31 @@ +import { homedir } from "node:os"; +import { findBinary } from "@internal/pane-runtime"; + +/** + * Shared plumbing for the SSH command arms (spec SSH-SUPPORT.md §3/§4). + * + * The one question every SSH executor asks first is WHERE the ssh binary is, + * and every answer must come from the node's own lookup ladder at this + * instant — never a path shipped over the wire (§2's inversion posture: the + * plane approves the destination, the machine owns the argv construction + * including which binary executes it). `SUBSHELL_SSH_PATH` is the operator + * override seam (the `CLAUDE_PATH` posture), and the knownPaths list is the + * union of OpenSSH install locations on the supported platforms (Linux/macOS, + * §1) — PATH first through the ladder, these as the fallbacks it names. + */ + +/** env-override seam for the ssh binary, spelled like the harness overrides. */ +export const SSH_BINARY_ENV = "SUBSHELL_SSH_PATH"; + +/** The known install locations the ladder falls back to. */ +const SSH_KNOWN_PATHS = ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh", "/opt/homebrew/bin/ssh"]; + +/** Resolve the ssh binary for this machine, or null. */ +export async function resolveSshBin(): Promise { + return await findBinary("ssh", SSH_BINARY_ENV, SSH_KNOWN_PATHS); +} + +/** The connecting account's home, the same way the daemon itself sees it. */ +export function connectingHomeDir(): string { + return process.env.HOME || homedir(); +} diff --git a/apps/node/agent/src/commands/tail.ts b/apps/node/agent/src/commands/tail.ts index 16d6a4df8..0557ed818 100644 --- a/apps/node/agent/src/commands/tail.ts +++ b/apps/node/agent/src/commands/tail.ts @@ -35,8 +35,8 @@ export const TAIL_BACKPRESSURE_BYTES = 512 * 1024; /** Safety net for missed watch events — same value the backend's log-tail pump uses. */ export const TAIL_POLL_MS = 50; -/** Re-check cadence inside the backpressure wait. */ -const TAIL_BACKPRESSURE_POLL_MS = 50; +/** Re-check cadence inside the backpressure wait. Exported for the session pump, which throttles the same shape. */ +export const TAIL_BACKPRESSURE_POLL_MS = 50; /** Consecutive `send` throws that self-stop a sub (a dead ws must not be pumped into forever). */ const TAIL_SEND_FAILURE_LIMIT = 2; @@ -163,10 +163,17 @@ export async function execTailStart(ctx: CommandContext, cmd: Cmd<"tail_start">) pumping = false; return; // disposed mid-read: the bytes belong to the next subscriber (fresh fromByte) } + // The transport's own ceiling (review C1b): a session runtime sets + // `ctx.outputChunkCeilingBytes` to the size its frame codec can carry; + // the plain node daemon leaves it unset and keeps TAIL_CHUNK_BYTES. + // Chunking HERE, at the source, is what keeps every emitted event one + // encodable frame without splitting inside the writer (which would + // desync the fromByte/toByte bookkeeping below). + const chunkBytes = Math.min(TAIL_CHUNK_BYTES, ctx.outputChunkCeilingBytes ?? TAIL_CHUNK_BYTES); // `cursor` walks the slice independently of `last`: `last` only moves on // a successful send (retry point), so fromByte must not be `last + off`. - for (let off = 0, cursor = last; off < bytes.byteLength && !stopped; off += TAIL_CHUNK_BYTES) { - const n = Math.min(TAIL_CHUNK_BYTES, bytes.byteLength - off); + for (let off = 0, cursor = last; off < bytes.byteLength && !stopped; off += chunkBytes) { + const n = Math.min(chunkBytes, bytes.byteLength - off); const fromByte = cursor; const toByte = cursor + n; // Throttle on socket backpressure before EVERY event (spec §3.1 mirror); diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 9cb136e71..5d6a578d2 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -17,6 +17,7 @@ import { backoffDelay } from "./backoff.js"; import type { CommandContext, CommandResult, CommandWs } from "./commands/context.js"; import { dispatchCommand } from "./commands/index.js"; import { buildSubshellsReport, maybeReportMaintenance, seedMaintenanceMemo } from "./commands/report.js"; +import { drainBrokeredSessions } from "./commands/ssh-session.js"; import { stopAllTails } from "./commands/tail.js"; import { sweepStaleTransfers } from "./commands/transfer-sweep.js"; import { cleanupStaleUploads } from "./commands/write-file.js"; @@ -739,7 +740,11 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom send(ws, { type: "result", ref: claims.jti, ...cached }); return; } - const result = await dispatchCommand(ctx, claims.cmd); + // The gate names THIS command's socket: the executor whose effect + // outlives the RPC (brokered-session open, review m-B) asks whether the + // delivering socket is still the daemon's live one before letting its + // child stand. + const result = await dispatchCommand(ctx, claims.cmd, { isCurrent: () => currentWs === ws }); idempotent.set(claims.jti, result); if (idempotent.size > IDEMPOTENCE_CAP) { const oldest = idempotent.keys().next().value as string | undefined; @@ -918,6 +923,26 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // the reconnect loop dials again (the control plane re-`tail_start`s // on the new connection with its own cursors; spec §3.4). stopAllTails(ctx); + // The same rule one layer deeper (review M1): a link close is + // TERMINAL for the brokered SSH sessions riding it - the plane marks + // them lost and never resumes them - so a child that outlives this + // socket is an orphan holding a quota slot and the destination's + // callback door for every future open there. Drain them through the + // supervisor's own close (group-kill; the destination's tmux and + // panes stay up for the next session's census - design §6). A node + // that never brokered pays nothing: this never builds a supervisor. + // The drain can throw nothing onto this path (review m-A): every ref + // already carries its own guard inside `drainBrokeredSessions`, and + // this is the outer belt the file's own rule demands - "never throws + // by contract, a broken sweep must not cost the node its + // connection". A throw that reached here would skip `resolve(close)` + // and the reconnect loop would never dial again. + try { + const drained = drainBrokeredSessions(config.dataDir); + if (drained > 0) log(`link closed: drained ${drained} brokered SSH session(s)`); + } catch (err) { + log(`brokered-session drain failed: ${err instanceof Error ? err.message : String(err)}`); + } resolve(close); }; // The per-socket link negotiator (spec 2026-09-24). It owns the wire diff --git a/apps/node/agent/src/input-generation.ts b/apps/node/agent/src/input-generation.ts new file mode 100644 index 000000000..e77bbb618 --- /dev/null +++ b/apps/node/agent/src/input-generation.ts @@ -0,0 +1,179 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { log } from "./log.js"; +import { isSubshellId } from "./subshell-meta.js"; + +/** + * The node's mirror of each pane's INPUT GENERATION. + * + * The plane owns the counter and raises it on every takeover/revocation + * transition; this mirror is what makes the fence a MACHINE fact rather than + * a plane promise: an input/prompt write that carries the additive + * `inputGeneration` field is checked here, and a write below the mirror's + * current value is refused, after any queueing, after a reconnect, and after + * an agent restart (the mirror is persisted beside the pane meta for exactly + * that reason - an agent that forgot its generations on restart would + * un-fence everything a takeover just fenced). + * + * The destination product's managed panes retired with it (design + * 2026-10-05 §7): the `ssh_input_control` and `ssh_terminal_launch` commands + * that wrote this store are gone, so nothing records a generation today, and + * the deny-by-construction rule below makes every pane an ordinary pane. The + * fence stays - the wire field, the frozen refusal, and the monotonic mirror + * are the seam the NEXT control-transition feature adopts instead of + * reinventing. + * + * **Deny by construction:** a pane with NO record is an ordinary pane and the + * policy does not apply to it (every existing input path is untouched). A pane + * WITH a record is managed, and a managed write carrying no generation, or one + * below the record, is refused with the frozen + * `NODE_RESULT_SSH_GENERATION_STALE` spelling. A generation ABOVE the record + * is accepted: the contract ("every managed input write carries at least this + * value") reads the plane's ordered link as the guarantor that the transition + * lands before or with the input it fences, and accepting equal-or-higher + * cannot un-fence anything - the only values that LOSE the fence are below, + * and they are refused. + * + * Deliberately dumb about WHO set the value: the command signature already + * proved the control plane sent it. What the plane must not be able to do is + * LOWER a mirror - the transition handlers refuse a lowering; this module's + * `record` is the monotonic setter they share. + */ + +/** On-disk file name inside the agent data dir (beside the pane metas). */ +const FILE = "ssh-input-generations.json"; + +/** Temp-name counter: two records inside one process never write the same temp. */ +let persistSeq = 0; + +/** On-disk shape. Versioned so a future format change is detectable, not misread. */ +interface GenerationsFile { + version: 1; + generations: Record; +} + +export class InputGenerationStore { + private readonly file: string; + private readonly dataDir: string; + /** Live counters by subshell id. Loaded once from disk; every change rewrites the file synchronously. */ + private readonly gens = new Map(); + private loaded = false; + + constructor(dataDir: string) { + this.dataDir = dataDir; + this.file = join(dataDir, FILE); + } + + /** Read the persisted mirror on first use. A corrupt file is treated as EMPTY and rewritten on the next record. */ + private ensureLoaded(): void { + if (this.loaded) return; + this.loaded = true; + if (!existsSync(this.file)) return; + try { + const parsed: unknown = JSON.parse(readFileSync(this.file, "utf8")); + const record = (parsed as Partial)?.generations; + if (typeof record !== "object" || record === null) throw new SyntaxError("missing generations map"); + for (const [id, value] of Object.entries(record)) { + // A persisted id that no longer passes the id gate is dropped rather + // than trusted: the same guard the live record() path enforces. + if (!isSubshellId(id)) continue; + if (typeof value === "number" && Number.isInteger(value) && value >= 1) { + this.gens.set(id, value); + } + } + } catch (err) { + log(`ssh input generations: ${this.file} unreadable; starting with no managed panes`); + void err; + } + } + + /** + * The pane's current generation, or null when this pane has NO record (the + * policy does not apply - an ordinary pane). + */ + current(subshellId: string): number | null { + this.ensureLoaded(); + return this.gens.get(subshellId) ?? null; + } + + /** + * Establish or RAISE a pane's generation (the seam a control-transition + * feature writes through). Monotonic: a lower value is refused by + * returning the still-current one, so a replayed old transition cannot + * un-fence input (the plane's counter is authoritative; the mirror only ever + * moves forward). + * @returns the generation in force after the call + * @throws when `subshellId` is not a valid id (the store-throwing-id rule) + */ + record(subshellId: string, generation: number): number { + if (!isSubshellId(subshellId)) throw new Error("invalid subshell id"); + if (!Number.isInteger(generation) || generation < 1) throw new Error("generation must be a positive integer"); + this.ensureLoaded(); + const had = this.gens.get(subshellId) ?? 0; + if (generation > had) { + this.gens.set(subshellId, generation); + this.persist(); + } + return Math.max(had, generation); + } + + /** + * Drop a pane's record. Idempotent; the file rewrite is skipped when + * nothing was there. The handlers this was written for (managed-pane delete + * and log cleanup) retired with the destination product, so like `record()` + * it is currently unwired: both stand ready as the seam the next + * control-transition feature writes through. + */ + forget(subshellId: string): void { + if (!isSubshellId(subshellId)) return; // id gate first; a junk id never had a record + this.ensureLoaded(); + if (this.gens.delete(subshellId)) this.persist(); + } + + /** + * The fence verdict for one input/prompt write: `"ok"` (ordinary pane, or a + * generation at/above the mirror), `"stale"` (managed pane, missing or lower + * generation - refuse with the frozen wire spelling). A missing id-shaped + * check is the CALLER's (`resolveSocket` owns the bad-id answer); a lookup + * for any string is total. + */ + check(subshellId: string, inputGeneration: number | undefined): "ok" | "stale" { + const current = this.current(subshellId); + if (current === null) return "ok"; // ordinary pane: no generation policy + if (inputGeneration === undefined || inputGeneration < current) return "stale"; + return "ok"; + } + + /** temp + rename at 0600, the `allowed-dirs.ts` discipline verbatim. */ + private persist(): void { + const body: GenerationsFile = { version: 1, generations: Object.fromEntries(this.gens) }; + const tmp = `${this.file}.${process.pid}.${++persistSeq}.tmp`; + mkdirSync(this.dataDir, { recursive: true, mode: 0o700 }); + writeFileSync(tmp, `${JSON.stringify(body)}\n`, { mode: 0o600 }); + renameSync(tmp, this.file); + } +} + +/** Process-wide stores by data dir - one daemon, one file; tests pass temp dirs and never collide. */ +const stores = new Map(); + +/** + * The agent's ONE generation mirror for this data dir (module-singleton + * precedent: `getAuth()` - constructed lazily on first use, never at import). + */ +export function getInputGenerationStore(dataDir: string): InputGenerationStore { + let store = stores.get(dataDir); + if (!store) { + store = new InputGenerationStore(dataDir); + stores.set(dataDir, store); + } + return store; +} + +/** + * @internal Test isolation: forgets the memoized store for a data dir so a + * fresh instance re-reads the file. + */ +export function resetInputGenerationStoresForTests(): void { + stores.clear(); +} diff --git a/apps/node/agent/src/runtime/__tests__/callback-sock-timeout.test.ts b/apps/node/agent/src/runtime/__tests__/callback-sock-timeout.test.ts new file mode 100644 index 000000000..acc1680ee --- /dev/null +++ b/apps/node/agent/src/runtime/__tests__/callback-sock-timeout.test.ts @@ -0,0 +1,134 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type CallbackRequest, paneCallbackSockPath, startCallbackDoors } from "../callback-sock.js"; + +/** + * The callback door's 504 (design 2026-10-05 §5): a request whose + * `rest_response` never arrives from the plane is answered `504 + * runtime_callback_timeout` when {@link CALLBACK_TIMEOUT} elapses - "the + * plane stopped answering, which is exactly what a lost session looks like + * from the pane side". + * + * The timeout constant is module-private and not injectable, so the test + * captures the scheduled callback through a narrow `globalThis.setTimeout` + * shim (only a `setTimeout(fn, 30000)` is taken; every other timer passes + * through to the real one), and fires it by hand. The real timer is restored + * when the capture scope unwinds - the `finally` after the test body, NOT + * before the response await inside it; the response delivery is I/O, so no + * timer stands between the fired callback and the 504. No source seam, no + * 30-second wall-clock wait. The captured ms value doubles as the pin that + * the door's budget is still 30 s. + */ + +/** The door's own timeout budget, asserted on the captured schedule (module doc's contract). */ +const CALLBACK_TIMEOUT = 30_000; + +const dirs: string[] = []; +function freshDataDir(): string { + const dir = mkdtempSync(join(tmpdir(), "subshell-cb504-")); + dirs.push(dir); + return dir; +} + +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +const realSetTimeout = globalThis.setTimeout; + +/** One door request over the real unix socket, the same client the pane's MCP child uses. */ +function ask(sockPath: string, path: string): Promise { + return fetch(`http://runtime${path}`, { unix: sockPath } as unknown as RequestInit & { unix: string }); +} + +async function waitSeen(seen: CallbackRequest[]): Promise { + const t0 = Date.now(); + while (seen.length < 1) { + if (Date.now() - t0 > 2000) throw new Error("the request never reached the door"); + await new Promise((r) => realSetTimeout(r, 10)); + } +} + +interface CapturedTimer { + ms: number | undefined; + fire(): void; +} + +/** Install the capture shim for one test body; the real timer is restored after. */ +async function withCapturedTimeout(run: (captured: CapturedTimer[]) => Promise): Promise { + const captured: CapturedTimer[] = []; + globalThis.setTimeout = ((cb: unknown, ms?: number | string, ...rest: unknown[]) => { + if (typeof cb === "function" && ms === CALLBACK_TIMEOUT) { + let cleared = false; + captured.push({ + ms, + fire: () => { + if (!cleared) { + cleared = true; + (cb as () => void)(); + } + }, + }); + return { unref: () => {} } as unknown as ReturnType; + } + return (realSetTimeout as (cb: unknown, ms: unknown, ...r: unknown[]) => unknown)(cb, ms, ...rest) as ReturnType< + typeof setTimeout + >; + }) as typeof setTimeout; + try { + await run(captured); + } finally { + globalThis.setTimeout = realSetTimeout; + } +} + +describe("callback door timeout", () => { + test("an unanswered request answers 504 at the 30 s budget, and a late plane answer is dropped", async () => { + const dataDir = freshDataDir(); + const seen: CallbackRequest[] = []; + const doors = await startCallbackDoors(dataDir, (req) => seen.push(req)); + try { + await withCapturedTimeout(async (captured) => { + const inflight = ask(doors.sharedPath, "/api/subshells/x"); + await waitSeen(seen); + expect(captured.length, "the door scheduled exactly one timeout for the request").toBe(1); + expect(captured[0]?.ms).toBe(CALLBACK_TIMEOUT); + captured[0]?.fire(); + const res = await inflight; // the shim is still installed (restore is the scope's finally); delivery is I/O, not a timer + expect(res.status).toBe(504); + expect(await res.json()).toEqual({ error: "runtime_callback_timeout" }); + // The pending entry was evicted with the 504; the plane's late answer + // for that reqId finds nothing and must not throw or double-answer. + doors.resolve((seen[0] as CallbackRequest).reqId, 200, "{}"); + }); + } finally { + await doors.stop(); + } + }); + + test("the timeout races the plane's answer once: whoever settles first wins, the other is dropped", async () => { + const dataDir = freshDataDir(); + const pane = "11111111-1111-4111-8111-111111111111"; + const seen: CallbackRequest[] = []; + const doors = await startCallbackDoors(dataDir, (req) => seen.push(req)); + try { + await doors.ensurePane(pane); + await withCapturedTimeout(async (captured) => { + // Answer first; the still-pending timeout then fires against an + // evicted entry (the guarded `settle`): the pane keeps the 200. + const inflight = ask(paneCallbackSockPath(dataDir, pane), `/api/subshells/${pane}/attention`); + await waitSeen(seen); + expect(captured.length).toBe(1); + doors.resolve((seen[0] as CallbackRequest).reqId, 200, JSON.stringify({ ok: true })); + captured[0]?.fire(); + const res = await inflight; + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); + } finally { + await doors.stop(); + } + }); +}); diff --git a/apps/node/agent/src/runtime/__tests__/callback-sock.test.ts b/apps/node/agent/src/runtime/__tests__/callback-sock.test.ts new file mode 100644 index 000000000..80d5f148a --- /dev/null +++ b/apps/node/agent/src/runtime/__tests__/callback-sock.test.ts @@ -0,0 +1,209 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type CallbackRequest, + LiveCallbackDoorError, + PANE_CALLBACK_DIR, + paneCallbackSockPath, + startCallbackDoors, +} from "../callback-sock.js"; + +/** + * The callback doors (design 2026-10-05 §5, task 25): the shared door answers + * un-attributed (the slice's rule), a pane door answers AS its pane (the + * per-connection attribution the plane executes with), and every door is a + * 0600 unix socket with no TCP bind. Requests are driven with Bun's own + * `fetch({ unix })` - the same client the pane's `subshell mcp` will use. + */ + +const dirs: string[] = []; +function freshDataDir(): string { + const dir = mkdtempSync(join(tmpdir(), "subshell-cb-")); + dirs.push(dir); + return dir; +} +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +const PANE_A = "11111111-1111-4111-8111-111111111111"; +const PANE_B = "22222222-2222-4222-8222-222222222222"; + +/** One door request, fire-and-awaitable: the caller answers it via `doors.resolve`. */ +function ask(sockPath: string, path: string, method = "GET", body?: string): Promise { + return fetch(`http://runtime${path}`, { + unix: sockPath, + method, + ...(body !== undefined ? { body } : {}), + } as RequestInit & { unix: string }); +} + +/** Wait until `cond()` holds (bun's unix fetch delivers on the microtask; asserts must follow, not lead). */ +async function waitUntil(cond: () => boolean): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > 2000) throw new Error("callback request never reached the door"); + await new Promise((r) => setTimeout(r, 10)); + } +} + +describe("callback doors", () => { + test("the shared door relays un-attributed requests and answers them by reqId", async () => { + const dataDir = freshDataDir(); + const seen: CallbackRequest[] = []; + const doors = await startCallbackDoors(dataDir, (req) => seen.push(req)); + try { + const inflight = ask(doors.sharedPath, "/api/subshells/x"); + await waitUntil(() => seen.length === 1); + const req = seen[0] as CallbackRequest; + expect(req.paneId).toBeUndefined(); // the shared door claims nothing (the plane's rule for it: one-pane sessions only) + expect(req.path).toBe("/api/subshells/x"); + doors.resolve(req.reqId, 200, JSON.stringify({ ok: true })); + const res = await inflight; + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + // The socket IS the ACL: 0600, and the file sits where the contract says. + expect(statSync(doors.sharedPath).mode & 0o777).toBe(0o600); + } finally { + await doors.stop(); + } + expect(() => statSync(join(dataDir, "callback.sock"))).toThrow(); // stop unlinks + }); + + test("pane doors attribute by connection, one reqId space answers across doors", async () => { + const dataDir = freshDataDir(); + const seen: CallbackRequest[] = []; + const doors = await startCallbackDoors(dataDir, (req) => seen.push(req)); + try { + await doors.ensurePane(PANE_A); + await doors.ensurePane(PANE_B); + await doors.ensurePane(PANE_A); // idempotent: a second ensure is not a second bind + + const a = ask(paneCallbackSockPath(dataDir, PANE_A), `/api/subshells/${PANE_A}/attention`); + const b = ask(paneCallbackSockPath(dataDir, PANE_B), `/api/subshells/${PANE_B}/attention`); + await waitUntil(() => seen.length === 2); + const byPane = new Map(seen.map((r) => [r.paneId, r])); + expect(byPane.get(PANE_A)?.path).toBe(`/api/subshells/${PANE_A}/attention`); + expect(byPane.get(PANE_B)?.path).toBe(`/api/subshells/${PANE_B}/attention`); + // resolve is door-blind: the reqId names the waiter, whichever door it came from. + const reqA = seen.find((r) => r.paneId === PANE_A) as CallbackRequest; + const reqB = seen.find((r) => r.paneId === PANE_B) as CallbackRequest; + doors.resolve(reqB.reqId, 403, JSON.stringify({ error: "forbidden" })); + doors.resolve(reqA.reqId, 200, "{}"); + expect((await b).status).toBe(403); + expect((await a).status).toBe(200); + + // The pane door path is the plane's composed template, byte for byte + // (`/callbacks/.sock`), and 0600 like the shared one. + expect(paneCallbackSockPath("/d", "id1")).toBe(join("/d", PANE_CALLBACK_DIR, "id1.sock")); + expect(statSync(paneCallbackSockPath(dataDir, PANE_A)).mode & 0o777).toBe(0o600); + + // dropPane unlinks; a later request on the dead door gets refused, + // which is the honest local reading for a retired pane. + await doors.dropPane(PANE_A); + expect(() => statSync(paneCallbackSockPath(dataDir, PANE_A))).toThrow(); + } finally { + await doors.stop(); + } + }); + + test("a malformed id opens no door; the POST body arrives text and the method rides", async () => { + const dataDir = freshDataDir(); + const seen: CallbackRequest[] = []; + const doors = await startCallbackDoors(dataDir, (req) => seen.push(req)); + try { + await doors.ensurePane("has space"); // the execLaunch id gate, same rule + expect(seen.length).toBe(0); + const inflight = ask(doors.sharedPath, "/api/subshells/x/input", "POST", '{"data":"ls"}'); + await waitUntil(() => seen.length === 1); + expect(seen[0]?.method).toBe("POST"); + expect(seen[0]?.body).toBe('{"data":"ls"}'); + doors.resolve((seen[0] as CallbackRequest).reqId, 200, ""); + expect((await inflight).status).toBe(200); + // An answer for a req nobody waited on is dropped, not a throw. + doors.resolve("nope", 200, ""); + } finally { + await doors.stop(); + } + }); + + /** + * The I5 collision (review 2026-10-06, mechanism VERIFIED against this + * code): `runtimeDataDir` is destination-deterministic, and a second live + * session's serve RE-CREATES every door the meta store names at startup + * (`serve.ts`'s reconcile loop) with the plain unlink-then-bind rule. + * Unlinking a LIVE socket the FIRST session's panes are dialing moves + * those panes' callbacks onto the second session's channel, where the + * plane never issued their tokens: every callback 403s and the first + * session's panes lose MCP until it re-attaches. The bind must therefore + * PROBE: a path with a live listener is not stale, and a door whose path + * is live elsewhere is refused, not stolen. A path nobody is listening on + * (the killed-previous-serve case below, and the whole point of §6's + * reconciliation) still unlinks and binds. + */ + test("a colliding serve dies at the shared door and steals nothing, pane doors intact (I5)", async () => { + const dataDir = freshDataDir(); + const first: CallbackRequest[] = []; + const secondSeen: CallbackRequest[] = []; + const doorsA = await startCallbackDoors(dataDir, (r) => first.push(r)); + await doorsA.ensurePane(PANE_A); + // The loser's startup bind fails at the shared door (bound FIRST in + // `startCallbackDoors`), so its reconcile loop never runs and no pane + // door can be unlinked out from under the live serve. The failure is the + // NAMED kind (m2): `serve.ts` maps it to the busy exit and nothing else, + // so the refusal must be distinguishable from a plain throw. + const loser = await startCallbackDoors(dataDir, (r) => secondSeen.push(r)).then( + () => null, + (err: unknown) => err, + ); + expect(loser).toBeInstanceOf(LiveCallbackDoorError); + expect((loser as Error).message).toMatch(/live/i); + try { + const inflight = ask(paneCallbackSockPath(dataDir, PANE_A), `/api/subshells/${PANE_A}/attention`); + await waitUntil(() => first.length === 1); // the live door still answers its own session + expect(secondSeen.length, "the refused serve touched no door").toBe(0); + doorsA.resolve((first[0] as CallbackRequest).reqId, 200, "{}"); + expect((await inflight).status).toBe(200); + } finally { + await doorsA.stop(); + } + }); + + test("the shared door of a live serve is refused, not re-bound, by a second one (I5)", async () => { + const dataDir = freshDataDir(); + const doorsA = await startCallbackDoors(dataDir, () => {}); + try { + // The second serve's startup bind of `/callback.sock` must + // FAIL while the first listens: the bind site is the ONE enforcement + // point (there is no plane-side second-session gate to have "missed" - + // a pre-open refusal would break the reopen-and-adopt journey, see the + // probe's header note), the death is PRE-hello, and the broker names + // it `session_in_use` off the serve's exit code (m2). The path must + // not move onto a channel no pane was baked into. + await expect(startCallbackDoors(dataDir, () => {})).rejects.toThrow(/live/i); + } finally { + await doorsA.stop(); + } + // With the first gone, the path is a plain stale file: the next bind + // takes it (the reconcile-on-reopen path the design REQUIRES). + const doorsC = await startCallbackDoors(dataDir, () => {}); + await doorsC.stop(); + }); + + test("a stale socket file from a killed previous serve does not block the bind", async () => { + const dataDir = freshDataDir(); + const seen: CallbackRequest[] = []; + writeFileSync(join(dataDir, "callback.sock"), "not a live listener"); // unlink-on-start must clear it + const doors = await startCallbackDoors(dataDir, (req) => seen.push(req)); + try { + const inflight = ask(doors.sharedPath, "/api/subshells/x"); // never answered: the bind working is the fact under test + await waitUntil(() => seen.length === 1); + doors.resolve((seen[0] as CallbackRequest).reqId, 200, ""); + expect((await inflight).status).toBe(200); // the request arrived on the freshly bound door + } finally { + await doors.stop(); + } + }); +}); diff --git a/apps/node/agent/src/runtime/__tests__/dispatch.test.ts b/apps/node/agent/src/runtime/__tests__/dispatch.test.ts new file mode 100644 index 000000000..7f3e31cb4 --- /dev/null +++ b/apps/node/agent/src/runtime/__tests__/dispatch.test.ts @@ -0,0 +1,153 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CommandContext } from "../../commands/context.js"; +import type { NodeConfig } from "../../config.js"; +import { SubshellMetaStore } from "../../subshell-meta.js"; +import { runRuntimeCommand } from "../dispatch.js"; + +/** + * The runtime dispatcher's task-25 arms. `detect` must answer through the + * AGENT'S OWN executor (plane-parses-version posture, inversion §4: raw text + * out, never a plugin interpretation), and it answers ONLY what was asked - + * the "plane asks; the runtime answers" rule the node link pins and the + * runtime inherits. Unknown frames keep the named `unsupported` refusal (a + * detect aimed at a dispatcher that cannot run it is a refusal, not silence). + */ + +const dataDir = mkdtempSync(join(tmpdir(), "subshell-dispatch-")); + +// The temp dir is LIVE while the tests run: bun evaluates the whole file +// before executing any test, so a top-level `rmSync` at the foot of the file +// deleted it before the first test started. Cleanup belongs in `afterAll`. +afterAll(() => { + rmSync(dataDir, { recursive: true, force: true }); +}); + +function makeCtx(): CommandContext { + const config: NodeConfig = { + serverUrl: "", + nodeId: "runtime:test", + nodeKey: "", + controlPublicKey: "", + dataDir, + name: "runtime-serve", + }; + return { + config, + tmux: {} as CommandContext["tmux"], + meta: new SubshellMetaStore(dataDir), + nowMs: () => 1_700_000_000_000, + ws: { send: () => {} }, + watchers: new Map(), + tails: new Map(), + uploads: new Map(), + runtime: null, + requestRestart: () => {}, + }; +} + +describe("runtime dispatch: detect", () => { + test("answers the specs' raw rows and ONLY the named env", async () => { + const result = await runRuntimeCommand(makeCtx(), "subshell-dest", { + type: "detect", + ref: "r-1", + specs: [ + { id: "no-binary-harness", binaryName: "", envOverride: "", knownPaths: [] }, + { id: "missing-harness", binaryName: "definitely-not-installed-xyz", envOverride: "", knownPaths: [] }, + ], + envNames: ["PATH", "SUBSHELL_DISPATCH_TEST_UNSET"], + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const data = result.data as { results: unknown[]; env: Record }; + expect(data.results).toEqual([ + { harnessId: "no-binary-harness", installed: false, reason: "no-binary" }, + { harnessId: "missing-harness", installed: false, reason: "not-on-path" }, + ]); + // Only asked, only present: PATH exists in every test env; the sentinel + // name must not appear at all (the answer never scans). + expect(Object.keys(data.env)).toEqual(["PATH"]); + }); + + test("a malformed specs element (deep grammar) is refused by name before any probe", async () => { + const result = await runRuntimeCommand(makeCtx(), "subshell-dest", { + type: "detect", + ref: "r-2", + // Shallow-parsed by the session frame parser; the DEEP node-link grammar + // is re-run here, and `binaryName: 5` must die at the parse, not reach + // detectBinary. + specs: [{ id: "x", binaryName: 5 } as never], + envNames: [], + }); + expect(result).toEqual({ ok: false, error: "unsupported" }); + }); +}); + +describe("runtime dispatch: named refusals unchanged", () => { + test("an unknown frame type answers the bare unsupported code", async () => { + const result = await runRuntimeCommand(makeCtx(), "subshell-dest", { + type: "teleport" as never, + ref: "r-3", + }); + expect(result).toEqual({ ok: false, error: "unsupported" }); + }); +}); + +describe("runtime dispatch: probe resolves the launch-recorded socket", () => { + /** + * The runtime's panes live on the ONE destination-wide socket (design + * 2026-10-05 §6), but `execProbe` used to re-derive the node link's + * per-subshell socket from the id - so a probe answered `alive: false` for + * a running runtime pane, and the live-terminal attach (whose first step + * is the probe) closed every runtime browser at 4004 "subshell not + * running". The fix is the shared `resolveSocket`: the launch record's + * socket wins, the derivation stays the orphan fallback. + */ + test("a pane on the destination socket probes alive; the derived socket would not", async () => { + const ctx = makeCtx(); + const id = crypto.randomUUID(); + // The launch record: what `execLaunch` writes for a runtime pane. + await ctx.meta.record({ + subshellId: id, + cwd: "/home/dst/work", + socket: "subshell-dest", + harnessId: "terminal", + name: id, + startedAt: new Date(1_700_000_000_000).toISOString(), + }); + const askedSockets: string[] = []; + ctx.tmux = { + hasSubshell: async (socket: string, subId: string) => { + askedSockets.push(socket); + // Destination truth: the pane exists on the destination socket only. + return socket === "subshell-dest" && subId === id; + }, + paneExitCode: async () => null, + paneTitle: async () => null, + capturePane: async () => "SCREEN", + } as unknown as CommandContext["tmux"]; + const result = await runRuntimeCommand(ctx, "subshell-dest", { type: "probe", ref: "r-4", subshellIds: [id] }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data).toEqual([{ subshellId: id, alive: true, exitCode: null, capture: "SCREEN" }]); + expect(askedSockets[0]).toBe("subshell-dest"); // the record, not `subshell-` + }); + + test("an id with no record falls back to the derivation (orphan posture, unchanged)", async () => { + const ctx = makeCtx(); + const id = crypto.randomUUID(); + const asked: string[] = []; + ctx.tmux = { + hasSubshell: async (socket: string) => { + asked.push(socket); + return false; + }, + paneExitCode: async () => null, + } as unknown as CommandContext["tmux"]; + const result = await runRuntimeCommand(ctx, "subshell-dest", { type: "probe", ref: "r-5", subshellIds: [id] }); + expect(result.ok).toBe(true); + expect(asked[0]).not.toBe("subshell-dest"); // the fallback derivation + }); +}); diff --git a/apps/node/agent/src/runtime/__tests__/serve-busy-exit.test.ts b/apps/node/agent/src/runtime/__tests__/serve-busy-exit.test.ts new file mode 100644 index 000000000..e48c1f7e4 --- /dev/null +++ b/apps/node/agent/src/runtime/__tests__/serve-busy-exit.test.ts @@ -0,0 +1,140 @@ +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SSH_RUNTIME_SERVE_IN_USE_EXIT } from "@internal/subshell-protocol"; +import { startCallbackDoors } from "../callback-sock.js"; +import { runRuntimeServe } from "../serve.js"; +import * as stdioModule from "../stdio.js"; + +/** + * The occupied-destination signal (review m2): a second `runtime-serve` on a + * destination that already carries a live session dies at the SHARED door's + * bind, and that death must exit {@link SSH_RUNTIME_SERVE_IN_USE_EXIT} - the + * code the broker's classifier names `session_in_use` - while every OTHER + * bootstrap posture keeps its own reading. The stdio pair is mocked the way + * `serve-throw-drop.test.ts` mocks it (the test process's stdin/stdout are + * not the session); the doors are the real thing, because the bind is what + * this suite is about. + * + * - live listener on `/callback.sock` → the named busy exit (before + * the fix this was a bare throw the CLI turned into exit 1, which the + * classifier read as "binary missing" on a machine running the binary); + * - a fresh dir → hello, transport close, exit 0 (the busy code is for the + * bind refusal, not for booting); + * - a STALE socket file (inode with no listener) → unlink-and-bind, exit 0 + * (the reconcile-on-reopen path the design requires must not read busy). + */ + +const realStdio = { ...stdioModule }; + +class RecordingWriter { + get bufferedAmount(): number { + return 0; + } + writeFrame(frame: unknown): void { + written.push(frame); + } +} +const written: unknown[] = []; +let diags: string[] = []; + +interface Captured { + onFrame(raw: unknown): void; + onDone(reason: string): void; +} +/** Every `startStdinReader` the serve loop made (the busy path must make none). */ +let captures: Captured[] = []; + +const dirs: string[] = []; +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + dirs.push(dir); + return dir; +} + +async function pollUntil(what: string, cond: () => boolean): Promise { + for (let i = 0; i < 200; i++) { + if (cond()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`pollUntil timed out: ${what}`); +} + +beforeAll(() => { + mock.module("../stdio.js", () => ({ + ...realStdio, + redirectConsoleToStderr: () => {}, + RuntimeWriter: RecordingWriter, + diag: (line: string) => { + diags.push(line); + }, + startStdinReader: (onFrame: (raw: unknown) => void, onDone: (reason: string) => void) => { + captures.push({ onFrame, onDone }); + }, + })); +}); + +afterAll(async () => { + mock.module("../stdio.js", () => realStdio); + expect(stdioModule.startStdinReader).toBe(realStdio.startStdinReader); + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +describe("runtime-serve busy exit (m2)", () => { + test("a destination with a LIVE door exits with the named busy code, not a throw", async () => { + const dataDir = tempDir("subshell-busy-live-"); + const holder = await startCallbackDoors(dataDir, () => {}); + written.length = 0; + diags = []; + captures = []; + try { + const code = await runRuntimeServe({ + session: crypto.randomUUID(), + tmuxSocket: "subshell-serve-busy-test", + dataDir, + }); + expect(code).toBe(SSH_RUNTIME_SERVE_IN_USE_EXIT); + expect(written, "no hello: the serve died before the transport opened").toEqual([]); + expect(captures.length, "no stdin reader: bootstrap refused first").toBe(0); + expect( + diags.some((l) => /live|session/i.test(l)), + "the diag line names what refused", + ).toBe(true); + } finally { + await holder.stop(); + } + }); + + test("a fresh destination serves and exits 0 on transport close (busy is for the bind refusal only)", async () => { + const dataDir = tempDir("subshell-busy-fresh-"); + written.length = 0; + captures = []; + const serve = runRuntimeServe({ + session: crypto.randomUUID(), + tmuxSocket: "subshell-serve-busy-test", + dataDir, + }); + await pollUntil("the reader never captured the serve loop", () => captures.length > 0); + expect(written.some((f) => (f as { type?: string }).type === "hello")).toBe(true); + (captures[0] as Captured).onDone("eof"); + expect(await serve).toBe(0); + }); + + test("a STALE socket file from a dead serve still binds and exits 0 (never reads busy)", async () => { + const dataDir = tempDir("subshell-busy-stale-"); + mkdirSync(join(dataDir, "subshells"), { recursive: true, mode: 0o700 }); + writeFileSync(join(dataDir, "callback.sock"), "an inode nobody listens on"); + written.length = 0; + captures = []; + const serve = runRuntimeServe({ + session: crypto.randomUUID(), + tmuxSocket: "subshell-serve-busy-test", + dataDir, + }); + await pollUntil("the stale-file serve never reached its reader", () => captures.length > 0); + expect(written.some((f) => (f as { type?: string }).type === "hello")).toBe(true); + (captures[0] as Captured).onDone("eof"); + expect(await serve).toBe(0); + }); +}); diff --git a/apps/node/agent/src/runtime/__tests__/serve-throw-drop.test.ts b/apps/node/agent/src/runtime/__tests__/serve-throw-drop.test.ts new file mode 100644 index 000000000..febd9308e --- /dev/null +++ b/apps/node/agent/src/runtime/__tests__/serve-throw-drop.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeAll, expect, mock, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { createConnection } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { paneCallbackSockPath } from "../callback-sock.js"; +import * as dispatchModule from "../dispatch.js"; +import { runRuntimeServe } from "../serve.js"; +import * as stdioModule from "../stdio.js"; + +/** + * M-1 (review wave 2026-10-05): the serve loop opens the launched pane's + * callback door BEFORE dispatch, and BOTH failure arms must roll it back. + * The `ok:false` arm always did; a throwing executor escaped through the + * chain's `.catch`, which answered `runtime-command-failed` but left the door + * standing over a pane that never spawned. This suite drives the real serve + * loop with the two seams the loop needs mocked: the stdio pair (the test + * process's stdin/stdout are not the session) and the dispatcher (the forced + * throw). The door facts are observed on the real filesystem, and the + * dispatch mock records whether the door existed at the moment the executor + * ran - the positive control that the door really opened first. + * + * The mock restores follow the I-1 pattern (snapshot the real namespaces at + * module-eval, restore TO the snapshot, identity-pin in afterAll) - this + * directory's sibling files import the same modules. + */ + +const realStdio = { ...stdioModule }; +const realDispatch = { ...dispatchModule }; + +const SESSION = crypto.randomUUID(); // isSshSessionRef: hex+hyphen, <= 64 +const PANE = crypto.randomUUID(); // isSubshellId: the door's id gate +const dataDir = mkdtempSync(join(tmpdir(), "subshell-serve-throw-")); +const paneSock = paneCallbackSockPath(dataDir, PANE); + +interface Captured { + onFrame(raw: unknown): void; + onDone(reason: string): void; +} +let reader: Captured | undefined; +const written: unknown[] = []; +let doorAtDispatch: boolean | undefined; + +class RecordingWriter { + get bufferedAmount(): number { + return 0; + } + writeFrame(frame: unknown): void { + written.push(frame); + } +} + +async function pollUntil(what: string, cond: () => boolean): Promise { + for (let i = 0; i < 200; i++) { + if (cond()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`pollUntil timed out: ${what}`); +} + +function connectDoor(): Promise<"open" | "refused"> { + return new Promise((resolve) => { + const sock = createConnection({ path: paneSock }); + sock.setTimeout(2000); + sock.once("connect", () => { + sock.destroy(); + resolve("open"); + }); + sock.once("error", () => { + sock.destroy(); + resolve("refused"); + }); + }); +} + +beforeAll(() => { + mock.module("../stdio.js", () => ({ + ...realStdio, + RuntimeWriter: RecordingWriter, + startStdinReader: (onFrame: (raw: unknown) => void, onDone: (reason: string) => void) => { + reader = { onFrame, onDone }; + }, + })); + mock.module("../dispatch.js", () => ({ + ...realDispatch, + runRuntimeCommand: async ( + ctx: unknown, + socket: string, + frame: { type?: string; cmd?: { subshellId?: string } }, + ) => { + if (frame?.type === "launch") { + // Runs AFTER serve opened the door (the dispatch-order fact M-1 is + // about); record, then force the throw the fix must roll back. + doorAtDispatch = existsSync(paneCallbackSockPath(dataDir, String(frame.cmd?.subshellId))); + throw new Error("forced executor throw"); + } + const real = realDispatch.runRuntimeCommand as unknown as (c: unknown, s: string, f: unknown) => Promise; + return real(ctx, socket, frame); + }, + })); +}); + +afterAll(async () => { + mock.module("../stdio.js", () => realStdio); + mock.module("../dispatch.js", () => realDispatch); + expect(stdioModule.startStdinReader).toBe(realStdio.startStdinReader); + expect(dispatchModule.runRuntimeCommand).toBe(realDispatch.runRuntimeCommand); + rmSync(dataDir, { recursive: true, force: true }); +}); + +test("a launch whose executor THREW drops the pane door (M-1 rollback on the catch arm)", async () => { + const serve = runRuntimeServe({ session: SESSION, tmuxSocket: "subshell-serve-throw-test", dataDir }); + await pollUntil("the mocked reader never captured the serve loop's callbacks", () => reader !== undefined); + expect( + written.some((f) => (f as { type?: string }).type === "hello"), + "hello goes out first", + ).toBe(true); + + reader?.onFrame({ + type: "launch", + ref: crypto.randomUUID(), + // Shallow session-frame grammar; the deep body never reaches an executor + // here - the dispatch mock throws before one is consulted. + cmd: { type: "launch", subshellId: PANE }, + }); + + await pollUntil("the serve loop never answered the throwing launch", () => + written.some( + (f) => + (f as { type?: string; ok?: boolean; error?: string }).type === "result" && + (f as { error?: string }).error === "runtime-command-failed", + ), + ); + // The door opened BEFORE dispatch ran (the order the fix preserves), and + // the throw path took it down with the failed launch (the order it adds). + expect(doorAtDispatch, "the pane door must be open when the executor starts").toBe(true); + expect(existsSync(paneSock), "the door file must not outlive the throw").toBe(false); + expect(await connectDoor(), "the door must answer connection-refused after the throw").toBe("refused"); + + reader?.onDone("eof"); + expect(await serve, "the serve loop exits 0 on transport close").toBe(0); +}); diff --git a/apps/node/agent/src/runtime/callback-sock.ts b/apps/node/agent/src/runtime/callback-sock.ts new file mode 100644 index 000000000..fd5d75314 --- /dev/null +++ b/apps/node/agent/src/runtime/callback-sock.ts @@ -0,0 +1,305 @@ +import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs"; +import { createConnection } from "node:net"; +import { join } from "node:path"; +import { isSubshellId } from "../subshell-meta.js"; +import { diag } from "./stdio.js"; + +/** + * The callback unix doors (design 2026-10-05 §5, task 25): listeners at + * `/...`, mode 0600, the ONE listener class permitted by "no + * inbound network listener" - inside the destination OS-account boundary, + * never a TCP bind. + * + * TWO door kinds, one mechanism (Bun's unix-socket HTTP): + * - the SHARED door `/callback.sock` (the slice's surface): whoever + * connects here identifies themselves by the PATH they request, and the + * plane's rule for such frames is unchanged - resolvable only for a + * one-pane session. This is the door the design §9 acceptance curl uses. + * - the PER-PANE doors `/callbacks/.sock` (task 25): the + * runtime creates one when a pane is launched (and re-creates them for the + * meta-store's panes when a session reconciles a live destination), and + * every request accepted on a pane's own door is attributed to that pane by + * the CONNECTION - the door it dialed, not anything it typed. That is what + * lets a multi-pane session execute each callback as its own pane's token + * while NO credential ever crosses the wire: the pane's env names its own + * door (the plane bakes `SUBSHELL_RUNTIME_CALLBACK_SOCK` at launch), and the + * plane maps door -> pane with facts it minted itself. + * + * The requests are relayed as `rest_request` frames (paneId on the pane doors, + * absent on the shared one) and answered by the matching `rest_response` the + * plane sends back over the session. The runtime never interprets the path: + * the ALLOWLIST is the plane's decision (it executes as the pane's own token), + * and forwarding anything else is what keeps this file free of duplicated + * policy. A request that outlives {@link CALLBACK_TIMEOUT_MS} answers 504 - + * the plane stopped answering, which is exactly what a lost session looks + * like from the pane side. + */ + +/** How long one callback waits for the plane's answer before answering 504 (a wedged session must not wedge a pane). */ +const CALLBACK_TIMEOUT_MS = 30_000; + +/** The pane doors' namespace beside the shared socket. */ +export const PANE_CALLBACK_DIR = "callbacks"; + +/** One pane's door path under a runtime data dir - the SAME template the plane composes into the pane env (pinned equal by test on both sides). */ +export function paneCallbackSockPath(dataDir: string, subshellId: string): string { + return join(dataDir, PANE_CALLBACK_DIR, `${subshellId}.sock`); +} + +/** An in-flight callback the serve loop must answer with `rest_response`. */ +export interface CallbackRequest { + reqId: string; + method: string; + path: string; + body?: string; + /** Set when the request arrived on this pane's own door (per-connection attribution); absent on the shared door. */ + paneId?: string; +} + +/** + * The runtime's callback doors. `ensurePane`/`dropPane` manage the per-pane + * namespace; the shared door is opened by the constructor call and lives as + * long as the serve process. `resolve` is one flat surface: reqIds are uuids + * (collision-free across panes), so the plane's answer needs no door naming - + * the owning door is found by the id it carries. + */ +export interface CallbackDoors { + /** Absolute path of the SHARED door (the slice's surface; hello reports the data dir it sits in). */ + readonly sharedPath: string; + /** Open (or keep) one pane's door. Idempotent; a throwing bind logs and leaves the pane un-attributed rather than killing the session. */ + ensurePane(subshellId: string): Promise; + /** Close and unlink one pane's door (terminate/kill/exit; unknown ids are no-ops). */ + dropPane(subshellId: string): Promise; + /** Deliver the plane's answer for one in-flight request (unknown reqIds are dropped: the waiter already 504'd). */ + resolve(reqId: string, status: number, body: string): void; + /** Close every door and unlink every socket file (idempotent). */ + stop(): Promise; +} + +/** Mint a callback request id; uuid keeps it collision-free across panes and reconnects. */ +function newReqId(): string { + return crypto.randomUUID(); +} + +/** + * The bind refused a path another session is LISTENING on (review m2). The + * name is a routing fact, not a nicer message: `serve.ts`'s bootstrap maps + * THIS kind (and no other startup failure) to the busy exit code the broker + * classifies as `session_in_use`, so "the destination is taken" must be + * tellable apart from "the serve broke on the way up". + */ +export class LiveCallbackDoorError extends Error { + constructor(message: string) { + super(message); + this.name = "LiveCallbackDoorError"; + } +} + +/** One bound listener: its own pending map, its own unlink discipline. */ +interface Door { + server: ReturnType | undefined; + readonly path: string; + readonly paneId?: string; + readonly pending: Map; +} + +/** + * Is another process LISTENING on this exact path? A refused connect (or a + * non-socket file, or a timeout - the kernel answers ECONNREFUSED for a + * socket inode whose owner died) is the honest word for STALE, which is the + * file §6's reconciliation exists to clear. A path that ACCEPTS is a live + * door belonging to a session, and stealing it (the I5 collision: a second + * live serve's reconcile loop unlinked the first session's pane doors, so + * every callback moved onto a channel that never issued those panes' tokens + * and 403'd them) is refused instead - HERE, at the bind, and that is the + * whole enforcement. The plane deliberately runs no refuse-a-second-live- + * session gate (review m1: `openSession` asks only the per-node quota). + * The precise reason (round-3 review NIT; the claim here earlier, that a + * pre-open refusal "would misread every reopen", overstated - an + * active-row-keyed gate would pass reopens after lost or closed rows): + * a gate that could actually catch a live-serve collision needs + * DESTINATION truth - who is listening behind this door RIGHT NOW - and + * the plane has none across sessions. Its own rows can read `lost` while + * a serve still holds the door, so any plane-side gate would miss exactly + * the collision it is for. What the plane cannot see, the bind can: both + * serves share the destination's filesystem, and the live listener IS the + * other session's proof of life. + * + * The stated residual (review m1, accepted): probe → unlink → bind is not + * atomic, so two serves starting for one destination within bind distance + * can both pass the probe and the later unlink steals the earlier bind + * (path → new inode; the robbed serve keeps answering only its already- + * connected clients). Narrow by construction - two concurrent OPEN acts on + * one destination, in a single-user instance - and a lockfile dance across + * process starts costs the reconcile path more than the race is worth. + */ +function hasLiveListener(path: string, budgetMs = 250): Promise { + return new Promise((resolve) => { + const socket = createConnection({ path }); + let settled = false; + const settle = (live: boolean): void => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(live); + }; + socket.setTimeout(budgetMs, () => settle(false)); // a silent accept is not an answering door either + socket.once("connect", () => settle(true)); + socket.once("error", () => settle(false)); + }); +} + +/** Bind one door: refuse a LIVE path, unlink a stale one, bind, then PIN the mode to 0600. */ +async function bindDoor( + path: string, + paneId: string | undefined, + onRequest: (req: CallbackRequest) => void, +): Promise { + if (existsSync(path) && (await hasLiveListener(path))) { + throw new LiveCallbackDoorError(`callback socket path has a live listener (another session serves it): ${path}`); + } + if (existsSync(path)) { + try { + unlinkSync(path); + } catch { + // races with the bind error path; nothing to interpret + } + } + const door: Door = { + server: undefined, + path, + ...(paneId !== undefined ? { paneId } : {}), + pending: new Map(), + }; + door.server = Bun.serve({ + unix: path, + // No `port`: a unix socket and nothing else. The Gate A proof asserts the + // serve process listens on no TCP socket at all; adding a port would fail + // that test on purpose. + fetch: async (request) => { + const url = new URL(request.url); + const reqId = newReqId(); + const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.text(); + return await new Promise((resolve) => { + const settle = (status: number, text: string): void => { + // A second settle (timeout raced the plane's answer) must not + // resolve the promise twice: resolve is idempotent, but the map + // delete must stay guarded or the answer arrives after eviction. + if (!door.pending.delete(reqId)) return; + resolve( + new Response(text, { + status, + headers: { "content-type": "application/json" }, + }), + ); + }; + door.pending.set(reqId, { settle }); + const timer = setTimeout(() => { + settle(504, JSON.stringify({ error: "runtime_callback_timeout" })); + }, CALLBACK_TIMEOUT_MS); + (timer as unknown as { unref?: () => void }).unref?.(); + onRequest({ + reqId, + method: request.method, + path: `${url.pathname}${url.search}`, + ...(body !== undefined ? { body } : {}), + ...(door.paneId !== undefined ? { paneId: door.paneId } : {}), + }); + }); + }, + }); + // The socket IS the access control surface: mode 0600 pins it to this OS + // user before any pane could connect (bind creates it 0755-and-umask). + // Acknowledged microsecond window between `listen` and this chmod (the + // pane-log-hygiene posture, restated): the path lives inside a 0700 dir + // that only this OS user can even traverse, so the directory mode is the + // door and the file mode the belt; a same-user connector could race the + // chmod, and a same-user attacker is the accepted boundary (security.md §0). + try { + chmodSync(path, 0o600); + } catch { + await door.server.stop(); + throw new Error(`callback socket mode refused: ${path}`); + } + return door; +} + +/** Close one door: answer its in-flight callbacks 503 (the door is going away under a live request), stop, unlink. */ +async function closeDoor(door: Door): Promise { + for (const [reqId, pending] of [...door.pending]) { + door.pending.delete(reqId); + pending.settle(503, JSON.stringify({ error: "runtime_callback_door_closed" })); + } + try { + await door.server?.stop(true); + } catch { + // already down + } + try { + unlinkSync(door.path); + } catch { + // already unlinked + } +} + +/** + * Open the shared door and return the manager that owns every door from here + * on. The pane-door directory is created on first `ensurePane` (a session + * that never launches a preset pane never makes it). + * + * @param dataDir - the runtime's data dir (0700); the shared socket lands at `/callback.sock` + * @param onRequest - called for each inbound pane callback; the serve loop frames it and awaits the plane + */ +export async function startCallbackDoors( + dataDir: string, + onRequest: (req: CallbackRequest) => void, +): Promise { + const sharedPath = join(dataDir, "callback.sock"); + const paneDir = join(dataDir, PANE_CALLBACK_DIR); + const doors = new Map(); // key: paneId for pane doors, "" for the shared door + doors.set("", await bindDoor(sharedPath, undefined, onRequest)); + + return { + sharedPath, + async ensurePane(subshellId: string): Promise { + if (!isSubshellId(subshellId)) return; // the same id gate execLaunch enforces + if (doors.has(subshellId)) return; + mkdirSync(paneDir, { recursive: true, mode: 0o700 }); + try { + doors.set(subshellId, await bindDoor(paneCallbackSockPath(dataDir, subshellId), subshellId, onRequest)); + } catch (err) { + // A pane whose door will not bind still LAUNCHES (the door is the + // callback surface, not the pane's); the failure is a diagnostic line, + // and its curl gets connection-refused - the honest local reading. + diag( + `runtime: pane callback door refused for ${subshellId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, + async dropPane(subshellId: string): Promise { + const door = doors.get(subshellId); + if (door === undefined) return; + doors.delete(subshellId); + await closeDoor(door); + }, + resolve(reqId: string, status: number, body: string): void { + // Scan the doors: a session holds a handful of panes, and the map's key + // is the reqId. No side routing table to age out is the simpler honest + // structure, and `settle` is self-guarding (it claims the pending entry + // by deleting it), so the timeout race answers once, not twice. + for (const door of doors.values()) { + const pending = door.pending.get(reqId); + if (pending !== undefined) { + pending.settle(status, body); + return; + } + } + }, + async stop(): Promise { + for (const [key, door] of [...doors]) { + doors.delete(key); + await closeDoor(door); + } + }, + }; +} diff --git a/apps/node/agent/src/runtime/dispatch.ts b/apps/node/agent/src/runtime/dispatch.ts new file mode 100644 index 000000000..ad44dfbd3 --- /dev/null +++ b/apps/node/agent/src/runtime/dispatch.ts @@ -0,0 +1,166 @@ +import { parseNodeCommandBody, type SshRuntimeCommandFrame } from "@internal/subshell-protocol"; +import { + execCapture, + execDetect, + execInput, + execKill, + execPaneCursor, + execPaneSize, + execProbe, + execRemovePaths, + execResize, + execStatDir, + execTerminate, +} from "../commands/basics.js"; +import type { CommandContext, CommandResult } from "../commands/context.js"; +import { execFsLs } from "../commands/fs-ls.js"; +import { execLaunch } from "../commands/launch.js"; +import { execLogRead, execTailStart, execTailStop } from "../commands/tail.js"; +import { diag } from "./stdio.js"; + +/** + * The runtime's command dispatcher (design 2026-10-05 §2/§6): the runtime + * answers the SAME command bodies as the node link, under the SAME executors, + * with one substitution - the launch body's `socket` is rewritten to the + * deterministic per-destination socket before `execLaunch` sees it. The + * override is load-bearing: the node link gives every pane its own tmux + * server, and design §6's reconciliation REQUIRES one destination-wide socket + * so a second session finds the first session's panes. Nothing else about the + * body changes; `parseNodeCommandBody`'s launch arm is re-run on the + * substituted object so the deep grammar that the session frame's envelope + * check deliberately skipped is enforced here, at the machine that executes. + * + * Reuse is total on purpose: `execLaunch` (id gate, meta record, pane-log + * attach, exit watcher), `execInput`/`execTerminate`/`execKill`/`execCapture`/ + * `execResize`/`execPaneSize`/`execPaneCursor`/`execProbe`/`execRemovePaths`, + * `execLogRead`/`execTailStart`/`execTailStop`, `execFsLs`/`execStatDir` - + * every promise those files make (input ordering, backpressure pumps, honest + * empty reads) is inherited, not restated. Maintenance and allowlist gates + * read the RUNTIME data dir, which has neither file, so they pass exactly as + * an unconfigured node would - the isolation is a fact about the directory, + * not a bypass in the code. + * + * `subshells_report` is answered here (a census over the meta store + tmux) + * rather than as a node command; `close` and `rest_response` are the serve + * loop's, not the dispatcher's. + */ + +/** A frame the dispatcher cannot answer at all (unknown type, bad launch body). */ +const REFUSE_UNSUPPORTED: CommandResult = { ok: false, error: "unsupported" }; + +/** + * Execute one inbound command frame, minus the serve-loop-owned arms (`close`, + * `rest_response`). The return is what becomes the frame's `result{ref}`. + * + * @param ctx - the runtime's execution context (runtime data dir, shared tmux, meta store, stdout ws adapter) + * @param destSocket - the deterministic per-destination tmux socket every pane is created on (see the module doc) + * @param frame - the already-parsed command frame + */ +export async function runRuntimeCommand( + ctx: CommandContext, + destSocket: string, + frame: SshRuntimeCommandFrame, +): Promise { + switch (frame.type) { + case "launch": { + const body = parseNodeCommandBody(frame.cmd); + if (body === null || body.type !== "launch") return REFUSE_UNSUPPORTED; + // The ONLY substitution: destination socket over the per-subshell one. + // The spread of the narrowed member keeps the `launch` discriminant, so + // `execLaunch` receives exactly the node-link command it was written + // against (that reuse is the whole design of this file). + return await execLaunch(ctx, { ...body, socket: destSocket }); + } + case "input": + return await execInput(ctx, { type: "input", subshellId: frame.subshellId, data: frame.data }); + case "terminate": + return await execTerminate(ctx, { type: "terminate", subshellId: frame.subshellId }); + case "kill": + return await execKill(ctx, { type: "kill", subshellId: frame.subshellId }); + case "capture": + return await execCapture(ctx, { + type: "capture", + subshellId: frame.subshellId, + ...(frame.lines !== undefined ? { lines: frame.lines } : {}), + }); + case "resize": + return await execResize(ctx, { + type: "resize", + subshellId: frame.subshellId, + cols: frame.cols, + rows: frame.rows, + }); + case "pane_size": + return await execPaneSize(ctx, { type: "pane_size", subshellId: frame.subshellId }); + case "pane_cursor": + return await execPaneCursor(ctx, { type: "pane_cursor", subshellId: frame.subshellId }); + case "probe": + return await execProbe(ctx, { type: "probe", subshellIds: frame.subshellIds }); + case "log_read": + return await execLogRead(ctx, { + type: "log_read", + subshellId: frame.subshellId, + fromByte: frame.fromByte, + maxBytes: frame.maxBytes, + }); + case "tail_start": + return await execTailStart(ctx, { + type: "tail_start", + subshellId: frame.subshellId, + subId: frame.subId, + fromByte: frame.fromByte, + }); + case "tail_stop": + return await execTailStop(ctx, { type: "tail_stop", subId: frame.subId }); + case "list_dirs": + return await execFsLs(ctx, { type: "fs_ls", path: frame.path }); + case "stat_dir": + return await execStatDir(ctx, { type: "stat_dir", path: frame.path }); + case "detect": { + // The node link's detect arm re-validated DEEP here (the session frame + // parser's envelope check is shallow, the launch arm's precedent): the + // executor consumes `specs` as lookup rules, so the grammar that names + // every field of a rule must pass before `detectBinary` sees one. The + // executor itself is the agent's - one probe, one answer shape, + // plane-parses-version posture (inversion §4), not a runtime copy. + const body = parseNodeCommandBody({ type: "detect", specs: frame.specs, envNames: frame.envNames }); + if (body === null || body.type !== "detect") return REFUSE_UNSUPPORTED; + return await execDetect(ctx, body); + } + case "remove_paths": + return await execRemovePaths(ctx, { type: "remove_paths", paths: frame.paths }); + case "subshells_report": + return await subshellsCensus(ctx, destSocket); + default: + diag(`runtime: refusing command ${String((frame as { type: string }).type)}`); + return REFUSE_UNSUPPORTED; + } +} + +/** + * The alive/dead census of every pane the runtime tracks (design §2's + * `subshells_report`): the meta store's records, probed on the destination + * socket. tmux that refuses to answer fails the batch like the node link's + * `probe` posture (never report-alive-as-dead on a tmux blip); the census + * caller retries. + */ +async function subshellsCensus(ctx: CommandContext, destSocket: string): Promise { + const metas = await ctx.meta.list(); + const rows: { subshellId: string; alive: boolean; exitCode: number | null }[] = []; + for (const meta of metas) { + const socket = meta.socket || destSocket; + if (!(await ctx.tmux.hasSubshell(socket, meta.subshellId))) { + rows.push({ + subshellId: meta.subshellId, + alive: false, + exitCode: await ctx.tmux.paneExitCode(socket, meta.subshellId), + }); + continue; + } + rows.push({ subshellId: meta.subshellId, alive: true, exitCode: null }); + } + return { ok: true, data: rows }; +} + +/** The census row shape, exported for the serve loop's final-report typing. */ +export type RuntimeReportRow = { subshellId: string; alive: boolean; exitCode: number | null }; diff --git a/apps/node/agent/src/runtime/serve.ts b/apps/node/agent/src/runtime/serve.ts new file mode 100644 index 000000000..767f5b0c4 --- /dev/null +++ b/apps/node/agent/src/runtime/serve.ts @@ -0,0 +1,386 @@ +import { mkdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { TmuxRunner } from "@internal/pane-runtime"; +import { + isSshSessionRef, + type NodeEvent, + parseSshRuntimeCommandFrame, + SSH_RUNTIME_PROTOCOL, + SSH_RUNTIME_SERVE_IN_USE_EXIT, + SSH_SESSION_LOG_WINDOW_BYTES, + type SshRuntimeEventFrame, +} from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import type { NodeConfig } from "../config.js"; +import { clientHome } from "../config.js"; +import { selfInvokePrefix } from "../self-invoke.js"; +import { SubshellMetaStore } from "../subshell-meta.js"; +import { NODE_VERSION } from "../version.js"; +import type { CallbackDoors } from "./callback-sock.js"; +import { LiveCallbackDoorError, startCallbackDoors } from "./callback-sock.js"; +import { type RuntimeReportRow, runRuntimeCommand } from "./dispatch.js"; +import { diag, RuntimeWriter, redirectConsoleToStderr, startStdinReader } from "./stdio.js"; + +/** + * `subshell runtime-serve` (design 2026-10-05 §1/§6): the session-scoped mode + * of the node binary. It behaves like a node whose link is the SSH child's + * stdio: it drives tmux on the destination, owns pane logs, answers the + * command/event semantics, and ENLISTS NOTHING - no enrollment config, no + * node key, no service, no network listener (the callback socket is the one + * permitted listener, and it is unix-only). + * + * Its lifetime is the child's: the SSH child dies, this process dies with the + * transport; the tmux server and its panes SURVIVE on the destination (tmux + * outlives its clients) and the next session reconciles them (the hello's + * `paneCount` is that reconciliation's first word). A `close` frame exits 0 + * AFTER forwarding the final census report (design §6); killing panes is + * always an explicit terminate, never a close side effect. + * + * Isolation (design §6 coexistence): everything this process touches lives + * under `/runtime/` (or `SUBSHELL_RUNTIME_DATA_DIR`), the + * tmux socket is the destination-deterministic name the broker passes, and no + * enrolled daemon's directory, socket, or record is ever read or written from + * here. The runtime is a guest with its own room. + */ + +export interface RuntimeServeInput { + /** The plane-minted session ref (`--session`), echoed for correlation only. */ + session: string; + /** The deterministic per-destination tmux socket the broker computed (`--tmux-socket`). */ + tmuxSocket: string; + /** Test seam: relocate the runtime namespace (production: `SUBSHELL_RUNTIME_DATA_DIR` or the default). */ + dataDir?: string; +} + +/** The runtime's default namespace: `/data/runtime` - the `runtime/` subdirectory beside (never inside) an enrolled daemon's data dir. */ +export function runtimeDataDir(override?: string): string { + const env = process.env.SUBSHELL_RUNTIME_DATA_DIR; + if (override !== undefined && override !== "") return override; + if (env !== undefined && env.trim() !== "") return env.trim(); + return join(clientHome(), "data", "runtime"); +} + +/** + * Run the serve loop until the session ends. Resolves with the process exit + * code once the transport closes or a `close` frame lands; the caller (the + * CLI verb) exits with it. + */ +export async function runRuntimeServe(input: RuntimeServeInput): Promise { + redirectConsoleToStderr(); // stdout is protocol-only; the logger's console transport moves to stderr + // The predicate narrows `string` to `never` on the false arm, so the + // refusal line names the flag, not the value: the broker's log already + // carries what it sent, and echoing a malformed token here is a second + // sink for it. + if (!isSshSessionRef(input.session)) { + diag("runtime-serve: refused --session (not a session ref)"); + return 2; + } + if (input.tmuxSocket === "" || !/^[A-Za-z0-9._-]+$/.test(input.tmuxSocket)) { + diag(`runtime-serve: refused --tmux-socket: ${input.tmuxSocket.slice(0, 80)}`); + return 2; + } + const dataDir = runtimeDataDir(input.dataDir); + // 0700 root + the subshells dir the meta store and pane logs share with the + // socket; `mkdir` mode is umask-masked, and the meta store re-tightens its + // own writes exactly as the enrolled daemon's does. + await mkdir(join(dataDir, "subshells"), { recursive: true, mode: 0o700 }); + + const writer = new RuntimeWriter(); + // A runtime never enrolls: the NodeConfig fields beyond dataDir exist for + // the daemon's link and service surface, neither of which a session mode + // reaches, and every reused executor reads ONLY dataDir (meta store root, + // allowlist file, maintenance file, input-generation store) - all of which + // live under the runtime namespace. The empty link fields are never dialed. + const config = { + serverUrl: "", + nodeId: `runtime:${input.session}`, + nodeKey: "", + controlPublicKey: "", + dataDir, + name: "runtime-serve", + } satisfies NodeConfig; + + const ctx: CommandContext = { + config, + tmux: new TmuxRunner(), + meta: new SubshellMetaStore(dataDir), + nowMs: Date.now, + watchers: new Map(), + tails: new Map(), + uploads: new Map(), + runtime: null, + requestRestart: () => diag("runtime: restart requested; a session runtime exits instead"), + // The tail pump chunks to the SESSION codec's transfer budget (review + // C1b): a node-link-sized 192 KiB chunk base64s to exactly the frame cap + // and its envelope would throw at the write, permanently stopping the + // pump (the same window retried forever). The plain daemon leaves this + // unset; here it is the one number both ends size log frames by. + outputChunkCeilingBytes: SSH_SESSION_LOG_WINDOW_BYTES, + ws: { + send: (ev) => { + // A pane's death retires its callback door: the exit watcher is the + // ONE truth of "this pane is gone" the runtime owns (terminate/kill + // results cover the plane-ordered deaths; this covers the natural + // ones), so the door closes here as well as there. + if (ev.type === "exit") void doors.dropPane(ev.subshellId); + // The node-link event vocabulary the reused executors emit maps onto + // the runtime's own event frames; anything unmapped (maintenance flips + // a runtime cannot have) is a diagnostic line, never a frame. + const mapped = mapNodeEventToRuntimeFrame(ev); + if (mapped === null) { + diag(`runtime: dropped unmapped event ${ev.type}`); + return; + } + writer.writeFrame(mapped); + }, + get bufferedAmount() { + return writer.bufferedAmount; + }, + }, + }; + + let doors: CallbackDoors; + try { + doors = await startCallbackDoors(dataDir, (req) => { + writer.writeFrame({ + type: "rest_request", + reqId: req.reqId, + method: req.method, + path: req.path, + ...(req.body !== undefined ? { body: req.body } : {}), + ...(req.paneId !== undefined ? { paneId: req.paneId } : {}), + } satisfies SshRuntimeEventFrame); + }); + } catch (err) { + // An occupied destination names ITSELF (review m2): the shared door's + // live-listener probe is the one second-session enforcement point, and + // when it refuses, this serve exits with {@link + // SSH_RUNTIME_SERVE_IN_USE_EXIT} so the broker's classifier can say + // "close the other session" - not "install the binary", which is what a + // generic bootstrap throw (the path this serve takes for every OTHER + // start failure) reads as, on a machine whose binary is running the + // first session right now. + if (err instanceof LiveCallbackDoorError) { + diag( + `runtime-serve: destination already serves this account (a live callback door refused the bind): ${dataDir}`, + ); + return SSH_RUNTIME_SERVE_IN_USE_EXIT; + } + throw err; + } + // A reconciled destination (design §6): the panes that SURVIVED the last + // session still hold their launch-baked `SUBSHELL_RUNTIME_CALLBACK_SOCK` + // pointing at their own door, so this serve re-creates every door the meta + // store names before answering the hello. A pane whose door is missing gets + // connection-refused; re-creating them here is what makes its callbacks + // work again without any new wire fact. + for (const meta of await ctx.meta.list()) { + await doors.ensurePane(meta.subshellId); + } + + // The hello (design §2: first frame or the open fails). `paneCount` is the + // census of the destination socket as it stands: a SECOND session on the + // same machine reports the first session's panes here, which is the + // reconciliation the plane then idempotently restores (design §6). + writer.writeFrame({ + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: NODE_VERSION, + os: process.platform, + arch: process.arch, + capabilities: ["ssh-runtime", "callback-sock", "detect", "pane-callback-sock"], + homeDir: process.env.HOME || homedir(), + dataDir, + tmuxSocket: input.tmuxSocket, + paneCount: countSocketPanes(ctx.tmux, input.tmuxSocket), + // How to re-enter THIS binary from a pane on the destination (the agent's + // `ready` fact, same prefix; the plane appends `mcp`/`report`). + selfInvoke: selfInvokePrefix(), + } satisfies SshRuntimeEventFrame); + + // Serial dispatch, the daemon's ordering contract ported: one in-flight + // command at a time, because tmux ordering IS the pane's input order and a + // reordered `terminate` after a `launch` is a lost kill, not a late one. + let chain: Promise = Promise.resolve(); + let closing = false; + let resolveDone: () => void = () => {}; + const done = new Promise((resolve) => { + resolveDone = () => resolve(shutdown(doors, ctx)); + }); + + const finish = (reason: string): void => { + diag(`runtime-serve: session ${input.session} ending (${reason})`); + resolveDone(); + }; + + startStdinReader( + (raw) => { + const refForError = extractRef(raw); + // Hoisted above the chain so the `.catch` sees it too: a launch whose + // executor THREW (not just refused) opened its door at the dispatch + // line below, and the throw path must roll it back exactly like the + // `ok:false` arm (M-1 - before, the door outlived a pane that never + // existed). + let paneIdForLaunch: string | null = null; + chain = chain + .then(async () => { + const frame = parseSshRuntimeCommandFrame(raw); + if (frame === null) { + // Fail-closed on an ungrammatical frame: plane and runtime agreed + // on this grammar; a violation means one end is lying, and the + // honest response is to stop, not to guess. + diag("runtime: refusing malformed command frame; closing"); + if (!closing) { + closing = true; + finish("protocol-violation"); + } + return; + } + if (closing) return; // already committed to the exit path + if (frame.type === "rest_response") { + doors.resolve(frame.reqId, frame.status, frame.body ?? ""); + return; + } + if (frame.type === "close") { + closing = true; + const census = await finalReport(ctx, input.tmuxSocket); + writer.writeFrame({ type: "result", ref: frame.ref, ok: true, data: census }); + finish("close"); + return; + } + // The pane door opens BEFORE the spawn (design §5's contract is that + // the pane's first callback can arrive as soon as its harness is + // up); a refused launch rolls it back with the pane. + paneIdForLaunch = frame.type === "launch" ? frame.cmd.subshellId : null; + if (paneIdForLaunch !== null) await doors.ensurePane(paneIdForLaunch); + const result = await runRuntimeCommand(ctx, input.tmuxSocket, frame); + if (result.ok) { + if (frame.type === "terminate" || frame.type === "kill") await doors.dropPane(frame.subshellId); + writer.writeFrame({ + type: "result", + ref: frame.ref, + ok: true, + ...(result.data !== undefined ? { data: result.data } : {}), + } satisfies SshRuntimeEventFrame); + } else { + if (paneIdForLaunch !== null) await doors.dropPane(paneIdForLaunch); + writer.writeFrame({ type: "result", ref: frame.ref, ok: false, error: result.error }); + } + }) + .catch(async (err: unknown) => { + // One executor throwing must not silently stall the chain; answer + // and keep going, exactly like dispatchCommand's TOTAL wrapper. + // And the launch rollback the `ok:false` arm performs belongs here + // too: the door opened before dispatch, so the throwing arm may + // not leave it standing over a pane that never spawned (M-1). + diag(`runtime: command threw: ${err instanceof Error ? err.message : String(err)}`); + if (paneIdForLaunch !== null) await doors.dropPane(paneIdForLaunch).catch(() => {}); + if (refForError !== null) + writer.writeFrame({ type: "result", ref: refForError, ok: false, error: "runtime-command-failed" }); + }); + }, + (reason) => { + if (!closing) { + closing = true; + finish(reason); + } + }, + ); + + return await done; +} + +/* ------------------------------------------------------------------ */ +/* helpers */ +/* ------------------------------------------------------------------ */ + +/** + * The census forwarded on close (design §6: "exits after forwarding its final + * report"), returned as the `close` command's result - the plane correlates + * panes to rows from there. A tmux that cannot answer reports panes ALIVE, + * never dead: the run family's honesty rule, a blip must not read as a death. + */ +async function finalReport(ctx: CommandContext, socket: string): Promise { + const metas = await ctx.meta.list(); + const rows: RuntimeReportRow[] = []; + for (const meta of metas) { + const s = meta.socket || socket; + let alive = true; + try { + alive = await ctx.tmux.hasSubshell(s, meta.subshellId); + } catch { + // tmux unreachable: unknown-not-dead reads as alive here; the plane's + // next live session reconciles the same rows through the same census. + } + rows.push({ + subshellId: meta.subshellId, + alive, + exitCode: alive ? null : await ctx.tmux.paneExitCode(s, meta.subshellId).catch(() => null), + }); + } + return rows; +} + +/** Panes already living on the destination socket at hello time (the reconcile count; tmux silence reads as 0, never a failed hello). */ +function countSocketPanes(tmux: TmuxRunner, socket: string): number { + try { + return tmux.listSubshellNames(socket).length; + } catch { + return 0; + } +} + +/** Map the node-link event vocabulary onto runtime event frames (design §2: the runtime event set is `NodeEvent` minus the link-management arms; those arms never originate here, and the default branch is the belt). */ +function mapNodeEventToRuntimeFrame(ev: NodeEvent): SshRuntimeEventFrame | null { + switch (ev.type) { + case "output": + return { + type: "output", + subshellId: ev.subshellId, + subId: ev.subId, + fromByte: ev.fromByte, + toByte: ev.toByte, + data_b64: ev.data_b64, + }; + case "exit": + return { type: "exit", subshellId: ev.subshellId, exitCode: ev.exitCode, at: ev.at }; + case "subshells_report": + return { type: "subshells_report", subshells: ev.subshells }; + case "result": + return ev.ok + ? { type: "result", ref: ev.ref, ok: true, ...(ev.data !== undefined ? { data: ev.data } : {}) } + : { type: "result", ref: ev.ref, ok: false, error: ev.error }; + default: + return null; + } +} + +/** + * Stop this process's OWN resources: the tail pumps (they write stdout) and + * the callback socket. The destination's tmux server and every pane on it are + * deliberately untouched (design §6: tmux and panes keep running under the + * destination user); stdout bytes already in the pipe drain when the process + * exits, which is the last write this function orders. + */ +function shutdown(doors: CallbackDoors, ctx: CommandContext): number { + for (const [, handle] of [...ctx.tails]) { + try { + handle.stop(); + } catch { + /* idempotent by contract */ + } + } + ctx.tails.clear(); + if (ctx.watchTick !== undefined) clearInterval(ctx.watchTick); + void doors.stop().catch(() => {}); + return 0; +} + +/** The frame's `ref` for the throw-path error, read from the raw object without trusting its grammar. */ +function extractRef(raw: unknown): string | null { + if (raw !== null && typeof raw === "object" && "ref" in raw && typeof (raw as { ref: unknown }).ref === "string") { + return (raw as { ref: string }).ref; + } + return null; +} diff --git a/apps/node/agent/src/runtime/stdio.ts b/apps/node/agent/src/runtime/stdio.ts new file mode 100644 index 000000000..8b3eb3ef8 --- /dev/null +++ b/apps/node/agent/src/runtime/stdio.ts @@ -0,0 +1,107 @@ +import { encodeSshSessionFrame, type SshRuntimeEventFrame, SshSessionFrameDecoder } from "@internal/subshell-protocol"; + +/** + * The runtime's stdio discipline (design 2026-10-05 §2: "stdout is + * protocol-only; stderr is diagnostic text forwarded to logs without + * interpretation"). + * + * Two rules, one module. FIRST: every byte this process writes to stdout goes + * through {@link RuntimeWriter} - one writer, one codec, no interleaving (a + * second writer would splice JSON). SECOND: the agent's `console`-transported + * logger, which every reused executor imports and which writes to STDOUT, + * is redirected to stderr for the life of a `runtime-serve` process. Without + * the redirect one `log("tail pump failed")` line would land between frames + * and the plane's fail-closed decoder would correctly kill the session, so + * the redirect is not politeness, it is the protocol. + */ + +export class RuntimeWriter { + #out = Bun.stdout; + /** Bytes written since stdout last reported drained; the tail pump's backpressure signal. */ + #backlog = 0; + + constructor() { + // Bun's WriteStream surfaces `drain` the way Node's does; on drain the + // backlog is not literally per-event accounting (there is no byte-count + // callback), so one drain clears the whole signal: the pump resumes and + // the NEXT saturated write re-arms the pause. Conservative in both + // directions, which is what a backpressure signal is for. + (Bun.stdout as unknown as { on?: (ev: string, cb: () => void) => void }).on?.("drain", () => { + this.#backlog = 0; + }); + } + + /** The queue depth the reused pumps read as `ws.bufferedAmount` (bytes awaiting drain). */ + get bufferedAmount(): number { + return this.#backlog; + } + + /** Write one protocol frame. Saturated stdout is recorded, never thrown into: a parked pump drains it. */ + writeFrame(frame: SshRuntimeEventFrame | unknown): void { + const bytes = encodeSshSessionFrame(frame); + const ok = this.#out.write(bytes) as unknown as boolean; + if (ok === false) this.#backlog += bytes.byteLength; + } +} + +/** + * Redirect the process's console to stderr for the whole runtime (see the + * module doc for why the redirect is the protocol, not hygiene). Idempotent; + * called once from the verb before any executor runs. + */ +export function redirectConsoleToStderr(): void { + const toErr = (...args: unknown[]): void => { + process.stderr.write(`${args.map((a) => (typeof a === "string" ? a : String(a))).join(" ")}\n`); + }; + console.log = toErr; + console.info = toErr; + console.debug = toErr; + console.warn = toErr; + console.error = toErr; +} + +/** One diagnostic line, stderr, timestamped like the agent's own log format. */ +export function diag(message: string): void { + process.stderr.write(`[subshell-runtime ${new Date().toISOString()}] ${message}\n`); +} + +/** + * The stdin frame reader: pull the byte stream through the shared codec and + * hand complete parsed frames to the callback. A codec failure (the + * session-protocol contract is fail-closed at both ends) stops the reader and + * reports the reason; the serve loop treats it exactly like an EOF: the + * session is over, close everything. + * + * @param onFrame - called per successfully parsed inbound frame + * @param onDone - called once, with the reason the stream ended + */ +export function startStdinReader( + onFrame: (frame: unknown) => void, + onDone: (reason: "eof" | "codec" | string) => void, +): void { + const decoder = new SshSessionFrameDecoder(); + void (async (): Promise => { + // `Bun.stdin` itself is not a stream in Bun 1.4 (no getReader on the + // namespace object); `Bun.stdin.stream()` is the byte stream. Measured on + // 1.4.2: the namespace object throws the reader lookup at the first push. + const reader = Bun.stdin.stream().getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + onDone("eof"); + return; + } + if (value === undefined) continue; + for (const frame of decoder.push(value)) onFrame(frame); + const failure = decoder.failed; + if (failure !== null) { + onDone(`codec:${failure}`); + return; + } + } + } catch (err) { + onDone(err instanceof Error ? err.message : String(err)); + } + })(); +} diff --git a/apps/node/agent/src/ssh-broker-dispatch.ts b/apps/node/agent/src/ssh-broker-dispatch.ts new file mode 100644 index 000000000..24ad6a5ad --- /dev/null +++ b/apps/node/agent/src/ssh-broker-dispatch.ts @@ -0,0 +1,40 @@ +/** Bounded desktop RPC concurrency. Slow host probing cannot stall traffic on + * another active session; writes and closes retain ordering within one ref. + * Refuse concurrent opens rather than stacking 30-second supervisor opens + * behind a shorter control-plane RPC timeout. Every accepted task participates + * in shutdown, including an open whose child has not reached hello yet. + */ +export class SshBrokerDispatcher { + private readonly tasks = new Set>(); + private readonly refTails = new Map>(); + private opening = false; + get size(): number { + return this.tasks.size; + } + dispatch( + command: { type: string; ref?: string }, + execute: () => Promise, + onError: (error: unknown) => void, + ): "accepted" | "open_busy" | "full" { + if (this.tasks.size >= 32) return "full"; + const opening = command.type === "ssh_session_open"; + if (opening && this.opening) return "open_busy"; + if (opening) this.opening = true; + const ref = command.type === "ssh_session_send" || command.type === "ssh_session_close" ? command.ref : undefined; + const previous = ref ? this.refTails.get(ref) : undefined; + const task = (previous ?? Promise.resolve()) + .then(execute) + .catch(onError) + .finally(() => { + this.tasks.delete(task); + if (opening) this.opening = false; + if (ref && this.refTails.get(ref) === task) this.refTails.delete(ref); + }); + this.tasks.add(task); + if (ref) this.refTails.set(ref, task); + return "accepted"; + } + async drain(): Promise { + await Promise.all([...this.tasks]); + } +} diff --git a/apps/node/agent/src/ssh-broker.ts b/apps/node/agent/src/ssh-broker.ts new file mode 100644 index 000000000..87b07acf4 --- /dev/null +++ b/apps/node/agent/src/ssh-broker.ts @@ -0,0 +1,386 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + closeSync, + constants, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + defaultSshConfigPath, + discoverSshAliases, + findBinary, + getSshSessionSupervisor, + resolveSshAliasConfig, +} from "@internal/pane-runtime"; +import { parseNodeCommandBody, SSH_CANCEL_GRACE_MS, SSH_SESSION_PUMP_CHUNK_BYTES } from "@internal/subshell-protocol"; +import { SshBrokerDispatcher } from "./ssh-broker-dispatch.js"; + +/** Native owns this process: stdin remains open, one JSON initialization line, + * then {type:"stop"} or EOF terminates the socket and drains its SSH children. + * Pair tokens/credentials never appear in argv, diagnostics, or stdout. + * Resume uses --broker-id and {} on stdin. Stdout has one attached metadata line. + */ +export function brokerServerOrigin(value: string): string { + const url = new URL(value); + if (url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) + throw new Error("Choose a server origin without a path or credentials."); + if ( + url.protocol !== "https:" && + !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) + ) + throw new Error("Desktop SSH requires HTTPS, except on loopback."); + return url.origin; +} +export function brokerCredentialPath(origin: string, id: string): string { + if (!/^desktop:[0-9a-f-]{36}$/.test(id)) throw new Error("Invalid desktop broker id."); + const root = process.env.SUBSHELL_CONFIG_HOME || join(homedir(), ".config", "subshell"); + return join(root, "ssh-brokers", `${createHash("sha256").update(`${origin}\n${id}`).digest("hex")}.json`); +} +export function forgetBrokerCredential(server: string, id: string): void { + rmSync(brokerCredentialPath(brokerServerOrigin(server), id), { force: true }); +} +function readCredential(origin: string, id: string): string { + const path = brokerCredentialPath(origin, id); + const stat = lstatSync(path); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + (stat.mode & 0o077) !== 0 || + (process.getuid && stat.uid !== process.getuid()) + ) + throw new Error("Desktop broker credential must be a private owner file."); + const value = JSON.parse(readFileSync(path, "utf8")); + if ( + value.origin !== origin || + value.id !== id || + typeof value.credential !== "string" || + !/^dsb_[0-9a-f-]{36}_[A-Za-z0-9_-]{43}$/.test(value.credential) + ) + throw new Error("Invalid desktop broker credential."); + return value.credential; +} +function saveCredential(origin: string, id: string, credential: string): void { + const path = brokerCredentialPath(origin, id); + const dir = join(path, ".."); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const stat = lstatSync(dir); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + (stat.mode & 0o077) !== 0 || + (process.getuid && stat.uid !== process.getuid()) + ) + throw new Error("Desktop broker credential directory must be private."); + const temp = `${path}.${randomUUID()}.tmp`; + try { + const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + try { + writeFileSync(fd, JSON.stringify({ origin, id, credential })); + } finally { + closeSync(fd); + } + renameSync(temp, path); + } finally { + rmSync(temp, { force: true }); + } +} + +export async function runSshBroker(server: string, brokerId?: string): Promise { + const origin = brokerServerOrigin(server); + const home = process.env.HOME || homedir(); + const reader = Bun.stdin.stream().getReader(); + let initial = ""; + let remainder = ""; + const decoder = new TextDecoder(); + while (!initial.includes("\n")) { + const part = await reader.read(); + if (part.done) throw new Error("Desktop broker requires one initialization line on stdin."); + initial += decoder.decode(part.value, { stream: true }); + if (initial.length > 4096) throw new Error("Desktop broker initialization exceeds its bound."); + } + const newline = initial.indexOf("\n"); + remainder = initial.slice(newline + 1); + initial = initial.slice(0, newline); + const init = JSON.parse(initial) as { pairingToken?: unknown }; + if (!init || typeof init !== "object" || Array.isArray(init)) throw new Error("Invalid broker initialization."); + const token = + typeof init.pairingToken === "string" && /^dsp_[A-Za-z0-9_-]{43}$/.test(init.pairingToken) + ? init.pairingToken + : brokerId + ? readCredential(origin, brokerId) + : null; + if (!token) throw new Error("Pair this desktop with your signed-in server account first."); + const dataRoot = process.env.SUBSHELL_CONFIG_HOME || join(homedir(), ".config", "subshell"); + mkdirSync(join(dataRoot, "ssh-broker-runtime"), { recursive: true, mode: 0o700 }); + const dataDir = mkdtempSync(join(dataRoot, "ssh-broker-runtime", "process-")); + const sshBin = await findBinary("ssh", "SUBSHELL_SSH_PATH", [ + "/usr/bin/ssh", + "/bin/ssh", + "/usr/local/bin/ssh", + "/opt/homebrew/bin/ssh", + ]); + if (!sshBin) { + rmSync(dataDir, { recursive: true, force: true }); + throw new Error("SSH is not installed on this computer."); + } + const sup = getSshSessionSupervisor({ dataDir, homeDir: home, sshBin, nowMs: Date.now }); + const url = new URL("/api/ssh-runtime/desktop-brokers/attach", origin); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + const Ws = WebSocket as unknown as new (url: string, options: { headers: Record }) => WebSocket; + const ws = new Ws(url.href, { headers: { Authorization: `Bearer ${token}` } }); + let stopping = false; + let attached = false; + let usedSshSession = false; + const dispatcher = new SshBrokerDispatcher(); + let lastRequest = 0; + let finish!: (code: number) => void; + const completed = new Promise((resolve) => { + finish = resolve; + }); + const send = (frame: unknown): void => { + if (stopping || ws.readyState !== WebSocket.OPEN || ws.bufferedAmount > 1024 * 1024) + throw new Error("Desktop broker link unavailable."); + ws.send(JSON.stringify(frame)); + }; + const stop = (): void => { + if (stopping) return; + stopping = true; + clearTimeout(handshake); + ws.close(1000, "Desktop broker stopped"); + for (const ref of sup.liveRefs()) sup.close(ref); + // A late open closes via its liveness check before the tracked tasks settle. + void dispatcher + .drain() + .catch(() => {}) + .then(async () => { + for (const ref of sup.liveRefs()) sup.close(ref); + if (usedSshSession) await Bun.sleep(SSH_CANCEL_GRACE_MS + 50); + rmSync(dataDir, { recursive: true, force: true }); + finish(attached ? 0 : 1); + }); + }; + const handshake = setTimeout(stop, 15_000); + const signals = (): void => stop(); + process.on("SIGTERM", signals); + process.on("SIGINT", signals); + ws.addEventListener("close", stop); + ws.addEventListener("error", stop); + ws.addEventListener("message", (event) => { + if (stopping || typeof event.data !== "string" || event.data.length > 1024 * 1024) { + stop(); + return; + } + let value: Record; + try { + value = JSON.parse(event.data); + } catch { + stop(); + return; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + stop(); + return; + } + if (!attached) { + if ( + value.type !== "attached" || + typeof value.id !== "string" || + !/^desktop:[0-9a-f-]{36}$/.test(value.id) || + typeof value.name !== "string" || + (brokerId && brokerId !== value.id) + ) { + stop(); + return; + } + try { + if (value.brokerCredential !== undefined) { + if ( + typeof value.brokerCredential !== "string" || + !/^dsb_[0-9a-f-]{36}_[A-Za-z0-9_-]{43}$/.test(value.brokerCredential) + ) + throw new Error("Invalid credential."); + saveCredential(origin, value.id, value.brokerCredential); + } + attached = true; + clearTimeout(handshake); + process.stdout.write(`${JSON.stringify({ type: "attached", id: value.id, name: value.name })}\n`); + } catch { + stop(); + } + return; + } + const cmd = parseNodeCommandBody(value.command); + if ( + value.type !== "command" || + typeof value.requestId !== "string" || + !/^[1-9][0-9]{0,15}$/.test(value.requestId) || + Number(value.requestId) !== lastRequest + 1 || + !cmd || + ![ + "ssh_discover_aliases", + "ssh_resolve_config", + "ssh_session_open", + "ssh_session_send", + "ssh_session_close", + ].includes(cmd.type) || + dispatcher.size >= 32 + ) { + stop(); + return; + } + // A monotonic counter provides a constant-memory replay fence. A dropped + // result never authorizes re-sending a mutating command. + lastRequest = Number(value.requestId); + const requestId = value.requestId; + const dispatch = dispatcher.dispatch( + cmd, + async () => { + if (stopping) return; + try { + let data: unknown; + switch (cmd.type) { + case "ssh_discover_aliases": { + const found = discoverSshAliases({ homeDir: home, configPath: defaultSshConfigPath(home) }); + data = { aliases: found.aliases, includeCycle: found.includeCycle, truncated: found.truncated }; + break; + } + case "ssh_resolve_config": + data = await resolveSshAliasConfig(cmd.alias, { + sshBin, + homeDir: home, + configPath: defaultSshConfigPath(home), + }); + break; + case "ssh_session_open": { + usedSshSession = true; + const outcome = await sup.open( + { ref: cmd.ref, target: cmd.target, runtimeCommand: cmd.runtimeCommand ?? "subshell" }, + { + emitBytes: async (bytes) => { + for (let off = 0; off < bytes.byteLength; off += SSH_SESSION_PUMP_CHUNK_BYTES) { + let waits = 0; + while (!stopping && ws.bufferedAmount > 1024 * 1024 && waits++ < 200) await Bun.sleep(50); + send({ + type: "session_frame", + ref: cmd.ref, + data_b64: Buffer.from(bytes.subarray(off, off + SSH_SESSION_PUMP_CHUNK_BYTES)).toString( + "base64", + ), + }); + } + }, + emitDiag: () => {}, + onLost: () => { + if (!stopping) { + try { + send({ type: "session_lost", ref: cmd.ref }); + } catch { + stop(); + } + } + }, + }, + ); + if (stopping) { + sup.close(cmd.ref); + return; + } + if (outcome.kind === "refused") { + send({ type: "result", requestId, ok: false, error: outcome.code }); + return; + } + data = outcome.result; + break; + } + case "ssh_session_send": + if ((await sup.sendAsync(cmd.ref, Buffer.from(cmd.data_b64, "base64"))) !== "ok") + throw new Error("session_unknown"); + break; + case "ssh_session_close": + if (sup.close(cmd.ref) !== "ok") throw new Error("session_unknown"); + break; + default: + stop(); + return; + } + send({ type: "result", requestId, ok: true, ...(data !== undefined ? { data } : {}) }); + } catch (error) { + if (!stopping) { + try { + send({ + type: "result", + requestId, + ok: false, + error: + error instanceof Error && error.message === "session_unknown" + ? "session_unknown" + : "connection_failed", + }); + } catch { + stop(); + } + } + } + }, + stop, + ); + if (dispatch === "open_busy") { + try { + send({ type: "result", requestId, ok: false, error: "connection_failed" }); + } catch { + stop(); + } + } else if (dispatch === "full") stop(); + }); + // EOF is authority loss, including a native parent crash. Subsequent input + // is bounded and accepts only the explicit shutdown message. + void (async () => { + let pending = remainder; + while (!stopping) { + while (pending.includes("\n")) { + const end = pending.indexOf("\n"); + const line = pending.slice(0, end); + pending = pending.slice(end + 1); + let message: unknown; + try { + message = JSON.parse(line); + } catch { + stop(); + return; + } + if (!message || typeof message !== "object" || (message as { type?: string }).type !== "stop") { + stop(); + return; + } + stop(); + return; + } + const part = await reader.read(); + if (part.done) { + stop(); + return; + } + pending += decoder.decode(part.value, { stream: true }); + if (pending.length > 4096) { + stop(); + return; + } + } + })().catch(stop); + try { + return await completed; + } finally { + process.off("SIGTERM", signals); + process.off("SIGINT", signals); + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} diff --git a/apps/server/api/src/__tests__/e2e-cross-subshell.test.ts b/apps/server/api/src/__tests__/e2e-cross-subshell.test.ts index 39d556af1..d1f45eaa1 100644 --- a/apps/server/api/src/__tests__/e2e-cross-subshell.test.ts +++ b/apps/server/api/src/__tests__/e2e-cross-subshell.test.ts @@ -38,13 +38,20 @@ const dbPath = join(dir, "subshell.db"); const backendLog = join(dir, "backend.log"); /** Env every child process needs: dev NODE_ENV (a test NODE_ENV would force - * the in-memory DB and ignore DATABASE_PATH — see constants.ts) + the file DB. */ + * the in-memory DB and ignore DATABASE_PATH — see constants.ts) + the file DB. + * The CONFIG DIR is pinned to the temp dir too: `index.ts` takes the + * instance-state lock at `/instance-state.lock`, and the + * default config dir is the operator's own - on a host with the live server + * running, an unpinned child died on "Instance state is already in use" + * before it could serve, and the crash line went to STDOUT (pino), not the + * stderr log this file reads, so the verdict arrived as an empty log. */ function childEnv(extra: Record = {}): Record { return { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? tmpdir(), NODE_ENV: "development", DATABASE_PATH: dbPath, + SUBSHELL_SERVER_CONFIG_DIR: join(dir, "server-config"), ...extra, }; } diff --git a/apps/server/api/src/api/__tests__/uploads-remote.route.test.ts b/apps/server/api/src/api/__tests__/uploads-remote.route.test.ts index ea5997ca3..c3e792842 100644 --- a/apps/server/api/src/api/__tests__/uploads-remote.route.test.ts +++ b/apps/server/api/src/api/__tests__/uploads-remote.route.test.ts @@ -2,13 +2,19 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test" import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { NodeCommandBody } from "@internal/subshell-protocol"; +import { + type NodeCommandBody, + SSH_RUNTIME_PROTOCOL, + type SshRuntimeHelloWire, + type SshSessionTargetWire, +} from "@internal/subshell-protocol"; import { hashPassword } from "better-auth/crypto"; import { uploadsRoutes } from "@/api/uploads.route.js"; import { db } from "@/db/index.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; import { attachConnection, detachConnection, @@ -17,6 +23,13 @@ import { resetNodeRegistryForTests, } from "@/services/nodes/node-registry.js"; import { resolveResult } from "@/services/nodes/node-rpc.js"; +import { SshRuntimeSession } from "@/services/ssh-runtime/session.js"; +import { + registerSession, + resetSessionRegistryForTests, + sessionHooks, +} from "@/services/ssh-runtime/session-registry.js"; +import { SshRuntimeSessionsRepository } from "@/services/ssh-runtime/sessions.repository.js"; import { authedRequest, deleteUserByEmailOrId, setupAuthTables, signIn } from "./helpers/auth-tables.js"; /** @@ -486,3 +499,172 @@ describe("uploads relay to agent nodes (spec §3.4)", () => { } }); }); + +/* ------------------------------------------------------------------ */ +/* runtime-kind panes: refused BY NAME, never relayed (review m3) */ +/* ------------------------------------------------------------------ */ + +const target: SshSessionTargetWire = { alias: "upl", host: "127.0.0.1", port: 22, user: null, identityFile: null }; + +function hello(): SshRuntimeHelloWire { + return { + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: ["ssh-runtime", "callback-sock"], + homeDir: "/home/dst", + dataDir: "/home/dst/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-upl0000000", + paneCount: 0, + }; +} + +describe("uploads to runtime-kind panes (m3)", () => { + let ownerId: string; + let ownerEmail: string; + let ownerToken: string; + const workDirs: string[] = []; + const runtimeNodeIds: string[] = []; + const paneIds: string[] = []; + + beforeAll(async () => { + await setupAuthTables(); + ownerEmail = `upl-rt-${crypto.randomUUID()}@subshell.local`; + ownerId = await new UsersRepository(db).createUser({ + email: ownerEmail, + name: ownerEmail, + passwordHash: await hashPassword(password), + role: "user", + }); + ownerToken = await signIn(ownerEmail, password); + }); + + afterAll(async () => { + resetSessionRegistryForTests(); + resetNodeRegistryForTests(); + for (const id of paneIds) { + await db + .deleteFrom("subshells") + .where("id", "=", id) + .execute() + .catch(() => {}); + } + await db + .deleteFrom("sshRuntimeSessions") + .execute() + .catch(() => {}); + for (const id of runtimeNodeIds) { + await db + .deleteFrom("nodes") + .where("id", "=", id) + .execute() + .catch(() => {}); + } + await deleteUserByEmailOrId(ownerEmail).catch(() => {}); + for (const dir of workDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + beforeEach(() => { + resetNodeRegistryForTests(); + resetSessionRegistryForTests(); + }); + + /** A pane on a hidden runtime node row; `registerLive` adds the live session (the m3 premise). */ + async function mkRuntimePane(registerLive: boolean): Promise<{ paneId: string; runtimeNodeId: string }> { + const runtimeNodeId = crypto.randomUUID(); + runtimeNodeIds.push(runtimeNodeId); + await new NodesRepository(db).create({ + id: runtimeNodeId, + ownerUserId: ownerId, + name: `upl-rt-${runtimeNodeId}`, + kind: NODE_KIND_RUNTIME, + status: "online", + }); + if (registerLive) { + // The live-session registration is exactly what makes I3's blessed + // `isNodeOffline` read FALSE for this pane (the reported bug's premise): + // without it, the route's offline-409 would answer instead and the + // write_file branch would never be reached. + const connectingNodeId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + await new NodesRepository(db).create({ + id: connectingNodeId, + ownerUserId: ownerId, + name: `upl-conn-${connectingNodeId}`, + kind: "agent", + }); + await new SshRuntimeSessionsRepository(db).create({ + id: sessionId, + ownerUserId: ownerId, + connectingNodeId, + runtimeNodeId, + alias: target.alias, + host: target.host, + port: target.port, + user: target.user, + }); + const session = new SshRuntimeSession({ + id: sessionId, + ownerId, + connectingNodeId, + runtimeNodeId, + target, + hello: hello(), + }); + session.hooks = sessionHooks(); + registerSession(session); + } + const dir = mkdtempSync(join(tmpdir(), "subshell-upl-rt-")); + workDirs.push(dir); + const paneId = crypto.randomUUID(); + paneIds.push(paneId); + await new SubshellsRepository(db).create({ + id: paneId, + userId: ownerId, + name: "upl-rt-test", + workingDir: dir, + harnessId: "claude-code", + presetId: crypto.randomUUID(), + status: "running", + tmuxSocket: `subshell-upl-rt-${paneId.slice(0, 8)}`, + nodeId: runtimeNodeId, + }); + return { paneId, runtimeNodeId }; + } + + async function refuse(paneId: string): Promise<{ status: number; body: { code: string; message: string } }> { + const res = await uploadsRoutes.fetch( + uploadRequest(paneId, ownerToken, new File([payload(4)], "rt.bin", { type: "application/octet-stream" })), + ); + return { status: res.status, body: (await res.json()) as { code: string; message: string } }; + } + + it("a LIVE runtime pane is refused by name with zero write_file frames (the m3 repro)", async () => { + const { paneId, runtimeNodeId } = await mkRuntimePane(true); + // A recording socket STANDS IN for any node connection: the pre-fix route + // sailed past the (correctly false) offline gate and composed the + // non-session `write_file` RPC anyway - the frame set the session never + // speaks. Zero frames is the fix's load-bearing fact. + const agent = attachFakeNode(runtimeNodeId); + try { + const { status, body } = await refuse(paneId); + expect(status).toBe(409); + expect(body.code).toBe("RUNTIME_UPLOAD_UNSUPPORTED"); + expect(body.message.toLowerCase()).toContain("ssh"); // names the family the pane belongs to + expect(body.message.toLowerCase()).not.toContain("restart"); // the runtime seal makes restart advice impossible + expect(agent.cmds.length).toBe(0); + } finally { + agent.detach(); + } + }); + + it("a runtime pane WITHOUT a live session answers the same named refusal, not the offline 'start it' 409", async () => { + const { paneId } = await mkRuntimePane(false); + const { status, body } = await refuse(paneId); + expect(status).toBe(409); + expect(body.code).toBe("RUNTIME_UPLOAD_UNSUPPORTED"); // honest by KIND: a runtime node cannot be "started" + expect(body.message.toLowerCase()).not.toContain("offline"); + }); +}); diff --git a/apps/server/api/src/api/__tests__/uploads-route.test.ts b/apps/server/api/src/api/__tests__/uploads-route.test.ts index 2b96f6d99..6f546d781 100644 --- a/apps/server/api/src/api/__tests__/uploads-route.test.ts +++ b/apps/server/api/src/api/__tests__/uploads-route.test.ts @@ -140,6 +140,22 @@ describe("subshell uploads route", () => { expect(res.status).toBe(404); }); + it("ordinary pane -> 200 and the bytes land under the working dir", async () => { + // Every pane is an ordinary pane since the SSH destination product + // retired (migration 0050): the server is the boundary, and the upload + // writes into the row's working directory. + const ws = tempWorkDir(); + const id = await makeSubshell(ownerId, ws); + const res = await uploadsRoutes.fetch( + uploadRequest(id, ownerToken, new File(["hello"], "notes.txt", { type: "text/plain" })), + ); + expect(res.status).toBe(200); + const ok = (await res.json()) as { path: string; size: number }; + expect(ok.size).toBe(5); + expect(readFileSync(ok.path, "utf8")).toBe("hello"); + await db.deleteFrom("subshells").where("id", "=", id).execute(); + }); + it("missing working directory -> 409", async () => { const ws = tempWorkDir(); const id = await makeSubshell(ownerId, ws); diff --git a/apps/server/api/src/api/auth-guard.ts b/apps/server/api/src/api/auth-guard.ts index 80265ac89..c6fb692df 100644 --- a/apps/server/api/src/api/auth-guard.ts +++ b/apps/server/api/src/api/auth-guard.ts @@ -205,6 +205,9 @@ export function requirePerm( ): void { if (ctx.actor !== "subshell-key") return; const grants = ctx.apiKeyPermissions?.[resource]; + // The `ssh` resource left the union with the destination product (design + // 2026-10-05 §7); a stale map carrying an `ssh` key on an old token is now + // unread data - no route asks for it. // One backward-compat concession (spec 2026-09-28 §Token scopes): a map // minted BEFORE the prompts feature carries `channels` but no `prompts` // key, and self-extension never re-mints it, so absence there predates the diff --git a/apps/server/api/src/api/nodes/node-view.ts b/apps/server/api/src/api/nodes/node-view.ts index 453e39454..9d130cab1 100644 --- a/apps/server/api/src/api/nodes/node-view.ts +++ b/apps/server/api/src/api/nodes/node-view.ts @@ -1,9 +1,10 @@ import { type Static, t } from "elysia"; +import { HttpError } from "@/api/auth-guard.js"; import { db } from "@/db/index.js"; import { NodeAllowedDirsRepository } from "@/db/repositories/node-allowed-dirs.repository.js"; import { UsersRepository } from "@/db/repositories/users.repository.js"; import type { NodeShareTable } from "@/db/types/node-shares.db-types.js"; -import type { NodeTable } from "@/db/types/nodes.db-types.js"; +import type { NodeKind, NodeTable } from "@/db/types/nodes.db-types.js"; import { type NodeAccess, nodeCanLaunchOn, nodeCanManageFor } from "@/lib/node-access.js"; import { type EffectiveHarnessReport, effectiveHarnessStates } from "@/services/nodes/inventory.js"; import { enabledHarnessPlugins } from "@/services/nodes/local-plugins.js"; @@ -310,6 +311,13 @@ function parseCapabilities(json: string | null): string[] { } } +/** The view's `kind` narrow, named once: a `runtime` row at this boundary is a caller bug (the repository filters it), and the refusal says so. */ +function nodeViewKind(kind: NodeKind, id: string): "local" | "agent" { + if (kind === "local") return "local"; + if (kind === "agent") return "agent"; + throw new HttpError(404, `node ${id} has no view`); +} + /** * Everything but the harness merge — the one place row→view fields are mapped. * `local`'s os/arch fall back to this process (see the inline note) — the only @@ -329,7 +337,10 @@ function nodeViewBase( allowedDirs, id: row.id, name: row.name, - kind: row.kind, + // The hidden runtime row never gets a view (design 2026-10-05 §4): the + // listing filters it in the repository, and a caller that reaches here + // with one anyway is refused, not laundered into the view grammar. + kind: nodeViewKind(row.kind, row.id), // `local` never sends `ready`, so its row CAN hold null os/arch (the seed // fills them only at creation) — report the control-plane host's real // platform from the view (spec 2026-09-02 §4b) so launch-picker labels diff --git a/apps/server/api/src/api/routes.ts b/apps/server/api/src/api/routes.ts index 93b011a5d..1544b192c 100644 --- a/apps/server/api/src/api/routes.ts +++ b/apps/server/api/src/api/routes.ts @@ -24,6 +24,7 @@ import { instancePublicRoutes } from "@/api/settings-public.route.js"; import { setupRoutes } from "@/api/setup.route.js"; import { setupAgentInstallRoute } from "@/api/setup-agent-install.route.js"; import { setupTmuxInstallRoute } from "@/api/setup-tmux-install.route.js"; +import { sshRuntimeRoutes } from "@/api/ssh-runtime/index.js"; import { subshellRoutes } from "@/api/subshells/index.js"; import { systemKeysRoutes } from "@/api/system-keys.route.js"; import { transferRoutes } from "@/api/transfers/index.js"; @@ -80,10 +81,15 @@ const paneRoutes = new Elysia() .use(filesRoutes) .use(workspaceRoutes); -// The machine-side surfaces: node administration and the node-to-node transfer -// relay. Its own group (not an eighth `.use()` on the pane basket) keeps the -// composed type shallow enough to compile. -const nodeRoutes = new Elysia().use(nodesRoutes).use(transferRoutes); +// The machine-side surfaces: node administration, the node-to-node transfer +// relay, and the SSH runtime-session surface (design 2026-10-05 §9). SSH +// runtime sessions mount HERE rather than as their own root arm because the +// root already carries six `.use()` groups and a seventh re-trips the TS2589 +// ceiling the header explains; `nodeRoutes` has headroom, and the runtime is +// genuinely a machine-side surface - every frame rides the node link and +// connects through a node - so the grouping is honest, not just a compile +// dodge. +const nodeRoutes = new Elysia().use(nodesRoutes).use(transferRoutes).use(sshRuntimeRoutes); const commsRoutes = new Elysia().use(notificationsRoutes).use(devicesRoutes).use(channelRoutes); diff --git a/apps/server/api/src/api/ssh-runtime/__tests__/node-rpc-mock-isolation.test.ts b/apps/server/api/src/api/ssh-runtime/__tests__/node-rpc-mock-isolation.test.ts new file mode 100644 index 000000000..976660b79 --- /dev/null +++ b/apps/server/api/src/api/ssh-runtime/__tests__/node-rpc-mock-isolation.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { NodeRpcError, sendCommand } from "@/services/nodes/node-rpc.js"; + +/** + * The I-1 test-isolation regression (review wave 2026-10-05): the sibling + * `services/ssh-runtime` suites install a capture `sendCommand` via + * `mock.module` and restore it in `afterAll`. bun's `mock.module` mutates the + * LIVE module namespace, so restoring with `() => ({ ...nodeRpc })` re-spied + * the already-mocked namespace and the capture leaked into every file that + * ran after it in the shared serial process - this directory's scripted-node + * routes suite then read NODE_UNREACHABLE "malformed payload" for commands + * its own scripted handlers answer (reproduced in either file order). + * + * This file is order-independent by construction: run BEFORE any mock (the + * module is real) or AFTER the fixed restores (the module is real again), the + * unmocked `sendCommand` rejects a never-connected node with `NodeRpcError` + * code `offline`. The capture mock resolves ANY command with `{ok:true}`, so + * the day the restore regresses, this test fails no matter which file order + * bun picks. + */ + +test("node-rpc answers the REAL module in this process (no leaked mock)", async () => { + const err = await sendCommand(crypto.randomUUID(), { type: "probe", subshellIds: [] }).then( + () => null, + (e: unknown) => e, + ); + expect(err, "a capture mock resolves any command; the real module cannot").toBeInstanceOf(NodeRpcError); + expect((err as NodeRpcError).code).toBe("offline"); +}); diff --git a/apps/server/api/src/api/ssh-runtime/__tests__/ssh-runtime-routes.test.ts b/apps/server/api/src/api/ssh-runtime/__tests__/ssh-runtime-routes.test.ts new file mode 100644 index 000000000..23bf02c12 --- /dev/null +++ b/apps/server/api/src/api/ssh-runtime/__tests__/ssh-runtime-routes.test.ts @@ -0,0 +1,567 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { encodeSshSessionFrame, SSH_RUNTIME_PROTOCOL, SshSessionFrameDecoder } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { sshRuntimeRoutes } from "@/api/ssh-runtime/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { PresetsRepository } from "@/db/repositories/presets.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { getSession } from "@/services/ssh-runtime/session-registry.js"; +import { SshRuntimeSessionsRepository } from "@/services/ssh-runtime/sessions.repository.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { attachScriptedNode, type ScriptedNode } from "@/test-helpers/scripted-node.js"; + +/** + * The widened auth of `/api/ssh-runtime` (workstream U; design 2026-10-05 + * §1/§7): this surface belongs to every signed-in HUMAN acting on their OWN + * machines and their OWN sessions. The rules this suite pins: + * + * - A plain member (NOT admin) who owns the connecting node reaches the whole + * family: discovery, resolve, open, list, detail, by-pane identity, close. + * - Ownership is REAL ownership, no admin shortcut and no share: an admin who + * does not own the node and a foreign member get the SAME 404 (same code, + * same message - no enumeration oracle) for node-scoped and session-scoped + * acts, while their own lists answer 200 with their own (empty) rows. + * - Machine credentials are refused outright on the family: a bearer 403s at + * every endpoint (there is no MCP door for this surface yet). + * - `by-pane` identity is the session owner's read: an ordinary pane (and a + * foreign pane) reads the same 404. + * + * The node is the scripted agent on the real registry: the open really rides + * `sendCommand`, the hello really parses, and the session really settles + * active, so the member-owner path is proven through the production door and + * not around it. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(sshRuntimeRoutes); + +const nodes = new NodesRepository(db); +const subshells = new SubshellsRepository(db); + +const memberEmail = `rtmember-${crypto.randomUUID()}@subshell.local`; +const foreignEmail = `rtforeign-${crypto.randomUUID()}@subshell.local`; +const adminEmail = `rtadmin-${crypto.randomUUID()}@subshell.local`; +const pw = "rtroute-pass-1"; +let memberId: string; +let memberCookie: string; +let foreignId: string; +let foreignCookie: string; +let adminCookie: string; +let node = ""; +let scripted: ScriptedNode; + +/** One answer, read ONCE (a Response body is single-use; this holds both views). */ +interface Answer { + status: number; + body: Record; +} + +async function call(path: string, init: RequestInit, opts: { cookie?: string; bearer?: string } = {}): Promise { + const headers = new Headers(init.headers); + headers.set("content-type", "application/json"); + headers.set("origin", "http://localhost:3080"); + if (opts.cookie) headers.set("cookie", `better-auth.session_token=${opts.cookie}`); + if (opts.bearer) headers.set("authorization", `Bearer ${opts.bearer}`); + const res = await app.fetch(new Request(`http://localhost:3080${path}`, { ...init, headers })); + const text = await res.text(); + let body: Record = {}; + try { + body = text === "" ? {} : (JSON.parse(text) as Record); + } catch { + body = { raw: text }; + } + return { status: res.status, body }; +} +const get = (path: string, opts: { cookie?: string; bearer?: string } = {}) => call(path, { method: "GET" }, opts); +const post = (path: string, body: unknown, opts: { cookie?: string; bearer?: string } = {}) => + call(path, { method: "POST", body: JSON.stringify(body) }, opts); + +/** The refusal's identity: everything the no-oracle claim is about, minus the per-occurrence errId. */ +function refusalOf(answer: Answer): string { + const { status, body } = answer; + return JSON.stringify({ + status, + code: body.code, + message: body.message, + metadataSafe: body.metadataSafe ?? null, + }); +} + +const HELLO = { + type: "hello" as const, + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: [], + homeDir: "/home/x", + dataDir: "/home/x/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-ab12cd34ef56", + paneCount: 0, +}; + +const SNAPSHOT = { + alias: "rtbox", + host: "10.9.8.7", + user: "theo", + port: 22, + identityFiles: ["/home/x/.ssh/id_ed25519"], + certificateFiles: [], + knownHostsFiles: ["/home/x/.ssh/known_hosts"], + authAgentSocket: null, + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, +}; + +/** The open body the wizard sends for the scripted destination. */ +function openBody(): Record { + return { + connectingNodeId: node, + target: { alias: "rtbox", host: "10.9.8.7", port: 22, user: "theo", identityFile: "/home/x/.ssh/id_ed25519" }, + }; +} + +const cleanup: string[] = []; +let resolvedHost = "10.9.8.7"; + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + resetNodeRegistryForTests(); + await ensureLocalNode(db); + const users = new UsersRepository(db); + memberId = await users.createUser({ + email: memberEmail, + name: memberEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + foreignId = await users.createUser({ + email: foreignEmail, + name: foreignEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + await users.createUser({ email: adminEmail, name: adminEmail, passwordHash: await hashPassword(pw), role: "admin" }); + memberCookie = await signIn(memberEmail, pw); + foreignCookie = await signIn(foreignEmail, pw); + adminCookie = await signIn(adminEmail, pw); + + node = crypto.randomUUID(); + cleanup.push(node); + await nodes.create({ id: node, ownerUserId: memberId, name: `rt-${node.slice(0, 8)}`, kind: "agent" }); + scripted = attachScriptedNode(node, { + ssh_discover_aliases: () => ({ aliases: ["rtbox", "other"], includeCycle: false, truncated: false }), + ssh_resolve_config: () => ({ + accepted: true, + snapshot: { ...SNAPSHOT, host: resolvedHost }, + connectingAccount: "x", + }), + ssh_session_open: (cmd) => { + if (cmd.type !== "ssh_session_open") throw new Error("wrong cmd"); + return { hello: HELLO, host: cmd.target.host, port: cmd.target.port, user: cmd.target.user }; + }, + // The scripted destination speaks the RUNTIME grammar too: every framed + // command (the F3 `stat_dir` gate, the reopen census, `close`) is decoded + // and answered as `runtime/dispatch.ts` would answer it, so no test here + // rides a command deadline. An empty census is this fake's own hello + // (paneCount 0: a destination with no panes on its socket). + ssh_session_send: (cmd) => { + if (cmd.type !== "ssh_session_send") throw new Error("wrong cmd"); + const session = getSession(cmd.ref); + if (session !== undefined) { + for (const frame of new SshSessionFrameDecoder().push(new Uint8Array(Buffer.from(cmd.data_b64, "base64")))) { + const inner = frame as { type?: string; ref?: string; path?: string }; + if (inner.ref === undefined) continue; + session.ingestBytes( + encodeSshSessionFrame({ + type: "result", + ref: inner.ref, + ok: true, + ...(inner.type === "stat_dir" + ? { data: { path: inner.path ?? "/home/x", isDirectory: true } } + : inner.type === "subshells_report" || inner.type === "close" + ? { data: [] } + : {}), + }), + ); + } + } + return undefined; + }, + ssh_session_close: () => ({ state: "closed" }), + }); +}); + +afterAll(async () => { + scripted.detach(); + for (const id of cleanup) + await db + .deleteFrom("nodes") + .where("id", "=", id) + .execute() + .catch(() => {}); + await deleteUserByEmailOrId(memberEmail).catch(() => {}); + await deleteUserByEmailOrId(foreignEmail).catch(() => {}); + await deleteUserByEmailOrId(adminEmail).catch(() => {}); +}); + +describe("the member-owner door (the widening)", () => { + it("lists sessions for a NON-admin member (200, own rows)", async () => { + const res = await get("/api/ssh-runtime/sessions", { cookie: memberCookie }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.sessions).toEqual([]); + }); + + it("discovers aliases on the caller's own node", async () => { + const res = await get(`/api/ssh-runtime/discovery?nodeId=${node}`, { cookie: memberCookie }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.aliases).toEqual(["rtbox", "other"]); + }); + + it("resolves an alias into reviewable destination facts", async () => { + const res = await post("/api/ssh-runtime/resolve", { nodeId: node, alias: "rtbox" }, { cookie: memberCookie }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.accepted).toBe(true); + expect(res.body.connectingAccount).toBe("x"); + }); + + it("opens a session through its own node and reads it back", async () => { + const opened = await post("/api/ssh-runtime/sessions", openBody(), { cookie: memberCookie }); + expect(opened.status, JSON.stringify(opened.body)).toBe(200); + const view = opened.body as unknown as { id: string; status: string; runtimeNodeId: string }; + expect(view.status).toBe("active"); + cleanup.push(view.runtimeNodeId); + + const list = await get("/api/ssh-runtime/sessions", { cookie: memberCookie }); + const sessions = list.body.sessions as { id: string }[]; + expect(sessions.map((s) => s.id)).toContain(view.id); + + const detail = await get(`/api/ssh-runtime/sessions/${view.id}`, { cookie: memberCookie }); + expect(detail.status).toBe(200); + + // An unknown pane reads the 404 like a foreign one (the hidden runtime + // row is never listed; by-pane is the only door to its facts). + const byPaneMissing = await get(`/api/ssh-runtime/sessions/by-pane/${crypto.randomUUID()}`, { + cookie: memberCookie, + }); + expect(byPaneMissing.status, "an unknown pane reads the 404 like a foreign one").toBe(404); + + // An ordinary pane on the runtime node answers the identity line from the + // session row (server facts: host, port, user, connecting machine name). + const paneId = crypto.randomUUID(); + await subshells.create({ + id: paneId, + userId: memberId, + harnessId: "terminal", + name: "rt pane", + workingDir: "/home/x", + nodeId: view.runtimeNodeId, + status: "running", + alive: 1, + tmuxSocket: `sock-${paneId}`, + presetId: null, + }); + const identity = await get(`/api/ssh-runtime/sessions/by-pane/${paneId}`, { cookie: memberCookie }); + expect(identity.status, JSON.stringify(identity.body)).toBe(200); + expect(identity.body.sessionId).toBe(view.id); + expect(identity.body.status).toBe("active"); + expect(identity.body.host).toBe("10.9.8.7"); + expect(identity.body.port).toBe(22); + expect(identity.body.user).toBe("theo"); + expect(identity.body.connectingNodeName).toBe(`rt-${node.slice(0, 8)}`); + + // Closing the session is the owner's act; the row settles closed. + const closed = await post(`/api/ssh-runtime/sessions/${view.id}/close`, {}, { cookie: memberCookie }); + expect(closed.status, JSON.stringify(closed.body)).toBe(200); + const after = await get(`/api/ssh-runtime/sessions/${view.id}`, { cookie: memberCookie }); + expect(after.body.status).toBe("closed"); + }, 20_000); +}); + +describe("the harnesses family (cached mirror, detect, preset launch)", () => { + let liveSessionId = ""; + let claudePresetId = ""; + + beforeAll(async () => { + const opened = await post("/api/ssh-runtime/sessions", openBody(), { cookie: memberCookie }); + expect(opened.status, JSON.stringify(opened.body)).toBe(200); + const view = opened.body as unknown as { id: string; runtimeNodeId: string }; + liveSessionId = view.id; + cleanup.push(view.runtimeNodeId); + const preset = await new PresetsRepository(db).create({ + id: crypto.randomUUID(), // the repository does not mint ids; the create route does + userId: memberId, + harnessId: "claude-code", + name: `rt-preset-${crypto.randomUUID().slice(0, 8)}`, + description: null, + envJson: JSON.stringify({ ANTHROPIC_MODEL: "route-test-model" }), + flagsJson: JSON.stringify([]), + settingsJson: null, + configIsolation: 0, + }); + claudePresetId = preset.id; + }, 20_000); + + // The close is answered by the scripted node's runtime arm (the fake + // speaks the framed grammar now, so no deadline is ridden); the generous + // budget stays as the teardown's slack. + afterAll(async () => { + if (liveSessionId !== "") + await post(`/api/ssh-runtime/sessions/${liveSessionId}/close`, {}, { cookie: memberCookie }); + if (claudePresetId !== "") + await db + .deleteFrom("presets") + .where("id", "=", claudePresetId) + .execute() + .catch(() => {}); + }, 30_000); + + it("owner reads the cached mirror (empty on a fresh destination, online true)", async () => { + const res = await get(`/api/ssh-runtime/sessions/${liveSessionId}/harnesses`, { cookie: memberCookie }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.sessionId).toBe(liveSessionId); + expect(res.body.online).toBe(true); + expect(res.body.harnesses).toEqual([]); + expect(res.body.env).toEqual({}); + }); + + it("detect names its refusal: a runtime without the capability answers 409 detect_unsupported", async () => { + const res = await post(`/api/ssh-runtime/sessions/${liveSessionId}/harnesses/detect`, {}, { cookie: memberCookie }); + // The service refuses 409 (the capability fact); the house SSH-refusal + // convention renders destination refusals on the wire as 403 with the + // named sshCode (spec 23 pins the same shape for runtime_missing). + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.metadataSafe).toEqual({ sshCode: "detect_unsupported" }); + }); + + it("launch-harness refuses bad input BEFORE any row is written (unknown harness 409, foreign preset 404, mismatch 409)", async () => { + const unknown = await post( + `/api/ssh-runtime/sessions/${liveSessionId}/launch-harness`, + { harnessId: "not-a-harness", cwd: "/home/x" }, + { cookie: memberCookie }, + ); + // 403 like every named refusal on this family (the flat mapper, module doc). + expect(unknown.status, JSON.stringify(unknown.body)).toBe(403); + + const foreignPreset = await post( + `/api/ssh-runtime/sessions/${liveSessionId}/launch-harness`, + { harnessId: "claude-code", presetId: crypto.randomUUID(), cwd: "/home/x" }, + { cookie: memberCookie }, + ); + expect(foreignPreset.status, JSON.stringify(foreignPreset.body)).toBe(404); + + // A claude preset on the TERMINAL harness: same shape as the manager's + // create door ("Preset harness mismatch"), and nothing was written. + const mismatch = await post( + `/api/ssh-runtime/sessions/${liveSessionId}/launch-harness`, + { harnessId: "terminal", presetId: claudePresetId, cwd: "/home/x" }, + { cookie: memberCookie }, + ); + expect(mismatch.status, JSON.stringify(mismatch.body)).toBe(403); + + const sessionRow = await new SshRuntimeSessionsRepository(db).findById(liveSessionId); + const panes = await db + .selectFrom("subshells") + .selectAll() + .where("userId", "=", memberId) + .where("nodeId", "=", sessionRow?.runtimeNodeId ?? "no-such-node") + .execute(); + expect(sessionRow, "the session row exists for the node id the panes must not have").not.toBeNull(); + expect(panes.length).toBe(0); + }); + + it("a relative cwd answers 409 dir_relative, in dir_missing's 409 family (not a flat 403)", async () => { + // Review I6 follow-through: the service refuses 409, and the route mapper + // puts dir_relative with dir_missing (both directory-shape refusals) so the + // browser sees the same 409 it sees for a missing dir, not the flat 403 the + // destination-refusal convention carries elsewhere on this family. + const rel = await post( + `/api/ssh-runtime/sessions/${liveSessionId}/launch-terminal`, + { cwd: "relative/path" }, + { cookie: memberCookie }, + ); + expect(rel.status, JSON.stringify(rel.body)).toBe(409); + expect(rel.body.metadataSafe).toEqual({ sshCode: "dir_relative" }); + }); + + it("foreign member and non-owning admin answer the same 404 on all three verbs", async () => { + const probes: Promise[] = [ + get(`/api/ssh-runtime/sessions/${liveSessionId}/harnesses`, { cookie: foreignCookie }), + post(`/api/ssh-runtime/sessions/${liveSessionId}/harnesses/detect`, {}, { cookie: foreignCookie }), + post( + `/api/ssh-runtime/sessions/${liveSessionId}/launch-harness`, + { harnessId: "claude-code", cwd: "/home/x" }, + { cookie: foreignCookie }, + ), + ]; + const byAdmin: Promise[] = [ + get(`/api/ssh-runtime/sessions/${liveSessionId}/harnesses`, { cookie: adminCookie }), + post(`/api/ssh-runtime/sessions/${liveSessionId}/harnesses/detect`, {}, { cookie: adminCookie }), + post( + `/api/ssh-runtime/sessions/${liveSessionId}/launch-harness`, + { harnessId: "claude-code", cwd: "/home/x" }, + { cookie: adminCookie }, + ), + ]; + const f = await Promise.all(probes); + const a = await Promise.all(byAdmin); + for (let i = 0; i < 3; i++) { + expect(f[i]?.status).toBe(404); + expect(a[i]?.status).toBe(404); + expect(refusalOf(a[i] as Answer)).toBe(refusalOf(f[i] as Answer)); + } + }); +}); + +describe("no-oracle refusals (foreign 404, admin-without-ownership 404)", () => { + it("refuses node-scoped acts identically for an admin without ownership and a foreign member", async () => { + const byAdmin = await get(`/api/ssh-runtime/discovery?nodeId=${node}`, { cookie: adminCookie }); + const byForeign = await get(`/api/ssh-runtime/discovery?nodeId=${node}`, { cookie: foreignCookie }); + expect(byAdmin.status).toBe(404); + expect(byForeign.status).toBe(404); + expect(refusalOf(byAdmin)).toBe(refusalOf(byForeign)); + // The admin's OWN surface stays theirs: the empty list answers 200, so the + // 404 above is invisibility of someone else's machine, not a locked page. + const adminList = await get("/api/ssh-runtime/sessions", { cookie: adminCookie }); + expect(adminList.status).toBe(200); + }); + + it("refuses open identically (no ownership oracle) and foreign session reads 404", async () => { + // Open a member session to have a foreign id to aim at. + const opened = await post("/api/ssh-runtime/sessions", openBody(), { cookie: memberCookie }); + expect(opened.status).toBe(200); + const view = opened.body as unknown as { id: string; runtimeNodeId: string }; + cleanup.push(view.runtimeNodeId); + + const byAdmin = await post("/api/ssh-runtime/sessions", openBody(), { cookie: adminCookie }); + const byForeign = await post("/api/ssh-runtime/sessions", openBody(), { cookie: foreignCookie }); + expect(byAdmin.status).toBe(404); + expect(byForeign.status).toBe(404); + expect(refusalOf(byAdmin)).toBe(refusalOf(byForeign)); + + expect((await get(`/api/ssh-runtime/sessions/${view.id}`, { cookie: foreignCookie })).status).toBe(404); + expect((await post(`/api/ssh-runtime/sessions/${view.id}/close`, {}, { cookie: foreignCookie })).status).toBe(404); + expect( + (await post(`/api/ssh-runtime/sessions/${view.id}/list-dirs`, { path: "" }, { cookie: foreignCookie })).status, + ).toBe(404); + // by-pane for a FOREIGN pane: the session owner cannot read another + // account's identity line (and cannot tell the row exists). + const foreignPane = crypto.randomUUID(); + await subshells.create({ + id: foreignPane, + userId: foreignId, + harnessId: "terminal", + name: "foreign pane", + workingDir: "/home/y", + nodeId: view.runtimeNodeId, + status: "running", + alive: 1, + tmuxSocket: `sock-${foreignPane}`, + presetId: null, + }); + expect((await get(`/api/ssh-runtime/sessions/by-pane/${foreignPane}`, { cookie: memberCookie })).status).toBe(404); + + // Clean up the surviving session so the registry holds nothing for teardown. + const closed = await post(`/api/ssh-runtime/sessions/${view.id}/close`, {}, { cookie: memberCookie }); + expect(closed.status, JSON.stringify(closed.body)).toBe(200); + }, 20_000); +}); + +describe("machine credentials stay refused on the whole family", () => { + let bearer = ""; + beforeAll(async () => { + const paneId = crypto.randomUUID(); + await subshells.create({ + id: paneId, + userId: memberId, + harnessId: "claude-code", + name: "rt bearer probe", + workingDir: "/srv", + nodeId: node, + status: "running", + alive: 1, + tmuxSocket: `sock-${paneId}`, + presetId: null, + }); + bearer = await issueSubshellToken(paneId, memberId); + }); + + it("403s every endpoint of the family, before any ownership read", async () => { + const probes: Promise[] = [ + get("/api/ssh-runtime/sessions", { bearer }), + get("/api/ssh-runtime/locations", { bearer }), + post("/api/ssh-runtime/locations", { sessionId: "whatever", path: "/" }, { bearer }), + post("/api/ssh-runtime/locations/whatever/connect", {}, { bearer }), + call("/api/ssh-runtime/locations/whatever", { method: "DELETE" }, { bearer }), + get(`/api/ssh-runtime/discovery?nodeId=${node}`, { bearer }), + post("/api/ssh-runtime/resolve", { nodeId: node, alias: "rtbox" }, { bearer }), + post("/api/ssh-runtime/sessions", openBody(), { bearer }), + get("/api/ssh-runtime/sessions/whatever", { bearer }), + post("/api/ssh-runtime/sessions/whatever/close", {}, { bearer }), + post("/api/ssh-runtime/sessions/whatever/list-dirs", { path: "" }, { bearer }), + post("/api/ssh-runtime/sessions/whatever/launch-terminal", { cwd: "/" }, { bearer }), + get(`/api/ssh-runtime/sessions/by-pane/${crypto.randomUUID()}`, { bearer }), + get("/api/ssh-runtime/sessions/whatever/harnesses", { bearer }), + post("/api/ssh-runtime/sessions/whatever/harnesses/detect", {}, { bearer }), + post("/api/ssh-runtime/sessions/whatever/launch-harness", { harnessId: "claude-code", cwd: "/" }, { bearer }), + ]; + for (const probe of probes) { + const answer = await probe; + expect(answer.status, JSON.stringify(answer.body)).toBe(403); + } + }); +}); + +describe("durable personal SSH locations", () => { + it("saves a verified folder, scopes reads, reuses exactly and refuses alias drift", async () => { + const opened = await post("/api/ssh-runtime/sessions", openBody(), { cookie: memberCookie }); + expect(opened.status).toBe(200); + const sessionId = opened.body.id as string; + cleanup.push(opened.body.runtimeNodeId as string); + const saved = await post( + "/api/ssh-runtime/locations", + { sessionId, path: "/home/theo/project" }, + { cookie: memberCookie }, + ); + expect(saved.status, JSON.stringify(saved.body)).toBe(200); + expect(saved.body.path).toBe("/home/theo/project"); + expect(saved.body).not.toHaveProperty("identityFile"); + const id = saved.body.id as string; + const foreign = await get("/api/ssh-runtime/locations", { cookie: foreignCookie }); + expect(foreign.body.locations).toEqual([]); + expect((await post(`/api/ssh-runtime/locations/${id}/connect`, {}, { cookie: foreignCookie })).status).toBe(404); + const reused = await post(`/api/ssh-runtime/locations/${id}/connect`, {}, { cookie: memberCookie }); + expect(reused.status).toBe(200); + expect((reused.body.session as { id: string }).id).toBe(sessionId); + await post(`/api/ssh-runtime/sessions/${sessionId}/close`, {}, { cookie: memberCookie }); + resolvedHost = "10.9.8.99"; + const drift = await post(`/api/ssh-runtime/locations/${id}/connect`, {}, { cookie: memberCookie }); + expect(drift.status, JSON.stringify(drift.body)).toBe(409); + expect(drift.body.message).toContain("different destination"); + resolvedHost = "10.9.8.7"; + const reconnected = await post(`/api/ssh-runtime/locations/${id}/connect`, {}, { cookie: memberCookie }); + expect(reconnected.status, JSON.stringify(reconnected.body)).toBe(200); + const next = reconnected.body.session as { id: string; runtimeNodeId: string }; + expect(next.id).not.toBe(sessionId); + expect((reconnected.body.location as { path: string }).path).toBe("/home/theo/project"); + cleanup.push(next.runtimeNodeId); + await post(`/api/ssh-runtime/sessions/${next.id}/close`, {}, { cookie: memberCookie }); + await call(`/api/ssh-runtime/locations/${id}`, { method: "DELETE" }, { cookie: memberCookie }); + }); +}); diff --git a/apps/server/api/src/api/ssh-runtime/desktop-brokers.route.ts b/apps/server/api/src/api/ssh-runtime/desktop-brokers.route.ts new file mode 100644 index 000000000..b508fd8a9 --- /dev/null +++ b/apps/server/api/src/api/ssh-runtime/desktop-brokers.route.ts @@ -0,0 +1,31 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { listDesktopBrokers, pairDesktopBroker, revokeDesktopBroker } from "@/services/ssh-runtime/desktop-broker.js"; +import { assertCookieWriteOrigin } from "@/services/ssh-runtime/ssh-actor.js"; + +export const desktopBrokerRoutes = new Elysia() + .use(authGuard) + .get("/desktop-brokers", async ({ user, actor }) => { + requireCookieActor(actor, "Desktop SSH brokers require a signed-in person."); + return await listDesktopBrokers(user.id); + }) + .post( + "/desktop-brokers", + async ({ user, actor, request, body }) => { + requireCookieActor(actor, "Desktop SSH brokers require a signed-in person."); + assertCookieWriteOrigin(actor, request); + return await pairDesktopBroker(user.id, body); + }, + { + body: t.Object({ + name: t.String({ minLength: 1, maxLength: 60, pattern: "^[^\\u0000-\\u001f\\u007f]+$" }), + id: t.Optional(t.String()), + }), + }, + ) + .delete("/desktop-brokers/:id", async ({ user, actor, request, params }) => { + requireCookieActor(actor, "Desktop SSH brokers require a signed-in person."); + assertCookieWriteOrigin(actor, request); + await revokeDesktopBroker(user.id, params.id); + return { ok: true }; + }); diff --git a/apps/server/api/src/api/ssh-runtime/index.ts b/apps/server/api/src/api/ssh-runtime/index.ts new file mode 100644 index 000000000..335053276 --- /dev/null +++ b/apps/server/api/src/api/ssh-runtime/index.ts @@ -0,0 +1,20 @@ +import { Elysia } from "elysia"; +import { sshSavedLocationsRoutes } from "@/api/ssh-runtime/locations.route.js"; +import { sshRuntimeNodeVerbRoutes } from "@/api/ssh-runtime/node-verbs.route.js"; +import { sshRuntimeSessionsRoutes } from "@/api/ssh-runtime/sessions.route.js"; +import { desktopBrokerRoutes } from "./desktop-brokers.route.js"; + +/** + * `/api/ssh-runtime` - the Connect-over-SSH REST family (design 2026-10-05 + * §1/§7): the node-scoped pre-session reads (discovery, resolve) and the + * session verbs. Composition only: one file per resource, the shapes and the + * refusal mapping live beside the handlers. `routes.ts` folds this basket into + * the machine-side group (the TS2589 rule the ssh basket's comment records: a + * new sub-aggregate joins an existing group with headroom, never the root's + * fullest chain). + */ +export const sshRuntimeRoutes = new Elysia({ prefix: "/api/ssh-runtime" }) + .use(desktopBrokerRoutes) + .use(sshRuntimeNodeVerbRoutes) + .use(sshRuntimeSessionsRoutes) + .use(sshSavedLocationsRoutes); diff --git a/apps/server/api/src/api/ssh-runtime/locations.route.ts b/apps/server/api/src/api/ssh-runtime/locations.route.ts new file mode 100644 index 000000000..54aaacef6 --- /dev/null +++ b/apps/server/api/src/api/ssh-runtime/locations.route.ts @@ -0,0 +1,38 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { runtimeCall } from "@/api/ssh-runtime/sessions.route.js"; +import { + connectLocation, + savedLocationsRepository, + saveLocation, +} from "@/services/ssh-runtime/saved-locations.service.js"; +import { assertCookieWriteOrigin, buildSshCaller } from "@/services/ssh-runtime/ssh-actor.js"; + +const HUMAN_ONLY = "Saved SSH locations require a signed-in person."; +export const sshSavedLocationsRoutes = new Elysia({ prefix: "/locations" }) + .use(authGuard) + .get("/", async ({ user, actor }) => { + requireCookieActor(actor, HUMAN_ONLY); + return { locations: await savedLocationsRepository.list(user.id) }; + }) + .post( + "/", + async ({ user, actor, body, request }) => { + requireCookieActor(actor, HUMAN_ONLY); + assertCookieWriteOrigin(actor, request); + return await runtimeCall(() => saveLocation(user.id, body.sessionId, body.path)); + }, + { body: t.Object({ sessionId: t.String(), path: t.String({ maxLength: 4096 }) }) }, + ) + .delete("/:id", async ({ user, actor, params, request }) => { + requireCookieActor(actor, HUMAN_ONLY); + assertCookieWriteOrigin(actor, request); + await savedLocationsRepository.delete(params.id, user.id); + return { ok: true }; + }) + .post("/:id/connect", async (guard) => { + requireCookieActor(guard.actor, HUMAN_ONLY); + assertCookieWriteOrigin(guard.actor, guard.request); + const caller = await buildSshCaller(guard); + return await runtimeCall(() => connectLocation(caller, guard.params.id)); + }); diff --git a/apps/server/api/src/api/ssh-runtime/node-verbs.route.ts b/apps/server/api/src/api/ssh-runtime/node-verbs.route.ts new file mode 100644 index 000000000..0398c40f4 --- /dev/null +++ b/apps/server/api/src/api/ssh-runtime/node-verbs.route.ts @@ -0,0 +1,115 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SshErrorCodeSchema, SshSnapshotSchema } from "@/api/ssh-runtime/ssh-schemas.js"; +import { apiModels } from "@/schema/index.js"; +import { sshDiscovery, sshResolve } from "@/services/ssh-runtime/discovery.service.js"; +import { assertCookieWriteOrigin, buildSshCaller } from "@/services/ssh-runtime/ssh-actor.js"; + +/** + * `/api/ssh-runtime/discovery` and `/api/ssh-runtime/resolve` (design + * 2026-10-05 §7's selection UX): the two node-scoped reads the Connect-over- + * SSH wizard needs BEFORE a session exists - the alias list discovered on the + * caller's own machine, and the concrete `host:port user` a chosen alias + * resolves to. They are the same node RPC the old admin door rode (the + * salvage rule: the adapter is reused, not re-forked), reached through the + * SAME eligibility implementation `sshNodeGate` provides - real owner, no + * admin boost, no share; a foreign node 404s exactly like a missing one. + * + * The human gate is the whole product rule here: these acts belong to every + * signed-in person, not to admins and not to machine credentials. + * `requireCookieActor` refuses bearers with 403 at the route; the policy's + * own cookie arm stands behind it for defense in depth. + * + * These endpoints ride the existing `/api/ssh-runtime` aggregate rather than + * a new `.use()` link because the ssh basket's comment records the reason: a + * new sub-aggregate on the machine-side group re-trips the TS2589 depth + * ceiling; endpoints fold into what is already mounted. + */ + +/** The 403 machine credentials get on this family (mirrors the sessions route's line). */ +const HUMAN_ONLY = "SSH connections are managed from the browser by a signed-in person."; + +const DiscoveryQuerySchema = t.Object({ + nodeId: t.String({ description: "The connecting node to parse the account's SSH config on" }), +}); + +const DiscoveryViewSchema = t.Object({ + aliases: t.Array(t.String({ description: "One alias name" }), { + description: "Alias names, sorted, wildcard-only excluded", + }), + includeCycle: t.Boolean({ description: "An include cycle was detected during the bounded parse" }), + truncated: t.Boolean({ description: "The alias cap was hit; more exist" }), +}); + +const ResolveBodySchema = t.Object({ + nodeId: t.String({ description: "The connecting node to parse on" }), + alias: t.String({ description: "The alias to resolve" }), +}); + +const ResolveViewSchema = t.Union([ + t.Object({ + accepted: t.Literal(true, { description: "The alias normalized into reviewable destination facts" }), + snapshot: SshSnapshotSchema, + connectingAccount: t.Optional( + t.String({ description: "The connecting OS account, when the node could report it" }), + ), + }), + t.Object({ + accepted: t.Literal(false, { description: "Resolution refused; the code names the limitation" }), + code: SshErrorCodeSchema, + settings: t.Array(t.String({ description: "One config keyword that blocked" }), { + description: "Config keywords that blocked, when the code names several", + }), + }), +]); + +export const sshRuntimeNodeVerbRoutes = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/discovery", + async ({ query, user, actor, principal, apiKeyId }) => { + requireCookieActor(actor, HUMAN_ONLY); + return await sshDiscovery(await buildSshCaller({ actor, user, principal, apiKeyId }), query.nodeId); + }, + { + query: DiscoveryQuerySchema, + response: { + 200: DiscoveryViewSchema, + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "sshRuntimeDiscovery", + tags: ["ssh-runtime"], + description: "SSH config alias names on a node the caller owns (names only, never config contents)", + }, + }, + ) + .post( + "/resolve", + async ({ body, user, actor, principal, apiKeyId, request }) => { + requireCookieActor(actor, HUMAN_ONLY); + assertCookieWriteOrigin(actor, request); + return await sshResolve(await buildSshCaller({ actor, user, principal, apiKeyId }), body); + }, + { + body: ResolveBodySchema, + response: { + 200: ResolveViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "sshRuntimeResolve", + tags: ["ssh-runtime"], + description: + "Resolve one alias on an owned node into the concrete destination the session open will carry (a refusal is a 200 with a named code)", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh-runtime/sessions.route.ts b/apps/server/api/src/api/ssh-runtime/sessions.route.ts new file mode 100644 index 000000000..6041aeb37 --- /dev/null +++ b/apps/server/api/src/api/ssh-runtime/sessions.route.ts @@ -0,0 +1,462 @@ +import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { apiModels } from "@/schema/index.js"; +import { detectRuntimeSessionHarnesses, sessionHarnesses } from "@/services/ssh-runtime/harness-detect.js"; +import { getPaneIdentityView, openSession, SshRuntimeRefusal } from "@/services/ssh-runtime/sessions.service.js"; +import { + closeSession, + getSessionView, + listSessions, + sessionLaunchHarness, + sessionLaunchTerminal, + sessionListDirs, +} from "@/services/ssh-runtime/sessions-lifecycle.js"; + +/** + * `/api/ssh-runtime/sessions` - the personal Connect-over-SSH surface (design + * 2026-10-05 §1/§4/§7): open, view, list, close, list dirs, launch terminal, + * and the pane-identity read the terminal page draws its trusted line from. + * + * The auth is the product's, not the slice's: a signed-in HUMAN session, and + * within it only the owner's own rows and own nodes. `requireCookieActor` + * refuses every machine credential (bearer 403 on this family, no MCP door), + * and the ownership facts are the service's, stated once there: `openSession` + * requires the caller to really own the connecting node (foreign 404, no + * admin boost, no share), and every by-id verb reads through the owner's + * session - a foreign id and a gone id answer the same 404. + * + * The refusal mapping (module doc of the service states the equality rule): + * `SshRuntimeRefusal.status` carries the HTTP code the service decided + * (404 not-visible, 409 named refusal, 500/502 broken answer), and the named + * code rides as `metadataSafe.sshCode` - the equality table made visible to + * any client that wants to branch, never buried in prose. + */ + +/** Map a service refusal onto the house ApiError shape, code riding the metadata. */ +function refuseRuntime(err: SshRuntimeRefusal): never { + const code = + err.status === 404 + ? BackendErrorCodes.NOT_FOUND_ERROR + : err.status >= 500 + ? BackendErrorCodes.INTERNAL_SERVER_ERROR + : err.code === "session_quota" || + err.code === "session_in_use" || + err.code === "run_conflict" || + err.code === "dir_missing" || + err.code === "dir_relative" + ? BackendErrorCodes.EXISTS_ERROR + : BackendErrorCodes.ACCESS_DENIED; + throwApiError({ + code, + message: err.message, + doNotLog: true, + ...(err.code !== null ? { metadataSafe: { sshCode: err.code } } : {}), + }); +} + +/** Run a service call, translating its named refusal and letting anything else stand. */ +export async function runtimeCall(fn: () => Promise): Promise { + try { + return await fn(); + } catch (err) { + if (err instanceof SshRuntimeRefusal) refuseRuntime(err); + throw err; + } +} + +const HelloSchema = t.Object({ + type: t.Literal("hello", { description: "Frame discriminator (the parsed hello object, not a wire frame here)" }), + runtimeProtocol: t.Number({ description: "The runtime link's protocol (this plane speaks 1)" }), + agentVersion: t.String({ description: "The destination runtime binary's version" }), + os: t.String({ description: "Destination OS name as reported" }), + arch: t.String({ description: "Destination architecture as reported" }), + capabilities: t.Array(t.String({ description: "One advertised capability label" }), { + description: "Capability labels the runtime build advertises", + }), + homeDir: t.String({ description: "The destination account's home directory" }), + dataDir: t.String({ description: "The runtime's data dir (a `runtime/` namespace, isolated by design)" }), + tmuxSocket: t.String({ description: "The deterministic per-destination tmux socket the runtime serves" }), + paneCount: t.Number({ description: "Panes already on that socket at hello time (the reconcile count)" }), + // The runtime SENDS this (task 25's hello field, the launch's re-entry + // prefix); the optional keeps old rows honest (a hello stored before the + // field existed reads back without it). Published shape says true. + selfInvoke: t.Optional( + t.Object({ + command: t.String({ + description: "Absolute program path (or bare name) the pane uses to re-enter the runtime binary", + }), + args: t.Array(t.String({ description: "The self-invocation's prefix arguments" }), { + description: "Arguments placed before the verb (empty for a plain binary)", + }), + }), + ), +}); + +const SessionViewSchema = t.Object({ + id: t.String({ description: "Session id (the plane-minted broker ref)" }), + connectingNodeId: t.Nullable( + t.String({ description: "The node that brokered the SSH child; null once that machine row has been deleted" }), + ), + runtimeNodeId: t.String({ description: "The hidden runtime node row the session's panes carry" }), + alias: t.String({ description: "The config token chosen at review time" }), + host: t.String({ description: "Reviewed destination host" }), + port: t.Number({ description: "Reviewed destination port" }), + user: t.Nullable(t.String({ description: "Destination account; null = the connecting account's default" })), + status: t.Union([t.Literal("opening"), t.Literal("active"), t.Literal("lost"), t.Literal("closed")], { + description: "Session lifecycle (lost: panes unavailable, not completed)", + }), + hello: t.Nullable(HelloSchema), + createdAt: t.String({ description: "ISO 8601 open requested" }), + lastSeenAt: t.Nullable(t.String({ description: "ISO 8601 of the last proof of life" })), + closedAt: t.Nullable(t.String({ description: "ISO 8601 of the terminal transition" })), +}); + +const OpenBodySchema = t.Object({ + connectingNodeId: t.String({ description: "The enrolled node to broker through (must be the caller's own)" }), + target: t.Object({ + alias: t.String({ description: "The config token / label for the destination" }), + host: t.String({ description: "Destination host (reviewed at open; never free shell material)" }), + port: t.Number({ description: "Destination port, 1..65535" }), + user: t.Nullable(t.String({ description: "Destination account; null = the connecting account's own default" })), + identityFile: t.Nullable( + t.String({ description: "Absolute identity-file ref on the connecting node; null = defaults" }), + ), + }), + runtimeCommand: t.Optional( + t.String({ description: "Slice seam: program override for the runtime entry point (default `subshell`)" }), + ), +}); + +const ListDirsBodySchema = t.Object({ + path: t.String({ description: "Directory to list; empty means the destination account's home" }), +}); + +const LaunchTerminalBodySchema = t.Object({ + cwd: t.String({ description: "Absolute destination working directory (validated by stat_dir on the destination)" }), + cols: t.Optional(t.Number({ description: "Initial columns, when the opener knows one" })), + rows: t.Optional(t.Number({ description: "Initial rows, when the opener knows one" })), +}); + +const LaunchHarnessBodySchema = t.Object({ + prompt: t.Optional( + t.String({ maxLength: 100000, description: "Optional initial prompt, delivered after the remote agent starts" }), + ), + harnessId: t.String({ description: "Harness plugin id to launch (resolved against this plane's manifests)" }), + presetId: t.Optional( + t.Nullable(t.String({ description: "The caller's own preset row to launch with; null/absent = presetless" })), + ), + cwd: t.String({ description: "Absolute destination working directory (the folder the browser picked)" }), + cols: t.Optional(t.Number({ description: "Initial columns, when the opener knows one" })), + rows: t.Optional(t.Number({ description: "Initial rows, when the opener knows one" })), +}); + +const DetectBodySchema = t.Object({ + harnessIds: t.Optional( + t.Array(t.String({ description: "One harness plugin id" }), { + description: "Narrow the probe to these harnesses; absent asks every enabled manifest", + }), + ), +}); + +/** The harnesses view: the hidden runtime row's cached mirror plus the live session's env answers (service: `RuntimeSessionHarnessesView`). */ +const HarnessesViewSchema = t.Object({ + sessionId: t.String({ description: "The session id the view is keyed by" }), + runtimeNodeId: t.String({ description: "The hidden runtime node row the mirror is stored on" }), + online: t.Boolean({ description: "Whether a live session backs this view right now" }), + harnesses: t.Array( + t.Object({ + harnessId: t.String({ description: "Harness plugin id (the detect spec's id)" }), + harnessName: t.String({ + description: "Display name from the plane's manifest (the id when the plugin is unknown)", + }), + installed: t.Boolean({ description: "The destination's binary answer: found and executable there" }), + binaryPath: t.Nullable(t.String({ description: "Resolved binary path when installed" })), + rawVersion: t.Nullable( + t.String({ + description: "Best available version text (plugin-parsed when a parser ran, raw probe output otherwise)", + }), + ), + reason: t.Nullable( + t.String({ description: 'Why it was not found ("not-on-path" | "override-invalid" | "no-binary")' }), + ), + checkedAt: t.Nullable(t.String({ description: "ISO 8601 stamp of the probe" })), + }), + { description: "The cached inventory rows (the requested subset when the detect verb named one)" }, + ), + env: t.Record(t.String({ description: "Manifest-declared env name" }), t.String({ description: "Its value there" }), { + description: + "The destination's answers for the plane-named env vars (empty until the first detect; retired with a lost session)", + }), +}); + +/** The 403 every machine credential gets on this family (no MCP door for this surface yet). */ +const HUMAN_ONLY = "SSH connections are managed from the browser by a signed-in person."; + +/** + * The pane page's trusted identity read (design §7): the destination facts + * and the connecting machine's NAME, resolved server-side from the rows. + * Never composed from anything the pane's output claims, and never from + * user text: the caller sends only the pane id they are already viewing. + */ +const PaneIdentityViewSchema = t.Object({ + sessionId: t.String({ description: "The session that brokered this pane" }), + status: t.Union([t.Literal("opening"), t.Literal("active"), t.Literal("lost"), t.Literal("closed")], { + description: + "Session lifecycle (lost: the line still names the destination; the panes are unavailable, not completed)", + }), + alias: t.String({ description: "The config token chosen at review time" }), + host: t.String({ description: "Destination host as opened" }), + port: t.Number({ description: "Destination port as opened" }), + user: t.Nullable(t.String({ description: "Destination account; null = the connecting account's default" })), + connectingNodeName: t.Nullable( + t.String({ description: "The broker's machine name; null when that node row has since been deleted" }), + ), +}); + +export const sshRuntimeSessionsRoutes = new Elysia({ prefix: "/sessions" }) + .use(authGuard) + .use(apiModels) + .post( + "/", + async ({ body, user, actor }) => { + requireCookieActor(actor, HUMAN_ONLY); + return await runtimeCall(() => openSession(user.id, body)); + }, + { + body: OpenBodySchema, + response: { + 200: SessionViewSchema, + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "sshRuntimeOpenSession", + tags: ["ssh-runtime"], + description: + "Open a brokered SSH runtime session through an owned node: probe the destination runtime, spawn the SSH child, and answer with the runtime hello (protocol mismatch and an absent runtime refuse by name)", + }, + }, + ) + .get( + "/", + async ({ user, actor }) => { + requireCookieActor(actor, HUMAN_ONLY); + return { sessions: await runtimeCall(() => listSessions(user.id)) }; + }, + { + response: { + 200: t.Object({ sessions: t.Array(SessionViewSchema, { description: "The caller's sessions, newest first" }) }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, + detail: { + operationId: "sshRuntimeListSessions", + tags: ["ssh-runtime"], + description: "The caller's session history", + }, + }, + ) + .get( + "/by-pane/:subshellId", + async ({ params, user, actor }) => { + requireCookieActor(actor, HUMAN_ONLY); + return await runtimeCall(() => getPaneIdentityView(params.subshellId, user.id)); + }, + { + params: t.Object({ + subshellId: t.String({ description: "Ordinary subshell id whose node is a runtime session" }), + }), + response: { + 200: PaneIdentityViewSchema, + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "sshRuntimePaneIdentity", + tags: ["ssh-runtime"], + description: + "The trusted destination line for one pane opened through an SSH session (session owner only; an ordinary pane or a foreign row reads the same 404)", + }, + }, + ) + .get( + "/:id", + async ({ params, user, actor }) => { + requireCookieActor(actor, HUMAN_ONLY); + return await runtimeCall(() => getSessionView(params.id, user.id)); + }, + { + params: t.Object({ id: t.String({ description: "Session id" }) }), + response: { + 200: SessionViewSchema, + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "sshRuntimeGetSession", + tags: ["ssh-runtime"], + description: "One session view (owner only)", + }, + }, + ) + .post( + "/:id/close", + async ({ params, user, actor }) => { + requireCookieActor(actor, HUMAN_ONLY); + await runtimeCall(() => closeSession(params.id, user.id)); + return { ok: true as const }; + }, + { + params: t.Object({ id: t.String({ description: "Session id" }) }), + response: { + 200: t.Object({ + ok: t.Literal(true, { + description: "The close was ordered (panes on the destination keep running by design)", + }), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "sshRuntimeCloseSession", + tags: ["ssh-runtime"], + description: "Close a session: the runtime reports and exits; the destination tmux and its panes stay up", + }, + }, + ) + .post( + "/:id/list-dirs", + async ({ params, body, user, actor }) => { + requireCookieActor(actor, HUMAN_ONLY); + return await runtimeCall(() => sessionListDirs(params.id, user.id, body.path)); + }, + { + params: t.Object({ id: t.String({ description: "Session id" }) }), + body: ListDirsBodySchema, + response: { + 200: t.Object({ + path: t.String({ description: "Absolute realpath of the listed directory on the destination" }), + parent: t.Nullable(t.String({ description: "Parent directory; null at the filesystem root" })), + entries: t.Array( + t.Object({ + name: t.String({ description: "Entry name" }), + path: t.String({ description: "Absolute path of the entry" }), + kind: t.Literal("dir", { description: "Directories only, dotfiles hidden, like the node-link listing" }), + }), + { description: "The listed children, capped like the node-link listing" }, + ), + truncated: t.Boolean({ description: "True when the cap cut the listing" }), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "sshRuntimeListDirs", + tags: ["ssh-runtime"], + description: "One-level directory listing on the destination (the remote picker's read)", + }, + }, + ) + .post( + "/:id/launch-terminal", + async ({ params, body, user, actor }) => { + requireCookieActor(actor, HUMAN_ONLY); + return await runtimeCall(() => sessionLaunchTerminal(params.id, user.id, body)); + }, + { + params: t.Object({ id: t.String({ description: "Session id" }) }), + body: LaunchTerminalBodySchema, + response: { + 200: t.Object({ subshellId: t.String({ description: "The ordinary subshell row the runtime launched" }) }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "sshRuntimeLaunchTerminal", + tags: ["ssh-runtime"], + description: + "Open a terminal pane on the session: an ordinary subshells row on the hidden runtime node, launched through the framed byte channel", + }, + }, + ) + .get( + "/:id/harnesses", + async ({ params, user, actor }) => { + requireCookieActor(actor, HUMAN_ONLY); + return await runtimeCall(() => sessionHarnesses(params.id, user.id)); + }, + { + params: t.Object({ id: t.String({ description: "Session id" }) }), + response: { + 200: HarnessesViewSchema, + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "sshRuntimeSessionHarnesses", + tags: ["ssh-runtime"], + description: + "The cached harness inventory for the session's destination (a mirror read: works offline, answers from the hidden runtime node's row)", + }, + }, + ) + .post( + "/:id/harnesses/detect", + async ({ params, body, user, actor }) => { + requireCookieActor(actor, HUMAN_ONLY); + return await runtimeCall(() => detectRuntimeSessionHarnesses(params.id, user.id, body.harnessIds)); + }, + { + params: t.Object({ id: t.String({ description: "Session id" }) }), + body: DetectBodySchema, + response: { + 200: HarnessesViewSchema, + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "sshRuntimeDetectHarnesses", + tags: ["ssh-runtime"], + description: + "Ask the destination runtime what harness binaries and env vars it has NOW, merge the answer into the cached mirror (a runtime predating detect refuses with sshCode detect_unsupported)", + }, + }, + ) + .post( + "/:id/launch-harness", + async ({ params, body, user, actor }) => { + requireCookieActor(actor, HUMAN_ONLY); + return await runtimeCall(() => sessionLaunchHarness(params.id, user.id, body)); + }, + { + params: t.Object({ id: t.String({ description: "Session id" }) }), + body: LaunchHarnessBodySchema, + response: { + 200: t.Object({ + subshellId: t.String({ description: "The ordinary subshell row the runtime launched" }), + promptDelivered: t.Optional(t.Boolean({ description: "Whether the initial prompt reached the remote pane" })), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "sshRuntimeLaunchHarness", + tags: ["ssh-runtime"], + description: + "Open a harness pane on the session (optionally under the caller's own preset): an ordinary subshells row with the MCP registration and reporter hooks baked from the runtime's hello, never a credential (design §5)", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh-runtime/ssh-schemas.ts b/apps/server/api/src/api/ssh-runtime/ssh-schemas.ts new file mode 100644 index 000000000..04752e69a --- /dev/null +++ b/apps/server/api/src/api/ssh-runtime/ssh-schemas.ts @@ -0,0 +1,81 @@ +import { t } from "elysia"; + +/** + * The response-schema building blocks of the `/api/ssh-runtime` node verbs + * (re-homed from the retired `/api/ssh` family by Workstream C; design + * 2026-10-05 §7). What remains is the shape the wizard's resolve review and + * refusal mapping need: the named-code union and the approved normalized + * snapshot. The connection/run/grant/terminal views died with their routes. + */ + +/** + * The named SSH refusal set, as a schema arm (frozen in `ssh-errors.ts`). + * Written as an explicit tuple rather than `t.Union(SSH_ERROR_CODES.map(...))` + * because Elysia's `t.Union` return-type inference needs a non-empty tuple to + * narrow a handler's `MaybePromise<...>`; a `TLiteral[]` array collapses the + * route's declared return type and makes every consumer of this schema fall + * through to "must return a Response". The members mirror `SSH_ERROR_CODES` + * exactly; the frozen `isSshErrorCode` remains the runtime authority, and a + * coordinated code addition edits both. + */ +export const SshErrorCodeSchema = t.Union([ + t.Literal("unsupported_setting"), + t.Literal("config_missing"), + t.Literal("config_ambiguous"), + t.Literal("host_key_unknown"), + t.Literal("host_key_changed"), + t.Literal("host_key_revoked"), + t.Literal("key_unavailable"), + t.Literal("auth_mode_unsupported"), + t.Literal("proxy_chain_too_long"), + t.Literal("stale_command"), + t.Literal("run_conflict"), + t.Literal("run_unknown"), + t.Literal("connection_failed"), + // The session-runtime additions (design 2026-10-05); the header's rule in + // force: this tuple mirrors `SSH_ERROR_CODES`, so a code addition edits both. + t.Literal("runtime_missing"), + t.Literal("session_quota"), + t.Literal("session_protocol"), + t.Literal("session_in_use"), +]); + +/** One ProxyJump hop: a destination and nothing else (the reviewability rule). */ +export const SshHopSchema = t.Object({ + host: t.String({ description: "Hop hostname" }), + user: t.Nullable(t.String({ description: "Hop user; null = the connecting account's default" })), + port: t.Number({ description: "Hop port, already resolved (22 when the config omits Port)" }), +}); + +/** The eight absent-forbidden members, each a literal-null schema (present-non-null is refused by the parser). */ +const forbidden = (why: string) => t.Null({ description: `Always null; ${why}` }); + +/** The approved normalized snapshot, exactly the frozen `SshConnectionSnapshotWire` shape. */ +export const SshSnapshotSchema = t.Object({ + alias: t.String({ description: "The config token the human chose (display/review context, never routing input)" }), + host: t.String({ description: "Resolved destination hostname" }), + user: t.Nullable(t.String({ description: "Destination user; null = the connecting account's own default" })), + port: t.Number({ description: "Destination port, resolved (1..65535)" }), + identityFiles: t.Array(t.String({ description: "Absolute identity file PATH on the connecting node" }), { + description: "Identity file references (absolute paths, never contents)", + }), + certificateFiles: t.Array(t.String({ description: "Absolute certificate file PATH on the connecting node" }), { + description: "Certificate file references (path-only, like identities)", + }), + authAgentSocket: t.Nullable( + t.String({ description: "Absolute auth-agent socket path from the connecting account's setup" }), + ), + knownHostsFiles: t.Array(t.String({ description: "Absolute known-hosts file path" }), { + description: "Known-hosts files consulted for trust", + }), + hostKeyAlias: t.Nullable(t.String({ description: "OpenSSH HostKeyAlias when the config sets one" })), + proxyJumps: t.Array(SshHopSchema, { description: "Bounded ProxyJump chain, outermost first" }), + proxyCommand: forbidden("a resolved ProxyCommand makes a snapshot REFUSABLE, never renderable"), + forwards: forbidden("forwards found at resolution fail setup"), + tunnels: forbidden("tunnels found at resolution fail setup"), + localCommands: forbidden("local commands found at resolution fail setup"), + remoteCommand: forbidden("RemoteCommand found at resolution fails setup"), + sendEnv: forbidden("SendEnv found at resolution fails setup"), + setEnv: forbidden("SetEnv found at resolution fails setup"), + escapes: forbidden("live escape characters fail setup"), +}); diff --git a/apps/server/api/src/api/subshells/__tests__/subshells-runtime-seal.route.test.ts b/apps/server/api/src/api/subshells/__tests__/subshells-runtime-seal.route.test.ts new file mode 100644 index 000000000..7c9104d32 --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/subshells-runtime-seal.route.test.ts @@ -0,0 +1,109 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { subshellRoutes } from "@/api/subshells/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +/** + * The runtime seal at the HTTP surface (design 2026-10-05 §4, review M4): + * ordinary create and restart must refuse a `runtime`-kind node BY NAME. + * + * The invariant this pins is the load-bearing one the whole callback design + * rests on: "the token never leaves the plane". Today the accident that keeps + * plain launches off runtime rows is `RuntimeSessionLauncher.resolveBinary` + * answering null; the seal replaces the accident with the rule, so the day a + * later workstream gives that launcher a resolver, the bearer token and the + * plane URL baked into the launch env CANNOT ride onto a destination that must + * never learn either. A restart is a launch, so the same refusal stands on + * that door too - replacing what used to be the honest-but-useless "Harness is + * not installed on this machine" from a pane that can never be restarted. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(subshellRoutes); +const nodes = new NodesRepository(db); +const subshells = new SubshellsRepository(db); + +const pw = "rtseal-pass-1"; +const email = `rtseal-${crypto.randomUUID()}@subshell.local`; +let userId: string; +let cookie: string; +const nodeIds: string[] = []; +const subshellIds: string[] = []; + +async function mkRuntimeNode(): Promise { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ id, ownerUserId: userId, name: `rs-${id.slice(0, 8)}`, kind: NODE_KIND_RUNTIME }); + return id; +} + +async function mkPaneOn(nodeId: string): Promise { + const id = crypto.randomUUID(); + subshellIds.push(id); + await subshells.create({ + id, + userId, + presetId: null, + harnessId: "terminal", + name: `rs-${id.slice(0, 8)}`, + workingDir: "/tmp", + tmuxSocket: `rs-sock-${id}`, + nodeId, + alive: 0, + }); + return id; +} + +async function post(path: string, body?: unknown): Promise { + return app.fetch( + new Request(`http://localhost:3080${path}`, { + method: "POST", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${cookie}` }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + ); +} + +beforeAll(async () => { + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + cookie = await signIn(email, pw); +}); + +afterAll(async () => { + for (const id of subshellIds) await db.deleteFrom("subshells").where("id", "=", id).execute(); + for (const id of nodeIds) await nodes.deleteById(id); + await deleteUserByEmailOrId(email); +}); + +describe("the runtime seal on /api/subshells (review M4)", () => { + it("create naming a runtime row → 409, the refusal NAMES the runtime path", async () => { + const runtime = await mkRuntimeNode(); + const res = await post("/api/subshells", { harnessId: "terminal", workingDir: "/tmp", nodeId: runtime }); + expect(res.status).toBe(409); + const body = (await res.json()) as { message: string }; + expect(body.message).toContain("Runtime sessions launch through the SSH runtime path only"); + }); + + it("restart of a pane whose node is a runtime row → 409 naming the seal, never the resolveBinary accident", async () => { + const runtime = await mkRuntimeNode(); + const id = await mkPaneOn(runtime); + const res = await post(`/api/subshells/${id}/restart`); + expect(res.status).toBe(409); + const body = (await res.json()) as { message: string }; + expect(body.message).toContain("Runtime sessions launch through the SSH runtime path only"); + // The accident this replaces: the harness word must never be the answer. + expect(body.message).not.toMatch(/not installed/i); + }); +}); diff --git a/apps/server/api/src/api/subshells/index.ts b/apps/server/api/src/api/subshells/index.ts index 89b17f3ad..caec4ab1a 100644 --- a/apps/server/api/src/api/subshells/index.ts +++ b/apps/server/api/src/api/subshells/index.ts @@ -17,6 +17,22 @@ import { terminateSubshellRoute } from "@/api/subshells/terminate-subshell.route import { updateSubshellNameRoute } from "@/api/subshells/update-subshell-name.route.js"; import { updateSubshellNotifyRoute } from "@/api/subshells/update-subshell-notify.route.js"; +/** + * The three HARNESS SELF-REPORT doors (`attention`, `harness-session`, `exit` + * - "a harness reports about itself", security-context's exact trio), grouped + * into one sub-aggregate. The grouping is structural, not stylistic: Elysia's + * composed type is a LEFT-NESTED merge, so flattening these three onto the + * main chain pushed `App = ReturnType` past TypeScript's + * instantiation-depth ceiling (TS2589) - exactly the trap `api/routes.ts` + * documents and answers with balanced sub-aggregates. Nesting this trio into a + * side basket costs the main chain one merge instead of three, and the + * endpoints and their types are untouched. + */ +const selfReportRoutes = new Elysia() + .use(subshellAttentionRoute) + .use(subshellHarnessSessionRoute) + .use(subshellExitRoute); + /** * `/api/subshells` — one Elysia instance per endpoint, mounted in the original * monolithic route's order (convention, not a router constraint: Elysia ranks @@ -32,9 +48,7 @@ export const subshellRoutes = new Elysia({ prefix: "/api/subshells" }) .use(getSubshellLogRoute) .use(subshellSharesRoutes) .use(updateSubshellNotifyRoute) - .use(subshellAttentionRoute) - .use(subshellHarnessSessionRoute) - .use(subshellExitRoute) + .use(selfReportRoutes) .use(updateSubshellNameRoute) .use(inputSubshellRoute) .use(execSubshellRoute) diff --git a/apps/server/api/src/api/subshells/list-subshells.route.ts b/apps/server/api/src/api/subshells/list-subshells.route.ts index 00cbf7781..df0a8ea0f 100644 --- a/apps/server/api/src/api/subshells/list-subshells.route.ts +++ b/apps/server/api/src/api/subshells/list-subshells.route.ts @@ -17,7 +17,7 @@ export const listSubshellsRoute = new Elysia() // siblings); boundary pinned by __tests__ (subshells-list-visibility // .test.ts, enumerate-ok / act-denied) — do not tighten without // updating docs/security.md §3. - return await ctx.services.subshells.listSubshells(user.id); + return await ctx.services.subshells.listSubshells(user.id, {}); }, { response: { diff --git a/apps/server/api/src/api/uploads.route.ts b/apps/server/api/src/api/uploads.route.ts index d7453abac..776277e5b 100644 --- a/apps/server/api/src/api/uploads.route.ts +++ b/apps/server/api/src/api/uploads.route.ts @@ -4,8 +4,9 @@ import { MAX_UPLOAD_BYTES } from "@internal/subshell-protocol"; import { Elysia, t } from "elysia"; import { authGuard, ForbiddenError } from "@/api/auth-guard.js"; import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; -import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { LOCAL_NODE_ID, NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; import { apiErrorBody } from "@/lib/api-error.js"; import { apiModels } from "@/schema/index.js"; import { isNodeOffline } from "@/services/nodes/node-registry.js"; @@ -79,6 +80,28 @@ export const uploadsRoutes = new Elysia({ prefix: "/api/subshells" }) // copy of the path (or refusing because this host has no such dir) // would be meaningless. if (row.nodeId !== LOCAL_NODE_ID) { + // A `runtime` node is a session's hidden destination, reached ONLY + // through the session's framed channel (design 2026-10-05 §2's frame + // set): `write_file` is not a session frame, so no upload can EVER + // reach this pane. Refuse by kind, by name, before any RPC is + // composed (review m3): the offline-409 below says "start it" (a + // runtime node has no start) and once the blessed predicate learned + // to read a live session as online (I3), this branch instead + // composed the unreachable call and answered NODE_UNREACHABLE with + // restart advice the runtime seal (`subshells.service.ts`) makes + // impossible. Kind-first answers both eras honestly; the local and + // ordinary-agent paths below are untouched. + const node = await new NodesRepository(db).findById(row.nodeId); + if (node?.kind === NODE_KIND_RUNTIME) { + return status( + 409, + apiErrorBody({ + code: BackendErrorCodes.RUNTIME_UPLOAD_UNSUPPORTED, + message: + "File upload is not supported for Connect-over-SSH panes in this version. Put the file on the destination yourself, or launch the pane on a connected machine.", + }), + ); + } // Pre-gate the live connection (§5.6 — the blessed `isNodeOffline` // predicate, same deferral rule as the auto-restart): a dead node is // 409 before a single chunk goes out. Mid-stream drops land in the diff --git a/apps/server/api/src/api/workspaces/__tests__/workspaces-route.test.ts b/apps/server/api/src/api/workspaces/__tests__/workspaces-route.test.ts index 71f67f95d..ea0805114 100644 --- a/apps/server/api/src/api/workspaces/__tests__/workspaces-route.test.ts +++ b/apps/server/api/src/api/workspaces/__tests__/workspaces-route.test.ts @@ -7,7 +7,9 @@ import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { registerSession, resetSessionRegistryForTests } from "@/services/ssh-runtime/session-registry.js"; import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { mkRuntimeSession } from "@/test-helpers/runtime-session.js"; import { authedRequest, deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; /** Creates a preset + subshell owned by `userId`, returning the subshell id. */ @@ -714,4 +716,58 @@ describe("workspaces route", () => { if (apiKeyId) authDatabase().run(`DELETE FROM apikey WHERE id = ?`, [apiKeyId]); }); }); + it("a pane on a LIVE runtime session is not offline in the grid view (review I3)", async () => { + // The grid swaps a pane's terminal for an "offline, reconnecting" panel + // from `subshellNodeOffline`; the hidden runtime node is never in the WS + // registry, so the predicate must also see the live session. + const token = await signIn(ownerEmail, password); + const session = mkRuntimeSession({ ownerId }); + registerSession(session); + const preset = await new PresetsRepository(db).create({ + id: crypto.randomUUID(), + userId: ownerId, + harnessId: "claude-code", + name: `p-${crypto.randomUUID().slice(0, 8)}`, + description: null, + envJson: null, + flagsJson: null, + settingsJson: null, + configIsolation: 0, + }); + const subshellId = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: subshellId, + userId: ownerId, + presetId: preset.id, + harnessId: "claude-code", + name: "runtime-pane", + workingDir: "/tmp", + tmuxSocket: null, + nodeId: session.runtimeNodeId, + }); + const wsId = await createWorkspace(token, `i3-${crypto.randomUUID().slice(0, 8)}`); + const added = await workspaceRoutes.fetch( + authedRequest(`/api/workspaces/${wsId}/panes`, token, { + method: "POST", + body: JSON.stringify({ subshellId }), + }), + ); + expect(added.status).toBe(200); + + type PaneRow = { subshellNodeId: string; subshellNodeOffline: boolean }; + const readPanes = async (): Promise => { + const res = await workspaceRoutes.fetch(authedRequest(`/api/workspaces/${wsId}`, token)); + return ((await res.json()) as { panes: PaneRow[] }).panes; + }; + const [live] = await readPanes(); + expect(live?.subshellNodeId).toBe(session.runtimeNodeId); + expect(live?.subshellNodeOffline, "the live session is the runtime node's liveness").toBe(false); + + // Gone is gone: once the session settles out of the registry the row + // reads offline like any other unreachable machine (no fake liveness). + session.markLost("child-lost"); // unwired hooks here: the registry row is cleared below + resetSessionRegistryForTests(); + const [offline] = await readPanes(); + expect(offline?.subshellNodeOffline).toBe(true); + }); }); diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 9f1bb6dae..4ec2ecbc0 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -388,16 +388,25 @@ test("real entry: available older upgrade snapshot restores and migrates before mkdirSync(backupDir, { mode: 0o700 }); const snapshot = join(backupDir, "subshell-v1.7.0-20260101-000000.db"); const db = new Database(instance.databasePath); - const migration = db.query("SELECT * FROM kysely_migration WHERE name='0046-backup-recovery'").get() as { + // Model a snapshot one migration behind. Remove the newest table and its + // provider marker together, so the restored snapshot is a contiguous schema. + const lastMigration = "0051-ssh-saved-locations"; + const locationSchema = db.query("SELECT sql FROM sqlite_master WHERE name='ssh_saved_locations'").get() as { + sql: string; + }; + const migration = db.query(`SELECT * FROM kysely_migration WHERE name='${lastMigration}'`).get() as { name: string; timestamp: string; }; db.exec( - "DROP TABLE backup_recovery; DELETE FROM kysely_migration WHERE name='0046-backup-recovery'; CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + `DROP TABLE ssh_saved_locations; + DELETE FROM kysely_migration WHERE name='${lastMigration}'; + CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');`, ); db.query("VACUUM INTO ?").run(snapshot); + db.exec(locationSchema.sql); db.exec( - "CREATE TABLE backup_recovery(user_id TEXT PRIMARY KEY REFERENCES user(id)); UPDATE migration_restore_probe SET value='newer destination';", + "CREATE INDEX ssh_saved_locations_owner ON ssh_saved_locations(owner_user_id); UPDATE migration_restore_probe SET value='newer destination';", ); db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(migration.name, migration.timestamp); db.close(); @@ -407,17 +416,18 @@ test("real entry: available older upgrade snapshot restores and migrates before const restored = ok(await cli(["restore", snapshot, "--yes", "--no-start", "--json"], instance.configDir)); expect(restored).toMatchObject({ status: "pending-boot", legacyDatabaseOnly: true }); const offline = new Database(instance.databasePath, { readonly: true }); - expect(offline.query("SELECT name FROM kysely_migration WHERE name='0046-backup-recovery'").get()).toBeNull(); + expect(offline.query(`SELECT name FROM kysely_migration WHERE name='${lastMigration}'`).get()).toBeNull(); offline.close(); const running = await boot(instance.configDir, port); const migrated = new Database(instance.databasePath, { readonly: true }); - expect(migrated.query("SELECT name FROM kysely_migration WHERE name='0046-backup-recovery'").get()).toMatchObject({ - name: "0046-backup-recovery", + expect(migrated.query(`SELECT name FROM kysely_migration WHERE name='${lastMigration}'`).get()).toMatchObject({ + name: lastMigration, }); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); - expect(migrated.query("SELECT name FROM sqlite_schema WHERE name='backup_recovery'").get()).toEqual({ - name: "backup_recovery", - }); + // The snapshot lacked this table, proving boot ran the restored migration. + expect( + migrated.query("SELECT name FROM sqlite_master WHERE type='table' AND name='ssh_saved_locations'").get(), + ).toEqual({ name: "ssh_saved_locations" }); migrated.close(); expect(readFileSync(join(instance.configDir, "config.env"), "utf8")).toBe(originalConfig); const deadline = Date.now() + 15_000; diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 9d68dc0b0..1b651e2c9 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -46,6 +46,11 @@ import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cros import * as oauthCallbackBaseMigration from "@/db/migrations/0044-oauth-callback-base.js"; import * as nodeReregistrationMigration from "@/db/migrations/0045-node-reregistration.js"; import * as backupRecoveryMigration from "@/db/migrations/0046-backup-recovery.js"; +import * as sshConnectionsMigration from "@/db/migrations/0047-ssh-connections.js"; +import * as sshExecutionMigration from "@/db/migrations/0048-ssh-execution.js"; +import * as sshRuntimeSessionsMigration from "@/db/migrations/0049-ssh-runtime-sessions.js"; +import * as sshRetirementMigration from "@/db/migrations/0050-ssh-retirement.js"; +import * as sshSavedLocationsMigration from "@/db/migrations/0051-ssh-saved-locations.js"; /** * Runs all pending Kysely migrations against the app database. @@ -132,6 +137,22 @@ export async function runMigrations(): Promise { "0044-oauth-callback-base": oauthCallbackBaseMigration, "0045-node-reregistration": nodeReregistrationMigration, "0046-backup-recovery": backupRecoveryMigration, + // SSH feature (SSH-SUPPORT.md §4): config + grants, then the + // execution half. Gate A landed the schema for the destination + // product; Workstream C retired it (design 2026-10-05 §7), and + // migration 0050 drops these five tables on the same chain. + // 0047/0048 stay byte-identical: Kysely is forward-only and an + // older database must still replay them exactly. + "0047-ssh-connections": sshConnectionsMigration, + "0048-ssh-execution": sshExecutionMigration, + // SSH runtime sessions (design 2026-10-05 §4): brokered destination + // sessions + the hidden `runtime` node rows their panes carry. The + // `runtime` kind needs no ALTER (0017's `kind` is plain text). + "0049-ssh-runtime-sessions": sshRuntimeSessionsMigration, + // The retirement drop (0050): the destination product's five + // tables go; the runtime-session table from 0049 stays. + "0050-ssh-retirement": sshRetirementMigration, + "0051-ssh-saved-locations": sshSavedLocationsMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0047-ssh-connections.ts b/apps/server/api/src/db/migrations/0047-ssh-connections.ts new file mode 100644 index 000000000..07af2e580 --- /dev/null +++ b/apps/server/api/src/db/migrations/0047-ssh-connections.ts @@ -0,0 +1,105 @@ +import { type Kysely, sql } from "kysely"; + +/** + * SSH config half of the Gate A persistence contract (SSH-SUPPORT.md §4): + * `ssh_connections` (owner + connecting node + approved normalized snapshot + * + revision) and `ssh_grants` (per-pane, per-credential, per-revision + * authorization). The execution half (runs, panes, terminal execs) is 0048. + * + * Why these index/uniqueness choices - each one answers a revocation or + * reconciliation query the policy must be able to ask cheaply and correctly: + * + * - `idx_ssh_connections_user` / `_node`: the owner's settings list, and the + * "is this node still referenced before a node delete" fact. Node deletes + * CASCADE connections: the connection is a resource ON that machine, and an + * orphaned snapshot pointing at a removed node can never dispatch again. + * Runs survive the cascade via SET NULL + their immutable snapshot copy + * (0048). + * - `uq_ssh_grants_active` (PARTIAL UNIQUE on connection/pane/api-key WHERE + * revoked_at IS NULL): the spec's "unique ACTIVE binding for that tuple". + * Partial rather than full because a revoked grant STAYS while its rows + * are reachable (re-granting the same tuple after revocation must be + * possible, exactly the soft-state posture of 0029's partial unique + * index) - but it does NOT survive forever: `subshell_id` CASCADEs, so + * deleting the pane takes its grant rows, active and revoked, with it, + * and `ssh_runs.grant_id` SET NULLs. Workstream D must not build + * reconciliation on "grant id resolves forever": the pane row's + * `api_key_id` is the survivor that answers "who authorized this", and + * the grant row is the live binding only while it exists. + * - `idx_ssh_grants_pane` (partial, active only): the pane-delete cascade's + * companion read - "which live grants does this pane hold" on terminate, + * and the revocation sweep ("which grants die when this row's credential + * rotates"). + * - `idx_ssh_grants_connection` (connection, revision): reconciliation - + * "which active grants point at a revision that no longer matches the + * connection's current one" is the edit-invalidates-grants query, asked + * whenever a revision moves. + * + * Timestamps ride the house `strftime('%Y-%m-%dT%H:%M:%fZ','now')` default, + * the `0040-prompts` spelling (the space-separated `datetime('now')` form is + * the bug that sorts below ISO rows; see AGENTS.md). + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("ssh_connections") + .addColumn("id", "text", (col) => col.primaryKey()) + // user_id / granted_by_user_id are PLAIN text by house convention (the + // 0001-init posture: `subshells.user_id` and `nodes.owner_user_id` carry + // no FK). A real FK here would make the node-delete cascade prepare + // better-auth's `user` table, which by design does not exist in an + // app-migrations-only database (the 0036 boot-map test), and the app + // never deletes users anyway - the link is ownership metadata, enforced + // in the repository layer. + .addColumn("user_id", "text", (col) => col.notNull()) + .addColumn("node_id", "text", (col) => col.notNull().references("nodes.id").onDelete("cascade")) + .addColumn("display_name", "text", (col) => col.notNull()) + .addColumn("config_snapshot", "text", (col) => col.notNull()) + .addColumn("remote_dir", "text") + .addColumn("revision", "integer", (col) => col.notNull().defaultTo(1)) + .addColumn("created_at", "text", (col) => col.notNull().defaultTo(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`)) + .addColumn("updated_at", "text", (col) => col.notNull().defaultTo(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`)) + .execute(); + await db.schema.createIndex("idx_ssh_connections_user").on("ssh_connections").columns(["user_id"]).execute(); + await db.schema.createIndex("idx_ssh_connections_node").on("ssh_connections").columns(["node_id"]).execute(); + + await db.schema + .createTable("ssh_grants") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("connection_id", "text", (col) => col.notNull().references("ssh_connections.id").onDelete("cascade")) + .addColumn("connection_revision", "integer", (col) => col.notNull()) + .addColumn("subshell_id", "text", (col) => col.notNull().references("subshells.id").onDelete("cascade")) + .addColumn("api_key_id", "text", (col) => col.notNull()) + .addColumn("granted_by_user_id", "text", (col) => col.notNull()) + .addColumn("granted_at", "text", (col) => col.notNull().defaultTo(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`)) + .addColumn("revoked_at", "text") + .execute(); + + // Kysely's index builder cannot express a partial index (the 0029 posture), + // so the two WHERE-carrying indexes are raw sql. + await sql` + CREATE UNIQUE INDEX uq_ssh_grants_active + ON ssh_grants (connection_id, subshell_id, api_key_id) + WHERE revoked_at IS NULL + `.execute(db); + await sql` + CREATE INDEX idx_ssh_grants_pane + ON ssh_grants (subshell_id) + WHERE revoked_at IS NULL + `.execute(db); + await db.schema + .createIndex("idx_ssh_grants_connection") + .on("ssh_grants") + .columns(["connection_id", "connection_revision"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + // Reverse creation order: grants first (it references connections). + await db.schema.dropIndex("idx_ssh_grants_connection").ifExists().execute(); + await sql`DROP INDEX IF EXISTS idx_ssh_grants_pane`.execute(db); + await sql`DROP INDEX IF EXISTS uq_ssh_grants_active`.execute(db); + await db.schema.dropTable("ssh_grants").execute(); + await db.schema.dropIndex("idx_ssh_connections_node").ifExists().execute(); + await db.schema.dropIndex("idx_ssh_connections_user").ifExists().execute(); + await db.schema.dropTable("ssh_connections").execute(); +} diff --git a/apps/server/api/src/db/migrations/0048-ssh-execution.ts b/apps/server/api/src/db/migrations/0048-ssh-execution.ts new file mode 100644 index 000000000..e4194e3c6 --- /dev/null +++ b/apps/server/api/src/db/migrations/0048-ssh-execution.ts @@ -0,0 +1,137 @@ +import { type Kysely, sql } from "kysely"; + +/** + * SSH execution half of the Gate A persistence contract (SSH-SUPPORT.md §4): + * `ssh_runs` (dispatched structured commands), `ssh_panes` (managed SSH + * terminals and their control state) and `ssh_terminal_execs` (the + * exec_in_terminal recovery record). The config half is 0047. + * + * Index rationale, one query per index (all of them are revocation, + * reconciliation, or quota reads the policy and sweeps must ask): + * + * - `idx_ssh_runs_user_created`: the owner's run list, newest first. + * - `idx_ssh_runs_connection`: "is work active on this connection" - the + * connection delete/refuse-edit check (spec §4's "deletion refused while + * work is active") asks it by connection, not by owner. + * - `idx_ssh_runs_active` (partial: status IN accepted|running): the quota + * read this migration exists for - active runs per (user, node) against 4 + * and per node against 16 - and the revoke-cancellation sweep ("cancel the + * runs THIS credential initiated", spec §2) rides the companion + * `idx_ssh_runs_grant` partial below it. Partial because active rows are + * the small, hot set and completed history only grows. + * - `ssh_panes` is keyed BY the subshell id: the generic-pane-surface gate is + * "does this pane have a managed row", one primary-key read on the hottest + * path in the feature. `idx_ssh_panes_connection` answers "which managed + * panes does this connection/revision have live" - the same refusal and the + * revocation-fences-streams sweep. + * - `idx_ssh_terminal_execs_outstanding` (partial, state='outstanding'): the + * restart-recovery read - re-arm observation for the rows still outstanding + * in this pane's CURRENT incarnation, and only they can become `unknown`. + * + * `node_id` and `connection_id` on `ssh_runs` are SET NULL: deleting a node + * or a connection must never eat the run history the spec says to retain + * (the immutable destination snapshot travels in `config_snapshot` itself); + * `user_id` and `subshell_id` cascade with their owners because an + * invisible-owner row is a leak risk with no reader. + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("ssh_runs") + .addColumn("id", "text", (col) => col.primaryKey()) + // user_id / api_key_id are plain text (0001-init ownership convention; + // see 0047's note on why an FK to better-auth's table would break the + // node-delete cascade in app-migrations-only databases). + .addColumn("user_id", "text", (col) => col.notNull()) + .addColumn("node_id", "text", (col) => col.references("nodes.id").onDelete("set null")) + .addColumn("connection_id", "text", (col) => col.references("ssh_connections.id").onDelete("set null")) + .addColumn("connection_revision", "integer", (col) => col.notNull()) + .addColumn("config_snapshot", "text", (col) => col.notNull()) + .addColumn("initiated_by", "text", (col) => col.notNull()) + .addColumn("grant_id", "text", (col) => col.references("ssh_grants.id").onDelete("set null")) + .addColumn("api_key_id", "text") + .addColumn("command", "text", (col) => col.notNull()) + .addColumn("remote_dir", "text") + .addColumn("request_digest", "text", (col) => col.notNull()) + .addColumn("deadline_ms", "integer", (col) => col.notNull()) + .addColumn("status", "text", (col) => col.notNull().defaultTo("accepted")) + .addColumn("cancel_requested", "integer", (col) => col.notNull().defaultTo(0)) + .addColumn("cancel_local_confirmed", "integer", (col) => col.notNull().defaultTo(0)) + .addColumn("deadline_hit", "integer", (col) => col.notNull().defaultTo(0)) + .addColumn("remote_status", "integer") + .addColumn("remote_status_confirmed", "integer", (col) => col.notNull().defaultTo(0)) + .addColumn("local_exit_code", "integer") + .addColumn("local_exit_signal", "text") + .addColumn("created_at", "text", (col) => col.notNull().defaultTo(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`)) + .addColumn("started_at", "text") + .addColumn("finished_at", "text") + .addColumn("updated_at", "text", (col) => col.notNull().defaultTo(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`)) + .execute(); + await db.schema.createIndex("idx_ssh_runs_user_created").on("ssh_runs").columns(["user_id", "created_at"]).execute(); + await db.schema.createIndex("idx_ssh_runs_connection").on("ssh_runs").columns(["connection_id"]).execute(); + await sql` + CREATE INDEX idx_ssh_runs_active + ON ssh_runs (node_id, user_id) + WHERE status IN ('accepted', 'running') + `.execute(db); + await sql` + CREATE INDEX idx_ssh_runs_grant + ON ssh_runs (grant_id) + WHERE status IN ('accepted', 'running') + `.execute(db); + + await db.schema + .createTable("ssh_panes") + .addColumn("subshell_id", "text", (col) => col.primaryKey().references("subshells.id").onDelete("cascade")) + .addColumn("connection_id", "text", (col) => col.notNull().references("ssh_connections.id").onDelete("cascade")) + .addColumn("connection_revision", "integer", (col) => col.notNull()) + .addColumn("initiated_by", "text", (col) => col.notNull()) + .addColumn("grant_id", "text", (col) => col.references("ssh_grants.id").onDelete("set null")) + .addColumn("api_key_id", "text") + .addColumn("control_owner", "text", (col) => col.notNull()) + .addColumn("control_generation", "integer", (col) => col.notNull().defaultTo(1)) + .addColumn("log_generation", "integer", (col) => col.notNull().defaultTo(1)) + .addColumn("created_at", "text", (col) => col.notNull().defaultTo(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`)) + .execute(); + await db.schema + .createIndex("idx_ssh_panes_connection") + .on("ssh_panes") + .columns(["connection_id", "connection_revision"]) + .execute(); + + await db.schema + .createTable("ssh_terminal_execs") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("subshell_id", "text", (col) => col.notNull().references("subshells.id").onDelete("cascade")) + .addColumn("pane_incarnation", "text", (col) => col.notNull()) + .addColumn("initiated_by", "text", (col) => col.notNull()) + .addColumn("grant_id", "text", (col) => col.references("ssh_grants.id").onDelete("set null")) + .addColumn("api_key_id", "text") + .addColumn("input_generation", "integer", (col) => col.notNull()) + .addColumn("marker_token", "text", (col) => col.notNull()) + .addColumn("state", "text", (col) => col.notNull().defaultTo("outstanding")) + .addColumn("exit_code", "integer") + .addColumn("output", "text") + .addColumn("output_truncated", "integer", (col) => col.notNull().defaultTo(0)) + .addColumn("next_byte", "integer") + .addColumn("created_at", "text", (col) => col.notNull().defaultTo(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`)) + .addColumn("resolved_at", "text") + .execute(); + await sql` + CREATE INDEX idx_ssh_terminal_execs_outstanding + ON ssh_terminal_execs (subshell_id) + WHERE state = 'outstanding' + `.execute(db); +} + +export async function down(db: Kysely): Promise { + // Reverse creation order; the execs table is the leaf. + await sql`DROP INDEX IF EXISTS idx_ssh_terminal_execs_outstanding`.execute(db); + await db.schema.dropTable("ssh_terminal_execs").execute(); + await db.schema.dropIndex("idx_ssh_panes_connection").ifExists().execute(); + await db.schema.dropTable("ssh_panes").execute(); + await sql`DROP INDEX IF EXISTS idx_ssh_runs_grant`.execute(db); + await sql`DROP INDEX IF EXISTS idx_ssh_runs_active`.execute(db); + await db.schema.dropIndex("idx_ssh_runs_connection").ifExists().execute(); + await db.schema.dropIndex("idx_ssh_runs_user_created").ifExists().execute(); + await db.schema.dropTable("ssh_runs").execute(); +} diff --git a/apps/server/api/src/db/migrations/0049-ssh-runtime-sessions.ts b/apps/server/api/src/db/migrations/0049-ssh-runtime-sessions.ts new file mode 100644 index 000000000..d1d803698 --- /dev/null +++ b/apps/server/api/src/db/migrations/0049-ssh-runtime-sessions.ts @@ -0,0 +1,93 @@ +import { type Kysely, sql } from "kysely"; + +/** + * SSH runtime sessions (design 2026-10-05 §4, migration 0049): one row per + * brokered destination session, and the hidden `nodes` rows that make a + * session's panes ordinary panes. + * + * `ssh_runtime_sessions`: + * - The row is the SESSION record: who opened it (`owner_id`), through which + * connecting node it was brokered (`connecting_node_id` - an ordinary agent + * node, and opening requires REAL OWNERSHIP of it, no admin boost, no + * share, design §4), and the reviewed destination facts (`alias`, `host`, + * `port`, `user`) - refs and numbers only, never identity material (§8). + * - `runtime_node_id` names the hidden `nodes` row (kind `runtime`) that the + * session's PANES carry as their `nodeId`, so list/detail/log/live/ws ride + * the ordinary pane plumbing. Its FK CASCADEs (below): the hidden row + * belongs to the session, so a delete of it must not leave a history row + * naming a ghost node. `connecting_node_id` avoids CASCADE on purpose - + * SET NULL below: deleting the connecting machine loses the session's + * history for the audit trail, not the other way around (the same call + * `ssh_runs` makes for its `node_id`, migration 0048: the run outlives the + * machine it rode). A swept row's active-session quota count keys on the + * node id, so a NULL only ever drops history out of a live count, which is + * exactly right for a machine that is gone. + * - `status`: `opening` (spawn asked, hello not yet in) / `active` / `lost` + * (child died or the link dropped - design §6's honest state, panes stay + * `running` with `alive: 0`) / `closed` (the user's own act). + * - `hello_json` carries the parsed runtime hello (version, os, arch, socket, + * dataDir, paneCount) - display + reconciliation facts. The runtime's data + * dir is what the plane composes the callback-socket path and artifact + * paths from, so it is persisted here, not only held in memory. + * + * Indexes: per-owner list (the history), per-connecting-node reconcile sweep + * ("which active sessions died with this machine"), and per-runtime-node + * pane lookup ("which session does this pane's node id belong to", asked by + * the launcher registry and the callback executor on every command). + * + * The `nodes.kind` column is plain text with a comment (no CHECK constraint, + * the 0017 posture), so the new `runtime` spelling needs no ALTER here - the + * type is widened in `nodes.db-types.ts` and every listing query is narrowed + * in code (design §4's "never listed" rule). + * + * Timestamps ride the house `strftime('%Y-%m-%dT%H:%M:%fZ','now')` default + * (the 0040 spelling; the space-separated `datetime('now')` form sorts below + * ISO rows and is the bug). + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("ssh_runtime_sessions") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull()) + // History outlives the connecting machine (the module doc's argument): + // deleting the node NULLs this ref, it does not delete the session row. + .addColumn("connecting_node_id", "text", (col) => col.references("nodes.id").onDelete("set null")) + // The hidden runtime row belongs to the SESSION (its life is the + // session's): the FK points at the node, so a delete of the hidden row + // (only ever the failed-open unroll, which has no panes under it yet - + // settled sessions KEEP their runtime row, offline, as history) takes the + // session row with it rather than stranding a reference to a ghost. + .addColumn("runtime_node_id", "text", (col) => col.notNull().references("nodes.id").onDelete("cascade")) + .addColumn("alias", "text", (col) => col.notNull()) + .addColumn("host", "text", (col) => col.notNull()) + .addColumn("port", "integer", (col) => col.notNull()) + .addColumn("user", "text") + .addColumn("status", "text", (col) => col.notNull().defaultTo("opening")) + .addColumn("hello_json", "text") + .addColumn("created_at", "text", (col) => col.notNull().defaultTo(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`)) + .addColumn("last_seen_at", "text") + .addColumn("closed_at", "text") + .execute(); + await db.schema + .createIndex("idx_ssh_runtime_sessions_owner") + .on("ssh_runtime_sessions") + .columns(["owner_user_id", "created_at"]) + .execute(); + await db.schema + .createIndex("idx_ssh_runtime_sessions_connecting") + .on("ssh_runtime_sessions") + .columns(["connecting_node_id", "status"]) + .execute(); + await db.schema + .createIndex("idx_ssh_runtime_sessions_runtime_node") + .on("ssh_runtime_sessions") + .columns(["runtime_node_id"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex("idx_ssh_runtime_sessions_runtime_node").ifExists().execute(); + await db.schema.dropIndex("idx_ssh_runtime_sessions_connecting").ifExists().execute(); + await db.schema.dropIndex("idx_ssh_runtime_sessions_owner").ifExists().execute(); + await db.schema.dropTable("ssh_runtime_sessions").execute(); +} diff --git a/apps/server/api/src/db/migrations/0050-ssh-retirement.ts b/apps/server/api/src/db/migrations/0050-ssh-retirement.ts new file mode 100644 index 000000000..cfd0b15cd --- /dev/null +++ b/apps/server/api/src/db/migrations/0050-ssh-retirement.ts @@ -0,0 +1,79 @@ +import { type Kysely, sql } from "kysely"; + +/** + * SSH destination-product retirement (Workstream C, design 2026-10-05 §7, + * migration 0050): the five tables the superseded destination-execution + * product lived in drop HERE, as a real migration rather than a schema edit. + * + * `ssh_connections`, `ssh_grants`, `ssh_runs`, `ssh_panes`, + * `ssh_terminal_execs` (and, with them, every index 0047/0048 created - + * SQLite drops a table's indexes with the table): the replacement product + * (SSH remote runtime sessions, migration 0049) shares NONE of these rows. + * A session is a brokered destination runtime, its panes are ordinary panes + * on a hidden `runtime` node, and its open carries reviewed destination facts + * in the request, never a stored connection. Nothing reads these tables + * after this commit, and a dead security gate is worse than no gate: the + * plan's disposition table retires the doors deliberately. + * + * 0047 and 0048 are NOT touched (Kysely is forward-only; an already-applied + * migration must keep re-running identically on an older database on its way + * up). A fresh install therefore CREATEs the five tables and drops them one + * migration later; the chain stays contiguous, which is what the backup + * restore's migration math reads. The drop is `IF EXISTS` for the same + * reason the anchor test works: a restored snapshot missing ONLY this row + * re-runs it cleanly whether or not its tables are present. + * + * The data is gone by decision, not accident: single-user instances, the + * feature never released (PR #330 explicitly must not merge the old model), + * and the pane logs / node-side stores never belonged to these tables. + * + * The drop therefore also DELETES the pane rows the product had marked, while + * the marker table still exists to name them (one transaction: the migrator + * wraps every `up`). The old managed terminals were ORDINARY `subshells` rows, + * full pane rows whose only ssh-ish fact was their `ssh_panes` record, and + * dropping the marker alone would leave them behind as zombies: they list and + * attach as ordinary running panes (the old service used exactly the ordinary + * status/alive grammar), while the agent still holds the input-generation + * record it persisted per managed pane and the retired plane never sends the + * field. Every write to such a pane then reads stale and is refused forever, + * with nothing in the UI to say why. Sweeping the marked rows reproduces what + * an operator delete would have done: FK children ride the cascade, and the + * MCP token needs no extra step. Its better-auth `apikey` row is linked by + * metadata and the row's `apiKeyId`, not an FK, and the guard's own rule + * that a key whose subshell row is gone returns 401 (the row is lifecycle + * truth) is the designed handling for the leftover row, exactly the second + * layer the manager's delete path documents. The sweep is existence-guarded + * so the `IF EXISTS` idempotence above stays true: on a re-run after its own + * drop, or on a chain whose pane table never existed, there is nothing to + * sweep and nothing throws. + */ +export async function up(db: Kysely): Promise { + const present = await sql<{ n: number }>` + SELECT COUNT(*) AS n FROM sqlite_master + WHERE type = 'table' AND name IN ('subshells', 'ssh_panes') + `.execute(db); + if (Number(present.rows[0]?.n ?? 0) === 2) { + await sql` + DELETE FROM subshells + WHERE id IN (SELECT subshell_id FROM ssh_panes) + `.execute(db); + } + await db.schema.dropTable("ssh_terminal_execs").ifExists().execute(); + await db.schema.dropTable("ssh_panes").ifExists().execute(); + await db.schema.dropTable("ssh_runs").ifExists().execute(); + await db.schema.dropTable("ssh_grants").ifExists().execute(); + await db.schema.dropTable("ssh_connections").ifExists().execute(); + // The child order is deliberate (grants/runs/panes reference connections); + // SQLite enforces FKs per statement, so dropping the leaf tables first + // keeps every cascade satisfied even with foreign_keys pragma ON. +} + +/** + * No down half. This migration deletes the tables of a product that will not + * come back; recreating their grammar to "roll back" would resurrect doors + * the retirement closed on purpose (and Kysely never runs `down` in this + * app - the backup path restores whole snapshots, see docs). + */ +export async function down(_db: Kysely): Promise { + throw new Error("0050-ssh-retirement is not reversible: the destination product's doors stay closed"); +} diff --git a/apps/server/api/src/db/migrations/0051-ssh-saved-locations.ts b/apps/server/api/src/db/migrations/0051-ssh-saved-locations.ts new file mode 100644 index 000000000..dbfed9781 --- /dev/null +++ b/apps/server/api/src/db/migrations/0051-ssh-saved-locations.ts @@ -0,0 +1,22 @@ +import type { Kysely } from "kysely"; + +/** Personal bookmarks contain destination facts and folders, never SSH credentials. */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("ssh_saved_locations") + .addColumn("id", "text", (c) => c.primaryKey()) + .addColumn("owner_user_id", "text", (c) => c.notNull()) + .addColumn("origin_kind", "text", (c) => c.notNull()) + .addColumn("origin_id", "text", (c) => c.notNull()) + .addColumn("alias", "text", (c) => c.notNull()) + .addColumn("host", "text", (c) => c.notNull()) + .addColumn("port", "integer", (c) => c.notNull()) + .addColumn("user", "text") + .addColumn("path", "text", (c) => c.notNull()) + .addColumn("created_at", "text", (c) => c.notNull()) + .execute(); + await db.schema.createIndex("ssh_saved_locations_owner").on("ssh_saved_locations").column("owner_user_id").execute(); +} +export async function down(db: Kysely): Promise { + await db.schema.dropTable("ssh_saved_locations").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0047-ssh-connections.test.ts b/apps/server/api/src/db/migrations/__tests__/0047-ssh-connections.test.ts new file mode 100644 index 000000000..c4a441ef6 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0047-ssh-connections.test.ts @@ -0,0 +1,162 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { CamelCasePlugin, Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import { down as down0047, up as up0047 } from "@/db/migrations/0047-ssh-connections.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +/** + * `ssh_connections` + `ssh_grants` (SSH-SUPPORT.md §4, Gate A). What this + * pins is the schema the whole feature reads: the revision default, the + * node/owner/pane cascades, and above all the PARTIAL uniqueness of active + * grants - "unique ACTIVE binding per tuple", with revoked history kept and + * re-granting allowed. The FK targets are minimal mirror tables in the same + * posture as the 0040 test. + */ +interface MigrationDb { + user: { id: string }; + nodes: { id: string }; + subshells: { id: string }; + // Defaulted columns are optional in the insert shape (the 0040 precedent): + // the point of those defaults is what the bare `values()` rows exercise. + sshConnections: { + id: string; + userId: string; + nodeId: string; + displayName: string; + configSnapshot: string; + remoteDir: string | null; + revision?: number; + createdAt?: string; + updatedAt?: string; + }; + sshGrants: { + id: string; + connectionId: string; + connectionRevision: number; + subshellId: string; + apiKeyId: string; + grantedByUserId: string; + grantedAt?: string; + revokedAt: string | null; + }; +} + +const isoUtc = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +describe("0047 ssh connections/grants migration", () => { + let dbFile: string; + let sqlite: ReturnType; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0047-${Math.random().toString(36).slice(2)}.db`; + sqlite = openSqliteDatabase(dbFile); + db = new Kysely({ + dialect: new BunSqliteDialect({ database: sqlite }), + plugins: [new CamelCasePlugin()], + }); + // FK targets, minimally shaped (same posture as the 0040 test mirroring + // better-auth's user row): only `id` matters to these two tables. + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`CREATE TABLE nodes (id TEXT PRIMARY KEY)`.execute(db); + await sql`CREATE TABLE subshells (id TEXT PRIMARY KEY)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@subshell.local')`.execute(db); + await sql`INSERT INTO nodes (id) VALUES ('n1')`.execute(db); + await sql`INSERT INTO subshells (id) VALUES ('p1'), ('p2')`.execute(db); + await up0047(db as unknown as Kysely); + }); + + afterAll(async () => { + await db.destroy(); + sqlite.close(); + await Bun.file(dbFile) + .unlink() + .catch(() => {}); + }); + + async function insertConnection(id: string): Promise { + await db + .insertInto("sshConnections") + .values({ id, userId: "u1", nodeId: "n1", displayName: "Staging", configSnapshot: '{"host":"h"}' }) + .execute(); + } + + async function insertGrant(id: string, subshellId: string, apiKeyId = "k1", revoked = false): Promise { + await db + .insertInto("sshGrants") + .values({ + id, + connectionId: "c1", + connectionRevision: 1, + subshellId, + apiKeyId, + grantedByUserId: "u1", + revokedAt: revoked ? "2026-10-04T00:00:00.000Z" : null, + }) + .execute(); + } + + it("inserts a connection with revision 1 and ISO UTC default stamps", async () => { + await insertConnection("c1"); + const row = await db.selectFrom("sshConnections").where("id", "=", "c1").selectAll().executeTakeFirstOrThrow(); + expect(row.revision).toBe(1); + expect(row.remoteDir).toBeNull(); + expect(typeof row.createdAt).toBe("string"); + expect(typeof row.updatedAt).toBe("string"); + expect(isoUtc.test(row.createdAt as string)).toBe(true); + expect(isoUtc.test(row.updatedAt as string)).toBe(true); + }); + + it("keeps at most one ACTIVE grant per (connection, pane, key) tuple", async () => { + await insertGrant("g1", "p1"); + // Same tuple, still active: the partial unique index refuses it. + await expect(insertGrant("g2", "p1")).rejects.toThrow(); + // A DIFFERENT key for the same pane is a different tuple - the rotated + // pane needs its own grant row, and the grammar allows exactly that. + await insertGrant("g3", "p1", "k2"); + // A different pane is a different tuple too. + await insertGrant("g4", "p2"); + }); + + it("keeps revoked rows as history and lets the same tuple be re-granted", async () => { + await db.updateTable("sshGrants").set({ revokedAt: "2026-10-04T12:00:00.000Z" }).where("id", "=", "g1").execute(); + // g1's tuple is (c1, p1, k1) and now inactive: a fresh active row fits. + await insertGrant("g5", "p1", "k1"); + const active = await db + .selectFrom("sshGrants") + .selectAll() + .where("connectionId", "=", "c1") + .where("subshellId", "=", "p1") + .where("apiKeyId", "=", "k1") + .where("revokedAt", "is", null) + .execute(); + expect(active).toHaveLength(1); + // The revoked history row is STILL there - runs reference it by id. + const history = await db.selectFrom("sshGrants").selectAll().where("id", "=", "g1").executeTakeFirst(); + expect(history?.revokedAt).toBe("2026-10-04T12:00:00.000Z"); + }); + + it("cascades grants with the pane and connections with the node", async () => { + await sql`DELETE FROM subshells WHERE id = 'p2'`.execute(db); + const gone = await db.selectFrom("sshGrants").selectAll().where("id", "=", "g4").executeTakeFirst(); + expect(gone).toBeUndefined(); + // The node cascade takes connections AND (via connection cascade) grants. + await sql`DELETE FROM nodes WHERE id = 'n1'`.execute(db); + const conn = await db.selectFrom("sshConnections").selectAll().where("id", "=", "c1").executeTakeFirst(); + expect(conn).toBeUndefined(); + const grants = await db.selectFrom("sshGrants").selectAll().where("connectionId", "=", "c1").execute(); + expect(grants).toHaveLength(0); + // Restore for any later expectations. + await sql`INSERT INTO nodes (id) VALUES ('n1')`.execute(db); + }); + + it("down drops both tables and their indexes", async () => { + await down0047(db as unknown as Kysely); + for (const name of ["ssh_connections", "ssh_grants"]) { + const r = await sql<{ + name: string; + }>`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${name}`.execute(db); + expect(r.rows).toHaveLength(0); + } + }); +}); diff --git a/apps/server/api/src/db/migrations/__tests__/0048-ssh-execution.test.ts b/apps/server/api/src/db/migrations/__tests__/0048-ssh-execution.test.ts new file mode 100644 index 000000000..30a53ae31 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0048-ssh-execution.test.ts @@ -0,0 +1,272 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { CamelCasePlugin, Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import { down as down0047, up as up0047 } from "@/db/migrations/0047-ssh-connections.js"; +import { down as down0048, up as up0048 } from "@/db/migrations/0048-ssh-execution.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +/** + * `ssh_runs` + `ssh_panes` + `ssh_terminal_execs` (SSH-SUPPORT.md §4, Gate A), + * applied OVER 0047 because they reference its tables. What this pins is the + * survival grammar of run history: node and connection deletes SET NULL while + * the row and its immutable snapshot copy stay; the pane row rides the + * subshell by cascade; the partial active indexes carry the exact WHERE + * clauses the quota and recovery reads are written against; and the lifecycle + * defaults say "accepted, nothing observed" - never a fake success. + */ +interface MigrationDb { + user: { id: string }; + nodes: { id: string }; + subshells: { id: string }; + sshConnections: { + id: string; + userId: string; + nodeId: string; + displayName: string; + configSnapshot: string; + remoteDir: string | null; + revision: number; + createdAt: string; + updatedAt: string; + }; + sshGrants: { + id: string; + connectionId: string; + connectionRevision: number; + subshellId: string; + apiKeyId: string; + grantedByUserId: string; + grantedAt: string; + revokedAt: string | null; + }; + // Defaulted columns are optional in the insert shape (the 0040 precedent). + sshRuns: { + id: string; + userId: string; + nodeId: string | null; + connectionId: string | null; + connectionRevision: number; + configSnapshot: string; + initiatedBy: string; + grantId: string | null; + apiKeyId: string | null; + command: string; + remoteDir: string | null; + requestDigest: string; + deadlineMs: number; + status?: string; + cancelRequested?: number; + cancelLocalConfirmed?: number; + deadlineHit?: number; + remoteStatus?: number | null; + remoteStatusConfirmed?: number; + localExitCode?: number | null; + localExitSignal?: string | null; + createdAt?: string; + startedAt?: string | null; + finishedAt?: string | null; + updatedAt?: string; + }; + sshPanes: { + subshellId: string; + connectionId: string; + connectionRevision: number; + initiatedBy: string; + grantId: string | null; + apiKeyId: string | null; + controlOwner: string; + controlGeneration?: number; + logGeneration?: number; + createdAt?: string; + }; + sshTerminalExecs: { + id: string; + subshellId: string; + paneIncarnation: string; + initiatedBy: string; + grantId: string | null; + apiKeyId: string | null; + inputGeneration: number; + markerToken: string; + state?: string; + exitCode?: number | null; + output?: string | null; + outputTruncated?: number; + nextByte?: number | null; + createdAt?: string; + resolvedAt?: string | null; + }; +} + +describe("0048 ssh execution migration", () => { + let dbFile: string; + let sqlite: ReturnType; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0048-${Math.random().toString(36).slice(2)}.db`; + sqlite = openSqliteDatabase(dbFile); + db = new Kysely({ + dialect: new BunSqliteDialect({ database: sqlite }), + plugins: [new CamelCasePlugin()], + }); + await sql`CREATE TABLE user (id TEXT PRIMARY KEY)`.execute(db); + await sql`CREATE TABLE nodes (id TEXT PRIMARY KEY)`.execute(db); + await sql`CREATE TABLE subshells (id TEXT PRIMARY KEY)`.execute(db); + await sql`INSERT INTO user (id) VALUES ('u1')`.execute(db); + await sql`INSERT INTO nodes (id) VALUES ('n1')`.execute(db); + await sql`INSERT INTO subshells (id) VALUES ('p1')`.execute(db); + await up0047(db as unknown as Kysely); + await up0048(db as unknown as Kysely); + await sql`INSERT INTO ssh_connections (id, user_id, node_id, display_name, config_snapshot) + VALUES ('c1', 'u1', 'n1', 'S', '{"host":"h"}')`.execute(db); + await sql`INSERT INTO ssh_grants (id, connection_id, connection_revision, subshell_id, api_key_id, granted_by_user_id) + VALUES ('g1', 'c1', 1, 'p1', 'k1', 'u1')`.execute(db); + }); + + afterAll(async () => { + await db.destroy(); + sqlite.close(); + await Bun.file(dbFile) + .unlink() + .catch(() => {}); + }); + + async function insertRun(id: string, nodeId = "n1"): Promise { + await db + .insertInto("sshRuns") + .values({ + id, + userId: "u1", + nodeId, + connectionId: "c1", + connectionRevision: 1, + configSnapshot: '{"host":"h"}', + initiatedBy: "agent", + grantId: "g1", + apiKeyId: "k1", + command: "id", + remoteDir: null, + requestDigest: "a".repeat(64), + deadlineMs: 300_000, + }) + .execute(); + } + + it("inserts a run defaulting to accepted/unobserved with zeroed fact flags", async () => { + await insertRun("r1"); + const row = await db.selectFrom("sshRuns").where("id", "=", "r1").selectAll().executeTakeFirstOrThrow(); + expect(row.status).toBe("accepted"); + expect(row.cancelRequested).toBe(0); + expect(row.cancelLocalConfirmed).toBe(0); + expect(row.deadlineHit).toBe(0); + expect(row.remoteStatus).toBeNull(); + expect(row.remoteStatusConfirmed).toBe(0); + expect(row.startedAt).toBeNull(); + expect(row.finishedAt).toBeNull(); + }); + + it("connection delete SET NULLs the run and keeps the immutable snapshot", async () => { + await sql`DELETE FROM ssh_connections WHERE id = 'c1'`.execute(db); + const row = await db.selectFrom("sshRuns").where("id", "=", "r1").selectAll().executeTakeFirstOrThrow(); + expect(row.connectionId).toBeNull(); + expect(row.configSnapshot).toBe('{"host":"h"}'); + // The grant row cascaded with the connection; the run's grant_id SET NULLed. + const r = await sql<{ id: string }>`SELECT id FROM ssh_runs WHERE id = 'r1'`.execute(db); + expect(r.rows[0]?.id).toBe("r1"); + // Restore the config rows for later expectations (grants recreated fresh). + await sql`INSERT INTO ssh_connections (id, user_id, node_id, display_name, config_snapshot) + VALUES ('c1', 'u1', 'n1', 'S', '{"host":"h"}')`.execute(db); + await sql`INSERT INTO ssh_grants (id, connection_id, connection_revision, subshell_id, api_key_id, granted_by_user_id) + VALUES ('g1', 'c1', 1, 'p1', 'k1', 'u1')`.execute(db); + await db.updateTable("sshRuns").set({ grantId: "g1" }).where("id", "=", "r1").execute(); + }); + + it("node delete SET NULLs the run's node while the history row survives", async () => { + await sql`DELETE FROM nodes WHERE id = 'n1'`.execute(db); + const row = await db.selectFrom("sshRuns").where("id", "=", "r1").selectAll().executeTakeFirstOrThrow(); + expect(row.nodeId).toBeNull(); + // The delete cascaded the connection (and with it the grant): restore the + // config pair so the pane/exec tests below have their FK targets again. + await sql`INSERT INTO nodes (id) VALUES ('n1')`.execute(db); + await sql`INSERT INTO ssh_connections (id, user_id, node_id, display_name, config_snapshot) + VALUES ('c1', 'u1', 'n1', 'S', '{"host":"h"}')`.execute(db); + await sql`INSERT INTO ssh_grants (id, connection_id, connection_revision, subshell_id, api_key_id, granted_by_user_id) + VALUES ('g1', 'c1', 1, 'p1', 'k1', 'u1')`.execute(db); + }); + + it("managed pane row keys on the subshell, defaults generations to 1, cascades with the pane", async () => { + await db + .insertInto("sshPanes") + .values({ + subshellId: "p1", + connectionId: "c1", + connectionRevision: 1, + initiatedBy: "human", + grantId: null, + apiKeyId: null, + controlOwner: "human", + }) + .execute(); + const pane = await db.selectFrom("sshPanes").where("subshellId", "=", "p1").selectAll().executeTakeFirstOrThrow(); + expect(pane.controlGeneration).toBe(1); + expect(pane.logGeneration).toBe(1); + expect(typeof pane.createdAt).toBe("string"); + await sql`DELETE FROM subshells WHERE id = 'p1'`.execute(db); + const gone = await db.selectFrom("sshPanes").selectAll().where("subshellId", "=", "p1").executeTakeFirst(); + expect(gone).toBeUndefined(); + await sql`INSERT INTO subshells (id) VALUES ('p1')`.execute(db); + }); + + it("terminal exec defaults to outstanding with a null result", async () => { + await db + .insertInto("sshTerminalExecs") + .values({ + id: "e1", + subshellId: "p1", + paneIncarnation: "2026-10-04T00:00:00.000Z", + initiatedBy: "agent", + grantId: null, + apiKeyId: "k1", + inputGeneration: 1, + markerToken: "0123456789abcdef", + }) + .execute(); + const row = await db.selectFrom("sshTerminalExecs").where("id", "=", "e1").selectAll().executeTakeFirstOrThrow(); + expect(row.state).toBe("outstanding"); + expect(row.exitCode).toBeNull(); + expect(row.nextByte).toBeNull(); + expect(row.resolvedAt).toBeNull(); + }); + + it("the partial indexes carry exactly the WHERE clauses the quota/recovery reads assume", async () => { + const r = await sql<{ name: string; sql: string | null }>` + SELECT name, sql FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL + `.execute(db); + const byName = new Map(r.rows.map((row) => [row.name, row.sql ?? ""])); + expect(byName.get("idx_ssh_runs_active")).toContain("WHERE status IN ('accepted', 'running')"); + expect(byName.get("idx_ssh_runs_grant")).toContain("WHERE status IN ('accepted', 'running')"); + expect(byName.get("idx_ssh_terminal_execs_outstanding")).toContain("WHERE state = 'outstanding'"); + const unique = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'uq_ssh_grants_active' + `.execute(db); + expect(unique.rows).toHaveLength(1); + }); + + it("down drops the execution tables, leaving 0047's pair for its own down", async () => { + await down0048(db as unknown as Kysely); + for (const name of ["ssh_runs", "ssh_panes", "ssh_terminal_execs"]) { + const r = await sql<{ + name: string; + }>`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${name}`.execute(db); + expect(r.rows).toHaveLength(0); + } + await down0047(db as unknown as Kysely); + for (const name of ["ssh_connections", "ssh_grants"]) { + const r = await sql<{ + name: string; + }>`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${name}`.execute(db); + expect(r.rows).toHaveLength(0); + } + }); +}); diff --git a/apps/server/api/src/db/migrations/__tests__/0050-ssh-retirement.test.ts b/apps/server/api/src/db/migrations/__tests__/0050-ssh-retirement.test.ts new file mode 100644 index 000000000..ccc237d78 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0050-ssh-retirement.test.ts @@ -0,0 +1,140 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { rmSync } from "node:fs"; +import { CamelCasePlugin, Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import { up as up0047 } from "@/db/migrations/0047-ssh-connections.js"; +import { up as up0048 } from "@/db/migrations/0048-ssh-execution.js"; +import { up as up0049 } from "@/db/migrations/0049-ssh-runtime-sessions.js"; +import { down as down0050, up as up0050 } from "@/db/migrations/0050-ssh-retirement.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +/** + * The retirement drop (design 2026-10-05 §7): applied OVER 0047/0048/0049 on + * a chain that has created everything, 0050 removes EXACTLY the destination + * product's five tables - and nothing of the replacement's. What this pins: + * the five `ssh_*` destination tables are gone (indexes ride the SQLite + * table drop), `ssh_runtime_sessions` and `nodes` survive untouched, the + * drop is `IF EXISTS`-idempotent (the backup-restore path re-runs it on a + * snapshot whose tables are already gone), and `down` refuses: retirement is + * not reversible. + */ +describe("migration 0050-ssh-retirement", () => { + let sqlite: ReturnType; + let db: Kysely>; + const dbFile = `/tmp/subshell-0050-${process.pid}-${Date.now()}.sqlite`; + + beforeAll(async () => { + sqlite = openSqliteDatabase(dbFile); + db = new Kysely>({ + dialect: new BunSqliteDialect({ database: sqlite }), + plugins: [new CamelCasePlugin()], + }); + await up0047(db); + await up0048(db); + await up0049(db); + }); + + afterAll(async () => { + await db.destroy(); + sqlite.close(); + rmSync(dbFile, { force: true }); + }); + + const tables = async (): Promise> => { + const rows = await sql<{ name: string }>`SELECT name FROM sqlite_master WHERE type='table'`.execute(db); + return new Set(rows.rows.map((r) => r.name)); + }; + + it("drops the five destination tables and keeps the replacement's", async () => { + expect(await tables()).toContain("ssh_connections"); // 0047 created it + await up0050(db); + const after = await tables(); + for (const gone of ["ssh_connections", "ssh_grants", "ssh_runs", "ssh_panes", "ssh_terminal_execs"]) { + expect(after.has(gone), `${gone} must be dropped`).toBe(false); + } + // `nodes` is asserted absent here on purpose: this suite runs 0047-0050 + // over an empty file (the FK target never exists in isolation), so the + // replacement's table and nothing else must remain. + expect([...after].sort()).toEqual(["ssh_runtime_sessions"]); + // Indexes belong to the table: SQLite drops them with it, and a stale + // index name would collide with nothing today but proves the intent. + const idx = await sql<{ + name: string; + }>`SELECT name FROM sqlite_master WHERE type = 'index' AND (name LIKE 'idx_ssh_grants%' OR name LIKE 'idx_ssh_runs%' OR name LIKE 'idx_ssh_panes%')`.execute( + db, + ); + expect(idx.rows).toEqual([]); + }); + + it("re-runs cleanly on a database whose tables are already gone (restore path)", async () => { + await up0050(db); // the restored-snapshot case + expect((await tables()).has("ssh_connections")).toBe(false); + }); + + it("refuses the down half: retirement is not reversible", async () => { + await expect(down0050(db)).rejects.toThrow(/not reversible/); + // The chain below 0050 keeps its own reversibility (0047/0048/0049 each + // pin their `down` in their own suites); re-running them AFTER the drop + // would fight the missing tables. The point here is only that the + // retirement itself has no undo. + }); +}); + +/** + * The row sweep (review wave, design 2026-10-05 §7's deletion half): the old + * managed terminals were ORDINARY `subshells` rows marked by an `ssh_panes` + * record, and 0050 deletes exactly those before the marker table goes. On a + * stub pane table (the 0048-suite shape: only `id` matters to the FK) with one + * marked pane and two unmarked ones, this pins that the sweep removes the + * marked row, spares every unmarked pane, and re-runs cleanly once its own + * drop has removed `ssh_panes` (the restore-path idempotence the drop already + * has, extended to the sweep's existence guard). + */ +describe("migration 0050 sweeps the ssh_panes-marked pane rows", () => { + let sqlite: ReturnType; + let db: Kysely>; + const dbFile = `/tmp/subshell-0050-sweep-${process.pid}-${Date.now()}.sqlite`; + + const paneIds = async (): Promise => { + const rows = await sql<{ id: string }>`SELECT id FROM subshells ORDER BY id`.execute(db); + return rows.rows.map((r) => r.id); + }; + + beforeAll(async () => { + sqlite = openSqliteDatabase(dbFile); + db = new Kysely>({ + dialect: new BunSqliteDialect({ database: sqlite }), + plugins: [new CamelCasePlugin()], + }); + await up0047(db); + await up0048(db); + await up0049(db); + // The pane table the real chain has had since 0001/0019 (id plus whatever; + // only `id` participates in the FK and the sweep), plus the config rows + // the marker's own FKs require (the 0048-suite precedent). + await sql`CREATE TABLE subshells (id TEXT PRIMARY KEY)`.execute(db); + await sql`CREATE TABLE nodes (id TEXT PRIMARY KEY)`.execute(db); + await sql`INSERT INTO nodes (id) VALUES ('n1')`.execute(db); + await sql`INSERT INTO subshells (id) VALUES ('zombie'), ('ordinary-a'), ('ordinary-b')`.execute(db); + await sql`INSERT INTO ssh_connections (id, user_id, node_id, display_name, config_snapshot) + VALUES ('c1', 'u1', 'n1', 'S', '{"host":"h"}')`.execute(db); + await sql`INSERT INTO ssh_panes (subshell_id, connection_id, connection_revision, initiated_by, control_owner) + VALUES ('zombie', 'c1', 1, 'human', 'human')`.execute(db); + }); + + afterAll(async () => { + await db.destroy(); + sqlite.close(); + rmSync(dbFile, { force: true }); + }); + + it("deletes the marked pane and spares every unmarked pane", async () => { + await up0050(db); + expect(await paneIds()).toEqual(["ordinary-a", "ordinary-b"]); + }); + + it("re-runs cleanly after its own drop removed the marker table", async () => { + await up0050(db); // ssh_panes is gone: the existence guard skips, nothing throws + expect(await paneIds()).toEqual(["ordinary-a", "ordinary-b"]); + }); +}); diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts index 394df8fd3..fc3d37e84 100644 --- a/apps/server/api/src/db/repositories/nodes.repository.ts +++ b/apps/server/api/src/db/repositories/nodes.repository.ts @@ -1,5 +1,6 @@ import { BaseRepository } from "@/db/repositories/base.repository.js"; import type { MaintenanceSource, NewNode, NodeStatus, NodeTable } from "@/db/types/nodes.db-types.js"; +import { NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; import type { SubshellTable } from "@/db/types/subshells.db-types.js"; /** Fields a `ready` frame carries about the machine behind a node (spec §5.3). */ @@ -82,6 +83,13 @@ export class NodesRepository extends BaseRepository { return await this.db .selectFrom("nodes") .selectAll() + // Hidden runtime rows (design 2026-10-05 §4): a session's panes carry a + // `runtime` node id so ordinary pane plumbing works, and the LISTING + // must never show that row - it is not a machine anyone chose. The + // exclusion is a `kind` filter here (and in `listByOwner` below), not a + // per-route remember-to-filter, so every current and future reader of + // "nodes I can see" stays honest by construction. + .where("kind", "<>", NODE_KIND_RUNTIME) .where((eb) => eb.or([ eb("ownerUserId", "=", viewerUserId), @@ -114,9 +122,18 @@ export class NodesRepository extends BaseRepository { return await this.db.selectFrom("nodes").selectAll().where("kind", "=", "agent").orderBy("name").execute(); } - /** Every node one user owns, creation order. */ + /** + * Every node one user owns, creation order. `runtime` rows are excluded for + * the same design §4 reason as `findAccessible`; owner-visible does not mean + * listable, and this read feeds pickers. + */ async listByOwner(ownerUserId: string): Promise { - return await this.db.selectFrom("nodes").selectAll().where("ownerUserId", "=", ownerUserId).execute(); + return await this.db + .selectFrom("nodes") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("kind", "<>", NODE_KIND_RUNTIME) + .execute(); } /** Renames a node (the name is what the picker shows). */ diff --git a/apps/server/api/src/db/repositories/ssh-saved-locations.repository.ts b/apps/server/api/src/db/repositories/ssh-saved-locations.repository.ts new file mode 100644 index 000000000..57cf4b549 --- /dev/null +++ b/apps/server/api/src/db/repositories/ssh-saved-locations.repository.ts @@ -0,0 +1,31 @@ +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { SshSavedLocationTable } from "@/db/types/ssh-saved-locations.db-types.js"; + +export class SshSavedLocationsRepository extends BaseRepository { + list(ownerUserId: string) { + return this.db + .selectFrom("sshSavedLocations") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .orderBy("createdAt", "desc") + .execute(); + } + find(id: string, ownerUserId: string) { + return this.db + .selectFrom("sshSavedLocations") + .selectAll() + .where("id", "=", id) + .where("ownerUserId", "=", ownerUserId) + .executeTakeFirst(); + } + create(row: SshSavedLocationTable) { + return this.db.insertInto("sshSavedLocations").values(row).returningAll().executeTakeFirstOrThrow(); + } + async delete(id: string, ownerUserId: string) { + return this.db + .deleteFrom("sshSavedLocations") + .where("id", "=", id) + .where("ownerUserId", "=", ownerUserId) + .executeTakeFirst(); + } +} diff --git a/apps/server/api/src/db/repositories/subshells.repository.ts b/apps/server/api/src/db/repositories/subshells.repository.ts index a95401325..c1dcf7143 100644 --- a/apps/server/api/src/db/repositories/subshells.repository.ts +++ b/apps/server/api/src/db/repositories/subshells.repository.ts @@ -182,6 +182,28 @@ export class SubshellsRepository extends BaseRepository { return Number(counts.numUpdatedRows ?? counts.numUpdated ?? 0); } + /** + * Conditional {@link update} keyed to the node the row still sits on: applies + * the patch only while `nodeId` equals `fromNodeId`, and answers whether this + * caller moved the row. The SSH reopen re-adoption's claim seam + * (`services/ssh-runtime/session-adopt.ts`): two concurrent reopens of one + * destination can both see one survivor in their candidate snapshots, and + * exactly ONE may reparent it — the loser must then touch neither the token + * nor the pane registration. Same shape as {@link updateIfRunning}, same + * answer contract: 1 for the mover, 0 for everyone racing after. + * @returns rows updated — 0 means the row already left `fromNodeId` + */ + async updateIfOnNode(id: string, fromNodeId: string, update: SubshellUpdate): Promise { + const res = await this.db + .updateTable("subshells") + .set(update) + .where("id", "=", id) + .where("nodeId", "=", fromNodeId) + .executeTakeFirst(); + const counts = res as unknown as { numUpdated?: number | bigint; numUpdatedRows?: number | bigint }; + return Number(counts.numUpdatedRows ?? counts.numUpdated ?? 0); + } + /** * Take a "waiting for you" stamp off, but ONLY while one is set, and answer * whether this caller did it. diff --git a/apps/server/api/src/db/types/index.ts b/apps/server/api/src/db/types/index.ts index 075613c5c..7f9a4a97f 100644 --- a/apps/server/api/src/db/types/index.ts +++ b/apps/server/api/src/db/types/index.ts @@ -17,6 +17,8 @@ import type { PromptStackItemTable, PromptStackTable } from "@/db/types/prompt-s import type { PromptTable } from "@/db/types/prompts.db-types.js"; import type { RecentPathTable } from "@/db/types/recent-paths.db-types.js"; import type { SettingTable } from "@/db/types/settings.db-types.js"; +import type { SshRuntimeSessionTable } from "@/db/types/ssh-runtime-sessions.db-types.js"; +import type { SshSavedLocationTable } from "@/db/types/ssh-saved-locations.db-types.js"; import type { SubshellShareTable } from "@/db/types/subshell-shares.db-types.js"; import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import type { UserMetaTable } from "@/db/types/user-meta.db-types.js"; @@ -57,4 +59,12 @@ export interface Database { identities: IdentityTable; notificationsSubscriptions: NotificationSubscriptionTable; deviceTokens: DeviceTokenTable; + // SSH runtime sessions (design 2026-10-05 §4): the brokered-session record + // beside the hidden `runtime` node rows its panes carry. The five + // destination-product tables that used to sit here (connections, grants, + // runs, panes, terminal execs) were dropped by migration 0050 when the + // product retired; 0047/0048 still CREATE them for the upgrade path, and + // 0050 drops them on the same chain. + sshSavedLocations: SshSavedLocationTable; + sshRuntimeSessions: SshRuntimeSessionTable; } diff --git a/apps/server/api/src/db/types/nodes.db-types.ts b/apps/server/api/src/db/types/nodes.db-types.ts index 98c3f6794..916dd8359 100644 --- a/apps/server/api/src/db/types/nodes.db-types.ts +++ b/apps/server/api/src/db/types/nodes.db-types.ts @@ -1,7 +1,20 @@ /** Lifecycle status projection of a node (truth = the live agent socket). */ export type NodeStatus = "online" | "offline"; -/** Whether a node is the control-plane host or an enrolled agent machine. */ -export type NodeKind = "local" | "agent"; +/** Runtime-listed statuses (the enum-as-data rule beside every union here). */ +export const NODE_STATUSES: readonly NodeStatus[] = ["online", "offline"]; +/** + * Whether a node is the control-plane host, an enrolled agent machine, or the + * HIDDEN row behind an SSH runtime session (design 2026-10-05 §4): a + * `runtime` row carries pane `node_id`s so list/detail/log/ws plumbing works + * unchanged, and it is owner-only, never listed by `GET /api/nodes`, never + * dialable (no node key can point at it), never enrollable, and never in any + * picker. The listing exclusion lives in `nodes.repository.ts` (`findAccessible`). + */ +export type NodeKind = "local" | "agent" | "runtime"; +/** Runtime-listed kinds - the union above as data (one edit site for both readings). */ +export const NODE_KINDS: readonly NodeKind[] = ["local", "agent", "runtime"]; +/** The `runtime` spelling, named once so every exclusion filter and every insert says the same word. */ +export const NODE_KIND_RUNTIME = "runtime" as const; /** * Which end wrote the maintenance value that currently stands diff --git a/apps/server/api/src/db/types/ssh-runtime-sessions.db-types.ts b/apps/server/api/src/db/types/ssh-runtime-sessions.db-types.ts new file mode 100644 index 000000000..87962dbf6 --- /dev/null +++ b/apps/server/api/src/db/types/ssh-runtime-sessions.db-types.ts @@ -0,0 +1,60 @@ +/** Session lifecycle (design 2026-10-05 §4). The live broker decides it; the row records it. */ +export type SshRuntimeSessionStatus = "opening" | "active" | "lost" | "closed"; +/** Runtime list of {@link SshRuntimeSessionStatus}. */ +export const SSH_RUNTIME_SESSION_STATUSES: readonly SshRuntimeSessionStatus[] = ["opening", "active", "lost", "closed"]; + +/** + * Database table schema for `ssh_runtime_sessions` (migration 0049): one row + * per brokered destination session. The row is the SESSION record and the + * history (`lost`/`closed` rows outlive their sessions); the in-memory + * registry in `services/ssh-runtime/session-registry.ts` holds the live byte + * channel, and the two are reconciled at every transition (the registry is + * authority for "is this session open NOW", the row for everything else). + * + * `user` is the destination account or null (the connecting account's own + * default); identity files, config contents and key material never enter this + * shape (design §8 - the snapshot grammar's path-only rule, applied again). + */ +export interface SshRuntimeSessionTable { + /** Session id - the plane-minted broker ref (uuid; the `ref` on every session frame) */ + id: string; + /** Who opened the session; every pane through it belongs to this user (design §4) */ + ownerUserId: string; + /** + * The ENROLLED node that brokered the SSH child; opening required real + * ownership of it. NULLABLE by design (migration 0049): node ids are plain + * text refs by design here, and deleting the connecting machine SET NULLs + * this so the history outlives the machine. + */ + connectingNodeId: string | null; + /** The hidden `nodes` row (kind 'runtime') the session's panes carry as node_id */ + runtimeNodeId: string; + /** The config token the human chose (display/review context) */ + alias: string; + /** Reviewed destination host */ + host: string; + /** Reviewed destination port */ + port: number; + /** Destination account, or null for the connecting account's default */ + user: string | null; + /** {@link SshRuntimeSessionStatus}; `opening` until the hello answers */ + status: SshRuntimeSessionStatus; + /** Parsed runtime hello JSON (version, os, arch, tmuxSocket, dataDir, paneCount); null until `active` */ + helloJson: string | null; + /** ISO 8601 open requested */ + createdAt: string; + /** ISO 8601 of the last frame that proved the child alive (honest "last heard", not a heartbeat) */ + lastSeenAt: string | null; + /** ISO 8601 of the terminal transition (lost or closed) */ + closedAt: string | null; +} + +/** + * Insert payload for a fresh `opening` session. `connectingNodeId` is + * required HERE (the broker row must exist to open; the column's nullability + * is about the node's later deletion, not about the insert). + */ +export type NewSshRuntimeSession = Pick< + SshRuntimeSessionTable, + "id" | "ownerUserId" | "runtimeNodeId" | "alias" | "host" | "port" | "user" +> & { connectingNodeId: string } & Partial>; diff --git a/apps/server/api/src/db/types/ssh-saved-locations.db-types.ts b/apps/server/api/src/db/types/ssh-saved-locations.db-types.ts new file mode 100644 index 000000000..7f28bb0ce --- /dev/null +++ b/apps/server/api/src/db/types/ssh-saved-locations.db-types.ts @@ -0,0 +1,13 @@ +/** Origin IDs are broker identities, never a machine label or a credential. */ +export interface SshSavedLocationTable { + id: string; + ownerUserId: string; + originKind: "node" | "desktop"; + originId: string; + alias: string; + host: string; + port: number; + user: string | null; + path: string; + createdAt: string; +} diff --git a/apps/server/api/src/index.ts b/apps/server/api/src/index.ts index 6c12bc0f0..773bec5c0 100644 --- a/apps/server/api/src/index.ts +++ b/apps/server/api/src/index.ts @@ -13,23 +13,15 @@ // gate below, not the exit style. // biome-ignore-all assist/source/organizeImports: entry prelude must evaluate first — see comment import "./cli-bootstrap.js"; +import { LOCAL_SSH_SHUTDOWN_MS, stopLocalSshBroker } from "@/services/ssh-runtime/local-broker.js"; import { join, resolve } from "node:path"; -import { serverConfigDir } from "@/config-env.js"; -import { closeAuthDatabase } from "@/auth/database.js"; -import { acquireInstanceLock } from "@/services/instance-state-lock.js"; -import { - instanceRestoreJournalPath, - assertInstanceRestoreDestination, - finalizeInstanceRestore, - rollbackInstanceRestore, -} from "@/services/backups/index.js"; -import { sweepBackupDownloads } from "@/services/backup-download-jobs.js"; -import { sweepRestoreStages } from "@/services/backup-staging.js"; -import { instanceBackupPaths } from "@/services/instance-backup-source.js"; import { getHarness, setPluginDataDir } from "@internal/pane-runtime"; +import { closeAuthDatabase } from "@/auth/database.js"; import { ensureSystemUser } from "@/auth/system-user.js"; import { setAuthPolicyDb } from "@/auth.js"; +import { banner } from "@/banner.js"; import { isCliEngaged } from "@/cli.js"; +import { serverConfigDir } from "@/config-env.js"; import { APP_BASE_URL, assertProdAuthSecret, @@ -45,33 +37,43 @@ import { runMigrations } from "@/db/migrate.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; -import { publishLive } from "@/services/live-bus.js"; import { startServer } from "@/server.js"; +import { sweepBackupDownloads } from "@/services/backup-download-jobs.js"; +import { sweepRestoreStages } from "@/services/backup-staging.js"; +import { + assertInstanceRestoreDestination, + finalizeInstanceRestore, + instanceRestoreJournalPath, + rollbackInstanceRestore, +} from "@/services/backups/index.js"; +import { instanceBackupPaths } from "@/services/instance-backup-source.js"; +import { acquireInstanceLock } from "@/services/instance-state-lock.js"; +import { publishLive } from "@/services/live-bus.js"; import { loadAndApplyDebugLogging } from "@/services/logging-preference.js"; +import { refreshNetworkOrigins, seedNetworkOrigins, startOriginRefresh } from "@/services/network/origin-refresh.js"; +import { prepareNetworkGuards, prepareNetworkProcesses } from "@/services/network/prepare.js"; +import { stopAllProcesses } from "@/services/network/supervisor.js"; import { startInventoryRefresh } from "@/services/nodes/inventory-refresh.js"; +import { prepareLocalPlugins } from "@/services/nodes/local-plugins.js"; import { reconcileMaintenance } from "@/services/nodes/maintenance.js"; import { setNodeLifecycleHooks } from "@/services/nodes/node-events.js"; import { listOnline } from "@/services/nodes/node-registry.js"; -import { prepareNetworkGuards, prepareNetworkProcesses } from "@/services/network/prepare.js"; -import { refreshNetworkOrigins, seedNetworkOrigins, startOriginRefresh } from "@/services/network/origin-refresh.js"; -import { stopAllProcesses } from "@/services/network/supervisor.js"; -import { prepareLocalPlugins } from "@/services/nodes/local-plugins.js"; import { ensureLocalNode } from "@/services/nodes/seed-local.js"; import { subshellLogPath } from "@/services/nodes/subshell-paths.js"; -import { completeUpdate, readPending, recordFailure, revertUpdate } from "@/services/update-transaction.js"; import { getNotifyService } from "@/services/notify.service.js"; +import { createIdleWatcher, IDLE_TICK_MS } from "@/services/notify-idle.js"; import { sweepExpiredPaneLogs, tightenPaneLogModes } from "@/services/pane-log-hygiene.js"; import { expirePendingApprovals, remarkUnmarkedArrivals } from "@/services/pending-approvals.js"; -import { createIdleWatcher, IDLE_TICK_MS } from "@/services/notify-idle.js"; import { hasAnyUser } from "@/services/registration-gate.js"; +import { reconcileSshRuntimeSessionsAtBoot } from "@/services/ssh-runtime/session-settle.js"; import { SubshellManagerService } from "@/services/subshell-manager.service.js"; +import { completeUpdate, readPending, recordFailure, revertUpdate } from "@/services/update-transaction.js"; import { BANNER_GROUP, getLogger } from "@/utils/logger.js"; -import { banner } from "@/banner.js"; import { SERVER_VERSION } from "@/version.js"; import { sweepWsTokens } from "@/ws/ws-token.js"; export type { App } from "@/server.js"; -export type { BackupInspection, InstanceBackupManifest, BackupAdmin } from "@/services/backups/types.js"; +export type { BackupAdmin, BackupInspection, InstanceBackupManifest } from "@/services/backups/types.js"; // Plan 2 CLI gate: `isCliEngaged()` flips synchronously inside the prelude's // `dispatchCli` call the moment a subcommand is recognised, so an async CLI @@ -247,6 +249,21 @@ async function bootServer(): Promise { getLogger().withError(err).warn("could not install network request guards at boot"); } + // The SSH runtime sessions get the same fact BEFORE the listener (design + // 2026-10-05 §6): the broker links died with the previous process and their + // SSH children went with them, so an `opening`/`active` row at this point + // is history - the sweep marks it `lost`, takes the hidden runtime node + // rows offline, and re-announces presence. Before the listener, not after + // (review N-E): after the port opens, a session racing the sweep would have + // its fresh row marked lost and its runtime node left offline until the + // next settle. Boot-only by construction: the registry owns liveness from + // here on and a settled row can never hold a channel again. Never fatal: a + // sweep that cannot run leaves rows reading active; it is not a reason to + // fail a boot that already migrated. + await reconcileSshRuntimeSessionsAtBoot().catch((err: unknown) => { + getLogger().withError(err).warn("ssh-runtime boot reconcile failed"); + }); + // Children of this process, so nothing else reaps them: an exit that left // them running would leave a tunnel pointing at a port about to stop // answering. Registered before the listener for the same reason the stop @@ -459,10 +476,10 @@ function installShutdownHandlers(): void { if (stopping) return; stopping = true; getLogger().info(`received ${signal}; stopping supervised network processes`); - const deadline = setTimeout(() => process.exit(0), 10_000); + const deadline = setTimeout(() => process.exit(0), Math.max(10_000, LOCAL_SSH_SHUTDOWN_MS)); // Never blocks the exit on an unref'd timer of its own. deadline.unref?.(); - void stopAllProcesses() + void Promise.all([stopAllProcesses(), stopLocalSshBroker()]) .catch((err: unknown) => getLogger().withError(err).warn("could not stop every network process cleanly")) .finally(() => { clearTimeout(deadline); diff --git a/apps/server/api/src/server.ts b/apps/server/api/src/server.ts index 261ba70d8..850b321be 100644 --- a/apps/server/api/src/server.ts +++ b/apps/server/api/src/server.ts @@ -12,6 +12,7 @@ import { selectStaticPlugin } from "@/plugins/static.plugin.js"; import { apiModels } from "@/schema/index.js"; import { originRegistry } from "@/services/trusted-origins.js"; import { logger } from "@/utils/logger.js"; +import { desktopBrokerWsPlugin } from "@/ws/desktop-broker.plugin.js"; import { startLivePublisher } from "@/ws/live-publisher.js"; import { wsPlugin } from "@/ws/ws.plugin.js"; @@ -65,6 +66,7 @@ export function createApp() { // Disk dist wins when present (dev and checkout deployments behave alike); // else the SPA embedded by scripts/embed-web.ts; else boot fails loudly. .use(selectStaticPlugin(FRONTEND_DIST, EMBEDDED)) + .use(desktopBrokerWsPlugin) .use(wsPlugin) .use(routes); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts index 57afc7703..9bd199193 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts @@ -29,7 +29,9 @@ import { FakeNodeLauncher, nodeOnline } from "@/services/__tests__/helpers/node- import { seedPreset } from "@/services/__tests__/helpers/seed-preset.js"; import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; import { NodeRpcError } from "@/services/nodes/node-rpc.js"; +import { registerSession, resetSessionRegistryForTests } from "@/services/ssh-runtime/session-registry.js"; import { SubshellManagerService, type SubshellTokenProvider } from "@/services/subshell-manager.service.js"; +import { mkRuntimeSession } from "@/test-helpers/runtime-session.js"; /** * Per-node launcher routing in the manager (spec §6.3/§6.6): createSubshell @@ -327,4 +329,41 @@ describe("nodeOffline on views (spec §5.6)", () => { const singleOffline = await manager.getSubshell("u1", remoteSubshellId); expect(singleOffline?.nodeOffline).toBe(true); }); + it("a runtime-session pane's row is NOT offline while its session lives (review I3)", async () => { + const fake = new FakeNodeLauncher(testDir); + const manager = new SubshellManagerService({ + subshells: subshellsRepo, + presets: presetsRepo, + launcher: fake, + tokens, + audit: async () => {}, + }); + const session = mkRuntimeSession({ ownerId: "u1" }); + registerSession(session); + const runtimePaneId = crypto.randomUUID(); + await subshellsRepo.create({ + id: runtimePaneId, + userId: "u1", + presetId, + harnessId: "claude-code", + name: "v-runtime", + workingDir: "/tmp", + tmuxSocket: `sock-${runtimePaneId}`, + nodeId: session.runtimeNodeId, + }); + try { + const single = await manager.getSubshell("u1", runtimePaneId); + expect(single?.nodeOffline, "a live session IS the node's liveness").toBe(false); + const views = await manager.toViews(await subshellsRepo.listByUser("u1")); + expect(new Map(views.map((v) => [v.id, v])).get(runtimePaneId)?.nodeOffline).toBe(false); + } finally { + // The registry is liveness authority: once the session is gone (here: + // lost + evicted, exactly what the settle does), the row reads offline. + session.markLost("child-lost"); + resetSessionRegistryForTests(); + } + const offline = await manager.getSubshell("u1", runtimePaneId); + expect(offline?.nodeOffline).toBe(true); + await subshellsRepo.delete(runtimePaneId).catch(() => {}); + }); }); diff --git a/apps/server/api/src/services/__tests__/subshell-tokens.test.ts b/apps/server/api/src/services/__tests__/subshell-tokens.test.ts index 54c0ce80a..82359169a 100644 --- a/apps/server/api/src/services/__tests__/subshell-tokens.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-tokens.test.ts @@ -66,7 +66,9 @@ describe("subshell tokens", () => { expect(res.key.metadata).toEqual({ kind: "subshell", subshellId: sid }); // EXACT map, not a subset: `prompts` arrived with the saved-prompt // library (spec 2026-09-28), `transfers` with node-to-node archive - // transfer (spec 2026-10-01), and this line is what notices a mint drift. + // transfer (spec 2026-10-01); the `ssh` scope retired with the + // destination product (design 2026-10-05 §7), and this line is what + // notices a mint drift. expect(res.key.permissions).toEqual({ channels: ["read", "write"], subshells: ["read", "write"], diff --git a/apps/server/api/src/services/__tests__/subshells-resolve-launch-node.test.ts b/apps/server/api/src/services/__tests__/subshells-resolve-launch-node.test.ts index 9286b57ad..ffa9299e3 100644 --- a/apps/server/api/src/services/__tests__/subshells-resolve-launch-node.test.ts +++ b/apps/server/api/src/services/__tests__/subshells-resolve-launch-node.test.ts @@ -339,6 +339,63 @@ describe("resolveLaunchNode — server switched off as a node", () => { }); }); +/** + * The runtime seal (design 2026-10-05 §4, review M4): a `runtime` node row is + * the hidden machine a session's panes ride, never a launch target for an + * ordinary create. The refusal must be NAMED and it must fire HERE - the day + * the runtime launcher gains a real resolver, reaching `createSubshell` with + * such a row would push the pane's bearer token and the plane URL onto the + * destination (the env rides the launch plan verbatim), and the token must + * never leave the plane. A foreign viewer still gets the 404: the row is + * hidden, and naming it would make the id an existence oracle. + */ +describe("resolveLaunchNode — runtime rows refuse by name", () => { + async function mkRuntimeNode(ownerUserId: string): Promise { + const id = crypto.randomUUID(); + nodeIds.push(id); + await deps.nodes.create({ id, ownerUserId, name: `rt-${id.slice(0, 8)}`, kind: "runtime", status: "online" }); + return id; + } + + it("the row's OWNER naming it → 409 with the named runtime-path refusal", async () => { + const node = await mkRuntimeNode(ownerId); + const err = await grab(() => + resolveLaunchNode({ userId: ownerId, machineActor: false, requestedNodeId: node }, deps), + ); + expect(statusOf(err)).toBe(409); + expect((err as { code?: string }).code).toBe("runtime_session_only"); + expect((err as Error).message).toMatch(/SSH runtime path only/); + }); + + it("a foreign viewer naming a hidden runtime row → 404, never the seal's message (no existence oracle)", async () => { + const node = await mkRuntimeNode(otherId); + const err = await grab(() => + resolveLaunchNode({ userId: ownerId, machineActor: false, requestedNodeId: node }, deps), + ); + expect(statusOf(err)).toBe(404); + expect((err as Error).message).toBe("Node not found"); + }); + + it("auto-pick cannot land on a runtime row either (the listing filters keep it out of the candidates)", async () => { + // One online agent + one ONLINE runtime row owned by the same user: the + // step-3 candidate list is `findAccessible`/`listByOwner`, both of which + // exclude `kind = runtime` in the repository. The auto-pick must resolve + // to the AGENT, silently skipping the hidden row. + const agent = await mkNode(ownerId); + const runtime = await mkRuntimeNode(ownerId); + await deps.shares.replaceForNode(LOCAL_NODE_ID, [], systemId); + const off = online(agent); + try { + const res = await resolveLaunchNode({ userId: ownerId, machineActor: false }, deps); + expect(res).toEqual({ nodeId: agent }); + expect(res.nodeId).not.toBe(runtime); + } finally { + off(); + await deps.shares.replaceForNode(LOCAL_NODE_ID, [{ granteeUserId: null, permission: "edit" }], systemId); + } + }); +}); + describe("resolveLaunchNode — implicit local (step 2) and auto-pick (step 3)", () => { it("nothing requested + the local switch ON → local (today's behavior preserved)", async () => { expect(await resolveLaunchNode({ userId: ownerId, machineActor: false }, deps)).toEqual({ diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 2d0ac1be5..a9f45f9c8 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0046-backup-recovery"; +export const LATEST_BACKUP_MIGRATION = "0051-ssh-saved-locations"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/services/backups/metadata.ts b/apps/server/api/src/services/backups/metadata.ts index 6e8d3d925..982942e2a 100644 --- a/apps/server/api/src/services/backups/metadata.ts +++ b/apps/server/api/src/services/backups/metadata.ts @@ -19,35 +19,104 @@ export const METADATA_LIMITS = { type IndexColumn = { cid: number; name: string | null; key: number; desc: number; coll: string }; -/** Only migration 0029's literal predicate is allowed; an index name alone conveys no trust. */ +/** One partial index this build ships: its exact creating spelling, key for key. */ +interface ShippedPartialIndex { + /** Owning table */ + table: string; + /** Index name (conveys nothing alone; matched alongside the stored SQL) */ + name: string; + /** Whether the shipped index is UNIQUE (the workspaces rule's uniqueness, or the grants tuple's) */ + unique: 0 | 1; + /** Key columns in order, ASC/BINARY like every shipped spelling */ + keys: string[]; + /** The normalized `sqlite_schema.sql` the migration literally writes */ + sql: RegExp; +} + +/** + * The shipped partial indexes, each one's literal predicate pinned. An index + * name alone conveys no trust: the stored SQL must match the migration's + * spelling, because the whole point of refusing partials is that inspection + * never evaluates an ARCHIVE-chosen predicate - so only predicates THIS + * build demonstrably wrote are safe. Entries in migration order: + * 0029 (workspace drafts), 0047/0048 (the SSH destination product's + * active-state grammar - revoked grants, running runs, outstanding execs + * all read as `WHERE ` indexes). That product retired (0050 drops + * its five tables), so these five entries now exist for one honest reason: + * a pre-retirement archive still carries them at inspection time, and the + * first migration that restored database runs is the drop. A fresh + * instance's schema never contains them; deleting these lines would + * refuse every backup taken before the retirement. + */ +const SHIPPED_PARTIAL_INDEXES: readonly ShippedPartialIndex[] = [ + { + table: "workspaces", + name: "idx_workspaces_user_name", + unique: 1, + keys: ["user_id", "name"], + sql: /^CREATE UNIQUE INDEX (?:IF NOT EXISTS )?idx_workspaces_user_name ON workspaces\s*\(\s*user_id\s*,\s*name\s*\)\s*WHERE draft\s*=\s*0$/i, + }, + { + table: "ssh_grants", + name: "uq_ssh_grants_active", + unique: 1, + keys: ["connection_id", "subshell_id", "api_key_id"], + sql: /^CREATE UNIQUE INDEX (?:IF NOT EXISTS )?uq_ssh_grants_active ON ssh_grants\s*\(\s*connection_id\s*,\s*subshell_id\s*,\s*api_key_id\s*\)\s*WHERE revoked_at\s+IS NULL$/i, + }, + { + table: "ssh_grants", + name: "idx_ssh_grants_pane", + unique: 0, + keys: ["subshell_id"], + sql: /^CREATE INDEX (?:IF NOT EXISTS )?idx_ssh_grants_pane ON ssh_grants\s*\(\s*subshell_id\s*\)\s*WHERE revoked_at\s+IS NULL$/i, + }, + { + table: "ssh_runs", + name: "idx_ssh_runs_active", + unique: 0, + keys: ["node_id", "user_id"], + sql: /^CREATE INDEX (?:IF NOT EXISTS )?idx_ssh_runs_active ON ssh_runs\s*\(\s*node_id\s*,\s*user_id\s*\)\s*WHERE status IN\s*\(\s*'accepted'\s*,\s*'running'\s*\)$/i, + }, + { + table: "ssh_runs", + name: "idx_ssh_runs_grant", + unique: 0, + keys: ["grant_id"], + sql: /^CREATE INDEX (?:IF NOT EXISTS )?idx_ssh_runs_grant ON ssh_runs\s*\(\s*grant_id\s*\)\s*WHERE status IN\s*\(\s*'accepted'\s*,\s*'running'\s*\)$/i, + }, + { + table: "ssh_terminal_execs", + name: "idx_ssh_terminal_execs_outstanding", + unique: 0, + keys: ["subshell_id"], + sql: /^CREATE INDEX (?:IF NOT EXISTS )?idx_ssh_terminal_execs_outstanding ON ssh_terminal_execs\s*\(\s*subshell_id\s*\)\s*WHERE state\s*=\s*'outstanding'$/i, + }, +]; + +/** Only a shipped partial index's literal predicate is allowed; an index name alone conveys no trust. */ function isShippedPartialIndex( db: Database, table: string, index: { name: string; unique: number }, columns: IndexColumn[], ): boolean { - if (table !== "workspaces" || index.name !== "idx_workspaces_user_name" || index.unique !== 1) return false; + const spec = SHIPPED_PARTIAL_INDEXES.find( + (shipped) => shipped.table === table && shipped.name === index.name && shipped.unique === index.unique, + ); + if (!spec) return false; const keys = columns.filter((column) => column.key === 1); if ( - keys.length !== 2 || + keys.length !== spec.keys.length || keys.some( (column, position) => - column.cid < 0 || - column.name !== ["user_id", "name"][position] || - column.desc !== 0 || - column.coll !== "BINARY", + column.cid < 0 || column.name !== spec.keys[position] || column.desc !== 0 || column.coll !== "BINARY", ) ) return false; const row = db .query("SELECT sql FROM sqlite_schema WHERE type='index' AND name=? AND tbl_name=?") .get(index.name, table) as { sql: string | null } | null; - return ( - typeof row?.sql === "string" && - /^CREATE UNIQUE INDEX (?:IF NOT EXISTS )?idx_workspaces_user_name ON workspaces\s*\(\s*user_id\s*,\s*name\s*\)\s*WHERE draft\s*=\s*0$/i.test( - row.sql.trim().replace(/\s+/g, " "), - ) - ); + return typeof row?.sql === "string" && spec.sql.test(row.sql.trim().replace(/\s+/g, " ")); } /** Validate schemas before inspection or writes can evaluate archived expressions. */ diff --git a/apps/server/api/src/services/channels/nudge.ts b/apps/server/api/src/services/channels/nudge.ts index 7c3df761e..bbd277e08 100644 --- a/apps/server/api/src/services/channels/nudge.ts +++ b/apps/server/api/src/services/channels/nudge.ts @@ -44,15 +44,15 @@ export function setNudgeTransportForTests(tmux: TmuxRunner | null): void { */ export async function nudgeSubshell( socket: string, - subshellName: string, + subshellId: string, text: string, opts: { submit?: boolean } = {}, ): Promise { try { - await transport.sendInput(socket, subshellName, text); - if (opts.submit) await transport.pressEnter(socket, subshellName); + await transport.sendInput(socket, subshellId, text); + if (opts.submit) await transport.pressEnter(socket, subshellId); } catch (err) { // A vanished pane between liveness-check and type is a normal race. - logger.withError(err).debug(`nudge failed for ${subshellName}`); + logger.withError(err).debug(`nudge failed for ${subshellId}`); } } diff --git a/apps/server/api/src/services/nodes/__tests__/node-registry.test.ts b/apps/server/api/src/services/nodes/__tests__/node-registry.test.ts index e793f2437..76e6ded9d 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-registry.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-registry.test.ts @@ -1,10 +1,18 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { NODE_CLOSE_SUPERSEDED } from "@internal/subshell-protocol"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { + registerSession, + resetSessionRegistryForTests, + unregisterSession, +} from "@/services/ssh-runtime/session-registry.js"; +import { mkRuntimeSession } from "@/test-helpers/runtime-session.js"; import { attachConnection, detachConnection, disconnectNode, getLive, + isNodeOffline, listOnline, type NodeSocket, REVOKED_CLOSE_CODE, @@ -200,3 +208,45 @@ describe("node registry (spec 2026-08-31 §5.3)", () => { }); }); }); + +/** + * The session-aware liveness predicate (review Important 3): a runtime node + * is NEVER in the live registry (it is reached through the session's framed + * channel), so the registry-only probe read every LIVE runtime pane as + * "node offline" in the grid, the manager views, the summarize counts, and + * notify. The blessed predicate now consults the session registry too; a + * lost/absent session still reads offline, exactly as before for every + * ordinary agent node. + */ +describe("isNodeOffline with runtime sessions (review I3)", () => { + beforeEach(() => { + resetSessionRegistryForTests(); + }); + + it("a runtime node with a LIVE session is NOT offline; local never is", () => { + const session = mkRuntimeSession({ ownerId: "u-i3" }); + registerSession(session); + expect(getLive(session.runtimeNodeId)).toBeUndefined(); // the premise: never in the WS registry + expect(isNodeOffline(session.runtimeNodeId)).toBe(false); + expect(isNodeOffline(LOCAL_NODE_ID)).toBe(false); + }); + + it("a settled or absent runtime node IS offline (the honest unavailable reading)", () => { + const session = mkRuntimeSession({ ownerId: "u-i3" }); + registerSession(session); + expect(isNodeOffline(session.runtimeNodeId)).toBe(false); + session.markLost("child-lost"); // the settle unregisters; the predicate flips with it + expect(isNodeOffline(session.runtimeNodeId)).toBe(true); + unregisterSession(session); + expect(isNodeOffline(session.runtimeNodeId)).toBe(true); + expect(isNodeOffline("never-was")).toBe(true); + }); + + it("an ordinary agent node keeps the WS-registry answer untouched", () => { + const sock = fakeSocket(); + attachConnection("agent-i3", sock); + expect(isNodeOffline("agent-i3")).toBe(false); + detachConnection("agent-i3", sock); + expect(isNodeOffline("agent-i3")).toBe(true); + }); +}); diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index 4a7e2eaaa..cb30f10e6 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -20,6 +20,12 @@ import { type NodeReadyReport, NodesRepository } from "@/db/repositories/nodes.r import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import type { NodeKind, NodeTable } from "@/db/types/nodes.db-types.js"; import { subscribeLive } from "@/services/live-bus.js"; +import { + liveSessionForRuntimeNode, + registerSession, + resetSessionRegistryForTests, +} from "@/services/ssh-runtime/session-registry.js"; +import { mkRuntimeSession } from "@/test-helpers/runtime-session.js"; import { resetInputHoldsForTests } from "@/ws/input-hold.js"; import { resetInputWindowsForTests } from "@/ws/input-window.js"; import { handleSubshellMessage } from "@/ws/subshell-ws.js"; @@ -1310,6 +1316,171 @@ describe("handleNodeClose (superseded-close hygiene, spec §5.3)", () => { }); }); +/** + * Round-3 review MAJOR: a link death must lose the sessions that rode it, in + * EVERY ordering. The agent drains its brokered children on every link close + * (review M1) and the plane's close arm says so directly (`markSessionsLost- + * ForNode`); the gap was the SUPERSEDED arm - a black-hole flap (the plane + * never receives FIN for the old socket; the agent redials and the new + * connection's attach 4409s the old one) lands that close in the held/ + * superseded branch, which deliberately never touches sessions. Without a + * second truth-teller the plane kept `active` rows over runtimes the agent + * had already killed. + * + * The seam under test is the `ready` moment of the REPLACEMENT connection: + * a new brokered session can only exist once a link carries it, so every + * session not brokered on THIS connection rode the prior link and died with + * it. Sessions tagged to the live connection - one opened in the attach-to- + * ready window included - must survive the sweep untouched, in both orders. + */ +describe("brokered-session liveness across link-close orderings (review round-3 MAJOR)", () => { + beforeEach(() => { + resetNodeRegistryForTests(); + resetSessionRegistryForTests(); + }); + // The attached fakes and registered sessions are THIS suite's state; the + // suites below assume a clean node registry (the C14 probe reads it), so + // they leave with the case. + afterEach(() => { + resetNodeRegistryForTests(); + resetSessionRegistryForTests(); + }); + + /** One live session brokered on `conn`, registered with that connection tag. */ + function sessionOn(connectingNodeId: string, conn: NodeConnection | undefined) { + const session = mkRuntimeSession({ connectingNodeId }); + registerSession(session, conn); + return session; + } + + it("the CURRENT socket's close still loses the session riding it (branch-1 kept)", async () => { + const h = makeHarness(); + const ws = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, ws); + await handleNodeMessage(h.deps, ws, JSON.stringify(readyFrame())); + const riding = sessionOn("n1", ws.data.nodeConn); + await handleNodeClose(h.deps, ws); + expect(riding.status).toBe("lost"); + expect(liveSessionForRuntimeNode(riding.runtimeNodeId)).toBeUndefined(); + }); + + it("black-hole flap: old socket closes SUPERSEDED, and the new connection's ready loses the prior-link sessions", async () => { + const h = makeHarness(); + const first = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, first); + await handleNodeMessage(h.deps, first, JSON.stringify(readyFrame())); + const riding = sessionOn("n1", first.data.nodeConn); + + // The redial: the NEW socket attaches (4409s the old one); the old + // socket's close lands in the superseded branch. Per its pinned contract + // that branch touches neither the registry nor the sessions, so AFTER + // THIS STEP the session is still `active` - the divergence the reviewer + // measured. + const second = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, second); + await handleNodeClose(h.deps, first); + expect(riding.status).toBe("active"); + expect(h.statuses).toEqual([]); // branch 2 never touches the projection + + // The convergence: the replacement link's `ready` says the prior link is + // gone, so the session that rode it is lost (design §6: never resumable). + await handleNodeMessage(h.deps, second, JSON.stringify(readyFrame())); + expect(riding.status).toBe("lost"); + expect(liveSessionForRuntimeNode(riding.runtimeNodeId)).toBeUndefined(); + }); + + it("sessions brokered on the NEW connection are not clobbered: neither by the old close nor by the ready sweep", async () => { + const h = makeHarness(); + const first = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, first); + await handleNodeMessage(h.deps, first, JSON.stringify(readyFrame())); + const rodeOldLink = sessionOn("n1", first.data.nodeConn); + + const second = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, second); + // The attach-to-ready window: an open whose RPC rode the NEW socket + // lands tagged to it. The sweep must read the tag, not the clock. + const freshConn = second.data.nodeConn; + if (!freshConn) throw new Error("open must stash the registry record on ws.data"); + const openedHere = sessionOn("n1", freshConn); + + await handleNodeClose(h.deps, first); // superseded arm: touches nothing + await handleNodeMessage(h.deps, second, JSON.stringify(readyFrame())); // the sweep + expect(rodeOldLink.status).toBe("lost"); + expect(openedHere.status).toBe("active"); // not swept: it did not ride the dead link + + // A second `ready` on the SAME connection (a misbehaving agent) must not + // sweep the live one either - the sweep compares tags, not timestamps. + await handleNodeMessage(h.deps, second, JSON.stringify(readyFrame())); + expect(openedHere.status).toBe("active"); + }); + + /** + * Round-4 review MINOR1: the FORCED-EVICTION ordering. `disconnectNode` + * (rotate-key / delete-node / owner-disable / re-register) detaches the + * live entry synchronously, so the socket's close event later lands in the + * superseded arm (which touches no sessions), and the same act refuses or + * replaces the redial, so no replacement `ready` sweeps either. Without a + * mark at the eviction itself the plane kept `active` rows over runtimes + * the agent drained the moment its socket died. + */ + it("forced eviction (rotate/delete/disable) loses the riding session: its close lands superseded and no ready follows", async () => { + const h = makeHarness(); + const ws = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, ws); + await handleNodeMessage(h.deps, ws, JSON.stringify(readyFrame())); + const riding = sessionOn("n1", ws.data.nodeConn); + + expect(await disconnectNode("n1")).toBe(true); + expect(riding.status).toBe("lost"); + expect(liveSessionForRuntimeNode(riding.runtimeNodeId)).toBeUndefined(); + expect(isNodeOffline(riding.runtimeNodeId)).toBe(true); // nothing keeps the runtime node live + + // The (simulated) late close: getLive is empty, so it is the superseded + // arm exactly as on a real socket, and the session stays terminal. + await handleNodeClose(h.deps, ws); + expect(riding.status).toBe("lost"); + }); + + it("the targeted disable re-ask (`only` on the live record) marks the sessions too", async () => { + const h = makeHarness(); + const ws = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, ws); + await handleNodeMessage(h.deps, ws, JSON.stringify(readyFrame())); + const conn = ws.data.nodeConn; + if (!conn) throw new Error("open must stash the registry record on ws.data"); + const riding = sessionOn("n1", conn); + + expect(await disconnectNode("n1", OWNER_DISABLED_CLOSE_CODE, "the node's owner account is disabled", conn)).toBe( + true, + ); + expect(riding.status).toBe("lost"); + }); + + it("an `only` eviction that detaches nothing marks nothing: the replacement owns its sessions", async () => { + const h = makeHarness(); + const first = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, first); + await handleNodeMessage(h.deps, first, JSON.stringify(readyFrame())); + const oldConn = first.data.nodeConn; + if (!oldConn) throw new Error("open must stash the registry record on ws.data"); + const rodeOldLink = sessionOn("n1", oldConn); + + const second = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, second); // superseded: the old record left the live map here + const openedHere = sessionOn("n1", second.data.nodeConn); + + // The re-ask about an already-replaced socket: nothing of its own to + // evict, and it owns no session - the prior-link one goes by the + // replacement's ready sweep, not by this refused eviction. + expect(await disconnectNode("n1", OWNER_DISABLED_CLOSE_CODE, "the node's owner account is disabled", oldConn)).toBe( + false, + ); + expect(rodeOldLink.status).toBe("active"); + expect(openedHere.status).toBe("active"); + }); +}); + /** * Wave D wiring (spec 2026-09-21): a node's `ready` moment is one of the two * re-fire triggers for held plane→node input writes (`ws/input-hold.ts`). diff --git a/apps/server/api/src/services/nodes/inventory.ts b/apps/server/api/src/services/nodes/inventory.ts index 50f8f542b..bfe8e1bf0 100644 --- a/apps/server/api/src/services/nodes/inventory.ts +++ b/apps/server/api/src/services/nodes/inventory.ts @@ -23,7 +23,7 @@ import { logger } from "@/utils/logger.js"; /** * Per-node harness-state resolution, post-inversion (spec 2026-09-10). * - * There is ONE plugin store — the instance's own directory — and ONE rule: a + * There is ONE plugin store: the instance's own directory: and ONE rule: a * harness exists for a node when the INSTANCE has the plugin (installed and * enabled, `local-plugins.ts`), and whether it RUNS there is that node's * detection answer. The two halves come from different places per node, and @@ -36,10 +36,10 @@ import { logger } from "@/utils/logger.js"; * * The `node.kind` branch inside the usability GATE is gone with the per-node * plugin set it branched on (`nodes.plugins_json`/`plugins_at`, dropped by - * migration 0026 — this module stopped reading them first). + * migration 0026: this module stopped reading them first). * * Two consumers, deliberately two strictnesses: the VIEW - * ({@link effectiveHarnessStates}) is informational — a stale snapshot still + * ({@link effectiveHarnessStates}) is informational: a stale snapshot still * reports its values, flagged per node via `stale`; the LAUNCH GATE * (`harnessUsable` / `usableHarnessIds` in `api/harness-utils.ts`) is strict — * only a fresh (≤ {@link INVENTORY_TTL_MS}) answer that says installed counts. @@ -68,14 +68,14 @@ export interface EffectiveHarnessState { name: string; /** local: live binary probe; agent: cached inventory (false until the first detection lands) */ installed: boolean; - /** Version from the detection (agent nodes only — the local view skips nothing: the probe answers it) */ + /** Version from the detection (agent nodes only: the local view skips nothing: the probe answers it) */ version?: string; /** Why the binary was not found, when it was not. Absent when installed, and when no probe has run. */ reason?: DetectionReason; /** ISO 8601 stamp of when this entry was probed. Absent when no probe has run. */ checkedAt?: string; /** - * Why the plugin cannot be used at all, when it cannot — an INSTANCE fact + * Why the plugin cannot be used at all, when it cannot: an INSTANCE fact * now: the plugin failed to load in the control-plane process, so it is * unusable on every node alike. * @@ -102,13 +102,13 @@ export interface EffectiveHarnessReport { /** * The rows for this node: one per plugin the INSTANCE has installed and * enabled. Not per plugin this node's cache happens to mention (a ghost - * entry is inert), and not including a disabled plugin — disabling removes + * entry is inert), and not including a disabled plugin: disabling removes * the row everywhere, which is the whole point of the flag (§6.1). */ harnesses: EffectiveHarnessState[]; /** * Agent nodes: true when the cached detection is older than the TTL **or - * has never landed** — the reported `installed` values are the best + * has never landed**: the reported `installed` values are the best * available, not necessarily true. local: always false (the probe is live * per read). */ @@ -119,9 +119,9 @@ export interface EffectiveHarnessReport { export interface AgentInventory { /** `harnessId → entry`; entries without a usable harnessId and junk payloads read as empty */ entries: Map; - /** True only when `inventoryAt` parses and sits within the TTL — the gate's strict view */ + /** True only when `inventoryAt` parses and sits within the TTL: the gate's strict view */ fresh: boolean; - /** `!fresh` — aged OR never-reported (the view's informational flag) */ + /** `!fresh`: aged OR never-reported (the view's informational flag) */ stale: boolean; } @@ -162,14 +162,14 @@ export function readAgentInventory(node: NodeTable, now: number = Date.now()): A * * Since Task 9b the registry overlay resolves installed third-party plugins * too, so every healthy install has a `HarnessPlugin` to probe through. What - * still gets NO entry: a plugin the overlay does not hold — broken at the - * last refresh, or placed on disk without one — because `getHarness` is the + * still gets NO entry: a plugin the overlay does not hold: broken at the + * last refresh, or placed on disk without one: because `getHarness` is the * only place binary lookup rules live. Absent reads as "not found" for * gating and as a quiet unknown for the view, which is the honest answer, * not a shrug: the launch path has the same boundary (`subshell-manager` * builds argv from registry code too). * @param installed - the instance catalog to probe (already enabled-filtered; - * broken entries are skipped — there is no plugin object to ask) + * broken entries are skipped: there is no plugin object to ask) */ export async function probeLocally(installed: readonly PluginReportWire[]): Promise { // One clock for the batch: entries probed together should not drift by @@ -197,8 +197,8 @@ export async function probeLocally(installed: readonly PluginReportWire[]): Prom * The gate's shallow probe: resolve each plugin's binary, skip the * ` --version` spawn. * - * The launch gate asks exactly one question per plugin — is the program - * there — and {@link probeLocally}'s `versionAt` call pays a full subprocess + * The launch gate asks exactly one question per plugin: is the program + * there: and {@link probeLocally}'s `versionAt` call pays a full subprocess * (seconds for a Node CLI) for a version the gate then throws away. This is * the same `detect()` resolution `isInstalled()` ran behind the old gate, * kept from regressing the auto-restart sweeps; the VIEW still wants the @@ -238,7 +238,7 @@ export async function probeInstalledOnly(installed: readonly PluginReportWire[]) } /** - * Effective harness states for one node — the merge behind `NodeView.harnesses`. + * Effective harness states for one node: the merge behind `NodeView.harnesses`. * * ONE rule for every node, which is the whole shape of the inversion: rows * come from the instance catalog, the binary answer comes from the node. @@ -280,13 +280,13 @@ export async function effectiveHarnessStates( /* ------------------------------------------------------------------ */ /** - * One `detect` spec per RESOLVABLE plugin — the merged registry, so an + * One `detect` spec per RESOLVABLE plugin: the merged registry, so an * installed third-party plugin ships its spec exactly like a built-in does - * (Task 9b is what makes `allHarnesses()` include it) — built from the + * (Task 9b is what makes `allHarnesses()` include it): built from the * manifest data the pane-runtime adapter attached (`detectSpec`, Task 2/R5) * rather than re-reading manifests: the control plane ships the RULE, the * node runs the lookup. A plugin with no detect block travels as the empty - * spec — the node then answers `no-binary` without searching, exactly as + * spec: the node then answers `no-binary` without searching, exactly as * `detectFor` reads a manifest with no `detect` block. * * The registry is the right source and the whole boundary: a plugin the @@ -309,7 +309,7 @@ export function detectSpecs(harnesses: ReturnType = allHarn * * The declarations are manifest DATA the control plane holds (and the node * no longer does, §6), so the PLANE names the variables and the node only - * answers values. Callers pass the ENABLED set — a disabled plugin asks for + * answers values. Callers pass the ENABLED set: a disabled plugin asks for * nothing, its resume path is not a question anyone can ask. */ export function detectEnvNames(harnesses: readonly HarnessPlugin[]): string[] { @@ -336,7 +336,7 @@ export interface DetectOnNodeDeps { * The enabled harnesses whose `hostEnv` declarations the plane asks nodes * about: the instance catalog (`plugin_state`-filtered, §6.1) resolved * through the registry overlay. A catalog entry the registry cannot resolve - * (broken at the last refresh) contributes nothing — it has no manifest data + * (broken at the last refresh) contributes nothing: it has no manifest data * to ask about, exactly as it has no lookup rules to ship. * @internal exported for the wiring pin in `inventory-detect.test.ts`; * production calls it only through {@link detectOnNode}. @@ -350,7 +350,7 @@ export async function enabledEnvHarnesses(): Promise { } /** - * Map one RAW answer row onto a cached inventory entry — the exact shape + * Map one RAW answer row onto a cached inventory entry: the exact shape * `applyInventory` stores from the agent's own inventory event, so * `INVENTORY_TTL_MS` and every reader keep working unchanged. * @@ -358,9 +358,15 @@ export async function enabledEnvHarnesses(): Promise { * which is the behavior move of the inversion: the node answered text it could * not interpret (it holds no plugin code), and this process, which does, * interprets it. A parser-less plugin (or one this build never heard of) keeps - * the raw text — a banner still beats dropping the only version fact. + * the raw text: a banner still beats dropping the only version fact. + * + * Exported for `ssh-runtime/harness-detect.ts` (M-3 dedup): the runtime's + * detect answer is the SAME wire rows and gets the SAME merge, from the one + * function: `getHarness` and `allHarnesses()` resolve identically (built-ins + * answer first, the shadow rule), so the runtime mirror cannot drift a second + * mapping. */ -function detectRowToEntry(row: DetectResultWire, stamp: string): HarnessInventoryEntry { +export function detectRowToEntry(row: DetectResultWire, stamp: string): HarnessInventoryEntry { const entry: HarnessInventoryEntry = { harnessId: row.harnessId, installed: row.installed, @@ -382,18 +388,18 @@ function detectRowToEntry(row: DetectResultWire, stamp: string): HarnessInventor * * The whole flow is a request: the plane ships the rules ({@link detectSpecs}) * AND the env names it wants values for ({@link detectEnvNames} over the - * enabled manifests — the node holds no manifests to name them itself), the + * enabled manifests: the node holds no manifests to name them itself), the * node probes and answers RAW, the raw text is parsed HERE with the plugin's * `parseVersion`, and the result is merged over the cached snapshot and * written through the SAME `applyInventory` path the inventory event uses. - * Rows the answer does not cover keep their cached values — a ghost id only a + * Rows the answer does not cover keep their cached values: a ghost id only a * previous scan reported must survive a detect built from this server's * registry until something drops it. The env answers go to the live * connection's facts, where `RemoteLauncher.canResume` composes resume paths * from them. * * **The plane asks; the node only ever answers.** That is §4's load-bearing - * half and nothing here may weaken it — a node must never scan on its own + * half and nothing here may weaken it: a node must never scan on its own * initiative. What counts as an occasion to ask is the part that has grown: * the node page load (best-effort), Re-check, the launch-driven kick in * `RemoteLauncher.#kickDetect` (a failed launch's `binary missing` refresh), @@ -406,7 +412,7 @@ function detectRowToEntry(row: DetectResultWire, stamp: string): HarnessInventor * there is one request path and one merge, not a second mechanism. * `local` is a no-op (its view probes live on every read); an unknown id is * one, too. - * @throws whatever `sendCommand` throws (offline/timeout/failed — callers + * @throws whatever `sendCommand` throws (offline/timeout/failed: callers * decide), and a plain Error when the agent answered with a malformed payload */ export async function detectOnNode(nodeId: string, deps: DetectOnNodeDeps = {}): Promise { @@ -427,7 +433,7 @@ export async function detectOnNode(nodeId: string, deps: DetectOnNodeDeps = {}): // of real rows: the agent's `inventory` EVENT still arrives through the // `/ws/node` handler, but since the Task 7/8 demolition a protocol-3 agent // sends `harnesses: []` on every one of them, and the handler treats the - // empty claim as "nothing to apply" — it can never overwrite these rows. + // empty claim as "nothing to apply": it can never overwrite these rows. const merged = readAgentInventory(node).entries; for (const row of rows) merged.set(row.harnessId, detectRowToEntry(row, stamp)); await nodes.applyInventory(nodeId, JSON.stringify([...merged.values()])); @@ -446,13 +452,13 @@ export async function detectOnNode(nodeId: string, deps: DetectOnNodeDeps = {}): * The arm for every caller that has nothing to report a failure TO: the node * page load, the launch kick, the connect-time kick, and the periodic pass. * The page renders the cached last-known inventory either way (that is what - * §4's cache is for), so a node that is offline for the page open — the - * routine case the moment a machine sleeps — must not surface anything; a + * §4's cache is for), so a node that is offline for the page open: the + * routine case the moment a machine sleeps: must not surface anything; a * debug line is for the operator who then watches a page that never refreshes. * The same reasoning covers the two unattended callers a fortiori: nobody is * watching a timer, and a handshake must not fail over a probe. * - * Re-check is the one caller that does NOT use this — it is a person pressing + * Re-check is the one caller that does NOT use this: it is a person pressing * a button and waiting, so it awaits {@link detectOnNode} and reports. */ export function detectOnNodeBestEffort(nodeId: string): void { diff --git a/apps/server/api/src/services/nodes/launcher-registry.ts b/apps/server/api/src/services/nodes/launcher-registry.ts index e47392b0d..1deda7e39 100644 --- a/apps/server/api/src/services/nodes/launcher-registry.ts +++ b/apps/server/api/src/services/nodes/launcher-registry.ts @@ -1,4 +1,6 @@ import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { RuntimeSessionLauncher } from "@/services/ssh-runtime/runtime-session-launcher.js"; +import { liveSessionForRuntimeNode } from "@/services/ssh-runtime/session-registry.js"; import { getDefaultLocalLauncher } from "./local-launcher.js"; import type { NodeLauncher } from "./node-launcher.js"; import { RemoteLauncher } from "./remote-launcher.js"; @@ -14,6 +16,18 @@ import { RemoteLauncher } from "./remote-launcher.js"; */ const remoteLaunchers = new Map(); +/** + * Per-node cached runtime launchers, keyed with their session. A settled + * session is EVICTED (the settle path calls {@link forgetRuntimeLauncher}): + * the entry retains the session, and the session retains the pane-token + * plaintext map, which must not outlive the documented "leaves with the pane" + * promise. The live-session check below is the belt: an entry for a dead + * session never resolves to a launcher even before eviction. + */ +const runtimeLaunchers = new Map< + string, + { session: import("@/services/ssh-runtime/session.js").SshRuntimeSession; launcher: RuntimeSessionLauncher } +>(); /** * The launcher for one node id. Never throws — an unknown/offline agent id @@ -24,6 +38,22 @@ const remoteLaunchers = new Map(); */ export function launcherFor(nodeId: string): NodeLauncher { if (nodeId === LOCAL_NODE_ID) return getDefaultLocalLauncher(); + // The runtime branch (design 2026-10-05 §4): a `runtime`-kind node id whose + // session is live RIGHT NOW resolves to a `RuntimeSessionLauncher` bound to + // that session. No live session means fall through to the `RemoteLauncher` + // path, whose commands reject `offline` - the honest reading of a pane whose + // destination session died (the registry is liveness authority; the DB row + // is history). Cached per session id so the per-subshell pump contracts + // hold across callers. + const session = liveSessionForRuntimeNode(nodeId); + if (session) { + let rl = runtimeLaunchers.get(nodeId); + if (!rl || rl.session !== session) { + rl = { session, launcher: new RuntimeSessionLauncher(session) }; + runtimeLaunchers.set(nodeId, rl); + } + return rl.launcher; + } let launcher = remoteLaunchers.get(nodeId); if (!launcher) { launcher = new RemoteLauncher(nodeId); @@ -32,10 +62,34 @@ export function launcherFor(nodeId: string): NodeLauncher { return launcher; } +/** + * Forget the cached runtime launcher for one settled session's runtime node + * (called from the ssh-runtime settle path). A closed or lost session's + * launcher holds the session, and the session holds the pane-token plaintext + * until the settle clears it - the cache entry must not be the reason that + * memory outlives the session. Idempotent. + */ +export function forgetRuntimeLauncher(nodeId: string): void { + runtimeLaunchers.delete(nodeId); +} + +/** + * The cached entry for a runtime node id (peek, no construction). Test seam + * only - it observes the eviction the settle path promises; @internal. + */ +export function peekRuntimeLauncherForTests( + nodeId: string, +): + | { session: import("@/services/ssh-runtime/session.js").SshRuntimeSession; launcher: RuntimeSessionLauncher } + | undefined { + return runtimeLaunchers.get(nodeId); +} + /** * Drops the cached `RemoteLauncher` instances. Test seam only — production * callers must not call this; @internal. */ export function resetLauncherRegistryForTests(): void { remoteLaunchers.clear(); + runtimeLaunchers.clear(); } diff --git a/apps/server/api/src/services/nodes/local-launcher.ts b/apps/server/api/src/services/nodes/local-launcher.ts index 4df5513db..3d5933291 100644 --- a/apps/server/api/src/services/nodes/local-launcher.ts +++ b/apps/server/api/src/services/nodes/local-launcher.ts @@ -6,6 +6,7 @@ import { exitHookFor, type HarnessPlugin, paneEnvFor, + seedLogFromHistory, TmuxRunner, validateWorkingDir, } from "@internal/pane-runtime"; @@ -105,6 +106,12 @@ export class LocalLauncher implements NodeLauncher { this.#ensureLogDir(logFile); this.#tmux.pipePane(plan.socket, plan.id, logFile, logChild); } + // The pre-attach race backfill (see pane-runtime's seedLogFromHistory): + // the race has no machine class, so neither attach twin completes the + // attach without it. Never throws; a bestEffort path whose pipe failed + // still gets whatever history holds, and the note stays debug-quiet. + const seeded = await seedLogFromHistory(this.#tmux, plan.socket, plan.id, logFile); + if (seeded !== null) logger.debug(`local launch ${plan.id}: ${seeded}`); } /** diff --git a/apps/server/api/src/services/nodes/log-tail.ts b/apps/server/api/src/services/nodes/log-tail.ts index 9c7bc5bca..a0806bd96 100644 --- a/apps/server/api/src/services/nodes/log-tail.ts +++ b/apps/server/api/src/services/nodes/log-tail.ts @@ -147,7 +147,9 @@ export function cursorLinesFromWindow( return { lines: all, truncated: nextByte < size, nextByte }; } -/** The launcher seam's window triple, unbound (see `NodeLauncher.readLogWindow`). */ +/** + * The launcher seam's window triple, unbound (see `NodeLauncher.readLogWindow`). + */ export type LogWindowReader = ( fromByte: number, maxBytes: number, @@ -185,12 +187,12 @@ export async function readLogCursor( req: LogCursorRequest, ): Promise<{ lines: string[]; truncated: boolean; nextByte: number }> { if (req.fromByte === undefined) { - const { size } = await read(0, 1); - if (size === 0) return { lines: [], truncated: false, nextByte: 0 }; - const start = Math.max(0, size - LOG_TAIL_BYTES); - const { bytes } = await read(start, LOG_TAIL_BYTES); - const tail = tailLinesFromWindowText(Buffer.from(bytes).toString("utf8"), start === 0); - return { ...tail, nextByte: size }; + const first = await read(0, 1); + if (first.size === 0) return { lines: [], truncated: false, nextByte: 0 }; + const start = Math.max(0, first.size - LOG_TAIL_BYTES); + const second = await read(start, LOG_TAIL_BYTES); + const tail = tailLinesFromWindowText(Buffer.from(second.bytes).toString("utf8"), start === 0); + return { ...tail, nextByte: first.size }; } const fromByte = Math.max(0, Math.trunc(req.fromByte)); const maxBytes = Math.min(Math.max(1, Math.trunc(req.maxBytes ?? LOG_WINDOW_DEFAULT_BYTES)), LOG_MAX_WINDOW_BYTES); diff --git a/apps/server/api/src/services/nodes/node-registry.ts b/apps/server/api/src/services/nodes/node-registry.ts index c81bd5a66..1d333de18 100644 --- a/apps/server/api/src/services/nodes/node-registry.ts +++ b/apps/server/api/src/services/nodes/node-registry.ts @@ -1,6 +1,7 @@ import { NODE_CLOSE_SUPERSEDED, type NodeRuntimeReport } from "@internal/subshell-protocol"; import type { LinkSession } from "@internal/subshell-protocol/node-link-crypto"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { liveSessionForRuntimeNode, markSessionsLostForNode } from "@/services/ssh-runtime/session-registry.js"; import { projectNodeOffline } from "./node-presence-announce.js"; import type { NodeRpcError } from "./node-rpc.js"; @@ -444,6 +445,14 @@ export function getLive(nodeId: string): NodeConnection | undefined { * cheap in a per-row view loop. True means "the pane may still be running * there" — the UI shows a stale-banner, not a dead subshell. * + * RUNTIME nodes (design 2026-10-05 §4) are never in the WS registry by + * construction - their channel is the SSH session - so the probe consults + * BOTH registries (review I3): a hidden runtime node with a live session is + * REACHABLE (list/detail/log/live all stay normal for its panes), and one + * whose session settled lost/closed reads offline like every other dead + * machine. The session-registry import is cycle-clean: the session modules + * reach node facts only through type imports and the RPC module. + * * The BLESSED liveness predicate — every "is this row's node reachable" * decision must go through it. It lives here because it is pure over the * registry above, which makes it importable by services and repositories' @@ -452,7 +461,8 @@ export function getLive(nodeId: string): NodeConnection | undefined { * injected predicate rather than importing this module). */ export function isNodeOffline(nodeId: string): boolean { - return nodeId !== LOCAL_NODE_ID && getLive(nodeId) === undefined; + if (nodeId === LOCAL_NODE_ID) return false; + return getLive(nodeId) === undefined && liveSessionForRuntimeNode(nodeId) === undefined; } /** @@ -557,7 +567,22 @@ export async function disconnectNode( } // Identity-guarded: a socket replaced between the two reads detaches // nothing and owns no projection — the current entry answers for itself. - if (detachConnection(nodeId, conn.ws)) evicted = true; + if (detachConnection(nodeId, conn.ws)) { + // Forced eviction OWNS the sessions' terminality too (round-4 review + // MINOR1). It detaches synchronously, so the socket's own close event + // later lands in the superseded arm (which touches no sessions) and + // the same act - rotate, delete, disable, re-register - refuses or + // replaces the redial, so the replacement `ready` sweep never runs + // either. The AGENT drained every brokered child the moment its + // socket died, and this is the one point that can say so: every + // session riding the node has lost its transport. Mark-all is right + // even for the `only` variant: `openSession` requires real ownership + // of the connecting node, so every session rides the evicted owner's + // axis, and a refused eviction (no detach) marks nothing, exactly as + // it projects nothing. + markSessionsLostForNode(nodeId); + evicted = true; + } } if (!evicted) return false; await projectNodeOffline(nodeId); diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index 45da89174..40a0e2c0c 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -29,6 +29,12 @@ import { } from "@/services/nodes/link-session.js"; import { loadNodeEncryptionKeys, nodeEncryptionPublicKey } from "@/services/nodes/node-encryption-keys.js"; import { announceNodePresence, projectNodeOffline } from "@/services/nodes/node-presence-announce.js"; +import { + deliverSessionFrame, + deliverSessionLost, + markSessionsLostBeforeConnection, + markSessionsLostForNode, +} from "@/services/ssh-runtime/session-registry.js"; import { logger } from "@/utils/logger.js"; import { refireInputHoldsForNode } from "@/ws/input-hold.js"; import { dispatchOutput, getNodeLifecycleHooks } from "./node-events.js"; @@ -843,6 +849,22 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: // facts are still on `conn.agent` for diagnosis (spec §6.4). const conn = ws.data.nodeConn ?? getLive(nodeId); if (conn) { + // A `ready` on this connection means the node's PRIOR link is gone: + // every session not brokered on THIS connection rode it, and the + // agent killed those children when the link died (the daemon's + // drain, review M1) - the plane must read them lost too. This is + // the convergence point for the close orderings the branch-1 arm + // cannot reach: a black-hole flap never lands a FIN, and the old + // socket's close arrives SUPERSEDED, whose branch deliberately + // touches no session (round-3 review MAJOR, design §6). The sweep + // compares connection IDENTITY, so a session brokered on this + // connection - even one opened in the attach-to-`ready` window - + // survives it, and a held outcome below loses the prior-link + // sessions all the same. The `encryption-required` hold (ledger R3) + // returns upstream of this sweep, so a pin-less legacy connection + // that somehow brokered a session pre-ready is not swept here; it + // self-settles send-failed on its first write. + markSessionsLostBeforeConnection(nodeId, conn); conn.agent = { dataDir: event.dataDir, capabilities: event.capabilities, @@ -1031,6 +1053,15 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: else logger.warn(`node ws: maintenance from ${nodeId} with no lifecycle hook`); return; } + case "session_frame": { + // The brokered-session pump (design 2026-10-05 §3): route by the OUTER + // ref through the runtime registry, which checks the session belongs to + // THIS node (frames naming another node's ref are dropped, not routed). + if (!deliverSessionFrame(nodeId, event.ref, event.data_b64)) { + logger.debug(`node ws: dropped session_frame for unknown/foreign ref ${event.ref.slice(0, 8)} from ${nodeId}`); + } + return; + } case "result": { // Connection-scoped settle: the frame can only resolve pendings on the // socket it arrived on (same record the close path drains). Fall back to @@ -1042,6 +1073,23 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: return; } case "error": + if (event.code === "ssh_session_lost") { + // The brokered child died (network, destination reboot, a user kill): + // the session's own close report. The message is the node's JSON + // ({ref, exitCode}); a malformed one names nothing and logs, exactly + // as an unparseable log line costs a log, never a state. + let ref: string | null = null; + try { + const parsed = JSON.parse(event.message) as { ref?: unknown }; + if (typeof parsed.ref === "string") ref = parsed.ref; + } catch { + // fall through to the generic log below + } + if (ref !== null) { + deliverSessionLost(nodeId, ref); + return; + } + } logger.withMetadata({ nodeId, code: event.code }).warn(`node reported error: ${event.message}`); return; } @@ -1098,6 +1146,10 @@ export async function handleNodeClose(deps: NodeWsDeps, ws: NodeWsSocket): Promi if (current && current.ws === mine) { detachConnection(nodeId, mine); failConnPendings(conn ?? current, "offline"); + // Brokered sessions rode this socket's child; with the socket gone the + // SSH child's only path to the plane is gone (design §6 Disconnect). Panes + // flip to unavailable, never "completed". + markSessionsLostForNode(nodeId); await deps.nodes.setStatus(nodeId, "offline" satisfies NodeStatus); // Witnessed, and deliberately NOT read as a restart (design 2026-09-25): // this branch is the authenticated socket's own death, which is exactly diff --git a/apps/server/api/src/services/nodes/pane-exec.ts b/apps/server/api/src/services/nodes/pane-exec.ts index a2b5054eb..22addc9db 100644 --- a/apps/server/api/src/services/nodes/pane-exec.ts +++ b/apps/server/api/src/services/nodes/pane-exec.ts @@ -235,3 +235,39 @@ export function execOutputTail(lines: string[], capBytes: number): { text: strin const kept = lines.slice(start); return { text: kept.join("\n"), truncated: true }; } + +/* ------------------------------------------------------------------ */ +/* the pane reservation */ +/* ------------------------------------------------------------------ */ + +/** + * One reservation per pane, taken SYNCHRONOUSLY before the first await (two + * racing calls must never both proceed) and released when the caller's wait + * ends. `SubshellsService` is per request, so the map is module-scoped + * exactly like `restartInFlight` in the manager. + */ +const holds = new Map>(); +const holdResolvers = new Map void>(); + +/** Claim the pane for one exec. False = already claimed (the EXEC_IN_FLIGHT answer). */ +export function tryHoldPane(subshellId: string): boolean { + if (holds.has(subshellId)) return false; + let release: () => void = () => {}; + holds.set( + subshellId, + new Promise((r) => { + release = r; + }), + ); + holdResolvers.set(subshellId, release); + return true; +} + +/** Release the reservation (idempotent; a lost claim is a no-op). */ +export function releasePane(subshellId: string): void { + const release = holdResolvers.get(subshellId); + if (!release) return; + holdResolvers.delete(subshellId); + holds.delete(subshellId); + release(); +} diff --git a/apps/server/api/src/services/server-restart.ts b/apps/server/api/src/services/server-restart.ts index a42c7d827..5e6ad217c 100644 --- a/apps/server/api/src/services/server-restart.ts +++ b/apps/server/api/src/services/server-restart.ts @@ -1,5 +1,6 @@ import { stopAllProcesses } from "@/services/network/supervisor.js"; import { disconnectAllNodes } from "@/services/nodes/node-registry.js"; +import { stopLocalSshBroker } from "@/services/ssh-runtime/local-broker.js"; import { logger } from "@/utils/logger.js"; import { closeAllViewers } from "@/ws/viewers.js"; @@ -60,7 +61,11 @@ export function performRestart(deps: RestartDeps = {}): void { const closeViewers = deps.closeViewers ?? closeAllViewers; const closeNodes = deps.closeNodes ?? disconnectAllNodes; const exit = deps.exit ?? ((code: number) => process.exit(code)); - const stopProcesses = deps.stopProcesses ?? stopAllProcesses; + const stopProcesses = + deps.stopProcesses ?? + (async () => { + await Promise.all([stopAllProcesses(), stopLocalSshBroker()]); + }); timer(async () => { // The callback is async and its promise has no observer — a throw from // either closer would reject it, skip the rest of the shutdown, and leave diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/boot-reconcile.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/boot-reconcile.test.ts new file mode 100644 index 000000000..676c2cc0a --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/boot-reconcile.test.ts @@ -0,0 +1,166 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; +import { reconcileSshRuntimeSessionsAtBoot } from "../session-settle.js"; +import { SshRuntimeSessionsRepository } from "../sessions.repository.js"; + +/** + * The boot sweep (review I1: `reconcileAtBoot` had ZERO callers tree-wide, so + * after a plane restart `opening`/`active` rows read active forever, the + * hidden runtime node rows stayed online, and the quota mirror counted dead + * rows until an 8-session exhaustion with no remedy). + * + * This is the test that proves the sweep RUNS and DOES the thing: rows become + * `lost` with `closedAt`, the runtime nodes go `offline`, terminal rows are + * untouched, and the boot entry point returns what it swept. The wiring half + * (the call from `index.ts`'s boot sequence) is read by the composition test + * below it; this file executes the sweep against the migrated test database. + */ + +const repo = new SshRuntimeSessionsRepository(db); +const nodes = new NodesRepository(db); + +const cleanup: string[] = []; + +interface Fixture { + connectingNodeId: string; + runtimeNodeId: string; + sessionId: string; +} + +async function mkPair(status: "opening" | "active" | "lost" | "closed"): Promise { + const connectingNodeId = crypto.randomUUID(); + const runtimeNodeId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + cleanup.push(sessionId, runtimeNodeId, connectingNodeId); + await nodes.create({ + id: connectingNodeId, + ownerUserId: "boot-test-owner", + name: `bt-${sessionId.slice(0, 8)}`, + kind: "agent", + status: "online", + }); + await nodes.create({ + id: runtimeNodeId, + ownerUserId: "boot-test-owner", + name: `bt-rt-${sessionId.slice(0, 8)}`, + kind: NODE_KIND_RUNTIME, + status: "online", + }); + await repo.create({ + id: sessionId, + ownerUserId: "boot-test-owner", + connectingNodeId, + runtimeNodeId, + alias: "bt", + host: "127.0.0.1", + port: 22, + user: null, + status, + }); + return { connectingNodeId, runtimeNodeId, sessionId }; +} + +beforeAll(async () => { + await ensureMigratedTestDb(); +}); + +afterAll(async () => { + for (const id of cleanup) { + await db + .deleteFrom("sshRuntimeSessions") + .where("id", "=", id) + .execute() + .catch(() => {}); + await db + .deleteFrom("nodes") + .where("id", "=", id) + .execute() + .catch(() => {}); + } +}); + +describe("reconcileSshRuntimeSessionsAtBoot (the I1 sweep, executed)", () => { + test("stale opening/active rows become lost with closedAt; terminal rows are untouched", async () => { + const staleOpening = await mkPair("opening"); + const staleActive = await mkPair("active"); + const wasLost = await mkPair("lost"); + const wasClosed = await mkPair("closed"); + + const swept = await reconcileSshRuntimeSessionsAtBoot(); + + // Returned exactly the stale pair (the sweep reports what it swept). + expect(new Set(swept)).toEqual(new Set([staleOpening.sessionId, staleActive.sessionId])); + + for (const f of [staleOpening, staleActive]) { + const row = await repo.findById(f.sessionId); + expect(row?.status).toBe("lost"); + expect(row?.closedAt).not.toBeNull(); // design §6: the terminal stamp, not a silent rewrite + const node = await nodes.findById(f.runtimeNodeId); + expect(node?.status).toBe("offline"); // the hidden runtime row stops reading online + } + + for (const f of [wasLost, wasClosed]) { + const row = await repo.findById(f.sessionId); + expect(row?.status).toBe(f.sessionId === wasLost.sessionId ? "lost" : "closed"); + // A terminal session's runtime row belongs to the transition that made + // it terminal; the boot sweep must not reach back for it. + const node = await nodes.findById(f.runtimeNodeId); + expect(node?.status).toBe("online"); + } + }); + + test("a second boot sweep finds nothing (the sweep converges; never rewrites the swept)", async () => { + // Same fixtures as the first test are already settled; the sweep is a + // no-op now, which is what pins "terminal states are final". + const swept = await reconcileSshRuntimeSessionsAtBoot(); + expect(swept).toEqual([]); + }); + + test("the connecting node's DELETE leaves the session row with a NULL ref (migration 0049's SET NULL, review M1: history outlives the machine)", async () => { + const f = await mkPair("active"); + // Sweep it first so the row is terminal (the same posture a deleted + // machine's history ends in; deleting a node with a live session is the + // disconnect path's business, not this fixture's). + await reconcileSshRuntimeSessionsAtBoot(); + await db.deleteFrom("nodes").where("id", "=", f.connectingNodeId).execute(); + const row = await repo.findById(f.sessionId); + expect(row).toBeDefined(); + expect(row?.connectingNodeId).toBeNull(); // the connecting row is gone; the history is not + expect(row?.status).toBe("lost"); + // The hidden runtime row (a different row) survives its own owner-node + // delete untouched: it cascades from the SESSION's runtime ref, not from + // the connecting machine. + expect(await nodes.findById(f.runtimeNodeId)).toBeDefined(); + }); +}); + +/** + * The boot wiring (the review's other half: "never wired into server boot"). + * Read as a composition guard: the entry module imports the sweep and calls + * it inside `bootServer`. A dropped call would otherwise be invisible to + * every other test here. + */ +describe("the boot sequence calls the sweep (composition, review I1)", () => { + test("index.ts imports reconcileSshRuntimeSessionsAtBoot and awaits it in bootServer", async () => { + const source = await Bun.file(`${import.meta.dir}/../../../index.ts`).text(); + expect(source).toMatch(/reconcileSshRuntimeSessionsAtBoot/); + // Called inside bootServer's body (after the migrations, the sweep reads + // tables that migration 0049 creates); a top-level call would read the DB + // before boot migrated it. + const bootStart = source.indexOf("async function bootServer"); + expect(bootStart).toBeGreaterThan(-1); + const callAt = source.indexOf("reconcileSshRuntimeSessionsAtBoot()", bootStart); + expect(callAt).toBeGreaterThan(bootStart); + // ...AFTER the migrations (the comment above promises it; a reorder above + // runMigrations would warn-only skip the sweep against missing tables). + expect(callAt).toBeGreaterThan(source.indexOf("await runMigrations()", bootStart)); + // ...and BEFORE the HTTP listener (review N-E): a session opening in the + // window between `listen` and the sweep would have its fresh row marked + // lost and its runtime node left offline until the next settle. + const listenerAt = source.indexOf("await startServer(", bootStart); + expect(listenerAt).toBeGreaterThan(callAt); + }); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/callback-allowlist.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/callback-allowlist.test.ts new file mode 100644 index 000000000..6ef50e177 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/callback-allowlist.test.ts @@ -0,0 +1,85 @@ +/** + * The pane's own-token callback gate, tested as the pure function it is + * (design 2026-10-05 §5): every refusal is a decision, never a downstream + * 404. The negative the e2e cannot cheaply prove (a frame naming a FOREIGN + * pane) is proven here. + */ +import { describe, expect, test } from "bun:test"; +import { matchCallbackPath } from "../callback-allowlist.js"; + +const OWN = "0f4c1a7e-9b62-4a11-8d3e-5c2b1a0f9e8d"; +const FOREIGN = "1e5d2b8f-0a73-5c22-9e41-6d3c2b1a0f7c"; + +describe("matchCallbackPath", () => { + test("GET and POST on the pane's own routes are allowed and carry the server-side id", () => { + expect(matchCallbackPath(`/api/subshells/${OWN}/input`, "GET", OWN)).toEqual({ allow: true, paneId: OWN }); + expect(matchCallbackPath(`/api/subshells/${OWN}/input`, "POST", OWN)).toEqual({ allow: true, paneId: OWN }); + expect(matchCallbackPath(`/api/subshells/${OWN}`, "GET", OWN)).toEqual({ allow: true, paneId: OWN }); + expect(matchCallbackPath(`/api/subshells/${OWN}/log`, "GET", OWN)).toEqual({ allow: true, paneId: OWN }); + }); + test("the identities list is in reach (the pane may see its own principals)", () => { + expect(matchCallbackPath("/api/identities", "GET", OWN)).toEqual({ allow: true, paneId: OWN }); + expect(matchCallbackPath("/api/identities/x", "GET", OWN).allow).toBe(true); + }); + test("every other verb is refused by name", () => { + for (const verb of ["PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) { + const d = matchCallbackPath(`/api/subshells/${OWN}/input`, verb, OWN); + expect(d.allow).toBe(false); + } + }); + test("a frame naming another pane is refused even though the token could not call it anyway", () => { + const d = matchCallbackPath(`/api/subshells/${FOREIGN}/input`, "POST", OWN); + expect(d.allow).toBe(false); + if (!d.allow) expect(d.reason).toContain("other than this session"); + }); + test("admin and machine surfaces are out of reach", () => { + for (const p of ["/api/users", "/api/nodes", "/api/admin/status", "/api/settings", "/api/audit"]) { + expect(matchCallbackPath(p, "GET", OWN).allow).toBe(false); + } + }); + test("prefix-shaped fakes do not slip through", () => { + // id-like garbage, missing id, and a path that merely STARTS like a pane + expect(matchCallbackPath("/api/subshells/", "GET", OWN).allow).toBe(false); + expect(matchCallbackPath("/api/subshells/", "POST", OWN).allow).toBe(false); + expect(matchCallbackPath(`/api/subshells/${OWN}-evil/input`, "POST", OWN).allow).toBe(false); + expect(matchCallbackPath("/api/subshells/../users", "GET", OWN).allow).toBe(false); + expect(matchCallbackPath("/api/subshells/NOT-A-UUID/input", "POST", OWN).allow).toBe(false); + }); + + /** + * The C2 belt (review 2026-10-06): the caller normalizes with `new URL` + * BEFORE matching and executes the SAME normalized value, so the raw + * traversal strings below cannot reach the matcher through the production + * path. They reach it DIRECTLY in tests and through a lying runtime, and + * the matcher must still refuse them by segment, not just by the first- + * segment id equality the old matcher could be walked around with. + */ + test("dot segments inside the pane path are refused outright (the C2 traversal belt)", () => { + // own id, then `../` to another pane's id: the old matcher sliced to the + // first segment, named the OWN id, and the executor's URL normalization + // took it to the OTHER pane's route with this pane's token. + expect(matchCallbackPath(`/api/subshells/${OWN}/../${FOREIGN}/input`, "POST", OWN).allow).toBe(false); + expect(matchCallbackPath(`/api/subshells/${OWN}/../${FOREIGN}/input`, "POST", OWN)).toMatchObject({ + allow: false, + }); + // own id, then `../` out of the family entirely: `/api/users`. + expect(matchCallbackPath(`/api/subshells/${OWN}/../../users`, "GET", OWN).allow).toBe(false); + // single-dot and encoded spellings (the URL parser folds these before the + // matcher sees them in production; this pins the belt anyway). + expect(matchCallbackPath(`/api/subshells/${OWN}/./input`, "GET", OWN).allow).toBe(false); + expect(matchCallbackPath(`/api/subshells/${OWN}/%2e%2e/users`, "GET", OWN).allow).toBe(false); + expect(matchCallbackPath(`/api/subshells/${FOREIGN}%2f..%2f${OWN}/input`, "POST", OWN).allow).toBe(false); + // the identities/channels families carry no id slot but obey the same rule + expect(matchCallbackPath("/api/channels/../users", "GET", OWN).allow).toBe(false); + expect(matchCallbackPath("/api/identities/..%2fadmin", "GET", OWN).allow).toBe(false); + }); + + test("a query on the own-pane path is uniform grammar: matched on pathname, id intact", () => { + // (the folded Minor: the old matcher glued `?…` into the id segment, so + // `/api/subshells/?x=1` refused while `/log?from=` passed.) + // Production matches u.pathname and passes u.search separately, so the + // bare pathname is what hits the matcher; the query never corrupts the id. + expect(matchCallbackPath(`/api/subshells/${OWN}`, "GET", OWN)).toEqual({ allow: true, paneId: OWN }); + expect(matchCallbackPath(`/api/subshells/${OWN}/log`, "GET", OWN)).toEqual({ allow: true, paneId: OWN }); + }); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/callback-executor.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/callback-executor.test.ts new file mode 100644 index 000000000..16f85a127 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/callback-executor.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { encodeSshSessionFrame, SSH_REQ_ID_MAX_CHARS, SSH_SESSION_FRAME_MAX_BYTES } from "@internal/subshell-protocol"; +import { fitCallbackBody } from "../callback-executor.js"; + +/** + * The response-cap promise (review M2, extended by review I-A): an oversize + * callback body must not slice characters off the end of a byte budget. The + * old code counted UTF-16 chars, appended a marker LINE (breaking the JSON it + * glued onto), and let `encodeSshSessionFrame` throw inside the settle - which + * answered a false 502 for a call that had actually succeeded. These tests pin + * the replacements: valid parseable JSON on every path, and an encoded + * `rest_response` frame at or under the session cap INCLUDING the envelope the + * ACTUAL reqId makes - not a padded stand-in that could under-count the real + * id's length and JSON escape cost (the I-A finding: a padded 64 plain zeros + * serializes 66 bytes; 64 quotes serialize 130, and the un-bounded inbound id + * made the frame-cap doc claim false for anyone willing to type). + */ + +const REQ = "0f4c1a7e-9b62-4a11-8d3e-5c2b1a0f9e8d"; + +/** The REAL encoder: every fit assertion lands here, so the measurement and the wire cannot disagree. */ +const frameBytes = (reqId: string, status: number, body: string): number => + encodeSshSessionFrame({ type: "rest_response", reqId, status, body }).byteLength; + +function unwrap(text: string): { truncated: boolean; body: string } { + const parsed = JSON.parse(text) as Record; + if ("subshell_truncated" in parsed) { + return { truncated: parsed.subshell_truncated === true, body: String(parsed.body ?? "") }; + } + return { truncated: false, body: text }; +} + +describe("fitCallbackBody (the M2 cap)", () => { + test("a body that already fits passes through byte-identical", () => { + const text = JSON.stringify({ ok: true, data: [1, 2, 3] }); + expect(fitCallbackBody(REQ, 200, text)).toBe(text); + }); + + test("an oversize MULTIBYTE body wraps in parseable JSON and the frame stays under the cap", () => { + // 3 bytes/char: 200k chars slice "by byte" would leak past the char count + // the old code counted; the point is the ENCODED frame, not the source. + const text = "é".repeat(200_000); + expect(Buffer.byteLength(text)).toBeGreaterThan(SSH_SESSION_FRAME_MAX_BYTES); + const wrapped = fitCallbackBody(REQ, 200, text); + const { truncated, body } = unwrap(wrapped); + expect(truncated).toBe(true); + expect(body.length).toBeGreaterThan(0); // it kept a real prefix, not just the marker + expect(frameBytes(REQ, 200, wrapped)).toBeLessThanOrEqual(SSH_SESSION_FRAME_MAX_BYTES); + // The prefix was cut on a UTF-8 boundary: re-encoding it reproduces it + // exactly (an orphaned continuation byte would arrive as U+FFFD on the + // far end; `body` here came from a clean Buffer.slice-then-decode). + expect(Buffer.from(body, "utf8").toString("utf8")).toBe(body); + expect(body.includes("�")).toBe(false); + }); + + test("escape-blowup content (JSON chars that stringify doubles) still fits", () => { + // Every quote becomes \" and every NUL becomes \\u0000 in the frame's own + // JSON: the budget must be measured AFTER encoding, not before. + const text = '"'.repeat(150_000) + "\u0000".repeat(100_000); + const wrapped = fitCallbackBody(REQ, 200, text); + expect(frameBytes(REQ, 200, wrapped)).toBeLessThanOrEqual(SSH_SESSION_FRAME_MAX_BYTES); + expect(unwrap(wrapped).truncated).toBe(true); + }); + + test("a 4-byte codepoint cut never splits a surrogate pair", () => { + const text = "😀".repeat(70_000); // 4 bytes/char: UTF-16 surrogate pairs + const wrapped = fitCallbackBody(REQ, 200, text); + const { body } = unwrap(wrapped); + // A kept prefix decodes to complete codepoints only: encoding it and + // reading it back yields the identical byte stream (an orphan half-pair + // would have arrived as U+FFFD from the boundary walk-back). + const bytes = Buffer.from(body, "utf8"); + expect(bytes.toString("utf8")).toBe(body); + expect(body.includes("�")).toBe(false); + expect(() => JSON.parse(wrapped)).not.toThrow(); + expect(frameBytes(REQ, 200, wrapped)).toBeLessThanOrEqual(SSH_SESSION_FRAME_MAX_BYTES); + }); + + test("status survives the wrap (a refused route must not read as a 200)", () => { + const wrapped = fitCallbackBody(REQ, 403, JSON.stringify({ error: "no".repeat(300_000) })); + expect(frameBytes(REQ, 403, wrapped)).toBeLessThanOrEqual(SSH_SESSION_FRAME_MAX_BYTES); + }); +}); + +describe("the reqId axis (review I-A: the measured envelope is the REAL one)", () => { + // The pad the old code measured with was 64 plain chars; the worst legal id + // (64 quotes, printable, grammar-bounded) costs 64 escaped quotes. Measuring + // with the stand-in under-counted by exactly that much, and the frame the + // session then encoded overflowed the cap - the encode threw inside the + // settle and the callback was never answered. These tests pin the + // measurement to the id the response will actually carry. + const worstLegalReqId = '"'.repeat(SSH_REQ_ID_MAX_CHARS); + + test("a padded stand-in really does under-count the worst legal id (the axis, proven)", () => { + const body = JSON.stringify({ ok: true }); + const padded = encodeSshSessionFrame({ type: "rest_response", reqId: "0".repeat(64), status: 200, body }); + expect(frameBytes(worstLegalReqId, 200, body)).toBeGreaterThan(padded.byteLength); + }); + + test("an oversize body under the WORST legal reqId still produces an encodable frame", () => { + const wrapped = fitCallbackBody(worstLegalReqId, 200, "x".repeat(300_000)); + expect(frameBytes(worstLegalReqId, 200, wrapped)).toBeLessThanOrEqual(SSH_SESSION_FRAME_MAX_BYTES); + expect(unwrap(wrapped).truncated).toBe(true); + }); + + test("a body sized by the real id encodes through the codec without throwing (no settle-path surprise)", () => { + // The exact failure the review proved: fit-by-pad, encode-for-real. The + // fit here uses the real id, so the encoder accepts the frame it is given. + const wrapped = fitCallbackBody(worstLegalReqId, 200, "é".repeat(200_000)); + expect(() => + encodeSshSessionFrame({ type: "rest_response", reqId: worstLegalReqId, status: 200, body: wrapped }), + ).not.toThrow(); + }); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/desktop-broker.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/desktop-broker.test.ts new file mode 100644 index 000000000..4a4d48cfb --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/desktop-broker.test.ts @@ -0,0 +1,340 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { SSH_RUNTIME_PROTOCOL } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { sshRuntimeRoutes } from "@/api/ssh-runtime/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { desktopBrokerWsPlugin } from "@/ws/desktop-broker.plugin.js"; +import { + attachDesktopBroker, + authenticateDesktopBroker, + type DesktopLink, + desktopCommand, + desktopOwnerLive, + listDesktopBrokers, + pairDesktopBroker, + parseDesktopCommand, + revokeDesktopBroker, +} from "../desktop-broker.js"; +import { sshDiscovery } from "../discovery.service.js"; +import { openSession } from "../sessions.service.js"; + +let owner: string; +let foreign: string; +let cookie: string; +const ids: string[] = []; +const links: DesktopLink[] = []; +const password = "desktop-broker-testing-password"; +beforeAll(async () => { + await setupAuthTables(); + const users = new UsersRepository(db); + const email = `desktop-owner-${crypto.randomUUID()}@example.test`; + owner = await users.createUser({ + email, + name: "Desktop owner", + passwordHash: await hashPassword(password), + role: "user", + }); + foreign = await users.createUser({ + email: `desktop-foreign-${crypto.randomUUID()}@example.test`, + name: "Other account", + passwordHash: await hashPassword(password), + role: "admin", + }); + cookie = await signIn(email, password); +}); +afterAll(async () => { + for (const link of links) link.close(); + await db.deleteFrom("nodes").where("id", "in", ids).execute(); + await deleteUserByEmailOrId(owner); + await deleteUserByEmailOrId(foreign); +}); +async function pairing() { + const value = await pairDesktopBroker(owner, { name: `Desktop ${crypto.randomUUID().slice(0, 8)}` }); + ids.push(value.id); + return value; +} +async function attach() { + const pair = await pairing(); + const identity = await authenticateDesktopBroker(`Bearer ${pair.pairingToken}`); + const frames: Record[] = []; + const link = await attachDesktopBroker(identity, { + send: (value) => { + frames.push(JSON.parse(value)); + }, + close: () => {}, + }); + links.push(link); + return { pair, identity, frames, link }; +} +test("pair token is single use, short lived, invisible to ordinary REST auth", async () => { + const pair = await pairing(); + expect(Date.parse(pair.expiresAt) - Date.now()).toBeLessThanOrEqual(300_000); + await authenticateDesktopBroker(`Bearer ${pair.pairingToken}`); + await expect(authenticateDesktopBroker(`Bearer ${pair.pairingToken}`)).rejects.toThrow(); + const app = new Elysia().use(errorHandlerPlugin).use(sshRuntimeRoutes); + const response = await app.fetch( + new Request("http://localhost/api/ssh-runtime/desktop-brokers", { + headers: { authorization: `Bearer ${pair.pairingToken}` }, + }), + ); + expect(response.status).toBe(401); +}); +test("owner isolation includes admin accounts and preserves stable resume identity", async () => { + const { pair, identity, link } = await attach(); + expect(await desktopOwnerLive(pair.id, owner)).toBe(true); + expect(await desktopOwnerLive(pair.id, foreign)).toBe(false); + expect((await listDesktopBrokers(foreign)).brokers).toEqual([]); + await expect(pairDesktopBroker(foreign, { name: "Foreign", id: pair.id })).rejects.toThrow(); + await expect(revokeDesktopBroker(foreign, pair.id)).rejects.toThrow(); + const credential = identity.credential; + if (!credential) throw new Error("Pairing did not mint a credential."); + const row = await new NodesRepository(db).findById(pair.id); + expect(row?.publicKey).not.toBe(credential); + link.close(); + const resumed = await authenticateDesktopBroker(`Bearer ${credential}`); + expect(resumed.id).toBe(pair.id); + await revokeDesktopBroker(owner, pair.id); + await expect(authenticateDesktopBroker(`Bearer ${credential}`)).rejects.toThrow(); +}); +test("detached links cannot enqueue; drop rejects pending requests; arbitrary commands refused", async () => { + const pair = await pairing(); + await expect(desktopCommand(pair.id, { type: "ssh_discover_aliases" })).rejects.toThrow(); + const { link, frames } = await attach(); + const pending = desktopCommand(link.identity.id, { type: "ssh_discover_aliases" }); + await Bun.sleep(10); + expect(frames[0]?.type).toBe("attached"); + expect(frames[1]?.type).toBe("command"); + link.close(); + await expect(pending).rejects.toThrow("disconnected"); + expect(parseDesktopCommand({ type: "service", verb: "stop" })).toBeNull(); + expect(parseDesktopCommand({ type: "list_dirs", path: "/" })).toBeNull(); +}); +test("discovery uses attached desktop RPC and cookie owner gate", async () => { + const { link, frames } = await attach(); + const caller = { + actor: "cookie" as const, + userId: owner, + isAdmin: false, + principal: null, + apiKeyId: null, + subshellId: null, + }; + const pending = sshDiscovery(caller, link.identity.id); + await Bun.sleep(10); + const frame = frames.find((frame) => frame.type === "command"); + if (!frame) throw new Error("Broker did not receive discovery."); + expect(frame.command).toEqual({ type: "ssh_discover_aliases" }); + await link.message({ + type: "result", + requestId: frame.requestId, + ok: true, + data: { aliases: ["my-host"], includeCycle: false, truncated: false }, + }); + expect(await pending).toEqual({ aliases: ["my-host"], includeCycle: false, truncated: false }); + await expect(sshDiscovery({ ...caller, userId: foreign, isAdmin: true }, link.identity.id)).rejects.toThrow(); +}); +test("cookie pairing API enforces foreign ownership and rejects cross-origin writes", async () => { + const app = new Elysia().use(errorHandlerPlugin).use(sshRuntimeRoutes); + const pair = await pairing(); + const response = await app.fetch( + new Request("http://localhost/api/ssh-runtime/desktop-brokers", { + method: "POST", + headers: { + cookie: `better-auth.session_token=${cookie}`, + "content-type": "application/json", + origin: "https://attacker.example", + }, + body: JSON.stringify({ name: "My laptop", id: pair.id }), + }), + ); + expect(response.status).toBe(403); +}); + +test("actual native CLI attaches without enrollment, discovers local aliases, drains on EOF, resumes privately and honors revocation", async () => { + const root = mkdtempSync(join(tmpdir(), "desktop-broker-integration-")); + const home = join(root, "home"); + const config = join(root, "config"); + mkdirSync(join(home, ".ssh"), { recursive: true, mode: 0o700 }); + writeFileSync(join(home, ".ssh", "config"), "Host desktop-fixture\n HostName 127.0.0.1\n", { mode: 0o600 }); + const server = new Elysia() + .use(errorHandlerPlugin) + .use(desktopBrokerWsPlugin) + .listen({ port: 0, hostname: "127.0.0.1" }); + if (!server.server) throw new Error("Fixture failed to listen."); + const origin = `http://127.0.0.1:${server.server.port}`; + const pair = await pairing(); + const children: ReturnType[] = []; + const spawn = (resume: boolean) => { + const child = Bun.spawn( + [process.execPath, "src/main.ts", "ssh-broker", "--server", origin, ...(resume ? ["--broker-id", pair.id] : [])], + { + cwd: resolve(import.meta.dir, "../../../../../../node/agent"), + env: { ...process.env, HOME: home, SUBSHELL_CONFIG_HOME: config }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }, + ); + children.push(child); + child.stdin.write(`${JSON.stringify(resume ? {} : { pairingToken: pair.pairingToken })}\n`); + return child; + }; + async function attached(child: ReturnType) { + const reader = child.stdout.getReader(); + let timeout: ReturnType | undefined; + try { + const result = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("CLI attach timed out")), 10_000); + }), + ]); + if (result.done) throw new Error(`CLI failed: ${await new Response(child.stderr).text()}`); + const line = new TextDecoder().decode(result.value).trim(); + const value = JSON.parse(line); + expect(value).toEqual({ type: "attached", id: pair.id, name: pair.name }); + expect(line).not.toContain("dsb_"); + expect(line).not.toContain("dsp_"); + } finally { + if (timeout) clearTimeout(timeout); + reader.releaseLock(); + } + } + try { + const first = spawn(false); + await attached(first); + expect(await desktopCommand(pair.id, { type: "ssh_discover_aliases" })).toEqual({ + aliases: ["desktop-fixture"], + includeCycle: false, + truncated: false, + }); + const credentialFile = readdirSync(join(config, "ssh-brokers"))[0]; + if (!credentialFile) throw new Error("Credential was not saved."); + expect(statSync(join(config, "ssh-brokers", credentialFile)).mode & 0o777).toBe(0o600); + expect(readdirSync(join(config, "ssh-brokers"))).toHaveLength(1); + first.stdin.end(); + expect(await first.exited).toBe(0); + expect(await desktopOwnerLive(pair.id, owner)).toBe(false); + const second = spawn(true); + await attached(second); + await revokeDesktopBroker(owner, pair.id); + expect(await second.exited).toBe(0); + const third = spawn(true); + third.stdin.end(); + expect(await third.exited).toBe(1); + } finally { + for (const child of children) + if (child.exitCode === null) { + child.kill(); + await child.exited; + } + await server.stop(); + rmSync(root, { recursive: true, force: true }); + } +}, 30_000); + +test("loss after open acknowledgement but before registration cannot create an active runtime", async () => { + const pair = await pairing(); + const identity = await authenticateDesktopBroker(`Bearer ${pair.pairingToken}`); + let link: DesktopLink | undefined; + link = await attachDesktopBroker(identity, { + close: () => {}, + send: (value) => { + const frame = JSON.parse(value); + if (frame.type !== "command" || frame.command.type !== "ssh_session_open") return; + const ref = frame.command.ref; + void (async () => { + await link?.message({ + type: "result", + requestId: frame.requestId, + ok: true, + data: { + hello: { + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: [], + homeDir: "/home/remote", + dataDir: "/home/remote/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-ab12cd34ef56", + paneCount: 0, + }, + host: "127.0.0.1", + port: 22, + user: null, + }, + }); + await link?.message({ type: "session_lost", ref }); + })(); + }, + }); + links.push(link); + await expect( + openSession(owner, { + connectingNodeId: pair.id, + target: { alias: "fixture", host: "127.0.0.1", port: 22, user: null, identityFile: null }, + }), + ).rejects.toThrow("disconnected while opening"); + expect( + await db.selectFrom("sshRuntimeSessions").select("id").where("connectingNodeId", "=", pair.id).execute(), + ).toEqual([]); +}); + +test("live account disable removes broker authority before any command is enqueued", async () => { + const { link, frames, identity } = await attach(); + await db.updateTable("userMeta").set({ disabled: 1 }).where("userId", "=", owner).execute(); + try { + await expect(desktopCommand(link.identity.id, { type: "ssh_discover_aliases" })).rejects.toThrow("unavailable"); + expect(frames.filter((frame) => frame.type === "command")).toEqual([]); + await expect(authenticateDesktopBroker(`Bearer ${identity.credential}`)).rejects.toThrow(); + } finally { + await db.updateTable("userMeta").set({ disabled: 0 }).where("userId", "=", owner).execute(); + } +}); + +test("duplicate desktop names return actionable 409 and Pair again preserves the existing identity", async () => { + const name = `Named desktop ${crypto.randomUUID().slice(0, 8)}`; + const pair = await pairDesktopBroker(owner, { name }); + ids.push(pair.id); + const app = new Elysia().use(errorHandlerPlugin).use(sshRuntimeRoutes); + const response = await app.fetch( + new Request("http://localhost/api/ssh-runtime/desktop-brokers", { + method: "POST", + headers: { + cookie: `better-auth.session_token=${cookie}`, + "content-type": "application/json", + origin: "http://localhost:3080", + }, + body: JSON.stringify({ name }), + }), + ); + expect(response.status).toBe(409); + const duplicate = (await response.json()) as { message: string }; + expect(duplicate.message).toContain("Pair again"); + const again = await pairDesktopBroker(owner, { name, id: pair.id }); + expect(again.id).toBe(pair.id); + expect(again.pairingToken).not.toBe(pair.pairingToken); + await expect(authenticateDesktopBroker(`Bearer ${pair.pairingToken}`)).rejects.toThrow(); +}); + +test("simultaneous adds with the same name map the database uniqueness race to 409", async () => { + const name = `Racing desktop ${crypto.randomUUID().slice(0, 8)}`; + const results = await Promise.allSettled([pairDesktopBroker(owner, { name }), pairDesktopBroker(owner, { name })]); + const accepted = results.filter((result) => result.status === "fulfilled"); + expect(accepted).toHaveLength(1); + for (const result of accepted) if (result.status === "fulfilled") ids.push(result.value.id); + const refused = results.find((result) => result.status === "rejected"); + if (refused?.status !== "rejected") throw new Error("Duplicate add should refuse."); + expect(refused.reason.status).toBe(409); + expect(refused.reason.message).toContain("Pair again"); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/launch-dir-check.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/launch-dir-check.test.ts new file mode 100644 index 000000000..64b9f0093 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/launch-dir-check.test.ts @@ -0,0 +1,305 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + encodeSshSessionFrame, + SSH_RUNTIME_PROTOCOL, + type SshRuntimeHelloWire, + SshSessionFrameDecoder, + type SshSessionTargetWire, +} from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; +import { SshRuntimeSession } from "@/services/ssh-runtime/session.js"; +import { + registerSession, + resetSessionRegistryForTests, + sessionHooks, +} from "@/services/ssh-runtime/session-registry.js"; +import { SshRuntimeSessionsRepository } from "@/services/ssh-runtime/sessions.repository.js"; +import { SshRuntimeRefusal } from "@/services/ssh-runtime/sessions.service.js"; +import { sessionLaunchHarness, sessionLaunchTerminal } from "@/services/ssh-runtime/sessions-lifecycle.js"; +import { attachScriptedNode, ok, type ScriptedNode } from "@/test-helpers/scripted-node.js"; + +/** + * F3: the launch verbs stat-verify the destination working dir BEFORE the + * first plane write (design §7 "invalid directory names the remedy"). The + * acceptance run measured `launch-terminal cwd=/no/such` answering 200 with a + * row claiming the missing path while the destination shell fell back to `~` + * - a lying row. The gate is the runtime's own `stat_dir` (the node link's + * `validateWorkingDir` executor, shared), so the refusal is destination truth. + * + * Pinned here at the service seam: + * - `ENOENT`/`ENOTDIR` on the stat refuses `dir_missing` with 409, a message + * naming the value and the remedy, and NOTHING written (no row, no token, + * no registration) - the guard is before the row/token/frame order. + * - the row and the launch frame carry the stat's REALPATH, not the typed + * string. + * - the harness verb obeys the same rule (and its cheap guards - unknown + * harness, foreign preset, mismatch - stay frameless: they precede the stat). + * - a dead session answers the channel's own error, not a fake refusal. + */ + +const email = `dircheck-${crypto.randomUUID()}@subshell.local`; +let userId: string; +const cleanupNodes: string[] = []; +const cleanupSessions: string[] = []; +const cleanupPanes: string[] = []; +let sim: ScriptedNode | undefined; + +const sessionsRepo = new SshRuntimeSessionsRepository(db); +const nodesRepo = new NodesRepository(db); +const subshellsRepo = new SubshellsRepository(db); + +const target: SshSessionTargetWire = { alias: "dc", host: "127.0.0.1", port: 22, user: null, identityFile: null }; + +function hello(): SshRuntimeHelloWire { + return { + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: ["ssh-runtime", "callback-sock", "detect", "pane-callback-sock"], + homeDir: "/home/dst", + dataDir: "/home/dst/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-dc0000000000", + paneCount: 0, + }; +} + +/** How the scripted runtime answers `stat_dir`: realpath success, or the named refusal. */ +let statAnswer: { ok: true; path: string } | { ok: false; error: string } = { ok: true, path: "/home/dst/work" }; +/** Inner frames the scripted runtime has seen, in wire order. */ +let innerFrames: string[] = []; + +async function mkSession(): Promise { + const connectingNodeId = crypto.randomUUID(); + const runtimeNodeId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + cleanupNodes.push(runtimeNodeId, connectingNodeId); + cleanupSessions.push(sessionId); + await nodesRepo.create({ + id: connectingNodeId, + ownerUserId: userId, + name: `dc-${sessionId.slice(0, 8)}`, + kind: "agent", + }); + await nodesRepo.create({ + id: runtimeNodeId, + ownerUserId: userId, + name: `dc-rt-${sessionId.slice(0, 8)}`, + kind: NODE_KIND_RUNTIME, + status: "online", + lastSeenAt: new Date().toISOString(), + }); + await sessionsRepo.create({ + id: sessionId, + ownerUserId: userId, + connectingNodeId, + runtimeNodeId, + alias: target.alias, + host: target.host, + port: target.port, + user: target.user, + status: "active", + }); + await sessionsRepo.settle(sessionId, "active", JSON.stringify(hello())); + const session = new SshRuntimeSession({ + id: sessionId, + ownerId: userId, + connectingNodeId, + runtimeNodeId, + target, + hello: hello(), + }); + session.hooks = sessionHooks(); + registerSession(session); + sim?.detach(); + sim = attachScriptedNode(connectingNodeId, { + ssh_session_close: ok, + ssh_session_send: (cmd) => { + if (cmd.type !== "ssh_session_send") throw new Error("wrong cmd"); + for (const frame of new SshSessionFrameDecoder().push(new Uint8Array(Buffer.from(cmd.data_b64, "base64")))) { + const inner = frame as { type?: string; ref?: string; cwd?: string }; + if (inner.ref === undefined) continue; + innerFrames.push(inner.type ?? "?"); + if (inner.type === "stat_dir") { + session.ingestBytes( + encodeSshSessionFrame( + statAnswer.ok + ? { type: "result", ref: inner.ref, ok: true, data: { path: statAnswer.path, isDirectory: true } } + : { type: "result", ref: inner.ref, ok: false, error: statAnswer.error }, + ), + ); + continue; + } + session.ingestBytes(encodeSshSessionFrame({ type: "result", ref: inner.ref, ok: true })); + } + return undefined; + }, + }); + return session; +} + +function mkLaunchRows(): Promise { + return subshellsRepo.listByUser(userId); +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("dc-pass-1"), + role: "user", + }); +}); + +afterAll(async () => { + sim?.detach(); + resetSessionRegistryForTests(); + for (const id of cleanupPanes) await subshellsRepo.delete(id).catch(() => {}); + await db + .deleteFrom("sshRuntimeSessions") + .execute() + .catch(() => {}); + for (const id of cleanupNodes) { + await db + .deleteFrom("nodes") + .where("id", "=", id) + .execute() + .catch(() => {}); + } + await deleteUserByEmailOrId(email).catch(() => {}); +}); + +describe("launch-terminal: the directory gate precedes every write", () => { + test("ENOENT on the destination refuses dir_missing with 409 and writes nothing", async () => { + const session = await mkSession(); + statAnswer = { ok: false, error: "ENOENT: /no/such/dir-dc" }; + innerFrames = []; + const err = await sessionLaunchTerminal(session.id, userId, { cwd: "/no/such/dir-dc" }).then( + () => null, + (e: unknown) => e, + ); + expect(err, "a missing destination directory must refuse").toBeInstanceOf(SshRuntimeRefusal); + const refusal = err as SshRuntimeRefusal; + expect(refusal.status).toBe(409); + expect(refusal.code).toBe("dir_missing"); // the sshCode the routes carry + expect(refusal.message).toContain("/no/such/dir-dc"); // names the refused value + expect(refusal.message).toContain("Choose an existing folder"); // names the remedy + // The gate is BEFORE the row/token/frame order: nothing was written. + const rows = (await mkLaunchRows()) as { workingDir: string }[]; + expect(rows.filter((r) => r.workingDir.includes("no/such/dir-dc")).length).toBe(0); + expect(session.paneIds()).toEqual([]); + expect(innerFrames).toEqual(["stat_dir"]); // the probe asked, nothing launched + }); + + test("ENOTDIR refuses the same named way (a file is not a working dir)", async () => { + const session = await mkSession(); + statAnswer = { ok: false, error: "ENOTDIR: /home/dst/work/notes.txt" }; + innerFrames = []; + const err = await sessionLaunchTerminal(session.id, userId, { cwd: "/home/dst/work/notes.txt" }).then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(SshRuntimeRefusal); + expect((err as SshRuntimeRefusal).code).toBe("dir_missing"); + expect(session.paneIds()).toEqual([]); + }); + + test("success records the REALPATH the stat returned, in the row and the launch frame", async () => { + const session = await mkSession(); + statAnswer = { ok: true, path: "/home/dst/work" }; // the stat echoes the resolved target + innerFrames = []; + const { subshellId } = await sessionLaunchTerminal(session.id, userId, { cwd: "/home/dst/work" }); + cleanupPanes.push(subshellId); + const row = await subshellsRepo.findById(subshellId); + expect(row?.workingDir).toBe("/home/dst/work"); + expect(innerFrames).toEqual(["stat_dir", "launch"]); + }); + + /** + * The I6 fix (review 2026-10-06): `stat_dir`'s frame grammar REQUIRES an + * absolute path (`isAbsRef`), so a relative cwd typed into the launch body + * used to ride the wire as a MALFORMED COMMAND FRAME, and the runtime's + * fail-closed rule on grammar violations closed the SESSION for a typo. + * The plane now refuses the shape BEFORE composing: a named 409 in the + * F3 family, no frame, and the session stays live. + */ + test("a relative cwd refuses dir_relative with 409 and composes NO frame (the session lives)", async () => { + const session = await mkSession(); + statAnswer = { ok: true, path: "/home/dst/work" }; + innerFrames = []; + const err = await sessionLaunchTerminal(session.id, userId, { cwd: "work/scratch" }).then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(SshRuntimeRefusal); + const refusal = err as SshRuntimeRefusal; + expect(refusal.status).toBe(409); + expect(refusal.code).toBe("dir_relative"); // the named sibling of dir_missing + expect(refusal.message).toContain("work/scratch"); // names the refused value + expect(innerFrames, "the frame grammar is never violated: nothing was dialed").toEqual([]); + expect(session.status).toBe("active"); + // The empty string is a relative path too (and the picker can clear). + const empty = await sessionLaunchTerminal(session.id, userId, { cwd: "" }).then( + () => null, + (e: unknown) => e, + ); + expect((empty as SshRuntimeRefusal).code).toBe("dir_relative"); + expect(innerFrames).toEqual([]); + // An absolute path still reaches the destination stat (the gate's real job). + const okLaunch = await sessionLaunchTerminal(session.id, userId, { cwd: "/home/dst/work" }); + cleanupPanes.push(okLaunch.subshellId); + expect(innerFrames[0]).toBe("stat_dir"); + }); + + test("a dead session is the door that closed, not a fake dir refusal", async () => { + const session = await mkSession(); + statAnswer = { ok: false, error: "ENOENT: /no/such/dir-dc" }; + session.markLost("child-lost"); + const err = await sessionLaunchTerminal(session.id, userId, { cwd: "/no/such/dir-dc" }).then( + () => null, + (e: unknown) => e, + ); + // The lost settle evicted the session from the registry, so the launch + // verb's owner door answers 404 ("gone reads like never was"). The + // dir_missing refusal is the DESTINATION's answer to a live session, + // never a guess about a dead one. + expect(err).toBeInstanceOf(SshRuntimeRefusal); + expect((err as SshRuntimeRefusal).status).toBe(404); + }); +}); + +describe("launch-harness: the same gate, guards first", () => { + test("cheap guard refusals stay frameless; the dir gate then precedes the row", async () => { + const session = await mkSession(); + statAnswer = { ok: true, path: "/home/dst/work" }; + innerFrames = []; + await expect( + sessionLaunchHarness(session.id, userId, { harnessId: "not-a-harness", cwd: "/home/dst/work" }), + ).rejects.toMatchObject({ status: 409 }); + expect(innerFrames, "the unknown-harness refusal never dials the destination").toEqual([]); + + statAnswer = { ok: false, error: "ENOENT: /no/such/harness-dir" }; + const err = await sessionLaunchHarness(session.id, userId, { + harnessId: "claude-code", + cwd: "/no/such/harness-dir", + }).then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(SshRuntimeRefusal); + expect((err as SshRuntimeRefusal).code).toBe("dir_missing"); + const rows = (await mkLaunchRows()) as unknown[]; + expect(rows.filter((r) => JSON.stringify(r).includes("harness-dir")).length).toBe(0); + expect(session.paneIds()).toEqual([]); + }); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/local-origin.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/local-origin.test.ts new file mode 100644 index 000000000..15ff114ba --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/local-origin.test.ts @@ -0,0 +1,93 @@ +import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; +import { encodeSshSessionFrame, SSH_RUNTIME_PROTOCOL, SshSessionFrameDecoder } from "@internal/subshell-protocol"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SettingsRepository } from "@/db/repositories/settings.repository.js"; +import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import * as broker from "../local-broker.js"; +import { getSession, resetSessionRegistryForTests } from "../session-registry.js"; +import { openSession } from "../sessions.service.js"; +import { closeSession } from "../sessions-lifecycle.js"; + +let userId: string; +const target = { alias: "dev", host: "dev.example", port: 22, user: null, identityFile: null }; +const hello = { + type: "hello" as const, + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.0", + os: "linux", + arch: "x64", + capabilities: [], + homeDir: "/home/dev", + dataDir: "/home/dev/.subshell", + tmuxSocket: "test", + paneCount: 0, +}; +const settings = new SettingsRepository(db); +const roles = new UserMetaRepository(db); +const opened = spyOn(broker, "openLocalBroker").mockResolvedValue({ hello, host: target.host, port: 22, user: null }); +const closed = spyOn(broker, "closeLocalBroker").mockImplementation(() => {}); +const sent = spyOn(broker, "sendLocalBroker").mockImplementation(async (id, bytes) => { + const frames = new SshSessionFrameDecoder().push(bytes) as { ref: string }[]; + for (const frame of frames) + getSession(id)?.ingestBytes( + encodeSshSessionFrame({ type: "result", ref: frame.ref, ok: true, data: { subshells: [] } }), + ); +}); + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email: `local-origin-${crypto.randomUUID()}@subshell.test`, + name: "SSH admin", + passwordHash: "unused", + role: "admin", + }); + const nodes = new NodesRepository(db); + if (!(await nodes.findById("local"))) + await nodes.create({ id: "local", kind: "local", name: "Server", ownerUserId: userId }); +}); +afterAll(() => { + opened.mockRestore(); + closed.mockRestore(); + sent.mockRestore(); + resetSessionRegistryForTests(); +}); + +test("server-origin rejects members, disabled launching and maintenance before any SSH process", async () => { + await roles.upsert({ userId, role: "user" }); + await expect(openSession(userId, { connectingNodeId: "local", target })).rejects.toMatchObject({ status: 404 }); + await roles.upsert({ userId, role: "admin" }); + await settings.set("allow_server_subshells", false); + await expect(openSession(userId, { connectingNodeId: "local", target })).rejects.toMatchObject({ status: 409 }); + await settings.set("allow_server_subshells", true); + await db.updateTable("nodes").set({ maintenance: 1 }).where("id", "=", "local").execute(); + await expect(openSession(userId, { connectingNodeId: "local", target })).rejects.toMatchObject({ status: 409 }); + await db.updateTable("nodes").set({ maintenance: 0 }).where("id", "=", "local").execute(); + expect(opened).not.toHaveBeenCalled(); +}); + +test("admin uses the local byte channel without an enrolled node; close reaps its broker", async () => { + const view = await openSession(userId, { connectingNodeId: "local", target }); + expect(view.status).toBe("active"); + expect(view.connectingNodeId).toBe("local"); + expect(opened).toHaveBeenCalledTimes(1); + await getSession(view.id)?.command({ type: "list_dirs", ref: crypto.randomUUID(), path: "" }, 1000); + expect(sent).toHaveBeenCalled(); + await closeSession(view.id, userId); + expect(closed).toHaveBeenCalledWith(view.id); +}); + +test("removing admin access invalidates the channel rather than retaining server credentials", async () => { + const view = await openSession(userId, { connectingNodeId: "local", target }); + const session = getSession(view.id); + expect(session).toBeDefined(); + await roles.upsert({ userId, role: "user" }); + await expect(session?.command({ type: "list_dirs", ref: crypto.randomUUID(), path: "" }, 1000)).rejects.toBeDefined(); + expect(session?.status).toBe("lost"); + expect(closed).toHaveBeenCalledWith(view.id); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/pane-doors-and-detect.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/pane-doors-and-detect.test.ts new file mode 100644 index 000000000..706385593 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/pane-doors-and-detect.test.ts @@ -0,0 +1,529 @@ +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; +import { + encodeSshSessionFrame, + SSH_RUNTIME_PROTOCOL, + type SshRuntimeHelloWire, + SshSessionFrameDecoder, +} from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { PresetsRepository } from "@/db/repositories/presets.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; +import * as mcpLaunch from "@/services/mcp-launch.js"; +import * as nodeRpc from "@/services/nodes/node-rpc.js"; +import { revokeSubshellToken } from "@/services/subshell-tokens.js"; + +/** + * The REAL node-rpc exports, spread at MODULE-EVAL time - before any + * `mock.module` has run. bun's `mock.module` mutates the live namespace, so + * spreading it back in `afterAll` re-installs the mock (the leak this pattern + * caused: the sibling `api/ssh-runtime` suite then answered every scripted + * command with this file's capture, i.e. NODE_UNREACHABLE "malformed + * payload"). Snapshot once, mock FROM the snapshot, restore TO the snapshot; + * the `afterAll` identity assertion pins the restore itself. Same posture as + * the `enroll-route.test.ts` constants snapshot. + */ +const realNodeRpc = { ...nodeRpc }; +/** The mcp-launch snapshot for the M-2 dialect-throw test's `mock.module` (same restore discipline as above). */ +const realMcpLaunch = { ...mcpLaunch }; + +import { detectRuntimeSessionHarnesses, sessionHarnesses } from "../harness-detect.js"; +import { RuntimeSessionLauncher } from "../runtime-session-launcher.js"; +import { RUNTIME_PANE_BASE_URL, SshRuntimeSession } from "../session.js"; +import { registerSession, resetSessionRegistryForTests, sessionHooks } from "../session-registry.js"; +import { SshRuntimeSessionsRepository } from "../sessions.repository.js"; +import { SshRuntimeRefusal } from "../sessions.service.js"; +import { sessionLaunchHarness } from "../sessions-lifecycle.js"; + +/** + * The task-25 plane seam, at the three doors this wave owns: + * + * - **Attribution**: a `rest_request` carrying a `paneId` is honored for a pane + * the session really issued and refuses a forged one; a frame WITHOUT a + * paneId keeps the slice's shared-door rule (one pane or nothing). Multi-pane + * attribution works with NO credential on the wire, and the one-pane surface + * does not regress. + * - **The bake**: a runtime pane env names its OWN door, the never-resolves + * sentinel and the destination data dir - and no minted token ever appears + * in a launch frame or a pane env (the HARD invariant, design §5), scanned. + * - **The detect verbs**: capability refusal named (`detect_unsupported`), the + * round trip merging into the hidden runtime node row's `inventory_json` + * (the mirror write), and the cached read answering without a round trip. + * + * The broker is the module mock (the sessions-close pattern): every outbound + * `sendCommand` is captured, and `ssh_session_send` payloads are decoded so + * the inner runtime frame (launch, detect) is asserted where it actually + * rides. + */ + +const sessionsRepo = new SshRuntimeSessionsRepository(db); +const nodesRepo = new NodesRepository(db); + +const email = `panedoors-${crypto.randomUUID()}@subshell.local`; +let userId: string; +const cleanupNodes: string[] = []; + +interface Captured { + nodeId: string; + cmd: { type: string; ref?: string; data_b64?: string }; + inner?: Record; +} +const captured: Captured[] = []; +let pumpTarget: SshRuntimeSession | undefined; +/** The detect answer the pump gives back (set per test). */ +let detectAnswer: { results: unknown[]; env: Record } | undefined; + +function hello(caps: string[]): SshRuntimeHelloWire { + return { + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: caps, + homeDir: "/home/dst", + dataDir: "/home/dst/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-ab12cd34ef56", + paneCount: 0, + }; +} + +const ALL_CAPS = ["ssh-runtime", "callback-sock", "detect", "pane-callback-sock"]; + +async function mkSession(caps: string[] = ALL_CAPS): Promise { + const connectingNodeId = crypto.randomUUID(); + const runtimeNodeId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + cleanupNodes.push(runtimeNodeId, connectingNodeId); + await nodesRepo.create({ + id: connectingNodeId, + ownerUserId: userId, + name: `pd-${sessionId.slice(0, 8)}`, + kind: "agent", + }); + await nodesRepo.create({ + id: runtimeNodeId, + ownerUserId: userId, + name: `pd-rt-${sessionId.slice(0, 8)}`, + kind: NODE_KIND_RUNTIME, + }); + await sessionsRepo.create({ + id: sessionId, + ownerUserId: userId, + connectingNodeId, + runtimeNodeId, + alias: "pd", + host: "127.0.0.1", + port: 22, + user: null, + status: "active", + }); + await sessionsRepo.settle(sessionId, "active", JSON.stringify(hello(caps))); + const session = new SshRuntimeSession({ + id: sessionId, + ownerId: userId, + connectingNodeId, + runtimeNodeId, + target: { alias: "pd", host: "127.0.0.1", port: 22, user: null, identityFile: null }, + hello: hello(caps), + }); + session.hooks = sessionHooks(); // the REAL hook object; executeCallback self-fetch would 504 - the recorded plumbing is the assertion + registerSession(session); + return session; +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("pd-pass-1"), + role: "user", + }); + mock.module("@/services/nodes/node-rpc.js", () => ({ + ...realNodeRpc, + sendCommand: async (nodeId: string, cmd: Captured["cmd"]) => { + const entry: Captured = { nodeId, cmd }; + if (cmd.type === "ssh_session_send" && pumpTarget !== undefined) { + const frames = new SshSessionFrameDecoder().push(new Uint8Array(Buffer.from(cmd.data_b64 ?? "", "base64"))); + entry.inner = frames[0] as Record; + captured.push(entry); + // Every framed command gets an answer (launch included - an unanswered + // command would sit on the session's 20 s deadline): detect answers + // with the fixture's rows/env, stat_dir with its F3 pre-launch + // realpath success (the launch verbs ask it before any write), and + // everything else with an empty success. + const inner = entry.inner as { type?: string; ref?: string; path?: string } | undefined; + if (inner?.ref !== undefined) { + pumpTarget.ingestBytes( + encodeSshSessionFrame({ + type: "result", + ref: inner.ref, + ok: true, + ...(inner.type === "detect" && detectAnswer !== undefined ? { data: detectAnswer } : {}), + ...(inner.type === "stat_dir" + ? { data: { path: inner.path ?? "/home/dst/work", isDirectory: true } } + : {}), + }), + ); + } + return { ok: true }; + } + captured.push(entry); + return { ok: true }; + }, + })); +}); + +afterAll(async () => { + mock.module("@/services/nodes/node-rpc.js", () => realNodeRpc); + // The isolation pin: bun's mock.module mutates the LIVE namespace, so this + // identity check fails exactly on the leak pattern (spreading the + // already-mocked namespace re-installs the mock; the sibling suite then + // dials the capture broker instead of the scripted node). + expect(nodeRpc.sendCommand).toBe(realNodeRpc.sendCommand); + resetSessionRegistryForTests(); + // Take the session ROWS with us: an unsettled `active` row outlives the + // file and joins the next file's boot-sweep census (the stale-row hygiene + // the sessions-close fixture learned the same way - its rows settle closed). + await db + .deleteFrom("sshRuntimeSessions") + .where("ownerUserId", "=", userId) + .execute() + .catch(() => {}); + for (const id of cleanupNodes) { + await db + .deleteFrom("nodes") + .where("id", "=", id) + .execute() + .catch(() => {}); + } + await deleteUserByEmailOrId(email).catch(() => {}); +}); + +describe("rest_request paneId plumbing", () => { + test("a frame naming a real pane reaches execute as that pane; a forged attribution refuses", async () => { + const session = await mkSession(); + const paneA = crypto.randomUUID(); + const paneB = crypto.randomUUID(); + session.registerPane(paneA, "token-A-plaintext"); + const hooks = sessionHooks(); + // The door rule, straight from the registry's real hook: + expect(hooks.resolveCallbackPane(session, `/api/subshells/${paneA}`, "GET", paneA)).toBe(paneA); + expect(hooks.resolveCallbackPane(session, `/api/subshells/${paneB}`, "GET", paneB)).toBeNull(); // not this session's pane + expect(hooks.resolveCallbackPane(session, `/api/subshells/${paneA}`, "DELETE", paneA)).toBeNull(); // method gate rides through + // Multi-pane with NO attribution (shared door): unresolvable; a single + // pane keeps answering (the slice's rule, unregressed). + session.registerPane(paneB, "token-B"); + expect(hooks.resolveCallbackPane(session, `/api/subshells/${paneA}`, "GET")).toBeNull(); + session.unregisterPane(paneB); + expect(hooks.resolveCallbackPane(session, `/api/subshells/${paneA}`, "GET")).toBe(paneA); + // The channels family (design §5's third allowlist line, task-25 gap close). + expect(hooks.resolveCallbackPane(session, "/api/channels", "POST", paneA)).toBe(paneA); + expect(hooks.resolveCallbackPane(session, "/api/channels/room/posts", "POST", paneA)).toBe(paneA); + + // And the plumbing: a pumped frame's paneId arrives at the hook. + pumpTarget = session; + const seen: { paneId?: string }[] = []; + session.hooks = { + ...hooks, + resolveCallbackPane: (_s, _p, _m, framePaneId) => { + seen.push({ paneId: framePaneId }); + return framePaneId ?? null; + }, + executeCallback: async () => ({ status: 200, body: "{}" }), + }; + session.ingestBytes( + encodeSshSessionFrame({ + type: "rest_request", + reqId: "q1", + method: "GET", + path: `/api/subshells/${paneA}`, + paneId: paneA, + }), + ); + for (let i = 0; i < 100 && seen.length === 0; i++) await new Promise((r) => setTimeout(r, 5)); + expect(seen[0]?.paneId).toBe(paneA); + }); +}); + +describe("runtime pane bake", () => { + test("a preset launch frames the registration block and NO credential material", async () => { + const session = await mkSession(); + const pane = crypto.randomUUID(); + const TOKEN = "subshell_SECRETSCAN_9f3a1c"; // planted: must never appear in a frame or a pane env + session.registerPane(pane, TOKEN); + pumpTarget = session; + captured.length = 0; + const launcher = new RuntimeSessionLauncher(session); + const configPath = `${session.hello.dataDir}/mcp/${pane}.json`; + const subshellEnv = { + SUBSHELL_ID: pane, + SUBSHELL_NAME: "pd pane", + SUBSHELL_RUNTIME_CALLBACK_SOCK: session.paneCallbackSockPath(pane), + SUBSHELL_DATA_DIR: session.hello.dataDir, + SUBSHELL_BASE_URL: RUNTIME_PANE_BASE_URL, + }; + const harness = { + id: "claude-code", + name: "Claude Code", + detectSpec: { binaryName: "claude", envOverride: "", knownPaths: [] }, + buildCommand: ({ binary, mcp }: { binary: string; mcp?: { args?: string[] } }) => [binary, ...(mcp?.args ?? [])], + } as never; + await launcher.launch({ + id: pane, + socket: "sock-x", + harness, + binary: "", + cwd: "/home/dst/work", + preset: { name: "p", description: null, env: {}, flags: [], settings: null, configIsolation: false }, + subshellName: "pd pane", + subshellEnv, + mcp: { + fileContent: JSON.stringify({ mcpServers: { subshell: { command: "subshell", args: ["mcp"] } } }), + args: ["--mcp-config", configPath], + }, + mcpConfigPath: configPath, + reporter: { command: "subshell", args: ["report"] }, + }); + const launchCmd = captured.find((c) => c.inner?.type === "launch"); + expect(launchCmd, "the launch frame must reach the broker").toBeDefined(); + const cmd = launchCmd?.inner?.cmd as Record; + expect(cmd.mcp).toBeDefined(); // preset parity: the registration rides the frame + expect((cmd.mcp as { path: string }).path).toBe(configPath); + expect(cmd.argv as string[]).toContain("--mcp-config"); + // THE HARD INVARIANT (design §5), scanned on every surface that crosses: + const wire = JSON.stringify([captured.map((c) => c.inner)]); + expect(wire).not.toContain(TOKEN); + expect(JSON.stringify(cmd.subshellEnv)).not.toContain(TOKEN); + // Door + sentinel + destination data dir present; no key, no plane URL. + expect(subshellEnv.SUBSHELL_RUNTIME_CALLBACK_SOCK).toBe(`${session.hello.dataDir}/callbacks/${pane}.sock`); + expect(subshellEnv.SUBSHELL_BASE_URL).toBe("http://subshell-callback.invalid"); + expect("SUBSHELL_API_KEY" in subshellEnv).toBe(false); + }); +}); + +describe("launch-harness (the real service path, credential scan)", () => { + const cleanupPanes: string[] = []; + const cleanupPresets: string[] = []; + const presetsRepo = new PresetsRepository(db); + const subshellsRepo = new SubshellsRepository(db); + + async function ownPreset(env: Record): Promise { + const row = await presetsRepo.create({ + id: crypto.randomUUID(), + userId, + harnessId: "claude-code", + name: `pd-preset-${crypto.randomUUID().slice(0, 8)}`, + description: null, + envJson: JSON.stringify(env), + flagsJson: JSON.stringify([]), + settingsJson: null, + configIsolation: 0, + }); + cleanupPresets.push(row.id); + return row.id; + } + + afterAll(async () => { + for (const id of cleanupPanes) { + await revokeSubshellToken(id).catch(() => {}); + await subshellsRepo.delete(id).catch(() => {}); + } + for (const id of cleanupPresets) { + await db + .deleteFrom("presets") + .where("id", "=", id) + .execute() + .catch(() => {}); + } + }); + + test("a presetless harness launch bakes the plan from the hello facts and NO credential crosses", async () => { + const session = await mkSession(); + pumpTarget = session; + captured.length = 0; + const res = await sessionLaunchHarness(session.id, userId, { + harnessId: "claude-code", + cwd: "/home/dst/work", + }); + cleanupPanes.push(res.subshellId); + // The REAL minted token (not a planted literal this time): whatever it is, + // it must appear in no frame and no pane env (design §5's invariant on the + // new path, held by the same scan as the sibling's bake test). + const token = session.paneToken(res.subshellId); + expect(typeof token).toBe("string"); + const launchCmd = captured.find((c) => c.inner?.type === "launch"); + expect(launchCmd, "the launch frame must reach the broker").toBeDefined(); + const cmd = launchCmd?.inner?.cmd as Record; + expect(cmd.harnessId).toBe("claude-code"); + // Baked from the HELLO facts, not the plane's paths: the destination's own + // data dir for the MCP registration, its own resolve rule for the binary. + expect((cmd.mcp as { path: string }).path).toBe(`${session.hello.dataDir}/mcp/${res.subshellId}.json`); + expect((cmd.resolve as { binaryName: string }).binaryName).toBe("claude"); + expect(cmd.argv as string[]).toContain("--mcp-config"); + // Fresh start-mode conversation for a resume-capable harness, and the + // same id pinned on the row (the census's later reader). + const hs = cmd.harnessSession as { id: string; mode: string }; + expect(hs.mode).toBe("start"); + const row = await subshellsRepo.findById(res.subshellId); + expect(row?.harnessId).toBe("claude-code"); + expect(row?.presetId).toBeNull(); + expect(row?.nodeId).toBe(session.runtimeNodeId); + expect(row?.harnessSessionId).toBe(hs.id); + // THE HARD INVARIANT, scanned on every surface that crosses: + const wire = JSON.stringify([captured.map((c) => c.inner)]); + expect(wire).not.toContain(token as string); + expect(wire).not.toContain("SUBSHELL_API_KEY"); + expect(JSON.stringify(cmd.subshellEnv)).not.toContain(token as string); + // Door + sentinel + destination data dir; the plane URL is the sentinel, + // never the real one. + const paneEnv = cmd.subshellEnv as Record; + expect(paneEnv.SUBSHELL_RUNTIME_CALLBACK_SOCK).toBe(`${session.hello.dataDir}/callbacks/${res.subshellId}.sock`); + expect(paneEnv.SUBSHELL_BASE_URL).toBe(RUNTIME_PANE_BASE_URL); + expect(paneEnv.SUBSHELL_DATA_DIR).toBe(session.hello.dataDir); + expect("SUBSHELL_API_KEY" in paneEnv).toBe(false); + // The reporter half baked into the argv (hooks re-enter the destination's + // own binary; a reporter-less plugin omits the hooks - claude has one): + expect(JSON.stringify(cmd.argv)).toContain("report"); + }); + + test("a preset launch rides the frame (preset env is user data, never the credential layer)", async () => { + const session = await mkSession(); + pumpTarget = session; + const presetId = await ownPreset({ PD_SCAN_MODEL: "svc-scan-model" }); + captured.length = 0; + const res = await sessionLaunchHarness(session.id, userId, { + harnessId: "claude-code", + presetId, + cwd: "/home/dst/work", + }); + cleanupPanes.push(res.subshellId); + const token = session.paneToken(res.subshellId) as string; + const launchCmd = captured.find((c) => c.inner?.type === "launch"); + const cmd = launchCmd?.inner?.cmd as Record; + const preset = cmd.preset as { name: string; env: Record }; + expect(preset.env.PD_SCAN_MODEL).toBe("svc-scan-model"); + const row = await subshellsRepo.findById(res.subshellId); + expect(row?.presetId).toBe(presetId); + const wire = JSON.stringify([captured.map((c) => c.inner)]); + expect(wire).not.toContain(token); + expect(wire).not.toContain("SUBSHELL_API_KEY"); + }); + + test("a throwing MCP dialect unrolls row + token + registration (M-2: compose is inside the guarded scope)", async () => { + const session = await mkSession(); + pumpTarget = session; + captured.length = 0; + let composedId = ""; + mock.module("@/services/mcp-launch.js", () => ({ + ...realMcpLaunch, + planRemoteSubshellMcp: (_harness: unknown, subshellId: string, _facts: unknown) => { + composedId = subshellId; // the row and the token exist at this point (the order is the test) + throw new Error("mcp dialect exploded (plugin bug)"); + }, + })); + try { + await expect( + sessionLaunchHarness(session.id, userId, { harnessId: "claude-code", cwd: "/home/dst/work" }), + ).rejects.toThrow("mcp dialect exploded"); + expect(composedId, "the compose runs after the row+token+registration (compose-then-send order holds)").not.toBe( + "", + ); + // The unroll, asserted at every door the failed launch left: the pane + // registration is gone, and the row is gone with it. Before the fix the + // compose ran OUTSIDE the try and all three outlived the throw. + expect(session.paneIds()).not.toContain(composedId); + expect(session.paneToken(composedId)).toBeUndefined(); + const row = await subshellsRepo.findById(composedId); + expect(row).toBeUndefined(); + // Nothing of the PANE crossed to the node: the dialect died before the + // launch frame. (The F3 pre-launch `stat_dir` is the one frame that + // legitimately precedes the row - the gate that passed, not the write.) + expect(captured.filter((c) => c.inner?.type !== "stat_dir").length).toBe(0); + } finally { + mock.module("@/services/mcp-launch.js", () => realMcpLaunch); + expect(mcpLaunch.planRemoteSubshellMcp).toBe(realMcpLaunch.planRemoteSubshellMcp); + } + }); + + test("input guards refuse BEFORE any row is written (unknown harness, foreign preset, mismatch)", async () => { + const session = await mkSession(); + await expect( + sessionLaunchHarness(session.id, userId, { harnessId: "not-a-harness", cwd: "/home/dst/work" }), + ).rejects.toMatchObject({ status: 409 }); + await expect( + sessionLaunchHarness(session.id, userId, { + harnessId: "claude-code", + presetId: crypto.randomUUID(), + cwd: "/home/dst/work", + }), + ).rejects.toMatchObject({ status: 404 }); + const presetId = await ownPreset({ ANTHROPIC_MODEL: "mismatch-check" }); + await expect( + sessionLaunchHarness(session.id, userId, { harnessId: "terminal", presetId, cwd: "/home/dst/work" }), + ).rejects.toMatchObject({ status: 409 }); + await expect( + sessionLaunchHarness(session.id, "someone-else", { harnessId: "claude-code", cwd: "/x" }), + ).rejects.toBeInstanceOf(SshRuntimeRefusal); + const rows = await subshellsRepo.listByUser(userId); + expect(rows.filter((r) => r.nodeId === session.runtimeNodeId).length).toBe(0); + }); +}); + +describe("harness-detect verbs", () => { + test("capability refusal: a runtime without detect answers the named 409", async () => { + const session = await mkSession(["ssh-runtime", "callback-sock"]); + await expect(detectRuntimeSessionHarnesses(session.id, userId)).rejects.toMatchObject({ + status: 409, + code: "detect_unsupported", + }); + }); + + test("a foreign owner reads 404 on both verbs", async () => { + const session = await mkSession(); + await expect(detectRuntimeSessionHarnesses(session.id, "someone-else")).rejects.toBeInstanceOf(SshRuntimeRefusal); + await expect(sessionHarnesses(session.id, "someone-else")).rejects.toMatchObject({ status: 404 }); + }); + + test("the round trip asks with plane-owned rules, merges into the node row, and the cached read answers", async () => { + const session = await mkSession(); + pumpTarget = session; + detectAnswer = { + results: [ + { harnessId: "claude-code", installed: true, binaryPath: "/usr/local/bin/claude", rawVersion: "claude 9.9.9" }, + ], + env: { CLAUDE_CONFIG_DIR: "/home/dst/.claude" }, + }; + captured.length = 0; + const view = await detectRuntimeSessionHarnesses(session.id, userId, ["claude-code"]); + expect(view.online).toBe(true); + expect(view.env.CLAUDE_CONFIG_DIR).toBe("/home/dst/.claude"); + const claude = view.harnesses.find((h) => h.harnessId === "claude-code"); + expect(claude?.installed).toBe(true); + expect(claude?.binaryPath).toBe("/usr/local/bin/claude"); + expect(claude?.rawVersion).not.toBeNull(); + // The frame the plane SENT: plane-owned specs (filtered to the ask), + // name-only env. (envNames is the FULL enabled union; specs the subset.) + const detectFrame = captured.find((c) => c.inner?.type === "detect"); + expect(detectFrame).toBeDefined(); + const specs = detectFrame?.inner?.specs as { id: string }[]; + expect(specs.map((s) => s.id)).toEqual(["claude-code"]); + expect(Array.isArray(detectFrame?.inner?.envNames)).toBe(true); + // The mirror write landed on the hidden runtime node row. + const node = await nodesRepo.findById(session.runtimeNodeId); + expect(node?.inventoryJson).toContain("claude-code"); + // The cached read answers with no new round trip. + captured.length = 0; + const cached = await sessionHarnesses(session.id, userId); + expect(captured.length).toBe(0); // pure read: the broker stayed silent + expect(cached.harnesses.some((h) => h.harnessId === "claude-code")).toBe(true); + expect(cached.env.CLAUDE_CONFIG_DIR).toBe("/home/dst/.claude"); + }); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/runtime-log-window.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/runtime-log-window.test.ts new file mode 100644 index 000000000..35e16c041 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/runtime-log-window.test.ts @@ -0,0 +1,120 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { + encodeSshSessionFrame, + parseSshRuntimeCommandFrame, + SSH_RUNTIME_PROTOCOL, + SSH_SESSION_LOG_WINDOW_BYTES, + type SshRuntimeHelloWire, + SshSessionFrameDecoder, + type SshSessionTargetWire, +} from "@internal/subshell-protocol"; +import { RuntimeSessionLauncher } from "@/services/ssh-runtime/runtime-session-launcher.js"; +import { SshRuntimeSession } from "@/services/ssh-runtime/session.js"; +import { attachScriptedNode, type ScriptedNode } from "@/test-helpers/scripted-node.js"; + +/** + * The C1 clamp (review 2026-10-06): the runtime's answers ride the 262 KiB + * session codec, but `RuntimeSessionLauncher` composes its `log_read` windows + * from the NODE LINK's sizes (LOG_TAIL_BYTES 256 KiB), whose base64 answers + * cannot encode. The clamp lives in `#readLogSized`, so every caller + * (tail, cursor read, window read, gap backfill) is covered at the one seam + * that composes the frame. Pinned here at the wire: whatever the caller + * asks, the `maxBytes` that reaches the destination never exceeds + * {@link SSH_SESSION_LOG_WINDOW_BYTES}. + */ + +const NODE_ID = "n-rlog-1"; +const hello: SshRuntimeHelloWire = { + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: ["ssh-runtime", "callback-sock"], + homeDir: "/home/dst", + dataDir: "/home/dst/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-rlog00000", + paneCount: 0, +}; +const target: SshSessionTargetWire = { alias: "rlog", host: "127.0.0.1", port: 22, user: null, identityFile: null }; + +/** Every `log_read` frame the scripted destination saw: the maxBytes asked. */ +const askedWindows: number[] = []; + +let simRef: ScriptedNode | undefined; +let sessionRef: SshRuntimeSession | undefined; + +/** + * A live session on a scripted destination that answers every `log_read` as + * an EMPTY read and records the window it was asked for. The clamp test + * inspects the ASK, not the bytes. + */ +function mkSessionLive(): SshRuntimeSession { + simRef = attachScriptedNode(NODE_ID, { + ssh_session_send: (cmd) => { + if (cmd.type !== "ssh_session_send") throw new Error("wrong cmd"); + for (const raw of new SshSessionFrameDecoder().push(new Uint8Array(Buffer.from(cmd.data_b64, "base64")))) { + const frame = parseSshRuntimeCommandFrame(raw); + if (frame === null || frame.type !== "log_read") continue; + askedWindows.push(frame.maxBytes); + sessionRef?.ingestBytes( + encodeSshSessionFrame({ + type: "result", + ref: frame.ref, + ok: true, + data: { bytes_b64: "", next: Math.min(frame.fromByte, 0), size: 0 }, + }), + ); + } + return undefined; + }, + }); + const s = new SshRuntimeSession({ + id: "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", + ownerId: "u1", + connectingNodeId: NODE_ID, + runtimeNodeId: "r-rlog", + target, + hello, + }); + sessionRef = s; + return s; +} + +afterAll(() => simRef?.detach()); + +describe("RuntimeSessionLauncher log windows are session-sized (C1)", () => { + test("readLogWindow asks no more than the runtime budget, whatever the caller named", async () => { + askedWindows.length = 0; + const launcher = new RuntimeSessionLauncher(mkSessionLive()); + await launcher.readLogWindow("0f4c1a7e-9b62-4a11-8d3e-5c2b1a0f9e8d", 4096, 512 * 1024); + expect(askedWindows).toEqual([SSH_SESSION_LOG_WINDOW_BYTES]); + }); + + test("readLog (the tail-relay gap backfill) is clamped too", async () => { + askedWindows.length = 0; + const launcher = new RuntimeSessionLauncher(mkSessionLive()); + await launcher.readLog("0f4c1a7e-9b62-4a11-8d3e-5c2b1a0f9e8d", 0, 1024 * 1024); + expect(askedWindows).toEqual([SSH_SESSION_LOG_WINDOW_BYTES]); + }); + + test("readLogTail anchors its window at the clamped budget: size probe first, then a window that fits", async () => { + askedWindows.length = 0; + const launcher = new RuntimeSessionLauncher(mkSessionLive()); + await launcher.readLogTail("0f4c1a7e-9b62-4a11-8d3e-5c2b1a0f9e8d"); + // First the 1-byte size probe; the tail window that follows must be the + // budget, never the node link's LOG_TAIL_BYTES (256 KiB), whose base64 + // answer cannot encode into one session frame. + expect(askedWindows[0]).toBe(1); + expect(askedWindows.length).toBe(2); + expect(askedWindows[1]).toBe(SSH_SESSION_LOG_WINDOW_BYTES); + for (const asked of askedWindows) expect(asked).toBeLessThanOrEqual(SSH_SESSION_LOG_WINDOW_BYTES); + }); + + test("an honest small ask passes through unclamped (the clamp never grows or re-quantizes)", async () => { + askedWindows.length = 0; + const launcher = new RuntimeSessionLauncher(mkSessionLive()); + await launcher.readLogWindow("0f4c1a7e-9b62-4a11-8d3e-5c2b1a0f9e8d", 0, 8192); + expect(askedWindows).toEqual([8192]); + }); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/runtime-pane-refusals.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/runtime-pane-refusals.test.ts new file mode 100644 index 000000000..aca473b9d --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/runtime-pane-refusals.test.ts @@ -0,0 +1,308 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { ApiError, BackendErrorCodes } from "@internal/backend-errors"; +import { getHarness } from "@internal/pane-runtime"; +import { + encodeSshSessionFrame, + SSH_RUNTIME_PROTOCOL, + type SshRuntimeHelloWire, + SshSessionFrameDecoder, + type SshSessionTargetWire, +} from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; +import { ApiContext } from "@/lib/context.js"; +import { SshRuntimeSession } from "@/services/ssh-runtime/session.js"; +import { + registerSession, + resetSessionRegistryForTests, + sessionHooks, +} from "@/services/ssh-runtime/session-registry.js"; +import { SshRuntimeSessionsRepository } from "@/services/ssh-runtime/sessions.repository.js"; +import { SshRuntimeRefusal } from "@/services/ssh-runtime/sessions.service.js"; +import { sessionLaunchHarness, sessionLaunchTerminal } from "@/services/ssh-runtime/sessions-lifecycle.js"; +import { revokeSubshellToken } from "@/services/subshell-tokens.js"; +import { attachScriptedNode, ok } from "@/test-helpers/scripted-node.js"; +import { getLogger } from "@/utils/logger.js"; + +/** + * The two pane-door refusals the acceptance run of 2026-10-05 measured but + * the committed suite did not hold (walkthrough matrix: "stale input GAP", + * "harness-binary-missing-at-launch refusal untested"): + * + * - **Input after loss**: when the SSH child died, the lost settling flipped + * the pane row to `running, alive: 0` (unavailable, not completed), and + * `sendSubshellInput` answers the named 409 SUBSHELL_NOT_RUNNING BEFORE + * resolving any launcher: nothing is typed, nothing queues, nothing + * replays, and the broker sees zero new frames. + * - **Harness binary missing at launch**: the destination's resolve ladder + * answers the launch frame `ok:false` with the executor's own + * `harness binary missing:` error, and `sessionLaunchHarness` maps it to a + * named 409 pointing at the HARNESS on the destination - unrolling the row, + * the minted token, and the pane registration so no half-pane survives. + * + * The broker is the scripted node (the real RPC chain); its `ssh_session_send` + * arm decodes the runtime frame and answers it back through the session's own + * pump - success, or per-test refusal, for the frame types the test names. + */ + +const email = `panerefuse-${crypto.randomUUID()}@subshell.local`; +let userId: string; +let ctx: ApiContext; +const cleanupNodes: string[] = []; +const cleanupSessions: string[] = []; +const cleanupPanes: string[] = []; + +const target: SshSessionTargetWire = { alias: "pr", host: "127.0.0.1", port: 22, user: null, identityFile: null }; + +function hello(): SshRuntimeHelloWire { + return { + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: ["ssh-runtime", "callback-sock", "detect", "pane-callback-sock"], + homeDir: "/home/dst", + dataDir: "/home/dst/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-pr00000000", + paneCount: 0, + }; +} + +/* The scripted pump: one live session per test receives the answered frames. */ +let pumpTarget: SshRuntimeSession | undefined; +/** Frame types the pump must REFUSE (with this error), instead of answering ok. */ +let refuseFrames: Map = new Map(); + +const sessionsRepo = new SshRuntimeSessionsRepository(db); +const subshellsRepo = new SubshellsRepository(db); + +async function mkSession(tag: string): Promise { + const connectingNodeId = crypto.randomUUID(); + const runtimeNodeId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + cleanupNodes.push(runtimeNodeId, connectingNodeId); + cleanupSessions.push(sessionId); + const now = new Date().toISOString(); + await new NodesRepository(db).create({ id: connectingNodeId, ownerUserId: userId, name: `pr-${tag}`, kind: "agent" }); + await new NodesRepository(db).create({ + id: runtimeNodeId, + ownerUserId: userId, + name: `pr-rt-${tag}`, + kind: NODE_KIND_RUNTIME, + status: "online", + lastSeenAt: now, + }); + await sessionsRepo.create({ + id: sessionId, + ownerUserId: userId, + connectingNodeId, + runtimeNodeId, + alias: target.alias, + host: target.host, + port: target.port, + user: target.user, + status: "active", + }); + await sessionsRepo.settle(sessionId, "active", JSON.stringify(hello())); + const session = new SshRuntimeSession({ + id: sessionId, + ownerId: userId, + connectingNodeId, + runtimeNodeId, + target, + hello: hello(), + }); + session.hooks = sessionHooks(); // the REAL hooks: the lost settling this suite asserts rides them + registerSession(session); + return session; +} + +/** Poll a condition with the real clock (settle writes are async `void`s off the hooks). */ +async function waitUntil(cond: () => boolean | Promise, what: string): Promise { + const t0 = Date.now(); + for (;;) { + if (await cond()) return; + if (Date.now() - t0 > 2000) throw new Error(`timed out waiting for: ${what}`); + await new Promise((r) => setTimeout(r, 10)); + } +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("pr-pass-1"), + role: "user", + }); + ctx = new ApiContext({ db, log: getLogger() }); +}); + +afterAll(async () => { + resetSessionRegistryForTests(); + for (const id of cleanupPanes) { + await revokeSubshellToken(id).catch(() => {}); + await subshellsRepo.delete(id).catch(() => {}); + } + await db + .deleteFrom("sshRuntimeSessions") + .execute() + .catch(() => {}); + for (const id of cleanupNodes) { + await db + .deleteFrom("nodes") + .where("id", "=", id) + .execute() + .catch(() => {}); + } + await deleteUserByEmailOrId(email).catch(() => {}); +}); + +describe("input after the child died", () => { + test("a lost session's pane answers the named stale refusal with zero frames written", async () => { + const session = await mkSession(`lost-${crypto.randomUUID().slice(0, 8)}`); + pumpTarget = session; + refuseFrames = new Map(); + const sim = attachScriptedNode(session.connectingNodeId, { + ssh_session_close: ok, + ssh_session_send: (cmd) => { + if (cmd.type !== "ssh_session_send" || pumpTarget === undefined) return new Error("unexpected send"); + for (const frame of new SshSessionFrameDecoder().push(new Uint8Array(Buffer.from(cmd.data_b64, "base64")))) { + const inner = frame as { type?: string; ref?: string; path?: string }; + if (inner.ref === undefined) continue; + const refusal = inner.type === undefined ? undefined : refuseFrames.get(inner.type); + pumpTarget.ingestBytes( + encodeSshSessionFrame( + refusal === undefined + ? { + type: "result", + ref: inner.ref, + ok: true, + // The F3 pre-launch `stat_dir` answers with the executor's + // realpath success shape; every other ok frame needs none. + ...(inner.type === "stat_dir" + ? { data: { path: inner.path ?? "/home/dst/work", isDirectory: true } } + : {}), + } + : { type: "result", ref: inner.ref, ok: false, error: refusal }, + ), + ); + } + return undefined; + }, + }); + try { + // A real terminal pane on the live session: row + token + launch frame. + const { subshellId } = await sessionLaunchTerminal(session.id, userId, { cwd: "/home/dst/work" }); + cleanupPanes.push(subshellId); + const sentAfterLaunch = sim.countOf("ssh_session_send"); + const row0 = await subshellsRepo.findById(subshellId); + expect(row0?.status).toBe("running"); + expect(row0?.alive).toBe(1); + + // The child dies: the loss settling (the REAL hooks) flips the row to + // the shot-16 posture - `running` with `alive: 0`, unavailable not + // completed - and evicts the session from the registry. + session.markLost("child-lost"); + await waitUntil(async () => (await subshellsRepo.findById(subshellId))?.alive === 0, "pane row alive: 0"); + const row = await subshellsRepo.findById(subshellId); + expect(row?.status).toBe("running"); // the outcome the plane never witnessed stays as it was + expect(row?.alive).toBe(0); + + // The launcher's door from REST: the named stale refusal, before the + // launcher is even resolved, so nothing can be written. + const err = await ctx.services.subshells.sendSubshellInput(userId, subshellId, "WALK-PROBE", true, "cookie").then( + () => null, + (e: unknown) => e, + ); + expect(err, "input into the lost pane must refuse").toBeInstanceOf(ApiError); + const apiErr = err as ApiError; + expect(apiErr.code).toBe(BackendErrorCodes.SUBSHELL_NOT_RUNNING); + expect(apiErr.statusCode).toBe(409); + expect(apiErr.message).toContain("not running; nothing was typed"); + // Zero frames: the broker saw nothing more after the launch that made + // the pane, so nothing queued and nothing replayed. + expect(sim.countOf("ssh_session_send")).toBe(sentAfterLaunch); + } finally { + sim.detach(); + pumpTarget = undefined; + } + }); +}); + +describe("harness binary missing at launch", () => { + test("the destination's launch refusal maps to the named harness remedy and unrolls every write", async () => { + const session = await mkSession(`nobin-${crypto.randomUUID().slice(0, 8)}`); + pumpTarget = session; + // The runtime's own executor refuses (apps/node/agent/src/commands/launch.ts): + // the resolve ladder found nothing on the destination. + refuseFrames = new Map([["launch", `harness binary missing: claude-code`]]); + const sim = attachScriptedNode(session.connectingNodeId, { + ssh_session_close: ok, + ssh_session_send: (cmd) => { + if (cmd.type !== "ssh_session_send" || pumpTarget === undefined) return new Error("unexpected send"); + for (const frame of new SshSessionFrameDecoder().push(new Uint8Array(Buffer.from(cmd.data_b64, "base64")))) { + const inner = frame as { type?: string; ref?: string; path?: string }; + if (inner.ref === undefined) continue; + const refusal = inner.type === undefined ? undefined : refuseFrames.get(inner.type); + pumpTarget.ingestBytes( + encodeSshSessionFrame( + refusal === undefined + ? { + type: "result", + ref: inner.ref, + ok: true, + // The F3 pre-launch `stat_dir` answers with the executor's + // realpath success shape; every other ok frame needs none. + ...(inner.type === "stat_dir" + ? { data: { path: inner.path ?? "/home/dst/work", isDirectory: true } } + : {}), + } + : { type: "result", ref: inner.ref, ok: false, error: refusal }, + ), + ); + } + return undefined; + }, + }); + try { + const harness = getHarness("claude-code"); + expect(harness, "the built-in claude-code harness must resolve for the fixture").toBeDefined(); + const err = await sessionLaunchHarness(session.id, userId, { + harnessId: "claude-code", + cwd: "/home/dst/work", + }).then( + () => null, + (e: unknown) => e, + ); + expect(err, "a missing destination harness binary must refuse").toBeInstanceOf(SshRuntimeRefusal); + const refusal = err as SshRuntimeRefusal; + expect(refusal.status).toBe(409); + // The remedy names the HARNESS on the destination (not the Subshell + // binary - that pointer belongs to the terminal verb's own refusal). + expect(refusal.message).toBe(`Harness "${harness?.name}" is not installed on the destination.`); + // The unroll: the pane never existed. Row, token, and registration are + // all gone (the terminal verb's failed-spawn posture, same writes). + const paneIds = session.paneIds(); + expect(paneIds).toEqual([]); + const rows = await subshellsRepo.listByUser(userId); + expect(rows.filter((r) => r.nodeId === session.runtimeNodeId)).toEqual([]); + // The attempt did reach the destination: the F3 `stat_dir` gate passed + // (the directory probe is not what refused) and the LAUNCH refusal is + // the destination's own answer - exactly two framed commands crossed. + expect(sim.countOf("ssh_session_send")).toBe(2); + } finally { + sim.detach(); + pumpTarget = undefined; + refuseFrames = new Map(); + } + }); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/runtime-prompt.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/runtime-prompt.test.ts new file mode 100644 index 000000000..9d1550cf5 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/runtime-prompt.test.ts @@ -0,0 +1,35 @@ +import { expect, spyOn, test } from "bun:test"; +import { RuntimeSessionLauncher } from "../runtime-session-launcher.js"; +import type { SshRuntimeSession } from "../session.js"; + +/** No runtime is touched: spy at the launcher's existing capture/input transport seam. */ +function fixture() { + const launcher = new RuntimeSessionLauncher({} as SshRuntimeSession); + const capture = spyOn(launcher, "capture").mockResolvedValue("ready>"); + const input = spyOn(launcher, "sendInput").mockResolvedValue(); + return { launcher, capture, input }; +} + +test("initial prompt waits for output and submits exactly once", async () => { + const { launcher, capture, input } = fixture(); + capture.mockResolvedValueOnce(""); + expect(await launcher.deliverPrompt("socket", "pane", "Review this project", 100, 1)).toBe(true); + expect(input.mock.calls).toEqual([ + ["socket", "pane", "Review this project"], + ["socket", "pane", "\r"], + ]); +}); + +test("uncertain input failure never replays the prompt", async () => { + const { launcher, input } = fixture(); + input.mockRejectedValue(new Error("connection lost")); + expect(await launcher.deliverPrompt("socket", "pane", "Review", 100, 1)).toBe(false); + expect(input).toHaveBeenCalledTimes(1); +}); + +test("no readiness means no input", async () => { + const { launcher, capture, input } = fixture(); + capture.mockResolvedValue(""); + expect(await launcher.deliverPrompt("socket", "pane", "Review", 5, 1)).toBe(false); + expect(input).not.toHaveBeenCalled(); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/session-adopt.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/session-adopt.test.ts new file mode 100644 index 000000000..8723b6f50 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/session-adopt.test.ts @@ -0,0 +1,529 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + encodeSshSessionFrame, + SSH_RUNTIME_PROTOCOL, + type SshRuntimeHelloWire, + type SshRuntimeReportRow, + SshSessionFrameDecoder, + type SshSessionTargetWire, +} from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { authDatabase } from "@/auth/database.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; +import { SshRuntimeSession } from "@/services/ssh-runtime/session.js"; +import { type AdoptSummary, adoptReconciledPanes } from "@/services/ssh-runtime/session-adopt.js"; +import { + registerSession, + resetSessionRegistryForTests, + sessionHooks, +} from "@/services/ssh-runtime/session-registry.js"; +import { SshRuntimeSessionsRepository } from "@/services/ssh-runtime/sessions.repository.js"; +import { sessionLaunchTerminal } from "@/services/ssh-runtime/sessions-lifecycle.js"; +import { issueSubshellToken, revokeSubshellToken } from "@/services/subshell-tokens.js"; +import { attachScriptedNode, ok, type ScriptedNode } from "@/test-helpers/scripted-node.js"; + +/** + * F2: the reopen RE-ADOPT (design 2026-10-05 §6 "plane matches reported pane + * ids to existing rows (idempotent restore)"), pinned at the service seam. + * + * The scenario the acceptance run measured as F2: a session dies (ssh child + * killed), its panes keep running on the destination's deterministic tmux + * socket, and the plane has today no path from "the fresh runtime reports the + * id" to "the ordinary row reads alive again on the NEW session". The pump is + * the scripted connecting node (the real RPC chain; the runtime half answered + * per-frame, like `runtime-pane-refusals`): the census is destination truth + * fed back frame-for-frame. + * + * The decisions pinned here, each stated in `session-adopt.ts`: + * - survivor: SAME id, row repointed to the new runtime node, `alive` + * restored, token ROTATED on the same row (key id changes, the old mint + * dies, the new plaintext reaches the new session's registry). + * - dead/absent in the census: settled dead (the boot-sweep leftover that + * still says alive gets flipped and revoked). + * - reported-but-unknown: recorded, no row invented. + * - the old hidden runtime node: kept, offline, as history. + * - a refused census: every row stays as the settle wrote it (skipped). + * - a different destination's rows are never touched (host/port/user match). + * - a session that DIES mid-walk (MINOR-1, both re-check seams): the row the + * walk had just claimed ends `alive: 0` with its token revoked and no + * registration - never a live row on a settled session. + * - two CONCURRENT reopens of one survivor (MINOR-2): exactly one walk wins + * the conditional flip; the loser touches nothing, and the winner's + * rotation stays live. + */ + +const email = `adopt-${crypto.randomUUID()}@subshell.local`; +let userId: string; +const cleanupNodes: string[] = []; +const cleanupSessions: string[] = []; +const cleanupPanes: string[] = []; +const sims: ScriptedNode[] = []; + +const sessionsRepo = new SshRuntimeSessionsRepository(db); +const nodesRepo = new NodesRepository(db); +const subshellsRepo = new SubshellsRepository(db); + +const target: SshSessionTargetWire = { alias: "ad", host: "127.0.0.1", port: 22, user: null, identityFile: null }; + +function hello(): SshRuntimeHelloWire { + return { + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: ["ssh-runtime", "callback-sock", "detect", "pane-callback-sock"], + homeDir: "/home/dst", + dataDir: "/home/dst/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-ad0000000000", + paneCount: 0, + }; +} + +/** The census the scripted runtime answers the NEXT `subshells_report` with. */ +let censusRows: SshRuntimeReportRow[] = []; +/** When set, the census frame is REFUSED with this error instead of answered. */ +let censusRefusal: string | null = null; + +/** One session: rows, registry registration, scripted connecting node. */ +async function mkSession(tag: string, overTarget: Partial = {}): Promise { + const t = { ...target, ...overTarget }; + const connectingNodeId = crypto.randomUUID(); + const runtimeNodeId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + cleanupNodes.push(runtimeNodeId, connectingNodeId); + cleanupSessions.push(sessionId); + const now = new Date().toISOString(); + await nodesRepo.create({ id: connectingNodeId, ownerUserId: userId, name: `ad-${tag}`, kind: "agent" }); + await nodesRepo.create({ + id: runtimeNodeId, + ownerUserId: userId, + name: `ad-rt-${tag}`, + kind: NODE_KIND_RUNTIME, + status: "online", + lastSeenAt: now, + }); + await sessionsRepo.create({ + id: sessionId, + ownerUserId: userId, + connectingNodeId, + runtimeNodeId, + alias: t.alias, + host: t.host, + port: t.port, + user: t.user, + status: "active", + }); + await sessionsRepo.settle(sessionId, "active", JSON.stringify(hello())); + const session = new SshRuntimeSession({ + id: sessionId, + ownerId: userId, + connectingNodeId, + runtimeNodeId, + target: t, + hello: hello(), + }); + session.hooks = sessionHooks(); // the REAL settling: markLost below must write like production + registerSession(session); + sims.push( + attachScriptedNode(connectingNodeId, { + ssh_session_close: ok, + ssh_session_send: (cmd) => { + if (cmd.type !== "ssh_session_send") throw new Error("wrong cmd"); + for (const frame of new SshSessionFrameDecoder().push(new Uint8Array(Buffer.from(cmd.data_b64, "base64")))) { + const inner = frame as { type?: string; ref?: string; path?: string }; + if (inner.ref === undefined) continue; + if (inner.type === "subshells_report") { + session.ingestBytes( + encodeSshSessionFrame( + censusRefusal !== null + ? { type: "result", ref: inner.ref, ok: false, error: censusRefusal } + : { type: "result", ref: inner.ref, ok: true, data: censusRows }, + ), + ); + continue; + } + if (inner.type === "stat_dir") { + session.ingestBytes( + encodeSshSessionFrame({ + type: "result", + ref: inner.ref, + ok: true, + data: { path: inner.path ?? "/home/dst/work", isDirectory: true }, + }), + ); + continue; + } + session.ingestBytes(encodeSshSessionFrame({ type: "result", ref: inner.ref, ok: true })); + } + return undefined; + }, + }), + ); + return session; +} + +/** A row + minted token OUTSIDE the launch verbs (the boot-sweep leftover shape). */ +async function seedRowOnNode(nodeId: string, opts: { alive: 0 | 1; withToken: boolean }): Promise { + const id = crypto.randomUUID(); + cleanupPanes.push(id); + await subshellsRepo.create({ + id, + userId, + harnessId: "terminal", + name: `ad-pane-${id.slice(0, 8)}`, + workingDir: "/home/dst/work", + presetId: null, + nodeId, + tmuxSocket: `sock-${id}`, + status: "running", + alive: opts.alive, + startedAt: new Date().toISOString(), + notify: 1, + crossAgent: 0, + }); + if (opts.withToken) await issueSubshellToken(id, userId); + return id; +} + +/** Whether the apikey row behind a token is live (1) or revoked (0). */ +function keyEnabled(apiKeyId: string): number | undefined { + const row = authDatabase() + .prepare<{ enabled: number }, [string]>(`SELECT enabled FROM apikey WHERE id = ?`) + .get(apiKeyId); + return row?.enabled; +} + +/** Poll a condition (settle writes are async `void`s off the hooks). */ +async function waitUntil(cond: () => Promise, what: string): Promise { + const t0 = Date.now(); + for (;;) { + if (await cond()) return; + if (Date.now() - t0 > 2000) throw new Error(`timed out waiting for: ${what}`); + await new Promise((r) => setTimeout(r, 10)); + } +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("ad-pass-1"), + role: "user", + }); +}); + +afterAll(async () => { + for (const sim of sims) sim.detach(); + resetSessionRegistryForTests(); + for (const id of cleanupPanes) { + await revokeSubshellToken(id).catch(() => {}); + await subshellsRepo.delete(id).catch(() => {}); + } + await db + .deleteFrom("sshRuntimeSessions") + .execute() + .catch(() => {}); + for (const id of cleanupNodes) { + await db + .deleteFrom("nodes") + .where("id", "=", id) + .execute() + .catch(() => {}); + } + await deleteUserByEmailOrId(email).catch(() => {}); +}); + +describe("re-adopt: the reopen restores what the destination still has", () => { + test("survivor repoints with a rotated token; dead settles; unknown is recorded, never a row", async () => { + const a = await mkSession(`a-${crypto.randomUUID().slice(0, 8)}`); + // A real launched pane (the ordinary row+token+frame order), then the + // child dies: the lost settle is the F2 starting state (alive 0, token + // revoked, node offline, row still pinned to the dead session's node). + const { subshellId } = await sessionLaunchTerminal(a.id, userId, { cwd: "/home/dst/work" }); + cleanupPanes.push(subshellId); + const keyBefore = (await subshellsRepo.findById(subshellId))?.apiKeyId; + // A boot-sweep leftover on the SAME settled node: alive 1, token live, + // never registered with the session (what `reconcileAtBoot` can leave). + const leftoverId = await seedRowOnNode(a.runtimeNodeId, { alive: 1, withToken: true }); + // A dead row too (the census will confirm it dead). + const deadId = await seedRowOnNode(a.runtimeNodeId, { alive: 1, withToken: true }); + + a.markLost("child-lost"); + await waitUntil(async () => (await subshellsRepo.findById(subshellId))?.alive === 0, "lost settle alive: 0"); + + // Reopen the same destination; the fresh runtime's census says: survivor + // alive, dead dead, and a GHOST the plane never created. + const b = await mkSession(`b-${crypto.randomUUID().slice(0, 8)}`); + const ghostId = crypto.randomUUID(); + censusRows = [ + { subshellId, alive: true, exitCode: null }, + { subshellId: deadId, alive: false, exitCode: 3 }, + { subshellId: ghostId, alive: true, exitCode: null }, + ]; + censusRefusal = null; + + const summary = await adoptReconciledPanes(b); + + expect(summary.adopted).toEqual([subshellId]); + expect(summary.settledDead).toContain(deadId); + expect(summary.settledDead).toContain(leftoverId); // not reported alive -> settled dead + expect(summary.unknown).toEqual([ghostId]); + expect(summary.skipped).toBeNull(); + + // The survivor: same id, NEW runtime node, alive restored. + const row = await subshellsRepo.findById(subshellId); + expect(row?.nodeId).toBe(b.runtimeNodeId); + expect(row?.alive).toBe(1); + expect(row?.status).toBe("running"); + // Token rotated on the same row: a new key id, the new plaintext in the + // NEW session's registry (what lets the re-created destination door's + // callbacks attribute again), and the old mint is not the live one. + expect(row?.apiKeyId).not.toBeNull(); + expect(row?.apiKeyId).not.toBe(keyBefore); + expect(b.paneToken(subshellId)).toBeTypeOf("string"); + expect(a.paneToken(subshellId)).toBeUndefined(); + + // The dead/absent rows: settled, and never re-adopted. + expect((await subshellsRepo.findById(deadId))?.alive).toBe(0); + expect((await subshellsRepo.findById(leftoverId))?.alive).toBe(0); + // The ghost: no row was invented. + expect(await subshellsRepo.findById(ghostId)).toBeUndefined(); + // No duplicates: one row per id across the whole owner. + const all = await subshellsRepo.listByUser(userId); + expect(all.filter((r) => r.id === subshellId).length).toBe(1); + + // The retired runtime node stays as history (documented decision): the + // old row is still there, offline, and never adopted anything back. + expect(await nodesRepo.findById(a.runtimeNodeId)).toBeDefined(); + expect((await nodesRepo.findById(a.runtimeNodeId))?.status).toBe("offline"); + }); + + test("idempotent across generations: die, reopen, adopt the same id again with a fresh rotation", async () => { + const a = await mkSession(`i-a-${crypto.randomUUID().slice(0, 8)}`); + const { subshellId } = await sessionLaunchTerminal(a.id, userId, { cwd: "/home/dst/work" }); + cleanupPanes.push(subshellId); + a.markLost("child-lost"); + await waitUntil(async () => (await subshellsRepo.findById(subshellId))?.alive === 0, "first settle"); + + const b = await mkSession(`i-b-${crypto.randomUUID().slice(0, 8)}`); + censusRows = [{ subshellId, alive: true, exitCode: null }]; + censusRefusal = null; + const s1 = await adoptReconciledPanes(b); + expect(s1.adopted).toEqual([subshellId]); + const keyOnB = (await subshellsRepo.findById(subshellId))?.apiKeyId; + + // B dies too (mid-life loss after a successful adopt): the row returns to + // the settled reading through the REAL hooks. + b.markLost("child-lost"); + await waitUntil(async () => (await subshellsRepo.findById(subshellId))?.alive === 0, "second settle"); + expect((await subshellsRepo.findById(subshellId))?.nodeId).toBe(b.runtimeNodeId); // history says B + + const c = await mkSession(`i-c-${crypto.randomUUID().slice(0, 8)}`); + const s2 = await adoptReconciledPanes(c); + expect(s2.adopted, "C's candidate set spans BOTH dead generations").toEqual([subshellId]); + expect((await subshellsRepo.findById(subshellId))?.nodeId).toBe(c.runtimeNodeId); + expect((await subshellsRepo.findById(subshellId))?.apiKeyId).not.toBe(keyOnB); // rotated again + expect(c.paneToken(subshellId)).toBeTypeOf("string"); + + // Running the same census once more on the LIVE C adopts nothing (the row + // now lives on C's own active node - never a candidate) and cannot + // duplicate: the id's row count stays one. + const s3 = await adoptReconciledPanes(c); + expect(s3.adopted).toEqual([]); + const all = await subshellsRepo.listByUser(userId); + expect(all.filter((r) => r.id === subshellId).length).toBe(1); + }); + + test("a refused census changes nothing; a foreign destination is never touched", async () => { + const a = await mkSession(`r-a-${crypto.randomUUID().slice(0, 8)}`); + const { subshellId } = await sessionLaunchTerminal(a.id, userId, { cwd: "/home/dst/work" }); + cleanupPanes.push(subshellId); + a.markLost("child-lost"); + await waitUntil(async () => (await subshellsRepo.findById(subshellId))?.alive === 0, "settle"); + const rowBefore = await subshellsRepo.findById(subshellId); + + // Census refused (an unreachable/dumb runtime): honest skip, rows stay + // exactly as the settle wrote them. + const b = await mkSession(`r-b-${crypto.randomUUID().slice(0, 8)}`); + censusRefusal = "unsupported"; + const summary = await adoptReconciledPanes(b); + expect(summary.skipped).toBe("census-failed"); + expect(summary.adopted).toEqual([]); + const afterRefusal = await subshellsRepo.findById(subshellId); + expect(afterRefusal?.nodeId).toBe(rowBefore?.nodeId); + expect(afterRefusal?.alive).toBe(0); + + // A different destination (same owner, other host) keeps its own dead + // rows out of this reconcile: candidate matching is on host/port/user. + const foreign = await mkSession(`r-f-${crypto.randomUUID().slice(0, 8)}`, { host: "10.0.0.9" }); + const foreignPane = await seedRowOnNode(foreign.runtimeNodeId, { alive: 1, withToken: true }); + censusRefusal = null; + censusRows = [{ subshellId, alive: true, exitCode: null }]; + const c = await mkSession(`r-c-${crypto.randomUUID().slice(0, 8)}`); + const s = await adoptReconciledPanes(c); + expect(s.adopted, "only THIS destination's rows are candidates").toEqual([subshellId]); + expect((await subshellsRepo.findById(foreignPane))?.nodeId).toBe(foreign.runtimeNodeId); + }); +}); + +describe("re-adopt: the race seams (MINOR-1 death mid-walk, MINOR-2 concurrent reopens)", () => { + /** + * MINOR-1, seam one: the session dies WHILE the walk's conditional flip is + * in flight. The fixture flips `markLost` the moment the walk issues its + * first `updateTable("subshells")` (the flip's builder) - the acceptance + * review's shape, where `settleLost` takes its pane snapshot AFTER its DB + * awaits and here races past a row that was never registered. Without the + * post-flip re-check the walk would mint onto the dead session and leave + * `alive: 1` + a live token behind; with it, the flipped row settles down. + */ + test("death during the flip: the claimed row ends alive:0, token revoked, unregistered", async () => { + // A destination of its OWN: the candidate set must hold exactly this + // scenario's rows, not the shared-destination leftovers of the tests above. + const host = { host: "10.77.1.1" }; + const a = await mkSession(`m1-a-${crypto.randomUUID().slice(0, 8)}`, host); + // A boot-sweep-shaped leftover: alive:1, token LIVE, never registered + // with any session - exactly the row `settleLost` can never see. + const rowId = await seedRowOnNode(a.runtimeNodeId, { alive: 1, withToken: true }); + a.markLost("child-lost"); + await waitUntil(async () => (await sessionsRepo.findById(a.id))?.status === "lost", "A settles lost"); + + const b = await mkSession(`m1-b-${crypto.randomUUID().slice(0, 8)}`, host); + censusRows = [{ subshellId: rowId, alive: true, exitCode: null }]; + censusRefusal = null; + + const dbHandle = db as unknown as { updateTable: (table: string) => unknown }; + const realUpdateTable = dbHandle.updateTable; + let armed = true; + dbHandle.updateTable = (table: string) => { + if (armed && table === "subshells") { + armed = false; + b.markLost("child-lost"); // the SSH child dies mid-reconcile + } + return realUpdateTable.call(db, table); + }; + let summary: AdoptSummary; + try { + summary = await adoptReconciledPanes(b); + } finally { + delete (db as unknown as Record).updateTable; // restore the prototype method + } + + expect(summary.adopted, "a flipped-then-dead row is not an adoption").toEqual([]); + expect(summary.settledDead).toEqual([rowId]); + expect(summary.skipped).toBe("session-inactive"); + const row = await subshellsRepo.findById(rowId); + expect(row?.alive).toBe(0); // never: alive:1 on a settled session + expect(row?.nodeId).toBe(b.runtimeNodeId); // the flip landed; the next open's candidate set sees it + expect(row?.apiKeyId, "the seed's live token is revoked by the walk").toBeTruthy(); + expect(keyEnabled(row?.apiKeyId as string)).toBe(0); + expect(b.paneToken(rowId)).toBeUndefined(); + }); + + /** + * MINOR-1, seam two: the session dies WHILE the token rotation is in + * flight (after the flip, after registration is due). The fixture flips + * `markLost` inside the walk's first `findById` of the row - the read + * `revokeSubshellToken` performs before the re-mint - so the rotation + * completes against a session that is already lost: the just-minted token + * is the one that must die, and the registration must not survive. + */ + test("death during the rotation: the row ends alive:0 with the ROTATED token revoked", async () => { + const host = { host: "10.77.2.1" }; + const a = await mkSession(`m2-a-${crypto.randomUUID().slice(0, 8)}`, host); + const rowId = await seedRowOnNode(a.runtimeNodeId, { alive: 1, withToken: true }); + const keySeed = (await subshellsRepo.findById(rowId))?.apiKeyId; + a.markLost("child-lost"); + await waitUntil(async () => (await sessionsRepo.findById(a.id))?.status === "lost", "A settles lost"); + + const b = await mkSession(`m2-b-${crypto.randomUUID().slice(0, 8)}`, host); + censusRows = [{ subshellId: rowId, alive: true, exitCode: null }]; + censusRefusal = null; + + const realFindById = SubshellsRepository.prototype.findById; + let armed = true; + SubshellsRepository.prototype.findById = async function (this: SubshellsRepository, id: string) { + if (armed && id === rowId) { + armed = false; + b.markLost("child-lost"); // dies between the flip and the re-mint + } + return realFindById.call(this, id); + }; + let summary: AdoptSummary; + try { + summary = await adoptReconciledPanes(b); + } finally { + SubshellsRepository.prototype.findById = realFindById; + } + + expect(summary.adopted, "a rotated-then-dead row is not an adoption").toEqual([]); + expect(summary.settledDead).toEqual([rowId]); + expect(summary.skipped).toBe("session-inactive"); + const row = await subshellsRepo.findById(rowId); + expect(row?.alive).toBe(0); + expect(row?.nodeId).toBe(b.runtimeNodeId); + // The rotation DID land on the row (a fresh key id, not the seed's - + // asserted, so a regression that skips the mint on the death path cannot + // stay green on two disabled keys) - and it is exactly that key the + // walk's re-check revoked, because the pane registered with a session + // that had just died. + expect(row?.apiKeyId).toBeTruthy(); + expect(row?.apiKeyId).not.toBe(keySeed); + expect(keyEnabled(row?.apiKeyId as string)).toBe(0); + expect(b.paneToken(rowId)).toBeUndefined(); + }); + + /** + * MINOR-2: two reopens of one destination run their reconcile concurrently + * over the same survivor. The flip is conditional on the settled node the + * walker snapshotted from, so exactly one walk moves the row; the loser + * stands down BEFORE touching the token (a revoke-first loser would kill + * the winner's just-rotated key) and registers nothing. + */ + test("concurrent reopens claim one survivor exactly once", async () => { + const host = { host: "10.77.3.1" }; + const a = await mkSession(`c-a-${crypto.randomUUID().slice(0, 8)}`, host); + const { subshellId } = await sessionLaunchTerminal(a.id, userId, { cwd: "/home/dst/work" }); + cleanupPanes.push(subshellId); + const keyBefore = (await subshellsRepo.findById(subshellId))?.apiKeyId; + a.markLost("child-lost"); + await waitUntil(async () => (await subshellsRepo.findById(subshellId))?.alive === 0, "A settles"); + + const b = await mkSession(`c-b-${crypto.randomUUID().slice(0, 8)}`, host); + const c = await mkSession(`c-c-${crypto.randomUUID().slice(0, 8)}`, host); + censusRows = [{ subshellId, alive: true, exitCode: null }]; + censusRefusal = null; + + const [sB, sC] = await Promise.all([adoptReconciledPanes(b), adoptReconciledPanes(c)]); + + const bWon = sB.adopted.includes(subshellId); + const cWon = sC.adopted.includes(subshellId); + expect(Number(bWon) + Number(cWon), "exactly one reopen adopts the survivor").toBe(1); + const winner = bWon ? b : c; + const loser = bWon ? c : b; + const loserSummary = bWon ? sC : sB; + // The loser stands down without recording or touching anything (or its + // candidate snapshot simply never included the row - both are silence). + expect(loserSummary.adopted).toEqual([]); + expect(loserSummary.settledDead).toEqual([]); + + const row = await subshellsRepo.findById(subshellId); + expect(row?.nodeId, "single-parented").toBe(winner.runtimeNodeId); + expect(row?.alive).toBe(1); + expect(row?.apiKeyId).not.toBe(keyBefore); // the winner rotated on the same row + expect(keyEnabled(row?.apiKeyId as string), "the loser revoked nothing").toBe(1); + expect(winner.paneToken(subshellId)).toBeTypeOf("string"); + expect(loser.paneToken(subshellId), "the loser registered nothing").toBeUndefined(); + // No duplicates: one row per id across the whole owner. + const all = await subshellsRepo.listByUser(userId); + expect(all.filter((r) => r.id === subshellId).length).toBe(1); + }); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/session-open-guardrails.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/session-open-guardrails.test.ts new file mode 100644 index 000000000..1c8d4e2ff --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/session-open-guardrails.test.ts @@ -0,0 +1,303 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + SSH_RUNTIME_PROTOCOL, + SSH_SESSIONS_PER_NODE, + type SshRuntimeHelloWire, + type SshSessionTargetWire, +} from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; +import { allLiveSessionIds, resetSessionRegistryForTests } from "@/services/ssh-runtime/session-registry.js"; +import { openSession, SshRuntimeRefusal, type SshRuntimeSessionView } from "@/services/ssh-runtime/sessions.service.js"; +import { attachScriptedNode, ok, type ScriptedHandlers } from "@/test-helpers/scripted-node.js"; + +/** + * The OPEN-door guardrails (design 2026-10-05 §2/§4), pinned at the service + * seam with a scripted connecting node (the real `sendCommand` chain, no RPC + * mocks): + * + * - **Incompatible runtime version refuses BY NAME** (the acceptance run of + * 2026-10-05 measured it: a well-framed hello with `runtimeProtocol: 99` + * is refused, the message says both numbers and the remedy, and NOTHING + * remains: no `opening` row, no hidden runtime node, no registry session, + * and the broker saw exactly one open plus the unroll's close - zero pump + * frames for a session that never spoke the language). + * - **The plane-side quota mirror** refuses the 9th open with the named + * `session_quota` BEFORE the broker is dialed (the node holds its own copy + * of the same number; the mirror is what the plane enforces first), and the + * node-side refusal (`result.error: "session_quota"` on the open RPC) maps + * to the same named refusal through `mapOpenError` - both sides of the + * quota land on one equality-mapped code. + */ + +const email = `openguard-${crypto.randomUUID()}@subshell.local`; +let userId: string; +const cleanupNodes: string[] = []; +const cleanupSessions: string[] = []; + +const target: SshSessionTargetWire = { + alias: "guard", + host: "127.0.0.1", + port: 22, + user: null, + identityFile: null, +}; + +function hello(runtimeProtocol: number): SshRuntimeHelloWire { + return { + type: "hello", + runtimeProtocol, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: ["ssh-runtime", "callback-sock", "detect", "pane-callback-sock"], + homeDir: "/home/dst", + dataDir: "/home/dst/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-guard000000", + paneCount: 0, + }; +} + +/** The `ssh_session_open` result the scripted node answers with. */ +function openResult(runtimeProtocol = SSH_RUNTIME_PROTOCOL) { + return { hello: hello(runtimeProtocol), host: target.host, port: target.port, user: target.user }; +} + +/** A fresh owned agent node row (the scripted connection is attached per test). */ +async function mkConnectingNode(name: string): Promise { + const id = crypto.randomUUID(); + cleanupNodes.push(id); + await new NodesRepository(db).create({ id, ownerUserId: userId, name, kind: "agent" }); + return id; +} + +/** One seeded history row with its hidden runtime node (the quota mirror counts these). */ +async function seedActiveSession(connectingNodeId: string, tag: string): Promise { + const runtimeNodeId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + cleanupNodes.push(runtimeNodeId); + cleanupSessions.push(sessionId); + const now = new Date().toISOString(); + await new NodesRepository(db).create({ + id: runtimeNodeId, + ownerUserId: userId, + name: `guard-rt-${tag}`, + kind: NODE_KIND_RUNTIME, + status: "online", + lastSeenAt: now, + }); + await db + .insertInto("sshRuntimeSessions") + .values({ + id: sessionId, + ownerUserId: userId, + connectingNodeId, + runtimeNodeId, + alias: target.alias, + host: target.host, + port: target.port, + user: target.user, + status: "active", + helloJson: null, + createdAt: now, + lastSeenAt: null, + closedAt: null, + }) + .execute(); +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("guard-pass-1"), + role: "user", + }); +}); + +afterAll(async () => { + resetSessionRegistryForTests(); + await db + .deleteFrom("sshRuntimeSessions") + .execute() + .catch(() => {}); + for (const id of cleanupNodes) { + await db + .deleteFrom("nodes") + .where("id", "=", id) + .execute() + .catch(() => {}); + } + await deleteUserByEmailOrId(email).catch(() => {}); +}); + +describe("open: incompatible runtime version refuses by name", () => { + test("hello(runtimeProtocol 99) answers the both-numbers refusal and leaves zero residue", async () => { + const node = await mkConnectingNode(`guard-v99-${crypto.randomUUID().slice(0, 8)}`); + const seenCloseRefs: string[] = []; + const sim = attachScriptedNode(node, { + ssh_session_open: () => openResult(99), + ssh_session_close: (cmd) => { + if (cmd.type === "ssh_session_close") seenCloseRefs.push(cmd.ref); + return undefined; + }, + }); + try { + const err = await openSession(userId, { connectingNodeId: node, target }).then( + () => null, + (e: unknown) => e, + ); + expect(err, "the mismatched open must refuse").toBeInstanceOf(SshRuntimeRefusal); + const refusal = err as SshRuntimeRefusal; + // The exact shipped sentence: BOTH numbers and the destination remedy. + expect(refusal.message).toBe( + `the destination runtime speaks protocol 99; this plane speaks ${SSH_RUNTIME_PROTOCOL} - update the subshell binary on the destination`, + ); + // No session residue, asserted at every door the failed open touched: + expect( + await db.selectFrom("sshRuntimeSessions").selectAll().where("connectingNodeId", "=", node).execute(), + ).toEqual([]); // no `opening`/`lost` row survived the unroll + expect( + await db + .selectFrom("nodes") + .selectAll() + .where("kind", "=", NODE_KIND_RUNTIME) + .where("ownerUserId", "=", userId) + .execute(), + ).toEqual([]); + expect(allLiveSessionIds()).toEqual([]); // the registry never held it + // The broker saw the open and the unroll's close, and NOT a live pump: + expect(sim.countOf("ssh_session_open")).toBe(1); + expect(sim.countOf("ssh_session_close")).toBe(1); + expect(seenCloseRefs.length).toBe(1); + expect(sim.countOf("ssh_session_send")).toBe(0); + } finally { + sim.detach(); + await db + .deleteFrom("nodes") + .where("id", "=", node) + .execute() + .catch(() => {}); + } + }); +}); + +describe("open: the plane-side quota mirror", () => { + test("the open after SSH_SESSIONS_PER_NODE active refuses session_quota BEFORE the broker", async () => { + const node = await mkConnectingNode(`guard-quota-${crypto.randomUUID().slice(0, 8)}`); + const handlers: ScriptedHandlers = { + ssh_session_open: () => openResult(), + ssh_session_close: ok, + }; + const sim = attachScriptedNode(node, handlers); + try { + // The gate is real below the cap first: one open answers 200-ish (the + // service's view) and lands `active` on the mirror. + const view: SshRuntimeSessionView = await openSession(userId, { connectingNodeId: node, target }); + expect(view.status).toBe("active"); + cleanupSessions.push(view.id); + // Fill the node to exactly the cap (the fresh open above is one of the + // counted rows; seed the rest). + for (let i = 1; i < SSH_SESSIONS_PER_NODE; i++) await seedActiveSession(node, `q${i}`); + const before = sim.countOf("ssh_session_open"); + + const err = await openSession(userId, { connectingNodeId: node, target }).then( + () => null, + (e: unknown) => e, + ); + expect(err, "the 9th open must refuse").toBeInstanceOf(SshRuntimeRefusal); + const refusal = err as SshRuntimeRefusal; + expect(refusal.status).toBe(409); + expect(refusal.code).toBe("session_quota"); // the equality-mapped name the routes carry as sshCode + expect(refusal.message).toBe("the connecting node carries its share of open sessions"); + expect(sim.countOf("ssh_session_open"), "the mirror refuses without dialing the broker").toBe(before); + const rows = await db.selectFrom("sshRuntimeSessions").selectAll().where("connectingNodeId", "=", node).execute(); + expect(rows.length).toBe(SSH_SESSIONS_PER_NODE); // no 9th row, not even `opening` + } finally { + sim.detach(); + resetSessionRegistryForTests(); + await db + .deleteFrom("sshRuntimeSessions") + .execute() + .catch(() => {}); + await db + .deleteFrom("nodes") + .where("id", "=", node) + .execute() + .catch(() => {}); + } + }); + + test("the node-side quota refusal maps to the same named session_quota, and the attempt unrolls", async () => { + const node = await mkConnectingNode(`guard-nodeq-${crypto.randomUUID().slice(0, 8)}`); + // The supervisor's own quota check answers the bare code on the wire; the + // plane's mapper must name it the same as its mirror's. + const sim = attachScriptedNode(node, { ssh_session_open: () => new Error("session_quota"), ssh_session_close: ok }); + try { + const err = await openSession(userId, { connectingNodeId: node, target }).then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(SshRuntimeRefusal); + const refusal = err as SshRuntimeRefusal; + expect(refusal.status).toBe(409); + expect(refusal.code).toBe("session_quota"); + expect(refusal.message).toBe("the connecting node carries its share of open sessions"); + // `mapOpenError` consumed it, so nothing was left behind to settle: + expect( + await db.selectFrom("sshRuntimeSessions").selectAll().where("connectingNodeId", "=", node).execute(), + ).toEqual([]); + expect(allLiveSessionIds()).toEqual([]); + } finally { + sim.detach(); + await db + .deleteFrom("nodes") + .where("id", "=", node) + .execute() + .catch(() => {}); + } + }); + + test("the occupied-destination refusal maps to the named session_in_use (m2)", async () => { + const node = await mkConnectingNode(`guard-busy-${crypto.randomUUID().slice(0, 8)}`); + // The broker classifies the second serve's bind death with this code (the + // pane-runtime side's own test); the plane's mapper must NAME it rather + // than let it fall into the generic `the destination refused the session` + // arm with no code, and it must not be `runtime_missing` on a machine + // whose binary is running the first session right now. + const sim = attachScriptedNode(node, { + ssh_session_open: () => new Error("session_in_use"), + ssh_session_close: ok, + }); + try { + const err = await openSession(userId, { connectingNodeId: node, target }).then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(SshRuntimeRefusal); + const refusal = err as SshRuntimeRefusal; + expect(refusal.status).toBe(409); + expect(refusal.code).toBe("session_in_use"); + expect(refusal.message).not.toContain("runtime_missing"); + // Same unroll as every refused open: nothing was left to settle. + expect( + await db.selectFrom("sshRuntimeSessions").selectAll().where("connectingNodeId", "=", node).execute(), + ).toEqual([]); + expect(allLiveSessionIds()).toEqual([]); + } finally { + sim.detach(); + await db + .deleteFrom("nodes") + .where("id", "=", node) + .execute() + .catch(() => {}); + } + }); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/session.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/session.test.ts new file mode 100644 index 000000000..f7148446d --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/session.test.ts @@ -0,0 +1,562 @@ +/** + * Unit coverage for the live session's own promises (design 2026-10-05 §2/§4): + * the fail-closed ingest (codec, second hello, inbound overflow), the output + * subscription delivery, the pane-token registry, and the idempotent loss + * transition. None of this touches the DB or the node link: the byte channel + * is exercised by feeding pumped chunks directly. + */ +import { describe, expect, mock, test } from "bun:test"; +import { + encodeSshSessionFrame, + SSH_RUNTIME_PROTOCOL, + SSH_SESSION_INBOUND_QUEUE_FRAMES, + type SshRuntimeHelloWire, + type SshRuntimeReportRow, + SshSessionFrameDecoder, + type SshSessionTargetWire, +} from "@internal/subshell-protocol"; +import { SERVER_PORT } from "@/constants.js"; +import * as nodeRpc from "@/services/nodes/node-rpc.js"; +import { type SessionLossReason, SshRuntimeSession } from "../session.js"; +import { sessionHooks } from "../session-registry.js"; + +const SESSION_ID = "0f4c1a7e-9b62-4a11-8d3e-5c2b1a0f9e8d"; +const PANE_ID = "1e5d2b8f-0a73-5c22-9e41-6d3c2b1a0f7c"; + +function hello(): SshRuntimeHelloWire { + return { + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: ["panes", "callback-sock"], + homeDir: "/home/theo", + dataDir: "/home/theo/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-ab12cd34ef56", + paneCount: 0, + }; +} + +function target(): SshSessionTargetWire { + return { alias: "e2edest", host: "127.0.0.1", port: 2222, user: "theo", identityFile: null }; +} + +function newSession(): { + session: SshRuntimeSession; + losses: SessionLossReason[]; + exits: [string, number | null][]; + reports: SshRuntimeReportRow[][]; + /** Mutable box: the `onClosed` hook counts into it as settles land async. */ + closeBox: { closes: number }; + /** Every settle/report hook call in arrival order (the N-D order pin: the census must be observed strictly before the close). */ + hookOrder: string[]; +} { + const session = new SshRuntimeSession({ + id: SESSION_ID, + ownerId: "u1", + connectingNodeId: "n1", + runtimeNodeId: "r1", + target: target(), + hello: hello(), + }); + const losses: SessionLossReason[] = []; + const exits: [string, number | null][] = []; + const reports: SshRuntimeReportRow[][] = []; + const box = { closes: 0 }; + const hookOrder: string[] = []; + session.hooks = { + ...session.hooks, + onLost: (s, reason) => { + losses.push(reason); + hookOrder.push(`lost:${reason}`); + expect(s.id).toBe(SESSION_ID); + }, + onPaneExit: (_s, id, code) => { + exits.push([id, code]); + hookOrder.push(`exit:${id}`); + }, + onReport: (_s, rows) => { + reports.push(rows); + hookOrder.push("report"); + }, + onClosed: (_s) => { + box.closes += 1; + hookOrder.push("closed"); + }, + }; + return { session, losses, exits, reports, closeBox: box, hookOrder }; +} + +describe("SshRuntimeSession.ingestBytes", () => { + test("a codec failure is terminal: status lost, one hook call, later bytes ignored", () => { + const { session, losses } = newSession(); + const body = new TextEncoder().encode("this is not json"); + const framed = new Uint8Array(4 + body.byteLength); + new DataView(framed.buffer).setUint32(0, body.byteLength, false); + framed.set(body, 4); + session.ingestBytes(framed); + expect(session.status).toBe("lost"); + expect(losses).toEqual(["codec"]); + // feeding a valid frame after the loss changes nothing (no throw, no hook) + session.ingestBytes(encodeSshSessionFrame({ type: "result", ref: SESSION_ID, ok: true })); + expect(losses).toEqual(["codec"]); + }); + test("a second hello is protocol noise, not a resync", () => { + const { session, losses } = newSession(); + session.ingestBytes(encodeSshSessionFrame(hello())); + expect(session.status).toBe("lost"); + expect(losses).toEqual(["codec"]); + }); + test("an inbound batch over the queue cap closes the session fail-closed", () => { + const { session, losses } = newSession(); + const one = encodeSshSessionFrame({ type: "result", ref: SESSION_ID, ok: true }); + const batch = new Uint8Array(one.byteLength * (SSH_SESSION_INBOUND_QUEUE_FRAMES + 1)); + for (let i = 0; i <= SSH_SESSION_INBOUND_QUEUE_FRAMES; i++) batch.set(one, i * one.byteLength); + session.ingestBytes(batch); + expect(losses).toEqual(["frame-overflow"]); + expect(session.status).toBe("lost"); + }); + test("just under the cap does NOT overflow", () => { + const { session, losses } = newSession(); + const one = encodeSshSessionFrame({ type: "result", ref: SESSION_ID, ok: true }); + const batch = new Uint8Array(one.byteLength * SSH_SESSION_INBOUND_QUEUE_FRAMES); + for (let i = 0; i < SSH_SESSION_INBOUND_QUEUE_FRAMES; i++) batch.set(one, i * one.byteLength); + session.ingestBytes(batch); + expect(losses).toEqual([]); + expect(session.status).toBe("active"); + }); + test("output events deliver decoded bytes only to a matching (subId, pane) pair", () => { + const { session } = newSession(); + const seen: [string, number][] = []; + session.subscribeOutput("sub-a", PANE_ID, (bytes, next) => seen.push([Buffer.from(bytes).toString("utf8"), next])); + const chunk = Buffer.from("hello pane").toString("base64"); + session.ingestBytes( + encodeSshSessionFrame({ + type: "output", + subshellId: PANE_ID, + subId: "sub-a", + fromByte: 0, + toByte: 10, + data_b64: chunk, + }), + ); + // wrong subId: dropped silently (another subscription's bytes) + session.ingestBytes( + encodeSshSessionFrame({ + type: "output", + subshellId: PANE_ID, + subId: "sub-b", + fromByte: 0, + toByte: 10, + data_b64: chunk, + }), + ); + // wrong pane id under a right subId: also dropped (the pairing is the guard) + session.ingestBytes( + encodeSshSessionFrame({ + type: "output", + subshellId: "other-pane", + subId: "sub-a", + fromByte: 0, + toByte: 10, + data_b64: chunk, + }), + ); + expect(seen).toEqual([["hello pane", 10]]); + }); + test("exit frames reach the hooks and loss is idempotent", () => { + const { session, losses, exits } = newSession(); + session.ingestBytes( + encodeSshSessionFrame({ type: "exit", subshellId: PANE_ID, exitCode: 3, at: "2026-10-05T00:00:00Z" }), + ); + expect(exits).toEqual([[PANE_ID, 3]]); + session.markLost("child-lost"); + session.markLost("node-disconnected"); + expect(losses).toEqual(["child-lost"]); // the second transition is a no-op + expect(session.status).toBe("lost"); + }); +}); + +/** + * The graceful close (review C1): the `close` frame MUST leave the plane + * before the status flips (a pre-flip `close()` refused its own command, so + * the runtime never heard, the SSH child leaked, and the doc claimed a census + * that never arrived). These tests intercept `sendCommand` - the only outbound + * door - and pin the frame order the close promises. + */ +describe("SshRuntimeSession.close (the C1 order: frame first, census, then settle)", () => { + /** Every `ssh_session_send` payload sent to "n1" while a test ran. */ + function interceptSendCommand(outcome: "ack" | "reject" = "ack"): { + frames: { ref: string; data_b64: string }[]; + restore: () => void; + } { + const frames: { ref: string; data_b64: string }[] = []; + const original = nodeRpc.sendCommand; + mock.module("@/services/nodes/node-rpc.js", () => ({ + ...nodeRpc, + sendCommand: async (nodeId: string, cmd: { type: string; ref?: string; data_b64?: string }) => { + if (nodeId === "n1" && cmd.type === "ssh_session_send") { + frames.push({ ref: cmd.ref ?? "", data_b64: cmd.data_b64 ?? "" }); + } + if (outcome === "reject") throw new Error("node offline"); + return { ok: true }; + }, + })); + return { + frames, + restore: () => mock.module("@/services/nodes/node-rpc.js", () => ({ ...nodeRpc, sendCommand: original })), + }; + } + + /** Run the microtask/timer queue until `cond` holds (bounded). */ + async function tickUntil(cond: () => boolean): Promise { + for (let i = 0; i < 50 && !cond(); i++) await new Promise((r) => setTimeout(r, 0)); + if (!cond()) throw new Error("tickUntil: condition never held"); + } + + const decodeCloseFrames = (frames: { data_b64: string }[]): string[] => { + const out: string[] = []; + for (const f of frames) { + const bytes = new Uint8Array(Buffer.from(f.data_b64, "base64")); + const d = new SshSessionFrameDecoder(); + for (const raw of d.push(bytes)) { + if (raw !== null && typeof raw === "object" && "type" in raw) out.push(String((raw as { type: string }).type)); + } + } + return out; + }; + + test("close sends the `close` frame BEFORE settling (the regression: the pre-flip status made command() refuse its own frame)", async () => { + const { session, reports, closeBox, hookOrder } = newSession(); + // A registered pane (review N-D): the census rows must name a pane the + // session actually carries, or the report-vs-close ordering claim holds + // only vacuously. + session.registerPane(PANE_ID, "subshell_pane_token"); + const { frames, restore } = interceptSendCommand(); + try { + const closer = session.close(); + await tickUntil(() => frames.length === 1); + // Find the pending command ref (the close's ref) from the encoded frame. + const d = new SshSessionFrameDecoder(); + const bytes = new Uint8Array(Buffer.from(frames[0]?.data_b64 ?? "", "base64")); + const [frame] = d.push(bytes); + expect(frame).not.toBeNull(); + const closeRef = (frame as { ref: string }).ref; + session.ingestBytes( + encodeSshSessionFrame({ + type: "result", + ref: closeRef, + ok: true, + data: [{ subshellId: PANE_ID, alive: false, exitCode: 0 }], + }), + ); + await closer; + // The frame carried a `close` command out while the session was active. + expect(decodeCloseFrames(frames)).toEqual(["close"]); + // The census rode the result and was delivered before the closed settle + // - in that OBSERVED order, asserted on the hook sequence, not just the + // fact that both fired (the N-D pin). + expect(reports).toEqual([[{ subshellId: PANE_ID, alive: false, exitCode: 0 }]]); + expect(hookOrder).toEqual(["report", "closed"]); + expect(session.status).toBe("closed"); + expect(closeBox.closes).toBe(1); + } finally { + restore(); + } + }); + + test("close settles closed when the runtime refuses the close instead of reporting (no census is invented)", async () => { + const { session, reports, closeBox } = newSession(); + const { frames, restore } = interceptSendCommand(); + try { + const closer = session.close(); + await tickUntil(() => frames.length === 1); + const d = new SshSessionFrameDecoder(); + const [frame] = d.push(new Uint8Array(Buffer.from(frames[0]?.data_b64 ?? "", "base64"))); + const closeRef = (frame as { ref: string }).ref; + session.ingestBytes(encodeSshSessionFrame({ type: "result", ref: closeRef, ok: false, error: "shutting" })); + await closer; + expect(session.status).toBe("closed"); + expect(closeBox.closes).toBe(1); + expect(reports).toEqual([]); // nothing was reported; nothing was invented + } finally { + restore(); + } + }); + + test("a transport death during close keeps the `send-failed` loss as the owner (design §6 Disconnect outranks the act)", async () => { + const { session, losses, closeBox } = newSession(); + const { frames, restore } = interceptSendCommand("reject"); + try { + await session.close(); + expect(frames.length).toBe(1); // the frame attempt went out WHILE active (the bug died here) + expect(losses).toEqual(["send-failed"]); // the link failure is the honest fact + expect(session.status).toBe("lost"); + expect(closeBox.closes).toBe(0); // close deferred to the loss settling + } finally { + restore(); + } + }); + + test("a second close is a no-op (the latch; no duplicate frame, no second onClosed)", async () => { + const { session, closeBox } = newSession(); + const { frames, restore } = interceptSendCommand(); + try { + const both = Promise.all([session.close(), session.close()]); + await tickUntil(() => frames.length === 1); + const d = new SshSessionFrameDecoder(); + const [frame] = d.push(new Uint8Array(Buffer.from(frames[0]?.data_b64 ?? "", "base64"))); + session.ingestBytes( + encodeSshSessionFrame({ type: "result", ref: (frame as { ref: string }).ref, ok: true, data: [] }), + ); + await both; + expect(closeBox.closes).toBe(1); + expect(decodeCloseFrames(frames)).toEqual(["close"]); // exactly one close frame left + } finally { + restore(); + } + }); + + test("a mid-close death keeps the `lost` settle as the owner (close adds no second transition)", async () => { + const { session, losses, closeBox } = newSession(); + const { frames, restore } = interceptSendCommand(); + try { + const closer = session.close(); + await tickUntil(() => frames.length === 1); + session.markLost("child-lost"); // the channel died while the close command was in flight + await closer; + expect(losses).toEqual(["child-lost"]); + expect(session.status).toBe("lost"); + expect(closeBox.closes).toBe(0); // close deferred to the lost settle + expect(frames.length).toBe(1); // one frame went out while it was still active + } finally { + restore(); + } + }); +}); + +/** + * The callback surface under adversarial ids (review I-A): the inbound + * grammar bounds `reqId` (length + printable), and the plane's answer path + * must stay honest under both halves of that fact - an unbounded id REFUSES + * the frame fail-closed (never a callback sized around a length nobody + * bounded), and an answer that cannot be encoded is a logged refusal, never + * an unhandled rejection escaping the void call site. + */ +describe("callback reqId honesty (I-A)", () => { + test("a rest_request with an unbounded reqId is refused at the parse: lost fail-closed, no callback started", () => { + const { session, losses, hookOrder } = newSession(); + let resolverRan = false; + session.hooks = { + ...session.hooks, + resolveCallbackPane: () => { + resolverRan = true; + return PANE_ID; + }, + }; + session.ingestBytes( + encodeSshSessionFrame({ + type: "rest_request", + reqId: "x".repeat(100), + method: "GET", + path: "/api/subshells/anything", + }), + ); + expect(session.status).toBe("lost"); + expect(losses).toEqual(["codec"]); // the grammar refusal IS the codec-verdict path + expect(resolverRan).toBe(false); // nothing was attempted with the refused id + expect(hookOrder.filter((h) => h.startsWith("report"))).toEqual([]); + }); + + test("a non-printable reqId refuses too, and an honest 64-char id (the cap) still round-trips to the hooks", () => { + const { session, losses } = newSession(); + session.ingestBytes( + encodeSshSessionFrame({ type: "rest_request", reqId: "a\u0001b", method: "GET", path: "/api/x" }), + ); + expect(losses).toEqual(["codec"]); + + const seen: string[] = []; + const { session: live } = newSession(); + live.hooks = { + ...live.hooks, + resolveCallbackPane: (_s, path, method) => { + seen.push(`${method} ${path}`); + return null; // refuse 403; the test is about the id arriving, not the answer + }, + }; + const capId = '"'.repeat(64); // printable, worst legal escape cost + live.ingestBytes(encodeSshSessionFrame({ type: "rest_request", reqId: capId, method: "get", path: "/api/x" })); + expect(seen).toEqual(["GET /api/x"]); // the id passed grammar; the frame reached the hooks + expect(live.status).toBe("active"); + expect(losses).toEqual(["codec"]); // the first session's verdict stands; this one lost nothing + }); + + test("an answer the codec refuses (oversize body from a rogue executor) is a logged refusal, never an unhandled rejection", async () => { + const { session, losses } = newSession(); + const rejections: unknown[] = []; + const onRejection = (err: unknown): void => { + rejections.push(err); + }; + process.on("unhandledRejection", onRejection); + const sent: { data_b64: string }[] = []; + const original = nodeRpc.sendCommand; + mock.module("@/services/nodes/node-rpc.js", () => ({ + ...nodeRpc, + sendCommand: async (_nodeId: string, cmd: { type: string; data_b64?: string }) => { + if (cmd.type === "ssh_session_send") sent.push({ data_b64: cmd.data_b64 ?? "" }); + return { ok: true }; + }, + })); + try { + session.hooks = { + ...session.hooks, + resolveCallbackPane: () => PANE_ID, + // A deliberately unfitted answer: 300 KB cannot encode into a 256 KiB + // frame. The production executor caps bodies before this point; the + // belt in `#answerCallback` must survive one that does not. + executeCallback: async () => ({ status: 200, body: "x".repeat(300_000) }), + }; + session.ingestBytes( + encodeSshSessionFrame({ + type: "rest_request", + reqId: "1b4e28ba-2fa1-11d2-883f-0016d3cca427", + method: "GET", + path: "/api/subshells/own", + }), + ); + await new Promise((r) => setTimeout(r, 20)); // let the void round trip run to completion + expect(rejections).toEqual([]); // the belt caught what would have escaped as an unhandled rejection + expect(losses).toEqual([]); // a refused ANSWER is not a lost session + expect(session.status).toBe("active"); + expect(sent).toEqual([]); // the over-cap frame never reached the node link + } finally { + process.off("unhandledRejection", onRejection); + mock.module("@/services/nodes/node-rpc.js", () => ({ ...nodeRpc, sendCommand: original })); + } + }); +}); + +/** + * The C2 traversal closure (review 2026-10-06): the plane normalizes the + * forwarded path with `new URL` BEFORE the allowlist match and executes the + * SAME normalized value, so the two normalization points cannot disagree. + * Real `sessionHooks()` wiring (the registry's matcher), a global-fetch spy + * (so a slipped traversal would be VISIBLE as a fetch to the wrong route, + * not an accidental real request), and intercepted `rest_response` frames. + */ +describe("callback path normalization (C2)", () => { + const OWN = "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d"; + + function wireRealHooks(session: SshRuntimeSession): { fetchCalls: string[]; sent: { status: number }[] } { + session.hooks = sessionHooks(); + session.registerPane(OWN, "subshell_token_for_own_pane"); + const fetchCalls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: Parameters[0]) => { + fetchCalls.push(String(input)); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) as typeof fetch; + const sent: { status: number }[] = []; + const original = nodeRpc.sendCommand; + mock.module("@/services/nodes/node-rpc.js", () => ({ + ...nodeRpc, + sendCommand: async (_nodeId: string, cmd: { type: string; data_b64?: string }) => { + if (cmd.type === "ssh_session_send") { + const d = new SshSessionFrameDecoder(); + for (const raw of d.push(new Uint8Array(Buffer.from(cmd.data_b64 ?? "", "base64")))) { + const f = raw as { type?: string; status?: number }; + if (f.type === "rest_response") sent.push({ status: f.status ?? -1 }); + } + } + return { ok: true }; + }, + })); + (restoreBag as { fetch: typeof fetch; rpc: typeof nodeRpc.sendCommand }).fetch = originalFetch; + (restoreBag as { fetch: typeof fetch; rpc: typeof nodeRpc.sendCommand }).rpc = original; + return { fetchCalls, sent }; + } + const restoreBag: Record = {}; + const restore = (): void => { + globalThis.fetch = restoreBag.fetch as typeof fetch; + mock.module("@/services/nodes/node-rpc.js", () => ({ ...nodeRpc, sendCommand: restoreBag.rpc })); + }; + + const request = (session: SshRuntimeSession, path: string): void => { + session.ingestBytes( + encodeSshSessionFrame({ type: "rest_request", reqId: crypto.randomUUID(), method: "GET", path, paneId: OWN }), + ); + }; + + test("own-id-then-../ to /api/users: normalized before matching, refused 403, NOTHING fetched", async () => { + const { session } = newSession(); + const { fetchCalls, sent } = wireRealHooks(session); + try { + request(session, `/api/subshells/${OWN}/../../users`); + await new Promise((r) => setTimeout(r, 30)); + expect(fetchCalls, "the traversal must never reach the executor").toEqual([]); + expect(sent).toEqual([{ status: 403 }]); + } finally { + restore(); + } + }); + + test("own-id-then-../ back to another pane's route: refused even though the first segment names own", async () => { + const { session } = newSession(); + const { fetchCalls, sent } = wireRealHooks(session); + try { + request(session, `/api/subshells/${OWN}/../1e5d2b8f-0a73-5c22-9e41-6d3c2b1a0f7c/input`); + await new Promise((r) => setTimeout(r, 30)); + expect(fetchCalls).toEqual([]); + expect(sent).toEqual([{ status: 403 }]); + } finally { + restore(); + } + }); + + test("a same-tree dot detour EXECUTES the normalized own path, not the raw string", async () => { + const { session } = newSession(); + const { fetchCalls, sent } = wireRealHooks(session); + try { + // `..` back into own id then down: the NORMALIZED path IS the allowed + // own route; the executor must be handed exactly that, on loopback. + request(session, `/api/subshells/other/../${OWN}/input`); + await new Promise((r) => setTimeout(r, 30)); + expect(fetchCalls.length).toBe(1); + expect(fetchCalls[0]).toBe(`http://127.0.0.1:${SERVER_PORT}/api/subshells/${OWN}/input`); + expect(sent).toEqual([{ status: 200 }]); + } finally { + restore(); + } + }); + + test("query glue is uniform: /api/subshells/?x=1 matches (id intact) and the search rides through", async () => { + const { session } = newSession(); + const { fetchCalls, sent } = wireRealHooks(session); + try { + request(session, `/api/subshells/${OWN}?x=1`); + await new Promise((r) => setTimeout(r, 30)); + expect(fetchCalls.length).toBe(1); + expect(fetchCalls[0]).toBe(`http://127.0.0.1:${SERVER_PORT}/api/subshells/${OWN}?x=1`); + expect(sent).toEqual([{ status: 200 }]); + } finally { + restore(); + } + }); +}); + +describe("SshRuntimeSession pane registry", () => { + test("token plaintext lives only in the session and leaves with the pane", () => { + const { session } = newSession(); + session.registerPane(PANE_ID, "subshell_secret_plaintext"); + expect(session.paneToken(PANE_ID)).toBe("subshell_secret_plaintext"); + expect(session.paneIds()).toEqual([PANE_ID]); + session.unregisterPane(PANE_ID); + expect(session.paneToken(PANE_ID)).toBeUndefined(); + expect(session.paneIds()).toEqual([]); + }); + test("the callback socket path composes from the hello's dataDir", () => { + const { session } = newSession(); + expect(session.callbackSockPath).toBe(`${hello().dataDir}/callback.sock`); + }); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/sessions-close.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/sessions-close.test.ts new file mode 100644 index 000000000..45a6947d6 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/sessions-close.test.ts @@ -0,0 +1,223 @@ +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; +import { + encodeSshSessionFrame, + SSH_RUNTIME_PROTOCOL, + type SshRuntimeHelloWire, + SshSessionFrameDecoder, +} from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; +import { + launcherFor, + peekRuntimeLauncherForTests, + resetLauncherRegistryForTests, +} from "@/services/nodes/launcher-registry.js"; +import * as nodeRpc from "@/services/nodes/node-rpc.js"; +import { RuntimeSessionLauncher } from "../runtime-session-launcher.js"; + +/** + * The REAL node-rpc exports, spread at MODULE-EVAL time (before any + * `mock.module`). bun's `mock.module` mutates the live namespace, so the + * old `() => ({ ...nodeRpc })` restore re-installed this file's capture mock + * and leaked it across files in a shared serial process - the sibling + * `pane-doors-and-detect.test.ts` header states the full mechanism. + */ +const realNodeRpc = { ...nodeRpc }; + +import { SshRuntimeSession } from "../session.js"; +import { registerSession, resetSessionRegistryForTests, sessionHooks } from "../session-registry.js"; +import { SshRuntimeSessionsRepository } from "../sessions.repository.js"; +import { closeSession } from "../sessions-lifecycle.js"; + +/** + * The close door, end to end at the service seam (review C1b + M5): + * + * - C1b: `closeSession` must reach the BROKER with `ssh_session_close` so the + * supervisor group-kills the SSH child. Before the fix the plane's close was + * local-only (and did not even send the runtime's `close` frame), and every + * closed session leaked its child onto the per-node quota until an agent + * restart. The e2e proves the process really dies; this pins the wire order: + * `ssh_session_send` (the close frame) first, `ssh_session_close` second. + * - M5: the launcher-registry cache entry leaves with the session (it holds + * the session, which holds the pane-token plaintext). + * + * The node link itself is mocked: `sendCommand` is the one outbound door the + * close path uses, and the census result is fed back through `ingestBytes` + * exactly as the pump would deliver it. + */ + +const sessionsRepo = new SshRuntimeSessionsRepository(db); +const nodesRepo = new NodesRepository(db); + +const email = `rtsvc-${crypto.randomUUID()}@subshell.local`; +const pw = "rtsvc-pass-1"; +let userId: string; + +interface Captured { + nodeId: string; + cmd: { type: string; ref?: string; data_b64?: string }; +} + +const captured: Captured[] = []; +/** The session whose pump answers the captured commands (set per test). */ +let pumpTarget: SshRuntimeSession | undefined; + +/** Rows created for the test, removed in afterAll (and by the next test's fixture). */ +const cleanupNodes: string[] = []; + +function hello(): SshRuntimeHelloWire { + return { + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: [], + homeDir: "/home/x", + dataDir: "/home/x/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-ab12cd34ef56", + paneCount: 0, + }; +} + +async function mkActiveSession(): Promise { + const connectingNodeId = crypto.randomUUID(); + const runtimeNodeId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + cleanupNodes.push(runtimeNodeId, connectingNodeId); + await nodesRepo.create({ + id: connectingNodeId, + ownerUserId: userId, + name: `svc-${sessionId.slice(0, 8)}`, + kind: "agent", + }); + await nodesRepo.create({ + id: runtimeNodeId, + ownerUserId: userId, + name: `svc-rt-${sessionId.slice(0, 8)}`, + kind: NODE_KIND_RUNTIME, + }); + await sessionsRepo.create({ + id: sessionId, + ownerUserId: userId, + connectingNodeId, + runtimeNodeId, + alias: "svc", + host: "127.0.0.1", + port: 22, + user: null, + status: "active", + }); + await sessionsRepo.settle(sessionId, "active", JSON.stringify(hello())); + const session = new SshRuntimeSession({ + id: sessionId, + ownerId: userId, + connectingNodeId, + runtimeNodeId, + target: { alias: "svc", host: "127.0.0.1", port: 22, user: null, identityFile: null }, + hello: hello(), + }); + session.hooks = sessionHooks(); + registerSession(session); + return session; +} + +async function pollUntil(what: string, cond: () => Promise | boolean): Promise { + for (let i = 0; i < 100; i++) { + if (await cond()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`pollUntil timed out: ${what}`); +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + mock.module("@/services/nodes/node-rpc.js", () => ({ + ...realNodeRpc, + sendCommand: async (nodeId: string, cmd: Captured["cmd"]) => { + captured.push({ nodeId, cmd }); + if (cmd.type === "ssh_session_send" && pumpTarget !== undefined) { + const decoder = new SshSessionFrameDecoder(); + const frames = decoder.push(new Uint8Array(Buffer.from(cmd.data_b64 ?? "", "base64"))); + const ref = (frames[0] as { ref?: string } | undefined)?.ref ?? ""; + // The runtime answers the close with its final census (empty here: + // the fixture has no panes) - the same frame the pump would carry. + pumpTarget.ingestBytes(encodeSshSessionFrame({ type: "result", ref, ok: true, data: [] })); + } + return { ok: true }; + }, + })); +}); + +afterAll(async () => { + mock.module("@/services/nodes/node-rpc.js", () => realNodeRpc); + // The isolation pin (identity check fails exactly on the leak pattern; the + // `pane-doors-and-detect.test.ts` header states the mechanism). + expect(nodeRpc.sendCommand).toBe(realNodeRpc.sendCommand); + resetSessionRegistryForTests(); + resetLauncherRegistryForTests(); + for (const id of cleanupNodes) { + await db + .deleteFrom("nodes") + .where("id", "=", id) + .execute() + .catch(() => {}); + } + await deleteUserByEmailOrId(email).catch(() => {}); +}); + +describe("closeSession (C1b broker kill + M5 eviction)", () => { + test("the runtime `close` frame goes out first, the broker `ssh_session_close` second, the row settles closed, the launcher cache evicts", async () => { + const session = await mkActiveSession(); + // Prime the launcher cache through the production door. + expect(launcherFor(session.runtimeNodeId)).toBeInstanceOf(RuntimeSessionLauncher); + expect(peekRuntimeLauncherForTests(session.runtimeNodeId)).toBeDefined(); + + captured.length = 0; + pumpTarget = session; + await closeSession(session.id, userId); + + // C1b, in order: the session's framed `close` command, then the broker's + // group-kill command naming the same ref. + const sentCloseFrame = captured.find( + (c) => c.nodeId === session.connectingNodeId && c.cmd.type === "ssh_session_send", + ); + expect(sentCloseFrame, "the close frame must reach the node").toBeDefined(); + const brokerClose = captured.find((c) => c.cmd.type === "ssh_session_close"); + expect(brokerClose?.nodeId, "ssh_session_close must name the connecting node").toBe(session.connectingNodeId); + expect(brokerClose?.cmd.ref).toBe(session.id); + + await pollUntil("the session row never settled closed", async () => { + const row = await sessionsRepo.findById(session.id); + return row?.status === "closed" && row.closedAt !== null; + }); + const node = await nodesRepo.findById(session.runtimeNodeId); + expect(node?.status).toBe("offline"); + + // M5: the cache entry is gone (the settle evicts it; the plaintext map + // rides the session, so the entry leaving IS the promise). + await pollUntil( + "the launcher cache never evicted", + () => peekRuntimeLauncherForTests(session.runtimeNodeId) === undefined, + ); + + // A second close reads 404 (the registry is the door, and it is shut). + const again = await closeSession(session.id, userId).then( + () => null, + (e: unknown) => e, + ); + expect((again as { status?: number }).status).toBe(404); + }); +}); diff --git a/apps/server/api/src/services/ssh-runtime/__tests__/settle-membership.test.ts b/apps/server/api/src/services/ssh-runtime/__tests__/settle-membership.test.ts new file mode 100644 index 000000000..55ee604eb --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/__tests__/settle-membership.test.ts @@ -0,0 +1,179 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; +import * as liveBus from "@/services/live-bus.js"; +import * as subshellTokens from "@/services/subshell-tokens.js"; +import { mkRuntimeSession } from "@/test-helpers/runtime-session.js"; +import { sessionHooks } from "../session-registry.js"; +import { installSessionSettlers } from "../session-settle.js"; + +/** + * The I4 membership guard (review 2026-10-06, design §8: remote output is + * untrusted): `onPaneExit`/`onReport` write the DB and revoke tokens for + * every id the runtime names, so an id naming a pane OUTSIDE the session - + * another session's pane, an agent node's row - must be SKIPPED entirely: + * no row flip, no revoke, no live-bus publish. The settle-all paths already + * iterate `session.paneIds()`; these two consume frame data directly, and + * this file is the whole point of the guard. Genuine members still settle. + * + * The revoke + publish seams are captured through the module namespaces + * (the settle's imports are live bindings; bun rewrites them on + * `mock.module`); the row truth is read straight from the test DB. + */ + +const email = `settlemem-${crypto.randomUUID()}@subshell.local`; +let userId: string; +const revokes: string[] = []; +const publishes: string[] = []; +const cleanup: string[] = []; + +const subshellsRepo = new SubshellsRepository(db); + +const OWN = "11111111-1111-4111-8111-111111111111"; +const FOREIGN = "22222222-2222-4222-8222-222222222222"; + +async function mkRow(id: string, nodeId: string): Promise { + cleanup.push(id); + await subshellsRepo.create({ + id, + userId, + harnessId: "claude-code", + name: `sm-${id.slice(0, 4)}`, + workingDir: "/tmp", + nodeId, + tmuxSocket: `sock-${id}`, + status: "running", + alive: 1, + }); +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("sm-pass-1"), + role: "user", + }); + // Capture the settle's two side-door seams BEFORE any session exists. + const originalRevoke = subshellTokens.revokeSubshellToken; + const originalPublish = liveBus.publishLive; + mock.module("@/services/subshell-tokens.js", () => ({ + ...subshellTokens, + revokeSubshellToken: async (id: string) => { + revokes.push(id); + return originalRevoke(id); + }, + })); + mock.module("@/services/live-bus.js", () => ({ + ...liveBus, + publishLive: (frame: { kind: string; id?: string }) => { + if (typeof frame.id === "string") publishes.push(frame.id); + return originalPublish(frame as never); + }, + })); + installSessionSettlers(); + await seedNodesAndRows(); +}); + +afterAll(async () => { + for (const id of cleanup) await subshellsRepo.delete(id).catch(() => {}); + await deleteUserByEmailOrId(email).catch(() => {}); +}); + +/** One hidden runtime node per role, seeded once (the rows have fixed ids). */ +let ownRuntimeNodeId = ""; +let foreignRuntimeNodeId = ""; + +async function seedNodesAndRows(): Promise { + ownRuntimeNodeId = crypto.randomUUID(); + foreignRuntimeNodeId = crypto.randomUUID(); + cleanup.push(ownRuntimeNodeId, foreignRuntimeNodeId); + const nodes = new NodesRepository(db); + for (const id of [ownRuntimeNodeId, foreignRuntimeNodeId]) { + await nodes.create({ + id, + ownerUserId: userId, + name: `sm-rt-${id.slice(0, 8)}`, + kind: NODE_KIND_RUNTIME, + status: "online", + lastSeenAt: new Date().toISOString(), + }); + } + await mkRow(OWN, ownRuntimeNodeId); + await mkRow(FOREIGN, foreignRuntimeNodeId); +} + +/** Fresh alive rows for both panes before every test (the settles write them down). */ +beforeEach(async () => { + revokes.length = 0; + publishes.length = 0; + await subshellsRepo.update(OWN, { alive: 1, exitCode: null }); + await subshellsRepo.update(FOREIGN, { alive: 1, exitCode: null }); +}); + +/** A live session owning exactly `OWN`, on the pre-seeded rows. */ +function fixture(): ReturnType { + const session = mkRuntimeSession({ ownerId: userId, runtimeNodeId: ownRuntimeNodeId }); + session.hooks = sessionHooks(); + session.registerPane(OWN, "subshell_pane_plaintext"); + return session; +} + +describe("settle membership (I4): reported ids touch only this session's panes", () => { + test("onReport with a foreign id: no row flip, no revoke, no publish; the member row still settles", async () => { + revokes.length = 0; + publishes.length = 0; + const session = fixture(); + // The census a lying (or merely confused) runtime might answer: the own + // pane dead, a FOREIGN valid id reported dead with it. + session.hooks.onReport(session, [ + { subshellId: OWN, alive: false, exitCode: 7 }, + { subshellId: FOREIGN, alive: false, exitCode: 3 }, + ]); + await new Promise((r) => setTimeout(r, 50)); // the settle's void DB writes + const own = await subshellsRepo.findById(OWN); + const foreign = await subshellsRepo.findById(FOREIGN); + expect(own?.alive).toBe(0); // the member settled exactly as before + expect(own?.exitCode).toBe(7); + expect(foreign?.alive, "a foreign row must read untouched").toBe(1); + expect(foreign?.exitCode, "no invented exit code").toBeNull(); + expect(revokes).toEqual([OWN]); + expect(publishes).toEqual([OWN]); + expect(session.paneIds()).toEqual([]); // only the member was unregistered + }); + + test("onPaneExit with a foreign id is skipped; a member exit still settles", async () => { + revokes.length = 0; + publishes.length = 0; + const session = fixture(); + session.hooks.onPaneExit(session, FOREIGN, 9, new Date().toISOString()); + await new Promise((r) => setTimeout(r, 50)); + expect((await subshellsRepo.findById(FOREIGN))?.alive).toBe(1); + expect(revokes).toEqual([]); + expect(publishes).toEqual([]); + + session.hooks.onPaneExit(session, OWN, 0, new Date().toISOString()); + await new Promise((r) => setTimeout(r, 50)); + expect((await subshellsRepo.findById(OWN))?.alive).toBe(0); + expect(revokes).toEqual([OWN]); + expect(publishes).toEqual([OWN]); + }); + + test("an id with NO row at all is skipped too (the census cannot mint liveness)", async () => { + revokes.length = 0; + publishes.length = 0; + const session = fixture(); + session.hooks.onReport(session, [{ subshellId: FOREIGN, alive: true, exitCode: null }]); + await new Promise((r) => setTimeout(r, 20)); + expect(publishes).toEqual([]); + expect(session.paneIds()).toEqual([OWN]); // membership intact, nothing touched + }); +}); diff --git a/apps/server/api/src/services/ssh-runtime/callback-allowlist.ts b/apps/server/api/src/services/ssh-runtime/callback-allowlist.ts new file mode 100644 index 000000000..a3a958a3a --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/callback-allowlist.ts @@ -0,0 +1,82 @@ +import { isNodeSubshellId } from "@internal/subshell-protocol"; + +/** + * The pane's own-token callback allowlist (design 2026-10-05 §5): a fixed + * prefix list with the pane's id substituted SERVER-SIDE - never a general + * proxy, and never an id taken from the frame. + * + * The runtime forwards whatever the pane typed into `callback.sock`; THIS + * decides what the plane will execute as that pane. The shape of the answer is + * a pure function so the whole gate has a unit test and no DB, no socket, and + * no session object to reach it. A frame naming `/api/users` or another + * pane's id must be refused here, where refusal is checkable, not in the + * auth guard's downstream accident. + * + * The set is exactly what the pane's own token can call: its own per-subshell + * routes (the self-report family, input, log, terminal, extend-token), the + * identities list, and the channels the token's map carries. Anything else - + * nodes, settings, admin, another pane's id - is out, by name. + */ + +/** Methods the callback door ever opens on (curl's GET and POST; a PUT is a pane editing resources the token does not own through this seam). */ +const ALLOWED_METHODS: ReadonlySet = new Set(["GET", "POST"]); + +/** The self-family and shared-surface path shapes; `{id}` is substituted with the SESSION's pane id before comparison. */ +const OWN_PANE_PREFIX = "/api/subshells/"; +const IDENTITIES_PATH = "/api/identities"; +/** + * The channels family (design §5's allowlist line, module doc's third item): + * the pane's token map already gates which channel operations its routes + * accept - the door forwards the path, the route decides. The slice shipped + * identities and the self family and left this line to task 25 because the + * MCP surface it serves (list/read/post) is exactly what a runtime pane's + * cross-agent coordination needs. + */ +const CHANNELS_PATH = "/api/channels"; + +/** The decision: allowed, with the pane the frame is (forced to) address; or a named refusal reason for the log line. */ +export type CallbackDecision = { allow: true; paneId: string } | { allow: false; reason: string }; + +/** +/** + * The C2 belt (review 2026-10-06): the caller already normalizes with + * `new URL` before matching, so a production path never carries `.`/`..` + * segments or percent-encoding into here. It still checks, because a + * `.`-segment or an encoded separator is the exact shape that let a traversal + * slip past the first-segment id equality, and no legitimate path in any + * allowed family contains either: ids are uuids, channel slugs are + * `[a-z0-9-]`, and percent signs have no place in a pathname this door + * executes. Refused by segment, before any other rule. + */ +function carriesTraversalShape(pathname: string): boolean { + if (pathname.includes("%")) return true; // encoded `.`/`/`/anything: never a legal callback byte + for (const segment of pathname.split("/")) { + if (segment === "." || segment === "..") return true; + } + return false; +} + +/** + * Match one forwarded callback against the pane's own-token reach. + * + * @param path - the frame's NORMALIZED pathname (the caller's `new URL`: no origin, no query - `u.search` rides to the executor separately, so a `?…` can never corrupt the id grammar) + * @param method - the HTTP verb + * @param paneId - the session's pane id, server-side truth (never read from the frame's path as authority: a path that NAMES this id is required to equal it) + */ +export function matchCallbackPath(path: string, method: string, paneId: string): CallbackDecision { + if (!ALLOWED_METHODS.has(method)) return { allow: false, reason: `method ${method} is not a callback verb` }; + if (carriesTraversalShape(path)) return { allow: false, reason: "path carries a dot or encoded segment" }; + if (path === IDENTITIES_PATH || path.startsWith(`${IDENTITIES_PATH}/`)) return { allow: true, paneId }; + if (path === CHANNELS_PATH || path.startsWith(`${CHANNELS_PATH}/`)) return { allow: true, paneId }; + if (!path.startsWith(OWN_PANE_PREFIX)) return { allow: false, reason: "not a per-subshell path" }; + const rest = path.slice(OWN_PANE_PREFIX.length); + const slash = rest.indexOf("/"); + const namedId = slash === -1 ? rest : rest.slice(0, slash); + if (!isNodeSubshellId(namedId)) return { allow: false, reason: "path names no valid subshell id" }; + // The substitution rule made concrete: the frame's id must EQUAL the + // session's pane. A path naming someone else's pane is refused even though + // the executing token belongs to this pane (the route would 404 it anyway - + // the refusal here is what stops the round trip and names why). + if (namedId !== paneId) return { allow: false, reason: "path names a pane other than this session's" }; + return { allow: true, paneId }; +} diff --git a/apps/server/api/src/services/ssh-runtime/callback-executor.ts b/apps/server/api/src/services/ssh-runtime/callback-executor.ts new file mode 100644 index 000000000..24c4dafa7 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/callback-executor.ts @@ -0,0 +1,156 @@ +import { SSH_SESSION_FRAME_MAX_BYTES } from "@internal/subshell-protocol"; +import { SERVER_PORT } from "@/constants.js"; +import { logger } from "@/utils/logger.js"; +import type { SshRuntimeSession } from "./session.js"; + +/** + * Execute one allowlisted callback as the pane's own token (design 2026-10-05 + * §5): the same HTTP the pane itself would send, pointed at this instance, + * authenticated with the token the plane minted at launch and never + * transmitted to the destination. + * + * Loopback self-fetch over `127.0.0.1:SERVER_PORT` rather than an in-process + * handler call is the decision, and it is load-bearing: the request then + * passes the REAL guard chain (`authGuard`, the per-subshell boost-and- + * grants switch-off keyed to the pane's own key) instead of a copy of it, + * which is exactly the promise §5 makes - "the plane executes the path AS + * the pane's own subshell token". `127.0.0.1` is hardcoded rather than read + * off `APP_BASE_URL` because the destination's view of where the plane lives + * must stay irrelevant (a hostname that resolves elsewhere would silently + * turn the callback into an outbound hop), and the port is the one this + * process binds. + * + * Known limitation of that choice, accepted by design (review Minor): an + * instance bound to a NON-loopback `HOST` only (no loopback listener) makes + * every runtime callback answer 504, because the fixed hop has no socket to + * reach. The fix is NOT a bind-host fallback, whatever form it takes - any + * address that lets a non-loopback bind satisfy the fetch also reopens the + * outbound-hop hole the hardcoding exists to prevent; operators who bind + * elsewhere keep loopback listening too (`HOST` accepts the loopback + * spelling, and the default `0.0.0.0` already includes it). + */ + +/** + * A self-fetch that dawdles past this is answered 504; a wedged route must not + * hold the callback slot forever. + */ +const CALLBACK_TIMEOUT_MS = 25_000; + +/** A prefix-cut UTF-8 buffer to a codepoint boundary (drop trailing continuation bytes). */ +function utf8Prefix(buf: Buffer, maxBytes: number): string { + let end = Math.min(maxBytes, buf.byteLength); + while (end > 0 && (buf[end] & 0xc0) === 0x80) end -= 1; + return buf.subarray(0, end).toString("utf8"); +} + +/** The honest wrap: parseable JSON saying the body was cut, with the kept prefix inside. */ +function wrapTruncated(prefix: string): string { + return JSON.stringify({ subshell_truncated: true, body: prefix }); +} + +/** + * Encoded size of the rest_response frame the answer WOULD produce: measured + * with the ACTUAL `reqId` (the id of the request being answered), not a + * stand-in. That is what makes the fit guarantee true rather than claimed: + * the id is bounded by the inbound frame grammar (`SSH_REQ_ID_MAX_CHARS`, + * printable), the status and keys are fixed-shape, and the composition + * mirrors the codec's exactly (4-byte prefix + the JSON bytes), so the frame + * `session.ts` encodes with this same id cannot exceed the cap. It is not + * measured THROUGH `encodeSshSessionFrame` itself: the binary search measures + * CANDIDATES over the cap, and the encoder THROWS past it - a throwing probe + * would have made the search throw exactly the failure this prevents. + */ +function responseFrameBytes(reqId: string, status: number, body: string): number { + const json = JSON.stringify({ type: "rest_response", reqId, status, body }); + return 4 + Buffer.byteLength(json); +} + +/** + * Cap a response body for the session frame (the review's M2, refined by the + * review's I-A): the old code sliced UTF-16 characters against a byte budget + * and appended a marker line, which (a) could encode past the 256 KiB frame + * cap (multibyte text costs more bytes than chars), (b) made the JSON + * unparsable (a sentence glued after a complete document), and (c) let the + * encode throw inside the settle - answering a false 502 for a call that + * succeeded. The I-A finding added the third measurement axis: an unbounded + * reqId defeats even a byte-exact body cut, so the id is now grammar-bounded + * inbound AND the ACTUAL id is used in the measurement here (no padded + * stand-in: a padded id that under-counts the real one's JSON escapes leaves + * the same overshoot, just smaller). + * + * Now: a body whose frame already fits passes through untouched; an oversize + * body is cut on a UTF-8 BYTE boundary and wrapped in `{"subshell_truncated": + * true, "body": ...}` - valid JSON by construction, at most the frame cap + * after encoding INCLUDING the wrapper and the envelope as THIS reqId makes + * it (binary search over prefix lengths against the real composition). + * + * @internal exported for the boundary tests; production calls go through + * {@link executeCallbackAsPane}. + */ +export function fitCallbackBody(reqId: string, status: number, text: string): string { + if (responseFrameBytes(reqId, status, text) <= SSH_SESSION_FRAME_MAX_BYTES) return text; + const buf = Buffer.from(text, "utf8"); + // Invariant: `lo` fits (the empty wrap always does - it is a few dozen + // bytes against a 256 KiB cap), `hi` is the largest prefix that might. + let lo = 0; + let hi = buf.byteLength; + let best = wrapTruncated(""); + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const candidate = wrapTruncated(utf8Prefix(buf, mid)); + if (responseFrameBytes(reqId, status, candidate) <= SSH_SESSION_FRAME_MAX_BYTES) { + best = candidate; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return best; +} + +/** + * Execute one allowlisted callback as the pane's own token and hand back the + * answer READY for the frame: the body is capped against the frame that + * carries THIS `reqId` (the id arrives grammar-bounded; the fit measurement + * uses it verbatim), so the answer the session encodes cannot exceed the cap. + */ +export async function executeCallbackAsPane( + session: SshRuntimeSession, + reqId: string, + paneId: string, + method: string, + path: string, + body: string | undefined, +): Promise<{ status: number; body: string }> { + const token = session.paneToken(paneId); + if (token === undefined) { + // The allowlist matched but the token is gone (the pane was unregistered + // mid-flight). Refuse rather than find a second identity: this door only + // ever opens as the pane. + return { status: 401, body: JSON.stringify({ error: "pane token no longer issued" }) }; + } + const url = new URL(path, `http://127.0.0.1:${SERVER_PORT}`); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CALLBACK_TIMEOUT_MS); + timer.unref?.(); + try { + const response = await fetch(url, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body !== undefined && body !== "" ? { "content-type": "application/json" } : {}), + }, + ...(body !== undefined && body !== "" ? { body } : {}), + signal: controller.signal, + }); + const text = await response.text(); + return { status: response.status, body: fitCallbackBody(reqId, response.status, text) }; + } catch (err) { + // warn, not debug: a silent 504 would make a refused callback indistinguishable + // from an unreachable plane, and the refusal distinction is the point of §5. + logger.withError(err).warn(`ssh-runtime self-fetch failed for ${method} ${path}`); + return { status: 504, body: JSON.stringify({ error: "callback execution timed out or failed" }) }; + } finally { + clearTimeout(timer); + } +} diff --git a/apps/server/api/src/services/ssh-runtime/desktop-broker.ts b/apps/server/api/src/services/ssh-runtime/desktop-broker.ts new file mode 100644 index 000000000..b88e9a793 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/desktop-broker.ts @@ -0,0 +1,352 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import type { NodeCommandBody } from "@internal/subshell-protocol"; +import { parseNodeCommandBody } from "@internal/subshell-protocol"; +import { HttpError, UnauthorizedError } from "@/api/auth-guard.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { accountDisabled, accountPending } from "@/services/account-status.js"; +import { backupPasswordChangeRequired } from "@/services/backup-admin-recovery.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; +import { deliverSessionFrame, deliverSessionLost, markSessionsLostForNode } from "./session-registry.js"; + +const nodes = new NodesRepository(db); +const pairings = new Map(); +const links = new Map(); +const MAX_FRAME = 1024 * 1024; +const MAX_PENDING = 32; +export const isDesktopBroker = (id: string): boolean => /^desktop:[0-9a-f-]{36}$/.test(id); +const digest = (token: string): string => createHash("sha256").update(token).digest("hex"); +export type DesktopCommand = Extract< + NodeCommandBody, + { + type: "ssh_discover_aliases" | "ssh_resolve_config" | "ssh_session_open" | "ssh_session_send" | "ssh_session_close"; + } +>; +export function parseDesktopCommand(value: unknown): DesktopCommand | null { + const cmd = parseNodeCommandBody(value); + if ( + !cmd || + ![ + "ssh_discover_aliases", + "ssh_resolve_config", + "ssh_session_open", + "ssh_session_send", + "ssh_session_close", + ].includes(cmd.type) + ) + return null; + return cmd as DesktopCommand; +} + +async function liveOwner(owner: string): Promise { + return ( + !!(await new UsersRepository(db).findByIdBasic(owner)) && + !(await accountDisabled(db, owner)) && + !(await accountPending(db, owner)) && + !(await backupPasswordChangeRequired(db, owner)) + ); +} +export async function desktopOwnerLive(id: string, owner: string): Promise { + const row = await nodes.findById(id); + return ( + isDesktopBroker(id) && + row?.kind === "runtime" && + row.ownerUserId === owner && + links.get(id)?.ready === true && + (await liveOwner(owner)) + ); +} +export async function listDesktopBrokers(owner: string) { + const rows = await db + .selectFrom("nodes") + .select(["id", "name"]) + .where("ownerUserId", "=", owner) + .where("kind", "=", "runtime") + .where("id", "like", "desktop:%") + .execute(); + return { brokers: rows.map((row) => ({ ...row, online: links.has(row.id) })) }; +} +export async function pairDesktopBroker(owner: string, body: { name: string; id?: string }) { + const id = body.id ?? `desktop:${randomUUID()}`; + if (!isDesktopBroker(id)) throw new HttpError(404, "Desktop broker not found."); + const row = await nodes.findById(id); + if (body.id && (row?.kind !== "runtime" || row.ownerUserId !== owner)) + throw new HttpError(404, "Desktop broker not found."); + if (links.has(id)) throw new HttpError(409, "This desktop is already connected."); + // A bounded owner set, including expired tokens, cannot exhaust this process. + for (const [key, value] of pairings) if (value.expires <= Date.now() || value.id === id) pairings.delete(key); + if ([...pairings.values()].filter((value) => value.owner === owner).length >= 8 || pairings.size >= 1024) + throw new HttpError(409, "Too many pending desktop pairings."); + if (!row) { + const named = await db + .selectFrom("nodes") + .select("id") + .where("ownerUserId", "=", owner) + .where("name", "=", body.name) + .executeTakeFirst(); + if (named) + throw new HttpError( + 409, + "This name is already in use. Pair again with the existing desktop, or choose a different name.", + ); + const count = await db + .selectFrom("nodes") + .select("id") + .where("ownerUserId", "=", owner) + .where("id", "like", "desktop:%") + .execute(); + if (count.length >= 16) throw new HttpError(409, "Too many desktop brokers."); + try { + await nodes.create({ id, ownerUserId: owner, name: body.name, kind: "runtime", status: "offline" }); + } catch (error) { + // The preceding name check is helpful UX; this guards a simultaneous + // add or node rename that wins the same database uniqueness constraint. + if (error && typeof error === "object" && "code" in error && error.code === "SQLITE_CONSTRAINT_UNIQUE") + throw new HttpError( + 409, + "This name is already in use. Pair again with the existing desktop, or choose a different name.", + ); + throw error; + } + } + const pairingToken = `dsp_${randomBytes(32).toString("base64url")}`; + const expires = Date.now() + 5 * 60_000; + const pairingHash = digest(pairingToken); + await db + .updateTable("nodes") + .set({ publicKey: `pair:${pairingHash}` }) + .where("id", "=", id) + .execute(); + pairings.set(pairingHash, { id, owner, expires }); + return { id, name: row?.name ?? body.name, pairingToken, expiresAt: new Date(expires).toISOString() }; +} +export async function revokeDesktopBroker(owner: string, id: string): Promise { + const row = await nodes.findById(id); + if (!isDesktopBroker(id) || row?.ownerUserId !== owner || row.kind !== "runtime") + throw new HttpError(404, "Desktop broker not found."); + for (const [key, value] of pairings) if (value.id === id) pairings.delete(key); + await db.updateTable("nodes").set({ publicKey: null, status: "offline" }).where("id", "=", id).execute(); + links.get(id)?.close(); +} +export interface DesktopIdentity { + id: string; + owner: string; + name: string; + credential?: string; + credentialHash: string; +} +export async function authenticateDesktopBroker(authorization: string | null): Promise { + const token = authorization?.startsWith("Bearer ") ? authorization.slice(7) : ""; + const hash = digest(token); + const pairing = pairings.get(hash); + if (pairing) { + pairings.delete(hash); // spend synchronously before any awaited authority check + if (pairing.expires <= Date.now()) throw new UnauthorizedError(); + const row = await nodes.findById(pairing.id); + if (!row || row.ownerUserId !== pairing.owner || links.has(row.id) || !(await liveOwner(pairing.owner))) + throw new UnauthorizedError(); + const credential = `dsb_${row.id.slice(8)}_${randomBytes(32).toString("base64url")}`; + const credentialHash = digest(credential); + const updated = await db + .updateTable("nodes") + .set({ publicKey: credentialHash }) + .where("id", "=", row.id) + .where("publicKey", "=", `pair:${hash}`) + .executeTakeFirst(); + if (updated.numUpdatedRows !== 1n) throw new UnauthorizedError(); + return { id: row.id, owner: pairing.owner, name: row.name, credential, credentialHash }; + } + const match = /^dsb_([0-9a-f-]{36})_[A-Za-z0-9_-]{43}$/.exec(token); + const row = match ? await nodes.findById(`desktop:${match[1]}`) : undefined; + if (row?.kind !== "runtime" || row.publicKey !== hash || links.has(row.id) || !(await liveOwner(row.ownerUserId))) + throw new UnauthorizedError(); + return { id: row.id, owner: row.ownerUserId, name: row.name, credentialHash: hash }; +} +export interface DesktopSocket { + send(data: string): unknown; + close(code?: number, reason?: string): void; + readonly bufferedAmount?: number; +} +interface Pending { + resolve(value: unknown): void; + reject(error: Error): void; + timer: ReturnType; +} +export class DesktopLink { + readonly pending = new Map(); + readonly refs = new Set(); + ready = false; + private requestSequence = 0; + private stopped = false; + private checking = false; + private readonly timer: ReturnType; + constructor( + readonly identity: DesktopIdentity, + readonly socket: DesktopSocket, + ) { + this.timer = setInterval(() => { + if (!this.checking) { + this.checking = true; + void this.authorized() + .then((ok) => { + if (!ok) this.close(); + }) + .catch(() => this.close()) + .finally(() => { + this.checking = false; + }); + } + }, 5000); + } + async authorized(): Promise { + if (this.stopped || links.get(this.identity.id) !== this) return false; + const row = await nodes.findById(this.identity.id); + return ( + row?.ownerUserId === this.identity.owner && + row.publicKey === this.identity.credentialHash && + (await liveOwner(this.identity.owner)) + ); + } + async rpc(cmd: DesktopCommand, timeoutMs: number): Promise { + if (!this.ready) throw new NodeRpcError("offline", "Desktop broker is not attached.", this.identity.id); + if (!(await this.authorized())) { + this.close(); + throw new NodeRpcError("offline", "Desktop broker unavailable.", this.identity.id); + } + if ( + !parseDesktopCommand(cmd) || + this.pending.size >= MAX_PENDING || + this.stopped || + (this.socket.bufferedAmount ?? 0) > MAX_FRAME + ) + throw new NodeRpcError("failed", "Desktop broker queue unavailable.", this.identity.id); + if (cmd.type === "ssh_session_open") this.refs.add(cmd.ref); + else if ((cmd.type === "ssh_session_send" || cmd.type === "ssh_session_close") && !this.refs.has(cmd.ref)) + throw new NodeRpcError("failed", "session_unknown", this.identity.id, "session_unknown"); + const requestId = String(++this.requestSequence); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(requestId); + reject(new NodeRpcError("timeout", "Desktop broker did not acknowledge the command.", this.identity.id)); + this.close(); + }, timeoutMs); + this.pending.set(requestId, { resolve, reject, timer }); + try { + this.socket.send(JSON.stringify({ type: "command", requestId, command: cmd })); + } catch { + this.close(); + } + }) + .catch((error: unknown) => { + if (cmd.type === "ssh_session_open") this.refs.delete(cmd.ref); + throw error; + }) + .finally(() => { + if (cmd.type === "ssh_session_close") this.refs.delete(cmd.ref); + }); + } + async message(value: unknown): Promise { + if (!(await this.authorized())) { + this.close(); + return; + } + if (typeof value === "string") { + if (value.length > MAX_FRAME) { + this.close(); + return; + } + try { + value = JSON.parse(value); + } catch { + this.close(); + return; + } + } + if (!value || typeof value !== "object" || JSON.stringify(value).length > MAX_FRAME) { + this.close(); + return; + } + const frame = value as Record; + if (frame.type === "result" && typeof frame.requestId === "string" && typeof frame.ok === "boolean") { + const pending = this.pending.get(frame.requestId); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(frame.requestId); + if (frame.ok) pending.resolve(frame.data); + else + pending.reject( + new NodeRpcError( + "failed", + "Desktop SSH command refused.", + this.identity.id, + typeof frame.error === "string" ? frame.error : "connection_failed", + ), + ); + } else if ( + frame.type === "session_frame" && + typeof frame.ref === "string" && + this.refs.has(frame.ref) && + typeof frame.data_b64 === "string" && + frame.data_b64.length <= 262144 && + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(frame.data_b64) + ) { + deliverSessionFrame(this.identity.id, frame.ref, frame.data_b64); + } else if (frame.type === "session_lost" && typeof frame.ref === "string" && this.refs.has(frame.ref)) { + this.refs.delete(frame.ref); + deliverSessionLost(this.identity.id, frame.ref); + } else this.close(); + } + close(): void { + if (this.stopped) return; + this.stopped = true; + clearInterval(this.timer); + if (links.get(this.identity.id) === this) { + links.delete(this.identity.id); + markSessionsLostForNode(this.identity.id); + void nodes.setStatus(this.identity.id, "offline").catch(() => {}); + } + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new NodeRpcError("offline", "Desktop broker disconnected.", this.identity.id)); + } + this.pending.clear(); + this.socket.close(1000, "Desktop broker closed"); + } +} +export async function attachDesktopBroker(identity: DesktopIdentity, socket: DesktopSocket): Promise { + if (links.has(identity.id)) throw new UnauthorizedError(); + const link = new DesktopLink(identity, socket); + links.set(identity.id, link); + if (!(await link.authorized())) { + link.close(); + throw new UnauthorizedError(); + } + try { + await nodes.setStatus(identity.id, "online"); + if (!(await link.authorized())) throw new UnauthorizedError(); + socket.send( + JSON.stringify({ + type: "attached", + id: identity.id, + name: identity.name, + ...(identity.credential ? { brokerCredential: identity.credential } : {}), + }), + ); + link.ready = true; + } catch (error) { + link.close(); + throw error; + } + return link; +} +export async function desktopCommand(id: string, cmd: DesktopCommand, timeoutMs = 60_000): Promise { + const link = links.get(id); + if (!link) throw new NodeRpcError("offline", "Desktop broker unavailable.", id); + return await link.rpc(cmd, timeoutMs); +} + +/** Pin a session to this exact attachment; resumed desktops never inherit uncertain RPCs. */ +export function getDesktopLink(id: string): DesktopLink | undefined { + return links.get(id); +} diff --git a/apps/server/api/src/services/ssh-runtime/discovery.service.ts b/apps/server/api/src/services/ssh-runtime/discovery.service.ts new file mode 100644 index 000000000..4098749c9 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/discovery.service.ts @@ -0,0 +1,419 @@ +import { homedir } from "node:os"; +import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; +import { defaultSshConfigPath, discoverSshAliases, findBinary, resolveSshAliasConfig } from "@internal/pane-runtime"; +import { + isSshErrorCode, + type NodeSshAliasListResult, + type NodeSshResolveOutcomeWire, + parseNodeSshAliasList, + parseNodeSshResolveOutcome, + parseSshConnectionSnapshot, + SSH_ERROR_DESCRIPTIONS, + SSH_FORBIDDEN_SNAPSHOT_FIELDS, + SSH_PROBE_DEADLINE_MS, + type SshConnectionSnapshotWire, + type SshErrorCode, +} from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { audit } from "@/services/audit.js"; +import { getLive } from "@/services/nodes/node-registry.js"; +import { DEFAULT_COMMAND_TIMEOUT_MS, NodeRpcError, sendCommand } from "@/services/nodes/node-rpc.js"; +import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import { logger } from "@/utils/logger.js"; +import { desktopCommand, desktopOwnerLive, isDesktopBroker } from "./desktop-broker.js"; +import type { SshCaller } from "./ssh-actor.js"; + +/** + * The two pre-session SSH reads the Connect-over-SSH wizard needs, re-homed + * here by Workstream C's retirement of the old destination-execution product + * (design 2026-10-05 §7: the doors retire, the live seams move). Everything + * the retired `ssh-connections.service` pair did is kept with the SAME + * semantics: the human-only cookie gate, the one node-eligibility + * implementation, the node verbs over the signed link, the `local` in-process + * twin, the frozen-grammar re-validation of an accepted snapshot, the + * `ssh.discover` / `ssh.resolve` audit rows, and the named-code refusals. + * + * What did NOT move is what died with the product: connections, grants, + * runs, terminals, the fixed test probe, and the policy's pane arms. The + * eligibility gate keeps only the arm these reads asked (`dispatch_rpc`): + * ownership facts AND a machine able to answer a short RPC now. + * + * Canonical-form strictness still lives HERE at the review gate: an accepted + * resolve outcome is run through the protocol's `parseSshConnectionSnapshot` + * before it is shown, so the human never reviews a destination the open + * would refuse (a resolver bug meets the same parser as every later use). + */ + +const nodes = new NodesRepository(db); + +/* ------------------------------------------------------------------ */ +/* the eligibility decision (the retired policy's shape, three arms) */ +/* ------------------------------------------------------------------ */ + +/** The named refusal set this surface can answer (frozen spellings, unchanged). */ +export type SshGateCode = "cookie_required" | "not_found" | "node_ineligible"; + +/** The decision the cookie gate and the node gate return, in the retired policy's shape. */ +export type SshDecision = + | { allow: true } + | { + allow: false; + code: SshGateCode; + /** Wire-level elaboration (display metadata only; never config contents or secrets). */ + detail?: SshErrorCode; + }; + +/** The one sentence per code; the remedy's LOCATION, never a diagnosis of hidden state. */ +const GATE_SENTENCES: Record = { + cookie_required: "SSH configuration acts require a signed-in human session, not a machine credential.", + not_found: "Not found.", + node_ineligible: "The connecting node is offline, in maintenance, or otherwise not taking SSH work right now.", +}; + +/** Throw the route-visible error for a refused decision (the retired refusal module's exact shape). */ +function refuseSshDecision(decision: Extract): never { + const { code, detail } = decision; + throwApiError({ + code: code === "not_found" ? BackendErrorCodes.NOT_FOUND_ERROR : BackendErrorCodes.ACCESS_DENIED, + message: GATE_SENTENCES[code], + doNotLog: true, + // The named code ALWAYS rides (the surface contract: equality over + // parsing a sentence); `detail` elaborates when the decision carried one. + metadataSafe: { sshCode: code, ...(detail === undefined ? {} : { detail }) }, + }); +} + +/** + * Throw for a named {@link SshErrorCode}: a config-grammar refusal from the + * server's own validation (or a node code the transport relayed). The + * sentence is the protocol's own shipped description; the code rides the + * metadata for equality mapping. The durable-dispatch pair keeps the 409 + * spelling (a state to wait out), everything else the 403. + */ +function refuseSshErrorCode(code: SshErrorCode, extra?: string): never { + throwApiError({ + code: + code === "run_conflict" || code === "run_unknown" + ? BackendErrorCodes.EXISTS_ERROR + : BackendErrorCodes.ACCESS_DENIED, + message: extra === undefined ? SSH_ERROR_DESCRIPTIONS[code] : `${SSH_ERROR_DESCRIPTIONS[code]} (${extra})`, + doNotLog: true, + metadataSafe: { sshCode: code }, + }); +} + +/* ------------------------------------------------------------------ */ +/* the node-eligibility seam (sshNodeGate's live posture, moved) */ +/* ------------------------------------------------------------------ */ + +/** + * The node-eligibility question the two wizard reads ask: ownership facts + * AND the machine able to answer a short RPC now. ONE implementation (the + * retired policy's rule 2: a local copy of a named check is the drift); + * `allow` means the arm's named facts are ALL confirmed - anything else + * refuses. + * + * - Only the node OWNER may drive discovery on an enrolled agent (admin + * status does NOT reach it); a foreign row is `not_found`, the usual + * non-enumerating convention. + * - Not in maintenance, and a LIVE agent socket. Refused agents are HELD, + * never live, so a held node takes no SSH work - the existing posture. + * - The built-in `local` node answers to an ADMIN cookie subject to its + * maintenance flag and the launch-enabled rule (`allow_server_subshells`, + * read live like the launch path reads it), and has no link to be down + * (it runs in-process). The launch picker offers this origin only to admins; + * the session open and each local write recheck this same authority. + */ +export async function sshNodeGate(caller: SshCaller, nodeId: string): Promise { + if (isDesktopBroker(nodeId)) + return (await desktopOwnerLive(nodeId, caller.userId)) ? { allow: true } : { allow: false, code: "not_found" }; + const row = await nodes.findById(nodeId); + if (!row) return { allow: false, code: "not_found" }; + if (row.kind === "local") { + if (caller.actor === "cookie" && !caller.isAdmin) return { allow: false, code: "not_found" }; + if (!(await serverSubshellsEnabled(db))) return { allow: false, code: "node_ineligible" }; + } else if (row.ownerUserId !== caller.userId) { + return { allow: false, code: "not_found" }; + } + if (row.maintenance === 1) return { allow: false, code: "node_ineligible" }; + if (row.kind !== "local" && !getLive(row.id)) return { allow: false, code: "node_ineligible" }; + return { allow: true }; +} + +/* ------------------------------------------------------------------ */ +/* the node adapter (ssh-node-client's two surviving verbs, moved) */ +/* ------------------------------------------------------------------ */ + +/** + * A node refusal the API surface must name: `code` is set when the agent's + * `result{error}` equals a frozen {@link SshErrorCode} (the equality rule + * the node maps live by). `transport` marks offline/timeout; `malformed` + * marks a validator-refused answer (a broken node, reported 5xx-honest, + * never as the caller's fault). + */ +class SshNodeRefusal extends Error { + /** The named code when the refusal is one of the frozen set; null for a free-text or transport failure. */ + readonly code: SshErrorCode | null; + /** True when the node had no live connection or the RPC timed out - the "offline/timeout" family. */ + readonly transport: boolean; + /** True when the agent ANSWERED but the payload failed the shared parser. */ + readonly malformed: boolean; + constructor(message: string, code: SshErrorCode | null, opts: { transport?: boolean; malformed?: boolean } = {}) { + super(message); + this.name = "SshNodeRefusal"; + this.code = code; + this.transport = opts.transport ?? false; + this.malformed = opts.malformed ?? false; + } +} + +/** Per-verb deadlines: discovery is a local bounded parse; resolve carries the probe budget. */ +const DISCOVER_TIMEOUT_MS = DEFAULT_COMMAND_TIMEOUT_MS; +const RESOLVE_TIMEOUT_MS = SSH_PROBE_DEADLINE_MS; + +/** The ssh-binary ladder (the agent's `ssh-shared.ts` twin verbatim: app->app imports are the architecture's wall, so the known-path list is stated twice rather than one app importing the other). */ +const SSH_KNOWN_PATHS = ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh", "/opt/homebrew/bin/ssh"]; +const SSH_BINARY_ENV = "SUBSHELL_SSH_PATH"; + +/** The connecting account's home, exactly how the agent sees its own (HOME, else the OS homedir). */ +function connectingHome(): string { + return process.env.HOME || homedir(); +} + +/** Ask `ssh_discover_aliases`; the alias-NAMES view, config contents never cross the link. */ +async function nodeSshDiscover(nodeId: string): Promise { + const data = await call(nodeId, { type: "ssh_discover_aliases" }, DISCOVER_TIMEOUT_MS, "ssh_discover_aliases"); + return requireParsed(parseNodeSshAliasList(data), nodeId, "ssh_discover_aliases"); +} + +/** Ask `ssh_resolve_config` for one alias; a refusal is IN the data, not a transport error. */ +async function nodeSshResolve(nodeId: string, alias: string): Promise { + const data = await call(nodeId, { type: "ssh_resolve_config", alias }, RESOLVE_TIMEOUT_MS, "ssh_resolve_config"); + return requireParsed(parseNodeSshResolveOutcome(data), nodeId, "ssh_resolve_config"); +} + +/** + * One command with its deadline; node refusals surface as {@link SshNodeRefusal}. + * + * The dispatch seam: a `local` target is always "live" in-process, so its + * verb is served by {@link dispatchLocalSsh} calling the SAME pane-runtime + * the agent daemon wraps - no frame, no socket, no signature - and its + * answer is fed through the IDENTICAL {@link classify} arm a socket answer's + * `NodeRpcError` takes, so a local refusal maps to the same + * {@link SshNodeRefusal} the plane already knows how to name. + */ +async function call( + nodeId: string, + cmd: { type: "ssh_discover_aliases" } | { type: "ssh_resolve_config"; alias: string }, + timeoutMs: number, + verb: string, +): Promise { + try { + if (nodeId === LOCAL_NODE_ID) { + const result = await dispatchLocalSsh(cmd); + if (result.ok) return result.data; + throw new NodeRpcError( + "failed", + `node "${LOCAL_NODE_ID}" reported: ${result.error}`, + LOCAL_NODE_ID, + result.error, + ); + } + if (isDesktopBroker(nodeId)) return await desktopCommand(nodeId, cmd, timeoutMs); + return await sendCommand(nodeId, cmd, { timeoutMs }); + } catch (err) { + if (err instanceof NodeRpcError) throw classify(err, verb); + throw err; + } +} + +/** + * The server-hosted node's two SSH verbs (the retired `ssh-local.ts` arms, + * kept so the `local` door answers exactly as it did). The result mirrors + * the agent's `CommandResult`: `{ok:true, data}` is the payload a live node + * would answer (the caller re-validates it with the frozen wire parsers + * exactly as it does a socket answer), and `{ok:false, error}` carries the + * SAME strings the agent emits. + */ +async function dispatchLocalSsh( + cmd: { type: "ssh_discover_aliases" } | { type: "ssh_resolve_config"; alias: string }, +): Promise<{ ok: true; data: unknown } | { ok: false; error: string }> { + const home = connectingHome(); + if (cmd.type === "ssh_discover_aliases") { + const found = discoverSshAliases({ homeDir: home, configPath: defaultSshConfigPath(home) }); + const data = parseNodeSshAliasList({ + aliases: found.aliases, + includeCycle: found.includeCycle, + truncated: found.truncated, + }); + return data === null ? { ok: false, error: "malformed alias discovery" } : { ok: true, data }; + } + // The agent's twin: `-G` evaluation against the account's OWN config, and + // the account's name for the review card. A refusal is a SUCCESSFUL answer + // (it rides `accepted:false` in the data); only a missing binary is a + // command failure. + const sshBin = await findBinary("ssh", SSH_BINARY_ENV, SSH_KNOWN_PATHS); + if (sshBin === null) return { ok: false, error: "ssh binary missing: ssh" }; + const outcome = await resolveSshAliasConfig(cmd.alias, { + sshBin, + homeDir: home, + configPath: defaultSshConfigPath(home), + }); + const data = parseNodeSshResolveOutcome(outcome); + return data === null ? { ok: false, error: "malformed resolve outcome" } : { ok: true, data }; +} + +/** Map a {@link NodeRpcError} by EQUALITY on `detail`, per the house rule. */ +function classify(err: NodeRpcError, verb: string): SshNodeRefusal { + // `isSshErrorCode` is the whole equality table; a refusal the grammar + // cannot name is a sentence, not a code. + if (err.code === "failed" && err.detail !== undefined && isSshErrorCode(err.detail)) { + return new SshNodeRefusal(err.message, err.detail, {}); + } + return new SshNodeRefusal( + err.code === "unsupported" ? `node ${err.nodeId} predates SSH support; update the node (${verb})` : err.message, + null, + { transport: err.code === "offline" || err.code === "timeout" }, + ); +} + +/** A validator-refused node answer: the node is broken, and say so without dressing it as a caller refusal. */ +function requireParsed(parsed: T | null, nodeId: string, verb: string): T { + if (parsed === null) { + logger.error(`node ${nodeId} answered ${verb} with a malformed payload`); + throw new SshNodeRefusal(`node ${nodeId} answered ${verb} with a malformed payload`, null, { malformed: true }); + } + return parsed; +} + +/** Map an adapter failure to the API: named codes first, then the transport family. */ +function mapNodeFailure(err: unknown, verb: string): Error { + if (err instanceof SshNodeRefusal) { + if (err.code !== null) refuseSshErrorCode(err.code, verb); + if (err.transport) { + throwApiError({ + code: BackendErrorCodes.NODE_OFFLINE, + message: `the connecting node could not answer ${verb}`, + doNotLog: true, + }); + } + throwApiError({ + code: BackendErrorCodes.NODE_UNREACHABLE, + message: err.malformed ? `the connecting node answered ${verb} with a malformed payload` : err.message, + doNotLog: !err.malformed, + }); + } + return err instanceof Error ? err : new Error(String(err)); +} + +/* ------------------------------------------------------------------ */ +/* the two reads */ +/* ------------------------------------------------------------------ */ + +/** `GET /api/ssh-runtime/discovery?nodeId=`: alias NAMES on an eligible node, human cookie only. */ +export async function sshDiscovery( + caller: SshCaller, + nodeId: string, +): Promise<{ aliases: string[]; includeCycle: boolean; truncated: boolean }> { + if (caller.actor !== "cookie") refuseSshDecision({ allow: false, code: "cookie_required" }); + const eligibility = await sshNodeGate(caller, nodeId); + if (!eligibility.allow) refuseSshDecision(eligibility); + let result: NodeSshAliasListResult; + try { + result = await nodeSshDiscover(nodeId); + } catch (err) { + throw mapNodeFailure(err, "discovery"); + } + await audit({ + actorUserId: caller.userId, + action: "ssh.discover", + targetType: "node", + targetId: nodeId, + metadataJson: "{}", + }); + return { aliases: result.aliases, includeCycle: result.includeCycle, truncated: result.truncated }; +} + +/** The resolve body (the wizard's pick), same shape the retired request type carried. */ +export interface SshResolveRequest { + /** The connecting node to parse on */ + nodeId: string; + /** The alias to resolve */ + alias: string; +} + +/** The resolve view: a named refusal is a 200 - "this config needs a ProxyCommand" is the answer to the question, not a transport failure. */ +export type SshResolveView = + | { + /** The alias normalized cleanly. */ + accepted: true; + /** The snapshot the human reviews; opening the session is a separate act. */ + snapshot: SshConnectionSnapshotWire; + /** The connecting OS account name when the node could report it (the review sentence). */ + connectingAccount?: string; + } + | { + /** Resolution refused: the config needs more than the approved normalization can run, or the destination is unusable. */ + accepted: false; + /** Named limitation ({@link SshErrorCode} subset). */ + code: SshErrorCode; + /** Config keywords that blocked, when the code names several. */ + settings: string[]; + }; + +/** `POST /api/ssh-runtime/resolve`: the reviewable outcome of one alias, human-only. */ +export async function sshResolve(caller: SshCaller, body: SshResolveRequest): Promise { + if (caller.actor !== "cookie") refuseSshDecision({ allow: false, code: "cookie_required" }); + const eligibility = await sshNodeGate(caller, body.nodeId); + if (!eligibility.allow) refuseSshDecision(eligibility); + let outcome: NodeSshResolveOutcomeWire; + try { + outcome = await nodeSshResolve(body.nodeId, body.alias); + } catch (err) { + throw mapNodeFailure(err, "resolution"); + } + await audit({ + actorUserId: caller.userId, + action: "ssh.resolve", + targetType: "node", + targetId: body.nodeId, + metadataJson: JSON.stringify({ + accepted: outcome.accepted, + ...(outcome.accepted ? {} : { code: outcome.code }), + }), + }); + if (!outcome.accepted) return { accepted: false, code: outcome.code, settings: outcome.settings }; + // The plane re-runs the grammar before showing the snapshot as approvable: + // a resolver bug that produced a `ProxyCommand` meets the same parser here + // as it would at the session open, and the human never sees a snapshot the + // open would refuse. + const snapshot = validateSnapshotForReview(outcome.snapshot); + return { + accepted: true, + snapshot, + ...(outcome.connectingAccount !== undefined ? { connectingAccount: outcome.connectingAccount } : {}), + }; +} + +/** + * Validate a candidate snapshot with the frozen grammar and refuse by NAME: + * a present forbidden member is `unsupported_setting` (never a silent drop), + * anything the grammar cannot express is a plain 400, because the caller + * sent a shape that was never a snapshot. + */ +function validateSnapshotForReview(value: unknown): SshConnectionSnapshotWire { + const snapshot = parseSshConnectionSnapshot(value); + if (snapshot !== null) return snapshot; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const record = value as Record; + const blocked = SSH_FORBIDDEN_SNAPSHOT_FIELDS.filter((f) => record[f] != null); + if (blocked.length > 0) refuseSshErrorCode("unsupported_setting", blocked.join(", ")); + } + throwApiError({ + code: BackendErrorCodes.BAD_REQUEST, + message: "The snapshot does not match the approved connection grammar", + doNotLog: true, + }); +} diff --git a/apps/server/api/src/services/ssh-runtime/harness-detect.ts b/apps/server/api/src/services/ssh-runtime/harness-detect.ts new file mode 100644 index 000000000..078363ddc --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/harness-detect.ts @@ -0,0 +1,196 @@ +import { allHarnesses, type HarnessInventoryEntry } from "@internal/pane-runtime"; +import { parseNodeDetectResults } from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { + detectEnvNames, + detectRowToEntry, + detectSpecs, + enabledEnvHarnesses, + readAgentInventory, +} from "@/services/nodes/inventory.js"; +import { liveSessionForRuntimeNode } from "./session-registry.js"; +import { SshRuntimeSessionsRepository } from "./sessions.repository.js"; +import { SshRuntimeRefusal } from "./sessions.service.js"; +import { requireOwnedSession } from "./sessions-lifecycle.js"; + +/** + * The harness-detect seam for runtime sessions (task 25, journey step 5): + * "which installed agent harnesses does THIS destination have?" answered by + * the plane asking, the runtime answering exactly what was asked (the node + * link's `detect` posture, design 2026-10-05 §2's mirror rule), and the answer + * merged into the SAME cached inventory the node views read + * (`nodes.inventory_json` via `applyInventory` on the hidden `runtime`-kind + * node row - the mirror write is this module's, the runtime row starts with + * nothing). + * + * The two verbs: + * - {@link detectRuntimeSessionHarnesses}: a round trip (owner-gated; a + * runtime that did not advertise `"detect"` refuses 409 with the named + * `sshCode` `detect_unsupported`, never a silent empty answer). + * - {@link sessionHarnesses}: the cached mirror read (works offline: it reads + * rows, never a session; `online` says whether the live session behind the + * row is answering right now, and `env` rides that live session's last + * detect answer - a closed session's env answers retired with it, the same + * reading an ordinary node's connection facts give). + */ + +/** One cached/just-detected harness row, in the view's stable serialization. */ +export interface RuntimeHarnessEntryView { + /** The harness plugin id (the detect spec's `id`). */ + harnessId: string; + /** The harness's display name from the plane's own manifest (the id itself when the plugin is unknown to this build - the mirror can outlive a disable). */ + harnessName: string; + /** The destination's binary answer: found and executable there. */ + installed: boolean; + /** Resolved binary path when installed (null otherwise or unreadable). */ + binaryPath: string | null; + /** Best available version text: the plugin-parsed version when a parser ran, the raw probe output otherwise (null when never answered). */ + rawVersion: string | null; + /** Why the binary was not found ("not-on-path" | "override-invalid" | "no-binary"); null when installed or unknown. */ + reason: string | null; + /** ISO 8601 stamp of the probe (null when an entry carries none). */ + checkedAt: string | null; +} + +/** The harnesses view one verb returns: the mirror, its freshness facts, and the env answers. */ +export interface RuntimeSessionHarnessesView { + /** The session id the view is keyed by. */ + sessionId: string; + /** The hidden runtime node row the mirror is stored on. */ + runtimeNodeId: string; + /** Whether a LIVE session backs this view right now. */ + online: boolean; + /** The cached inventory rows this view reports (the requested set when the detect verb named one). */ + harnesses: RuntimeHarnessEntryView[]; + /** The destination's answers for the plane-named env vars (empty until the first detect; retired with a lost session). */ + env: Record; +} + +const nodesRepo = new NodesRepository(db); +const sessionsRepo = new SshRuntimeSessionsRepository(db); + +/** Display names resolve against the plane's own plugin set; an unknown id renders its id (the mirror can outlive a plugin disable). */ +function harnessDisplayName(harnessId: string): string { + return allHarnesses().find((h) => h.id === harnessId)?.name ?? harnessId; +} + +function toEntryView(entry: HarnessInventoryEntry): RuntimeHarnessEntryView { + return { + harnessId: entry.harnessId, + harnessName: harnessDisplayName(entry.harnessId), + installed: entry.installed, + binaryPath: entry.binaryPath ?? null, + rawVersion: entry.version ?? null, + reason: entry.reason ?? null, + checkedAt: entry.checkedAt ?? null, + }; +} + +/** The cached mirror for one runtime node row + the live session's env answers (when any). */ +async function buildView( + sessionId: string, + runtimeNodeId: string, + restrictTo: string[] | undefined, +): Promise { + const node = await nodesRepo.findById(runtimeNodeId); + const live = liveSessionForRuntimeNode(runtimeNodeId); + const entries = node === undefined ? new Map() : readAgentInventory(node).entries; + const harnesses = [...entries.values()] + .filter((e) => restrictTo === undefined || restrictTo.includes(e.harnessId)) + .map(toEntryView); + return { + sessionId, + runtimeNodeId, + online: live !== undefined, + harnesses, + env: live?.harnessEnv ?? {}, + }; +} + +/** + * The cached mirror read: the hidden runtime node row's `inventory_json` + * rendered as the view, no round trip. Owner-gated through the session row + * (a foreign id and a gone id answer the same 404, the invisible-resource + * convention every by-id verb on this surface obeys). Works for closed + * sessions - the cache outlives the channel by design (spec §6.2's role for + * the inventory snapshot). + */ +export async function sessionHarnesses(sessionId: string, userId: string): Promise { + const row = await sessionsRepo.findById(sessionId); + if (row === undefined || row.ownerUserId !== userId) throw new SshRuntimeRefusal(404, "session not found"); + return await buildView(sessionId, row.runtimeNodeId, undefined); +} + +/** + * Ask the destination NOW (task 25's detect round trip), merge, cache, return. + * + * The composition mirrors `inventory.ts`'s `detectOnNode` exactly - plane + * ships the rules (`detectSpecs`) and the env NAMES + * (`detectEnvNames(enabledEnvHarnesses())`), the runtime probes and answers + * RAW, the plugin's `parseVersion` runs HERE, the merged rows ride + * `applyInventory` (the column's only writer of real rows), the env answers + * land on the live session (an ordinary node stashes them on its connection + * facts; a runtime's connection IS the session). The ONLY difference is the + * capability gate: a runtime predating `"detect"` refuses 409 with the named + * `sshCode` `detect_unsupported` rather than closing its session on an + * unknown frame - the frame would fail its own grammar there, and the + * honest plane-side reading of "the other end cannot answer this" is a named + * refusal. + * + * `harnessIds` (optional) narrows the SPECS asked (and the view returned); + * the env names are always the full enabled union - they are a property of + * the instance's manifests, not of the subset probed. + * @throws SshRuntimeRefusal 404 (foreign/absent session), 409 + * `detect_unsupported` (capability refused or answered unsupported), + * 502 (malformed runtime answer) + */ +export async function detectRuntimeSessionHarnesses( + sessionId: string, + userId: string, + harnessIds?: string[], +): Promise { + const session = requireOwnedSession(sessionId, userId); + if (!session.hello.capabilities.includes("detect")) { + throw new SshRuntimeRefusal( + 409, + "this destination's runtime does not support harness detection; update the runtime binary there", + "detect_unsupported", + ); + } + const all = detectSpecs(); + const specs = harnessIds === undefined ? all : all.filter((s) => harnessIds.includes(s.id)); + const envHarnesses = await enabledEnvHarnesses(); + let data: unknown; + try { + data = await session.command( + { type: "detect", ref: crypto.randomUUID(), specs, envNames: detectEnvNames(envHarnesses) }, + 20_000, + ); + } catch (err) { + // `unsupported` from the runtime itself (advertised capability, build + // that answers otherwise anyway) reads as the same named refusal. + if (err instanceof Error && "detail" in err && (err as { detail?: string }).detail === "unsupported") { + throw new SshRuntimeRefusal( + 409, + "the destination runtime refused the detect command as unsupported", + "detect_unsupported", + ); + } + throw err; + } + const answer = parseNodeDetectResults(data); + if (answer === null) + throw new SshRuntimeRefusal(502, "the runtime answered the detect command with a malformed payload"); + const node = await nodesRepo.findById(session.runtimeNodeId); + if (node === undefined) throw new SshRuntimeRefusal(404, "session not found"); + const stamp = new Date().toISOString(); + const merged = readAgentInventory(node).entries; + // The node-link's OWN row->entry mapping (M-3: one function, two mirrors - + // the runtime's answer merges exactly like an agent's, including the + // plugin-parsed-version rule). + for (const row of answer.rows) merged.set(row.harnessId, detectRowToEntry(row, stamp)); + await nodesRepo.applyInventory(session.runtimeNodeId, JSON.stringify([...merged.values()])); + session.harnessEnv = answer.env; + return await buildView(sessionId, session.runtimeNodeId, harnessIds); +} diff --git a/apps/server/api/src/services/ssh-runtime/launch-dir.ts b/apps/server/api/src/services/ssh-runtime/launch-dir.ts new file mode 100644 index 000000000..6158b48d2 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/launch-dir.ts @@ -0,0 +1,69 @@ +import { parseNodeStatDirResult } from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { SshRuntimeCommandError, type SshRuntimeSession } from "./session.js"; +import { SshRuntimeSessionsRepository } from "./sessions.repository.js"; +import { SshRuntimeRefusal } from "./sessions.service.js"; + +/** + * The F3 launch-directory gate (design 2026-10-05 §7): both launch verbs + * stat-verify their `cwd` ON THE DESTINATION through this one function before + * any plane write, so "terminal" and "harness" can never disagree about what + * a valid working directory means. Extracted from `sessions-lifecycle.ts`; + * the verbs own everything after the gate (the row/token/frame order). + */ + +const sessionsRepo = new SshRuntimeSessionsRepository(db); + +/** + * Stat-verify a launch `cwd` ON THE DESTINATION before any plane write + * (design §7: an invalid directory names the remedy). The node-link's own + * pre-launch rule (`validateWorkingDir` -> the shared `stat_dir` executor), + * run through the session's framed channel: a missing path or a non-directory + * refuses `dir_missing` with 409 BEFORE the row exists, so no launch ever + * writes a row whose workingDir the shell silently fell back from (the tmux + * fallback to `~` was F3's lying 200). On success the REALPATH returns, and + * the row and the launch frame carry it - the manager's resolved-dir rule, + * one layer across the SSH hop. + * + * @throws SshRuntimeRefusal 409 `dir_missing` (refused value), 502 (a + * malformed answer); anything else (a dead session, a timeout) rides + * out as the command error it is, like every other framed verb. + */ +export async function resolveDestinationDir(session: SshRuntimeSession, cwd: string): Promise { + // Absolute BEFORE the frame (review I6): `stat_dir`'s grammar requires an + // absolute path, so a relative one would arrive at the runtime as a + // MALFORMED COMMAND FRAME - and the runtime's fail-closed rule on grammar + // violations closes the SESSION. A typo must cost a 409, not the channel; + // the plane owns the frame grammar and refuses the shape it knows the + // destination will refuse, in the F3 family, by a sibling name. + if (!cwd.startsWith("/")) { + throw new SshRuntimeRefusal( + 409, + `${cwd} is not an absolute path on the destination. Pick the folder in the browser; a relative path means nothing to a machine you have not logged into.`, + "dir_relative", + ); + } + let resolved: string; + try { + const data = await session.command({ type: "stat_dir", ref: crypto.randomUUID(), path: cwd }, 10_000); + const parsed = parseNodeStatDirResult(data); + if (parsed === null) { + throw new SshRuntimeRefusal(502, "the runtime answered the directory check with a malformed payload"); + } + resolved = parsed.path; + } catch (err) { + if ( + err instanceof SshRuntimeCommandError && + (err.detail.startsWith("ENOENT:") || err.detail.startsWith("ENOTDIR:")) + ) { + throw new SshRuntimeRefusal( + 409, + `${cwd} does not exist on the destination (or is not a directory). Choose an existing folder on the destination disk.`, + "dir_missing", + ); + } + throw err; + } + void sessionsRepo.touch(session.id).catch(() => {}); + return resolved; +} diff --git a/apps/server/api/src/services/ssh-runtime/local-broker.ts b/apps/server/api/src/services/ssh-runtime/local-broker.ts new file mode 100644 index 000000000..72160dcb5 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/local-broker.ts @@ -0,0 +1,103 @@ +import { homedir } from "node:os"; +import { findBinary, getSshSessionSupervisor, peekSshSessionSupervisor } from "@internal/pane-runtime"; +import type { SshSessionTargetWire } from "@internal/subshell-protocol"; +import { + SSH_CANCEL_GRACE_MS, + SSH_SESSION_OPEN_DEADLINE_MS, + SSH_SESSION_PUMP_CHUNK_BYTES, +} from "@internal/subshell-protocol"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; + +let stopping = false; +let opening: Promise | null = null; +/** Shutdown must outlast one bounded open so an SSH child cannot be orphaned before its hello. */ +export const LOCAL_SSH_SHUTDOWN_MS = SSH_SESSION_OPEN_DEADLINE_MS + SSH_CANCEL_GRACE_MS + 5000; + +/** The server account uses the same bounded SSH supervisor as enrolled nodes. */ +export async function openLocalBroker( + ref: string, + target: SshSessionTargetWire, + runtimeCommand: string | undefined, + hooks: { + emitBytes: (bytes: Uint8Array) => Promise; + onLost: () => void; + }, +) { + if (stopping || opening) + throw new NodeRpcError( + "failed", + "The server is stopping or already connecting to an SSH host. Try again shortly.", + "local", + "connection_failed", + ); + const work = performOpen(ref, target, runtimeCommand, hooks); + opening = work; + try { + return await work; + } finally { + opening = null; + } +} + +async function performOpen( + ref: string, + target: SshSessionTargetWire, + runtimeCommand: string | undefined, + hooks: { emitBytes: (bytes: Uint8Array) => Promise; onLost: () => void }, +) { + const sshBin = await findBinary("ssh", "SUBSHELL_SSH_PATH", [ + "/usr/bin/ssh", + "/bin/ssh", + "/usr/local/bin/ssh", + "/opt/homebrew/bin/ssh", + ]); + if (!sshBin) throw new Error("SSH is not installed on the server machine."); + const supervisor = getSshSessionSupervisor({ + dataDir: SUBSHELL_SERVER_DATA_DIR, + homeDir: process.env.HOME || homedir(), + sshBin, + nowMs: Date.now, + }); + const outcome = await supervisor.open( + { ref, target, runtimeCommand: runtimeCommand || "subshell" }, + { + emitBytes: async (bytes) => { + for (let offset = 0; offset < bytes.byteLength; offset += SSH_SESSION_PUMP_CHUNK_BYTES) { + await hooks.emitBytes(bytes.subarray(offset, offset + SSH_SESSION_PUMP_CHUNK_BYTES)); + } + }, + // SSH diagnostics can contain account data; errors use the supervisor's bounded classifications. + emitDiag: () => {}, + onLost: hooks.onLost, + }, + ); + if (outcome.kind === "refused") throw new NodeRpcError("failed", outcome.code, "local", outcome.code); + if (stopping) { + supervisor.close(ref); + throw new Error("The server is stopping."); + } + return outcome.result; +} + +/** Writes preserve the session's serialization and the supervisor's input bound. */ +export async function sendLocalBroker(ref: string, bytes: Uint8Array): Promise { + if (peekSshSessionSupervisor(SUBSHELL_SERVER_DATA_DIR)?.send(ref, bytes) !== "ok") + throw new Error("SSH connection is no longer active."); +} + +/** Disconnect only the SSH runtime; destination panes retain their existing lifecycle. */ +export function closeLocalBroker(ref: string): void { + peekSshSessionSupervisor(SUBSHELL_SERVER_DATA_DIR)?.close(ref); +} + +/** Drain only children owned by this process, and allow the supervisor's kill deadline to run. */ +export async function stopLocalSshBroker(): Promise { + stopping = true; + const wasOpening = opening !== null; + await opening?.catch(() => {}); + const supervisor = peekSshSessionSupervisor(SUBSHELL_SERVER_DATA_DIR); + const refs = supervisor?.liveRefs() ?? []; + for (const ref of refs) supervisor?.close(ref); + if (refs.length || wasOpening) await Bun.sleep(SSH_CANCEL_GRACE_MS + 50); +} diff --git a/apps/server/api/src/services/ssh-runtime/runtime-session-launcher.ts b/apps/server/api/src/services/ssh-runtime/runtime-session-launcher.ts new file mode 100644 index 000000000..9c2b84afa --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/runtime-session-launcher.ts @@ -0,0 +1,420 @@ +import { stripAnsi } from "@internal/backend-errors"; +import type { HarnessPlugin } from "@internal/pane-runtime"; +import { + HARNESS_BINARY_PLACEHOLDER, + type NodeCommandBody, + parseNodeCaptureResult, + parseNodeFsLsResult, + parseNodeLogReadResult, + parseNodePaneCursorResult, + parseNodePaneSizeResult, + parseNodeProbeEntries, + parseNodeStatDirResult, + SSH_SESSION_LOG_WINDOW_BYTES, +} from "@internal/subshell-protocol"; +import { tailLinesFromWindowText } from "@/services/nodes/log-tail.js"; +import type { LaunchPlan, NodeLauncher } from "@/services/nodes/node-launcher.js"; +import { assertNodePathId } from "@/services/nodes/node-path-id.js"; +import { logger } from "@/utils/logger.js"; +import type { SshRuntimeSession } from "./session.js"; + +/** + * The launcher for panes carried by an SSH runtime session (design 2026-10-05 + * §4): the same {@link NodeLauncher} interface the whole pane plumbing calls, + * with every operation serialized through the session's framed byte channel + * instead of signed WS commands. `RemoteLauncher` is untouched (the design + * says so); this file is its sibling, and the method bodies mirror its + * semantics deliberately - a caller must not be able to tell, from anything + * it is allowed to observe, which machine class it is talking to. + * + * The deliberate differences, each with its reason: + * - `cleanSocket` refuses to unlink and does nothing: a runtime's panes SHARE + * one destination socket (design §6's reconciliation), so the per-pane + * socket-file reclaim the node link does would unlink a live server out from + * under its other panes. + * - `canResume` is false: the runtime has no resume probe. Initial prompts + * use the existing capture/input frames and the ordinary launch settle rule. + * - `resolveBinary` answers null: the RUNTIME resolves the binary on the + * destination (the inversion's late binding, one layer closer to the + * machine); the plane never needs the path to compose the frame. + */ + +/** Command deadlines; the runtime answers locally on the destination, so the node-link's budgets fit with headroom. */ +const COMMAND_TIMEOUT_MS = 10_000; +const LOG_READ_TIMEOUT_MS = 10_000; +const TAIL_START_TIMEOUT_MS = 10_000; + +export class RuntimeSessionLauncher implements NodeLauncher { + readonly #session: SshRuntimeSession; + + constructor(session: SshRuntimeSession) { + this.#session = session; + } + + /** `stat_dir` (10 s): the same `ENOENT:`/`ENOTDIR:` answer classes the node link gives (the shared executor produces them identically). */ + async validateWorkingDir(raw: string): Promise { + const data = await this.#session.command( + { type: "stat_dir", ref: crypto.randomUUID(), path: raw }, + COMMAND_TIMEOUT_MS, + ); + const parsed = parseNodeStatDirResult(data); + if (parsed === null) throw new Error(`validateWorkingDir: malformed runtime answer for ${raw}`); + return parsed.path; + } + + /** Not a plane-side question for a runtime (see the module doc). */ + async resolveBinary(_harness: HarnessPlugin): Promise { + return null; + } + + /** + * The node link's launch body, composed the same way {@link RemoteLauncher} + * composes it (argv with the placeholder, `resolve` from the harness's + * detect spec) - the runtime's frame carries that body VERBATIM and the + * runtime's `execLaunch` is the same executor a node runs, which is the + * whole reuse story of design §2. The task-25 addition is parity for a PRESET + * plan: the `mcp` registration (the caller baked it with + * `planRemoteSubshellMcp` against the hello facts - dataDir from the runtime, + * spawn command from the reported self-invoke), the `harnessSession` resume + * pin, and the reporter half of the ARGV (baked by `buildCommand` from the + * plan's `reporter`, the runtime's self-invoke of `report` - the hooks run + * against the pane's callback door, see mcp-core's report.ts). + * + * What does NOT ride, by design and load-bearing (design §5, task 25): the + * plan's `subshellEnv` for a runtime pane is composed by the CALLER without + * `SUBSHELL_API_KEY`/plane reachability - the id, the display name, the pane + * door (`SUBSHELL_RUNTIME_CALLBACK_SOCK`) and the destination data dir. The + * pane's REAL bearer token stays in `#paneTokens` on the plane; the MCP + * child and every hook route through the door, where the plane executes them + * AS the pane. A frame or pane env that ever carries a minted token is the + * invariant breaking - pinned by test, scanning both. + */ + async launch(plan: LaunchPlan): Promise { + if (!plan.harness.detectSpec) throw new Error(`harness binary missing: ${plan.harness.id}`); + if (plan.mcp && !plan.mcpConfigPath) { + // The frame grammar's own requirement (RemoteLauncher's identical local + // refusal): content without a write target is a caller bug, and nothing + // reaches the wire. + throw new Error(`runtime launch of "${plan.id}": LaunchPlan.mcpConfigPath is required when mcp is set`); + } + const argv = plan.harness.buildCommand({ + binary: HARNESS_BINARY_PLACEHOLDER, + cwd: plan.cwd, + preset: plan.preset, + subshellName: plan.subshellName, + mcp: plan.mcp, + harnessSession: plan.harnessSession, + reporter: plan.reporter, + }); + const cmd: NodeCommandBody = { + type: "launch", + subshellId: plan.id, + socket: plan.socket, + cwd: plan.cwd, + harnessId: plan.harness.id, + preset: plan.preset, + subshellEnv: plan.subshellEnv, + mcp: plan.mcp + ? { + path: plan.mcpConfigPath as string, + fileContent: plan.mcp.fileContent, + // Conditional spreads (the RemoteLauncher rule, kept verbatim): + // an absent half of a dialect stays ABSENT on the object, not + // undefined-on-it, because the frame parser refuses explicit + // undefined optionals. + ...(plan.mcp.args ? { args: plan.mcp.args } : {}), + ...(plan.mcp.env ? { env: plan.mcp.env } : {}), + } + : undefined, + harnessSession: plan.harnessSession, + subshellName: plan.subshellName, + ...(plan.bestEffortLog !== undefined ? { bestEffortLog: plan.bestEffortLog } : {}), + argv, + resolve: plan.harness.detectSpec, + }; + await this.#session.command({ type: "launch", ref: crypto.randomUUID(), cmd }, 20_000); + } + + /** Strict terminate (the runtime's executor mirrors `LocalLauncher.terminate`'s throwing kill-session). */ + async terminate(_socket: string, id: string): Promise { + await this.#session.command({ type: "terminate", ref: crypto.randomUUID(), subshellId: id }, COMMAND_TIMEOUT_MS); + } + + /** Best-effort kill; the runtime's executor already swallows "already gone". */ + async killSubshell(_socket: string, id: string): Promise { + try { + await this.#session.command({ type: "kill", ref: crypto.randomUUID(), subshellId: id }, COMMAND_TIMEOUT_MS); + } catch { + // already gone is the answer + } + } + + /** + * Deliberately a no-op (module doc): a runtime's panes share the one + * destination socket, so there is no per-pane socket file to reclaim, and + * unlinking the shared name would kill every other pane's server out from + * under them (design §6 coexistence). + */ + async cleanSocket(_socket: string): Promise {} + + /** One-entry probe. */ + async #probeEntry(subshellId: string) { + const data = await this.#session.command( + { type: "probe", ref: crypto.randomUUID(), subshellIds: [subshellId] }, + COMMAND_TIMEOUT_MS, + ); + const entries = parseNodeProbeEntries(data); + if (!entries) throw new Error("probe: malformed runtime answer"); + return entries[0]; + } + + async hasSubshell(_socket: string, id: string): Promise { + return (await this.#probeEntry(id))?.alive === true; + } + + async paneExitCode(_socket: string, id: string): Promise { + return (await this.#probeEntry(id))?.exitCode ?? null; + } + + async paneTitle(_socket: string, id: string): Promise<{ title: string; command: string } | null> { + const entry = await this.#probeEntry(id); + return entry?.alive && entry.title != null ? { title: entry.title, command: entry.command ?? "" } : null; + } + + async capture(_socket: string, id: string, scrollbackLines?: number): Promise { + const data = await this.#session.command( + scrollbackLines && scrollbackLines > 0 + ? { type: "capture", ref: crypto.randomUUID(), subshellId: id, lines: scrollbackLines } + : { type: "capture", ref: crypto.randomUUID(), subshellId: id }, + COMMAND_TIMEOUT_MS, + ); + const text = parseNodeCaptureResult(data); + if (typeof text !== "string") throw new Error("capture: malformed runtime answer"); + return text; + } + + async resize(_socket: string, id: string, cols: number, rows: number): Promise { + await this.#session.command( + { type: "resize", ref: crypto.randomUUID(), subshellId: id, cols, rows }, + COMMAND_TIMEOUT_MS, + ); + } + + async paneSize(_socket: string, id: string): Promise<{ cols: number; rows: number } | null> { + try { + return parseNodePaneSizeResult( + await this.#session.command( + { type: "pane_size", ref: crypto.randomUUID(), subshellId: id }, + COMMAND_TIMEOUT_MS, + ), + ); + } catch (err) { + if (err instanceof Error) logger.debug(`pane_size failed for ${id}: ${err.message}`); + return null; + } + } + + async paneCursor(_socket: string, id: string): Promise<{ x: number; y: number } | null> { + try { + return parseNodePaneCursorResult( + await this.#session.command( + { type: "pane_cursor", ref: crypto.randomUUID(), subshellId: id }, + COMMAND_TIMEOUT_MS, + ), + ); + } catch (err) { + if (err instanceof Error) logger.debug(`pane_cursor failed for ${id}: ${err.message}`); + return null; + } + } + + /** The node link says false for the same reason (no winch relay in this frame set); false routes the attach to its ±1 nudge fallback. */ + async signalPaneWinch(): Promise { + return false; + } + + async sendInput(_socket: string, id: string, input: string): Promise { + await this.#session.command( + { type: "input", ref: crypto.randomUUID(), subshellId: id, data: input }, + COMMAND_TIMEOUT_MS, + ); + } + + /** Wait for initial output, then deliver once through the existing capture/input frames. */ + async deliverPrompt( + socket: string, + id: string, + text: string, + settleTimeoutMs: number, + pollMs: number, + ): Promise { + const deadline = Date.now() + settleTimeoutMs; + while (Date.now() < deadline) { + let ready = false; + try { + ready = Boolean(stripAnsi(await this.capture(socket, id)).trim()); + } catch { + /* still starting */ + } + if (ready) { + try { + await this.sendInput(socket, id, text); + await this.sendInput(socket, id, "\r"); + return true; + } catch { + return false; + } // Never replay text after an uncertain write. + } + await Bun.sleep(pollMs); + } + return false; + } + + /** + * The pane log's path ON THE DESTINATION, composed from the hello's + * `dataDir` (the same `/subshells/.log` template the node + * link's `factsPath` uses, one layer closer to the machine). Throws for a + * non-conforming id before composing, the `assertNodePathId` posture. + */ + logPath(id: string): string { + assertNodePathId(id); + return `${this.#session.hello.dataDir}/subshells/${id}.log`; + } + + async readLogTail(id: string): Promise<{ lines: string[]; truncated: boolean }> { + const { size } = await this.#readLogSized(id, 0, 1); + // The tail window is the SESSION budget, not the node link's + // LOG_TAIL_BYTES: a 256 KiB raw answer base64s past the session frame + // cap and `encodeSshSessionFrame` throws (review C1a). The display cap + // is 200 lines either way; 128 KiB of tail text reaches it with room. + const start = Math.max(0, size - SSH_SESSION_LOG_WINDOW_BYTES); + const { bytes } = await this.#readLogSized(id, start, SSH_SESSION_LOG_WINDOW_BYTES); + return tailLinesFromWindowText(Buffer.from(bytes).toString("utf8"), start === 0); + } + + /** + * The one frame-composing seam for every log read (tail, cursor, window, + * tail-relay backfill): `maxBytes` is CLAMPED to the runtime transfer + * budget before the frame is built, so no caller's node-link-sized ask can + * compose a frame the session codec refuses (review C1a). A smaller ask + * passes through untouched; the answer's `next`/`size` come back verbatim, + * so a clamped read just means a reader pages again. + */ + async #readLogSized( + id: string, + fromByte: number, + maxBytes: number, + ): Promise<{ bytes: Uint8Array; next: number; size: number }> { + const windowBytes = Math.min(Math.max(1, Math.trunc(maxBytes)), SSH_SESSION_LOG_WINDOW_BYTES); + const data = await this.#session.command( + { type: "log_read", ref: crypto.randomUUID(), subshellId: id, fromByte, maxBytes: windowBytes }, + LOG_READ_TIMEOUT_MS, + ); + const r = parseNodeLogReadResult(data); + if (!r) throw new Error("log_read: malformed runtime answer"); + return { bytes: Buffer.from(r.bytes_b64, "base64"), next: r.next, size: r.size }; + } + + async readLog(id: string, fromByte: number, maxBytes: number): Promise<{ bytes: Uint8Array; next: number }> { + const { bytes, next } = await this.#readLogSized(id, fromByte, maxBytes); + return { bytes, next }; + } + + async readLogWindow( + id: string, + fromByte: number, + maxBytes: number, + ): Promise<{ bytes: Uint8Array; next: number; size: number }> { + return await this.#readLogSized(id, fromByte, maxBytes); + } + + /** + * The RemoteLauncher's tail relay, re-homed on the session's own output + * bus: subscribe before the round trip, gap-backfill through `log_read`, + * dup-slice, disposer idempotent and fire-and-forget on `tail_stop` + * (the twin's ordering promises are the contract, and this is deliberately + * the same shape so a viewer sees one streaming behavior across machine + * classes). + */ + async tailStart( + id: string, + subId: string, + fromByte: number, + onChunk: (bytes: Uint8Array, next: number) => void, + ): Promise<() => void> { + let cursor = fromByte; + let disposed = false; + let queue: Promise = Promise.resolve(); + const unsubscribe = this.#session.subscribeOutput(subId, id, (bytes, next) => { + queue = queue + .then(async () => { + if (disposed) return; + const expected = cursor + bytes.byteLength; + if (next > expected) { + const backfill = await this.readLog(id, cursor, next - cursor).catch(() => ({ + bytes: new Uint8Array(0), + next: cursor, + })); + if (disposed) return; + if (backfill.bytes.byteLength > 0 && backfill.next > cursor) { + cursor = backfill.next; + onChunk(backfill.bytes, cursor); + } + } + let slice = bytes; + const delta = cursor - (next - bytes.byteLength); + if (delta > 0) slice = slice.subarray(Math.min(delta, slice.byteLength)); + if (slice.byteLength === 0) return; + if (disposed) return; + cursor = next; + onChunk(slice, cursor); + }) + .catch((err: unknown) => logger.withError(err).warn(`runtime tail relay failed for ${id}`)); + }); + try { + await this.#session.command( + { type: "tail_start", ref: crypto.randomUUID(), subshellId: id, subId, fromByte }, + TAIL_START_TIMEOUT_MS, + ); + } catch (err) { + unsubscribe(); + throw err; + } + return () => { + if (disposed) return; + disposed = true; + unsubscribe(); + void this.#session.command({ type: "tail_stop", ref: crypto.randomUUID(), subId }, 5_000).catch(() => undefined); + }; + } + + /** No resume surface on a runtime pane in this slice (module doc). */ + async canResume(): Promise { + return false; + } + + /** The two files a runtime pane leaves (log + meta record; a runtime pane has no MCP registration file). */ + subshellArtifacts(id: string): string[] { + return [ + `${this.#session.hello.dataDir}/subshells/${id}.log`, + `${this.#session.hello.dataDir}/subshells/${id}.meta.json`, + ]; + } + + async removeArtifacts(paths: string[]): Promise { + if (paths.length === 0) return; + try { + await this.#session.command({ type: "remove_paths", ref: crypto.randomUUID(), paths }, COMMAND_TIMEOUT_MS); + } catch (err) { + logger.withError(err).debug(`runtime session ${this.#session.id.slice(0, 8)}: remove_paths failed (best-effort)`); + } + } + + /** `fs_ls` (design §2's `list_dirs`): the same answer shape the node link's picker read returns. */ + async listDirs(path: string): Promise { + const data = await this.#session.command({ type: "list_dirs", ref: crypto.randomUUID(), path }, COMMAND_TIMEOUT_MS); + const parsed = parseNodeFsLsResult(data); + if (parsed === null) throw new Error("list_dirs: malformed runtime answer"); + return parsed; + } +} diff --git a/apps/server/api/src/services/ssh-runtime/saved-locations.service.ts b/apps/server/api/src/services/ssh-runtime/saved-locations.service.ts new file mode 100644 index 000000000..54d1e58fe --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/saved-locations.service.ts @@ -0,0 +1,76 @@ +import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; +import { db } from "@/db/index.js"; +import { SshSavedLocationsRepository } from "@/db/repositories/ssh-saved-locations.repository.js"; +import { sshResolve } from "./discovery.service.js"; +import { resolveDestinationDir } from "./launch-dir.js"; +import { getSession } from "./session-registry.js"; +import { openSession } from "./sessions.service.js"; +import { getSessionView, listSessions, requireOwnedSession } from "./sessions-lifecycle.js"; +import type { SshCaller } from "./ssh-actor.js"; + +export const savedLocationsRepository = new SshSavedLocationsRepository(db); +function refuse(code: BackendErrorCodes, message: string): never { + throwApiError({ code, message, doNotLog: true }); +} +export async function saveLocation(userId: string, sessionId: string, path: string) { + const session = requireOwnedSession(sessionId, userId); + const canonicalPath = await resolveDestinationDir(session, path); + const row = { + id: crypto.randomUUID(), + ownerUserId: userId, + originKind: session.connectingNodeId.startsWith("desktop:") ? ("desktop" as const) : ("node" as const), + originId: session.connectingNodeId, + alias: session.target.alias, + host: session.target.host, + port: session.target.port, + user: session.target.user, + path: canonicalPath, + createdAt: new Date().toISOString(), + }; + const existing = (await savedLocationsRepository.list(userId)).find( + (l) => + l.originKind === row.originKind && l.originId === row.originId && sameDestination(l, row) && l.path === row.path, + ); + return existing ?? (await savedLocationsRepository.create(row)); +} +/** Exact destination identity is required for reconnect and reuse. */ +export function sameDestination( + a: { host: string; port: number; user: string | null }, + b: { host: string; port: number; user: string | null }, +) { + return a.host === b.host && a.port === b.port && a.user === b.user; +} +export async function connectLocation(caller: SshCaller, id: string) { + const location = await savedLocationsRepository.find(id, caller.userId); + if (!location) refuse(BackendErrorCodes.NOT_FOUND_ERROR, "Saved location not found."); + const active = (await listSessions(caller.userId)).find( + (s) => + s.status === "active" && + s.connectingNodeId === location.originId && + sameDestination(s, location) && + getSession(s.id)?.status === "active", + ); + if (active) return { location, session: await getSessionView(active.id, caller.userId) }; + const resolved = await sshResolve(caller, { nodeId: location.originId, alias: location.alias }); + if (!resolved.accepted) + refuse( + BackendErrorCodes.EXISTS_ERROR, + "This SSH host needs setup again. Connect and review its destination before launching.", + ); + if (!sameDestination(resolved.snapshot, location)) + refuse( + BackendErrorCodes.EXISTS_ERROR, + "This SSH alias now points to a different destination. Connect and review the new destination before saving it again.", + ); + const session = await openSession(caller.userId, { + connectingNodeId: location.originId, + target: { + alias: location.alias, + host: location.host, + port: location.port, + user: location.user, + identityFile: resolved.snapshot.identityFiles[0] ?? null, + }, + }); + return { location, session }; +} diff --git a/apps/server/api/src/services/ssh-runtime/session-adopt.ts b/apps/server/api/src/services/ssh-runtime/session-adopt.ts new file mode 100644 index 000000000..0f9834fa2 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/session-adopt.ts @@ -0,0 +1,246 @@ +import { parseSshRuntimeReportRows, type SshRuntimeReportRow } from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { publishLive } from "@/services/live-bus.js"; +import { issueSubshellToken, revokeSubshellToken } from "@/services/subshell-tokens.js"; +import { logger } from "@/utils/logger.js"; +import { SshRuntimeCommandError, type SshRuntimeSession } from "./session.js"; +import { SshRuntimeSessionsRepository } from "./sessions.repository.js"; + +/** + * Re-adoption: design 2026-10-05 §6's "plane matches reported pane ids to + * existing rows (idempotent restore)", executed when a session opens onto a + * destination whose earlier settled (lost or closed) sessions left panes + * running on the deterministic tmux socket. + * + * The mechanism, per pane id the fresh runtime reports: + * + * - **Reported alive, row exists on a settled session's runtime node** - the + * row is FLIPPED, not copied: same id, `nodeId` moves to the new hidden + * runtime node, `alive` restored. The flip is CONDITIONAL on the row still + * sitting on the settled node this walk snapshotted it from + * (`updateIfOnNode`): two concurrent reopens of one destination can both + * hold the row in their candidate snapshots, and exactly one of them wins + * the move - the loser stands down entirely (no revoke, no mint, no + * registration), leaving the row to its winner. On its own walk the winner + * rotates the row's token exactly the restart path rotates it (`#reviveRow` + * - revoke, re-mint, register on this session's `#paneTokens`): the token + * was revoked at the loss or close, and the pane holds no key (design §5 - + * the plaintext never left the plane). The destination's per-pane callback + * door was rebuilt by the runtime from its own meta store (doors are + * destination-stable by design), and the fresh registration is what lets + * the re-created door's callbacks attribute again. + * - **Reported dead, or not reported at all, row exists** - the row settles + * terminated as today's settle does: `alive: 0`, token dead. This also + * catches the boot sweep's leftovers (rows it left `alive` because it + * witnessed no outcome): the fresh census IS a witnessed reading now. + * - **Reported but not adopted by this walk** - recorded in `unknown` and + * SKIPPED. The membership: panes the plane never created here (no row at + * all - there is no census-insert path, deliberately, and it stays that + * way: a pane the plane never created gets no row, because minting one here + * would invent an owner, a name, and a working dir the plane never + * launched), plus ids whose rows this walk does not own (panes already + * running under THIS session, or rows settled on a different destination). + * The debug line names the count, so a dataDir collision (a different + * runtime root claiming the same destination socket) is at least + * observable. + * + * The OLD hidden runtime node rows are kept, `status: offline`, as history - + * the same reading `settleLost`/`settleClosed` write at loss time; the + * session rows still name them, deleting would break that history for zero + * concealment gain (the kind is already never-listed, never-dialable). + * + * Every step is guarded on the session still being ACTIVE: at the row, after + * the flip, and after the rotation. A session that dies mid-reconcile stops + * the walk, and a row this walk had just flipped is settled back down HERE + * (alive: 0, no live token, no registration) - the settle's own pane snapshot + * races the registration and may miss it, so relying on it would leave an + * `alive: 1` row and a live token on a settled session. Rows not reached keep + * their settled reading. Whatever any of these paths left is re-adoptable by + * the NEXT open - the just-settled session's runtime node joins the candidate + * set - the idempotence §6 names. + */ + +const sessionsRepo = new SshRuntimeSessionsRepository(db); +const subshellsRepo = new SubshellsRepository(db); + +/** What one reconcile pass did (the debug line and the service test). */ +export interface AdoptSummary { + /** Row ids flipped onto the new session's runtime node, tokens rotated. */ + adopted: string[]; + /** Row ids confirmed dead (census said so, or never said otherwise). */ + settledDead: string[]; + /** Census ids this walk did not adopt: no plane row, or not its candidate (module doc). */ + unknown: string[]; + /** Row ids whose step threw: caught, recorded, skipped (the best-effort posture). */ + failed: string[]; + /** Why nothing ran (or stopped early): the census round trip refused, or the session died. */ + skipped: "census-failed" | "session-inactive" | null; +} + +/** + * Census + restore for one freshly-opened session. Best-effort by rule: a + * refused census collapses into `skipped`, and a throwing ROW step is caught, + * recorded on `failed`, and walked past - a failing reconcile never fails a + * good open, and never abandons the rows behind the failure either. (The + * candidate reads BEFORE the walk are not wrapped; a throwing query there + * still rides the call site's `.catch` at the open seam.) + */ +export async function adoptReconciledPanes(session: SshRuntimeSession): Promise { + const summary: AdoptSummary = { adopted: [], settledDead: [], unknown: [], failed: [], skipped: null }; + if (session.status !== "active") { + summary.skipped = "session-inactive"; + return summary; + } + + // The candidate rows FIRST, from the plane's own tables: everything still + // pinned to a settled session's hidden runtime node for THIS destination + // (owner + host/port/user - the same material the deterministic socket + // hashes from). The fresh session's own rows can never appear: its node row + // is new, and candidate sessions are exactly the lost/closed ones. With no + // candidates there is NOTHING a census could change (reported-but-unknown + // ids never get a row - module doc), so the first open onto a fresh + // destination asks the runtime nothing at all: one round trip saved on the + // common path, and no frame for a session that has nothing to reconcile. + const priorRuntimeNodeIds = await sessionsRepo.settledRuntimeNodeIdsForDestination({ + ownerUserId: session.ownerId, + host: session.target.host, + port: session.target.port, + user: session.target.user, + }); + const candidateRows = + priorRuntimeNodeIds.length === 0 + ? [] + : await db + .selectFrom("subshells") + .select(["id", "userId", "alive", "nodeId"]) + .where("nodeId", "in", priorRuntimeNodeIds) + .execute(); + if (candidateRows.length === 0) return summary; + + // The census over the FRESH session (the runtime's own meta store probed + // on the destination socket - the ids are destination truth, not a cached + // hello count). A refusal here (dead child, timeout, malformed answer) + // leaves every settled row as the settle wrote it: unavailable, not + // adopted. The next open reconciles again. + let census: SshRuntimeReportRow[] | null; + try { + census = parseSshRuntimeReportRows( + await session.command({ type: "subshells_report", ref: crypto.randomUUID() }, 10_000), + ); + } catch (err) { + const why = err instanceof SshRuntimeCommandError ? err.detail : String(err); + logger.debug(`ssh-runtime adopt: census refused for session ${session.id.slice(0, 8)} (${why})`); + summary.skipped = "census-failed"; + return summary; + } + if (census === null) { + logger.debug(`ssh-runtime adopt: census malformed for session ${session.id.slice(0, 8)}`); + summary.skipped = "census-failed"; + return summary; + } + if (session.status !== "active") { + summary.skipped = "session-inactive"; + return summary; + } + + const aliveIds = new Set(census.filter((r) => r.alive).map((r) => r.subshellId)); + const knownIds = new Set(candidateRows.map((r) => r.id)); + + /** + * Settle a row this walk just flipped back down, for the session-death + * interleave: `settleLost` snapshots its pane list AFTER its DB awaits and + * may have raced past this row's registration (or found it unregistered - + * a boot-sweep leftover shape), so the walk settles what it flipped: + * `alive: 0`, the row's current token revoked (the stale one, or the one + * this walk minted), no registration left behind. Every step is idempotent + * against the settle's own writes, and the row - now pinned to this + * since-settled node - is a candidate for the next open. + */ + const settleFlippedDown = async (rowId: string): Promise => { + await subshellsRepo.update(rowId, { alive: 0 }).catch(() => {}); + await revokeSubshellToken(rowId).catch(() => {}); + session.unregisterPane(rowId); + summary.settledDead.push(rowId); + summary.skipped = "session-inactive"; + publishLive({ kind: "subshell.changed", id: rowId }); + }; + + for (const row of candidateRows) { + if (session.status !== "active") { + // Died before this row: stop walking. Rows this walk reached are fully + // adopted or settled back down (below); rows not reached keep their + // settled reading. The next open restores both sets. + summary.skipped = "session-inactive"; + break; + } + try { + if (aliveIds.has(row.id)) { + // Claim the row off its settled node (module doc: the loser of a + // concurrent reopen touches NOTHING - its leading revoke/mint would + // have raced the winner's rotation into a dead token on a live pane). + const won = await subshellsRepo.updateIfOnNode(row.id, row.nodeId, { + nodeId: session.runtimeNodeId, + alive: 1, + }); + if (won === 0) { + logger.debug(`ssh-runtime adopt: row ${row.id.slice(0, 8)} already reparented by a concurrent reopen`); + continue; + } + if (session.status !== "active") { + await settleFlippedDown(row.id); + break; + } + // Token rotation on the same row, the restart path's pair (the old + // process is gone and its baked key must die with it; the new life of + // this pane bakes the freshly issued one - and nothing is transmitted: + // the plaintext goes only into `#paneTokens`). + await revokeSubshellToken(row.id); + const token = await issueSubshellToken(row.id, row.userId); + session.registerPane(row.id, token); + if (session.status !== "active") { + // Died mid-rotation, after this row's registration: the settle's + // snapshot may or may not have carried the pane - settle it down + // regardless (the just-minted token dies with the session it was + // minted for; the next open mints again). + await settleFlippedDown(row.id); + break; + } + summary.adopted.push(row.id); + } else { + // Reported dead or absent: unavailable/terminated as settled - flip + // explicitly (the boot-sweep leftover may still say alive:1) and make + // sure no live token is left minted for a dead pane. Unconditional by + // design: racing a concurrent adoption here means the pane died + // between two censuses of the same destination socket, and the + // settle-down is the truthful outcome whoever won the reparent. + if (row.alive === 1) await subshellsRepo.update(row.id, { alive: 0 }); + await revokeSubshellToken(row.id); + summary.settledDead.push(row.id); + } + publishLive({ kind: "subshell.changed", id: row.id }); + } catch (err) { + // One row's failure is that row's, not the walk's (the function doc's + // posture): record it, skip it, keep going. Whatever partially landed + // reads as settled-or-adopted history; the next open reconciles the row + // again - that is §6's idempotence, and why no unroll is attempted here. + logger.debug(`ssh-runtime adopt: row ${row.id.slice(0, 8)} refused (${String(err)})`); + summary.failed.push(row.id); + } + } + + // Census ids this walk did not adopt (module doc): ghosts the plane never + // created, and rows outside this walk's candidate set (panes already + // carrying this session's own rows). Recorded; never a row invented. + for (const r of census) { + if (!knownIds.has(r.subshellId)) summary.unknown.push(r.subshellId); + } + + if (summary.adopted.length > 0 || summary.settledDead.length > 0 || summary.unknown.length > 0) { + logger.debug( + `ssh-runtime adopt: session ${session.id.slice(0, 8)} adopted ${summary.adopted.length}, ` + + `settled dead ${summary.settledDead.length}, unknown ${summary.unknown.length}`, + ); + } + return summary; +} diff --git a/apps/server/api/src/services/ssh-runtime/session-registry.ts b/apps/server/api/src/services/ssh-runtime/session-registry.ts new file mode 100644 index 000000000..5e9969e2b --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/session-registry.ts @@ -0,0 +1,201 @@ +import type { NodeConnection } from "@/services/nodes/node-registry.js"; +import { logger } from "@/utils/logger.js"; +import { matchCallbackPath } from "./callback-allowlist.js"; +import { executeCallbackAsPane } from "./callback-executor.js"; +import type { SessionEventHooks, SshRuntimeSession } from "./session.js"; + +/** + * The live session registry (design 2026-10-05 §4): three maps and the + * routing functions the node-event handler calls. This is the only place a + * `session_frame` can land, the only place a runtime node id resolves to a + * session, and the only thing that knows "is this session open RIGHT NOW" + * (the DB row records history; the registry is authority for liveness). + * + * Containment, per the design's frame-routing rule: a `session_frame` naming + * another node's ref, or a ref with no registered session, is DROPPED at the + * identity check below - never forwarded anywhere, never an error the node + * has to police. The node side enforces its own copy (one SSH child per + * ref, per its data dir); this is the plane's copy, and both are structural. + */ + +const sessionsById = new Map(); +const sessionIdsByRuntimeNode = new Map(); +/** + * Which node-link CONNECTION brokered which live session (round-3 review + * MAJOR). The tag is the registry's own connection bookkeeping, kept here + * rather than on the session so the byte-channel class stays free of node-ws + * facts: a session without a tag never survives a sweep (only test fixtures + * register untagged; every production open passes the live record). + */ +const brokeredConnections = new Map(); + +/** + * Register a session the service just opened (wires the event hooks in + * place). `brokeredOn` is the node's live connection the open RPC rode - + * the identity the ready-time sweep compares against to tell "rode the + * prior link" from "belongs to this one". + */ +export function registerSession(session: SshRuntimeSession, brokeredOn?: NodeConnection): void { + sessionsById.set(session.id, session); + sessionIdsByRuntimeNode.set(session.runtimeNodeId, session.id); + if (brokeredOn !== undefined) brokeredConnections.set(session.id, brokeredOn); +} + +/** Drop a settled session (close/lost finalization; the row keeps the history). */ +export function unregisterSession(session: SshRuntimeSession): void { + sessionsById.delete(session.id); + brokeredConnections.delete(session.id); + if (sessionIdsByRuntimeNode.get(session.runtimeNodeId) === session.id) { + sessionIdsByRuntimeNode.delete(session.runtimeNodeId); + } +} + +export function getSession(id: string): SshRuntimeSession | undefined { + return sessionsById.get(id); +} + +/** + * The launcher-registry branch: the live session behind a `runtime` node id. + * undefined means no live session (a lost/closed row, or a restarted plane), + * and the caller falls back to the offline-rejecting `RemoteLauncher` - which + * is the honest reading of "the pane's machine is gone". + */ +export function liveSessionForRuntimeNode(runtimeNodeId: string): SshRuntimeSession | undefined { + const id = sessionIdsByRuntimeNode.get(runtimeNodeId); + if (id === undefined) return undefined; + const session = sessionsById.get(id); + return session !== undefined && session.status === "active" ? session : undefined; +} + +/** + * Route one pumped `session_frame`. Returns false for a frame the registry + * drops (wrong node, unknown ref, dead channel) so the caller can log it. + */ +export function deliverSessionFrame(nodeId: string, ref: string, dataB64: string): boolean { + const session = sessionsById.get(ref); + if (session === undefined || session.connectingNodeId !== nodeId) return false; + let bytes: Uint8Array; + try { + const buf = Buffer.from(dataB64, "base64"); + bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } catch { + return false; // BASE64_RE already vetted the string at the parse; belt for non-Node bases + } + session.ingestBytes(bytes); + return true; +} + +/** The node's `ssh_session_lost` error event: the child died; the session ends. */ +export function deliverSessionLost(nodeId: string, ref: string): void { + const session = sessionsById.get(ref); + if (session === undefined || session.connectingNodeId !== nodeId) return; + session.markLost("child-lost"); +} + +/** + * The connecting node's link closed: every session it brokered is over + * (design §6 Disconnect - the SSH child rode that socket's daemon). Called + * from the node-close path; panes keep `status: running` with `alive: 0`. + */ +export function markSessionsLostForNode(connectingNodeId: string): void { + for (const session of sessionsById.values()) { + if (session.connectingNodeId === connectingNodeId) session.markLost("node-disconnected"); + } +} + +/** + * The replacement link came up (`ready`): every session this node carries + * that was NOT brokered on `current` rode the prior link, and that link's + * death killed its children on the agent side (the daemon's drain, review + * M1) - the plane must say `lost` too, in every close ordering. The superseded + * close arm cannot say it (it must not touch the newer connection's business) + * and a black-hole flap never runs it at all, so this is its convergence + * point; the branch-1 close arm keeps marking all sessions directly, as the + * faster path when the FIN does arrive. + * + * The tag decides "before this connection" by IDENTITY, not by clock: a + * session brokered on `current` (an open whose RPC rode the new socket before + * its own `ready`) survives the sweep, and re-running it on a repeated + * `ready` costs an already-lost session nothing. + */ +export function markSessionsLostBeforeConnection(connectingNodeId: string, current: NodeConnection): void { + for (const session of sessionsById.values()) { + if (session.connectingNodeId !== connectingNodeId) continue; + if (brokeredConnections.get(session.id) === current) continue; + session.markLost("node-disconnected"); + } +} + +/** Every live session's id (the shutdown path's sweep; @internal for tests too). */ +export function allLiveSessionIds(): string[] { + return [...sessionsById.keys()]; +} + +/** Drop every registration (test isolation; production never calls it). @internal */ +export function resetSessionRegistryForTests(): void { + sessionsById.clear(); + sessionIdsByRuntimeNode.clear(); + brokeredConnections.clear(); +} + +/* ------------------------------------------------------------------ */ +/* the shared hook wiring (one object for every session; see session.ts) */ +/* ------------------------------------------------------------------ */ + +/** + * Build the hook object a session is registered with. The settling behavior + * (row settle, node offline, pane `alive` flips, token revocation) lives in + * `session-settle.ts`; the four settle callbacks here forward to the injected + * settler, which `installSessionSettlers` installs once at service-module + * load. The injection keeps the cycle (service -> registry -> settle -> + * registry) out of the module graph. The two callback-surface hooks + * (`resolveCallbackPane`, `executeCallback`) are wired here directly: they + * are read-side policy and execution, not settle writes. + */ +type SessionSettler = Pick; + +let settler: SessionSettler | undefined; + +/** Install the settle handlers from `session-settle.ts` (called once from the service module's load, before any session exists). */ +export function setSessionSettlers(impl: SessionSettler): void { + settler = impl; +} + +/** The hooks every registered session shares. */ +export function sessionHooks(): SessionEventHooks { + return { + onLost: (s, reason) => { + logger.debug(`ssh-runtime session ${s.id.slice(0, 8)} lost (${reason})`); + settler?.onLost(s, reason); + }, + onClosed: (s) => settler?.onClosed(s), + onPaneExit: (s, id, code, at) => settler?.onPaneExit(s, id, code, at), + onReport: (s, rows) => settler?.onReport(s, rows), + resolveCallbackPane: (s, path, method, framePaneId) => { + // The door rule (task 25). A frame ATTRIBUTED by the runtime's pane door + // is honored only for a pane this session really issued: the id must + // name a live pane token (a forged or stale attribution is a 403 like + // any other), and the matcher still forces the PATH's id to equal the + // executing pane (design §5's substitution rule). The attribution came + // from the door the connection arrived on - no credential crossed the + // wire to produce it - but the plane's membership check is what makes a + // lying runtime no stronger than the pane whose door it used. + if (framePaneId !== undefined) { + if (s.paneToken(framePaneId) === undefined) return null; + const decision = matchCallbackPath(path, method, framePaneId); + return decision.allow ? decision.paneId : null; + } + // No attribution = the SHARED door (the slice's manual-curl surface). Its + // rule is unchanged: a session with exactly one pane has an unambiguous + // owner; a multi-pane session cannot resolve an unattributed request, + // because choosing among panes by the path's self-declared id is + // precisely the authority this rule refuses to grant a typed path. + if (s.paneIds().length !== 1) return null; + const paneId = s.paneIds()[0] as string; + const decision = matchCallbackPath(path, method, paneId); + return decision.allow ? decision.paneId : null; + }, + executeCallback: (s, reqId, paneId, method, path, body) => + executeCallbackAsPane(s, reqId, paneId, method, path, body), + }; +} diff --git a/apps/server/api/src/services/ssh-runtime/session-settle.ts b/apps/server/api/src/services/ssh-runtime/session-settle.ts new file mode 100644 index 000000000..601def519 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/session-settle.ts @@ -0,0 +1,188 @@ +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { publishLive } from "@/services/live-bus.js"; +import { forgetRuntimeLauncher } from "@/services/nodes/launcher-registry.js"; +import { announceNodePresence } from "@/services/nodes/node-presence-announce.js"; +import { revokeSubshellToken } from "@/services/subshell-tokens.js"; +import { logger } from "@/utils/logger.js"; +import { closeViewersForSubshell } from "@/ws/viewers.js"; +import type { SessionLossReason, SshRuntimeSession } from "./session.js"; +import { setSessionSettlers, unregisterSession } from "./session-registry.js"; +import { SshRuntimeSessionsRepository } from "./sessions.repository.js"; + +/** + * The settle half of the runtime-session service (design 2026-10-05 §4/§6): + * what the DB and the pane rows say when a session is lost, closed, reported, + * or when a plane restart left stale rows behind. The service module installs + * these handlers once at load (`installSessionSettlers`); the byte channel in + * `session.ts` calls them through the registry's hook object and owns nothing + * below this line. + * + * The asymmetry that shapes every function here: the registry is authority + * for liveness, the row is history. Settling therefore WRITES (row, node + * status, pane `alive`, token revocation) and never re-opens anything; lost + * sessions and closed ones are final, and a boot sweep is the same rule + * applied by a process that can no longer hold any channel at all. + */ + +const sessionsRepo = new SshRuntimeSessionsRepository(db); +const nodesRepo = new NodesRepository(db); +const subshellsRepo = new SubshellsRepository(db); + +/** + * Membership: an id is settleable by THIS session only if the session issued + * its pane token (review I4). The reported census/exit ids are remote output + * - design §8 says remote output is untrusted data - and the runtime naming + * a syntactically-valid foreign id must buy it nothing: no row flip, no + * revoke, no publish. The settle-all paths iterate `session.paneIds()` and + * get the same guarantee structurally. + */ +function isSessionPane(session: SshRuntimeSession, subshellId: string): boolean { + return session.paneIds().includes(subshellId); +} + +/** The one honest record of a refused id: a debug line, never a silent write. */ +function skipUnowned(session: SshRuntimeSession, what: "exit" | "report", subshellId: string): void { + logger.debug( + `ssh-runtime settle: refused ${what} for ${subshellId.slice(0, 8)} (not a pane of session ${session.id.slice(0, 8)})`, + ); +} + +/** + * Install the settle handlers on the shared hook object (called once, from + * the service module's load, before any session exists - the registry's + * injection seam exists so neither module imports the other in a cycle). + */ +export function installSessionSettlers(): void { + setSessionSettlers({ + onLost: (session, reason) => { + void settleLost(session, reason); + }, + onClosed: (session) => { + void settleClosed(session); + }, + onPaneExit: (session, subshellId, exitCode) => { + // Membership FIRST (review I4, design §8: remote output is untrusted). + // A syntactically-valid id this session never issued is another + // session's pane or an agent node's row; a lying or confused runtime + // must not be able to flip it, revoke its token, or announce it. + // Unknown ids are SKIPPED, the same posture `session-adopt.ts` models. + if (!isSessionPane(session, subshellId)) { + skipUnowned(session, "exit", subshellId); + return; + } + // The row keeps `status` (the outcome it witnessed or never saw) and + // `alive` goes 0 - design §6's "unavailable, not completed" for a pane + // whose death the runtime itself reported (the runtime's own exit watcher + // is the witness here; unlike a lost channel, this one KNOWS). + void subshellsRepo.update(subshellId, { alive: 0, exitCode: exitCode ?? 0 }).catch(() => {}); + session.unregisterPane(subshellId); + void revokeSubshellToken(subshellId).catch(() => {}); + publishLive({ kind: "subshell.changed", id: subshellId }); + }, + onReport: (session, rows) => { + // The census: delivered from the `subshells_report` event arm AND from + // the `close` command's result (design §6's final report - see + // `SshRuntimeSession.close`). Dead panes get their exit codes and leave + // the registry (their tokens die with them); live ones keep their row. + // The same membership rule as `onPaneExit` reads every row (review I4): + // the settle-all paths below iterate `session.paneIds()` and need no + // guard; these REPORTED ids are remote data until proven members. + for (const row of rows) { + if (!isSessionPane(session, row.subshellId)) { + skipUnowned(session, "report", row.subshellId); + continue; + } + void subshellsRepo + .update(row.subshellId, { alive: row.alive ? 1 : 0, ...(row.alive ? {} : { exitCode: row.exitCode ?? 0 }) }) + .catch(() => {}); + if (!row.alive) { + session.unregisterPane(row.subshellId); + void revokeSubshellToken(row.subshellId).catch(() => {}); + } + publishLive({ kind: "subshell.changed", id: row.subshellId }); + } + }, + }); +} + +/** The lost settling: one transition's writes, idempotent through the registry's status guard. */ +async function settleLost(session: SshRuntimeSession, reason: SessionLossReason): Promise { + unregisterSession(session); + forgetRuntimeLauncher(session.runtimeNodeId); + await sessionsRepo.settle(session.id, "lost", null).catch(() => {}); + await nodesRepo.setStatus(session.runtimeNodeId, "offline").catch(() => {}); + // Panes: unavailable, not completed (design §6). The row keeps `status`; + // `alive` flips 0 for every pane the session still carries (a pane the + // runtime had already reported exited was settled by the exit event). A + // browser terminal watching one learns the same truth NOW: the session's + // channel is gone, so the relay's stream is over - `closeViewersForSubshell` + // (1012, the retry convention) ends it, and the client's reconnect meets the + // 4004 the attach path gives a dead session. Unavailable, never completed. + for (const paneId of session.paneIds()) { + await subshellsRepo.update(paneId, { alive: 0 }).catch(() => {}); + session.unregisterPane(paneId); + await revokeSubshellToken(paneId).catch(() => {}); + closeViewersForSubshell(paneId, "session lost"); + publishLive({ kind: "subshell.changed", id: paneId }); + } + announceNodePresence(session.runtimeNodeId); + logger.debug(`ssh-runtime session ${session.id.slice(0, 8)} settled lost (${reason})`); +} + +/** The closed settling: same shape, different word, and the runtime row goes offline too (the channel is gone by definition). */ +async function settleClosed(session: SshRuntimeSession): Promise { + unregisterSession(session); + // The per-session launcher cache leaves with the session (the plaintext + // pane-token map rides the launcher's session reference; holding the entry + // would outlive the documented "leaves with the pane" promise). + forgetRuntimeLauncher(session.runtimeNodeId); + await sessionsRepo.settle(session.id, "closed", null).catch(() => {}); + await nodesRepo.setStatus(session.runtimeNodeId, "offline").catch(() => {}); + // A graceful close still leaves the destination's panes RUNNING on the + // tmux server (design §6 Close). The census (delivered through `onReport` + // BEFORE this settling) recorded each pane as the destination saw it; the + // plane's rows then flip survivors to `alive: 0` anyway, because "alive + // with a dead session" is exactly the unavailable reading, and a flip here + // beats waiting for a reader's probe. The next session's reconcile + // re-adopts them (`session-adopt.ts`, design §6's idempotent restore). + // Live terminals go with the channel: `closeViewersForSubshell` (1012), + // the same close the lost settling gives - the destination pane may still + // be running, but THIS plane cannot stream it until a session carries it. + for (const paneId of session.paneIds()) { + await subshellsRepo.update(paneId, { alive: 0 }).catch(() => {}); + session.unregisterPane(paneId); + await revokeSubshellToken(paneId).catch(() => {}); + closeViewersForSubshell(paneId, "session closed"); + publishLive({ kind: "subshell.changed", id: paneId }); + } + announceNodePresence(session.runtimeNodeId); +} + +/** + * The boot sweep (design §6's lost-marking surviving a plane restart): + * `opening`/`active` rows can only be history by now - the byte channels died + * with the previous process, and their SSH children went with the old broker + * link. Every such row becomes `lost`, its hidden runtime node goes + * `offline`, and the presence re-announces so an open page stops showing the + * machine as up. Panes keep `status` with `alive` as found (the sweep never + * witnessed a pane outcome; design §6's unavailable reading comes from the + * offline node). + * + * Called ONCE from the boot sequence (the registry can never re-open a swept + * row; there is nothing to poll for). + * + * @returns the ids swept (the boot log counts them) + */ +export async function reconcileSshRuntimeSessionsAtBoot(): Promise { + const swept = await sessionsRepo.reconcileAtBoot(); + for (const id of swept) { + const row = await sessionsRepo.findById(id); + if (row) announceNodePresence(row.runtimeNodeId); + } + if (swept.length > 0) { + logger.info(`ssh-runtime boot reconcile: ${swept.length} stale session row(s) marked lost, runtime nodes offline`); + } + return swept; +} diff --git a/apps/server/api/src/services/ssh-runtime/session.ts b/apps/server/api/src/services/ssh-runtime/session.ts new file mode 100644 index 000000000..74c3d2c11 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/session.ts @@ -0,0 +1,500 @@ +import { + encodeSshSessionFrame, + parseSshRuntimeEventFrame, + parseSshRuntimeReportRows, + SSH_SESSION_FRAME_MAX_BYTES, + SSH_SESSION_INBOUND_QUEUE_FRAMES, + type SshRuntimeCommandFrame, + type SshRuntimeHelloWire, + type SshRuntimeReportRow, + SshSessionFrameDecoder, + type SshSessionTargetWire, +} from "@internal/subshell-protocol"; +import { assertNodePathId } from "@/services/nodes/node-path-id.js"; +import { sendCommand } from "@/services/nodes/node-rpc.js"; +import { logger } from "@/utils/logger.js"; + +/** + * One LIVE brokered session's byte channel (design 2026-10-05 §3/§4): the + * plane end of the runtime link. The SSH child is the connecting node's; this + * object owns the framing codec, the command correlation, the output + * subscriptions, the pane-token registry, and the callback round trips. + * + * The two promises this object carries: + * + * - **Backpressure at the plane's own sink.** The child's stdout arrives as + * `session_frame` events the node has already throttled against the node + * link; between the frame and a plane handler there is no queue this process + * may grow without bound, so an ingest batch larger than + * {@link SSH_SESSION_INBOUND_QUEUE_FRAMES} frames CLOSES the session + * fail-closed (design §2's overflow rule, executed where the bytes arrive). + * - **Ordering.** Plane->runtime frames are one serialized chain per session + * (`#sendChain`): the runtime's serial dispatcher already enforces execution + * order, and a writer that raced two sends through two concurrent RPCs would + * scramble the order that chain exists to preserve (the node link's own + * `sendChain` lesson, one layer up). + */ + +/** A session command the runtime refused, or a command that cannot reach a live runtime. */ +export class SshRuntimeCommandError extends Error { + /** The runtime's verbatim refusal (an equality-mapped code where one exists), or the transport verdict. */ + readonly detail: string; + constructor(message: string, detail: string) { + super(message); + this.name = "SshRuntimeCommandError"; + this.detail = detail; + } +} + +/** One pending command's correlation entry. */ +interface PendingCommand { + resolve: (data: unknown) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +/** One live output subscription (a `tail_start` pump on the runtime side). */ +interface OutputSubscriber { + subshellId: string; + onChunk: (bytes: Uint8Array, next: number) => void; +} + +/** The callback answer shape the executor hands back. */ +export interface CallbackAnswer { + status: number; + body: string; +} + +/** A close transition's witness, for the log line and the row write. */ +export type SessionLossReason = "child-lost" | "node-disconnected" | "frame-overflow" | "codec" | "send-failed"; + +/** + * The event hooks the registry wires onto every session it registers. One + * shared object serves all sessions (every handler takes the session as its + * first argument), and wiring happens at registration so the class imports + * nothing from the registry - the cycle would otherwise be structural, not + * just textual. + */ +export interface SessionEventHooks { + onLost: (session: SshRuntimeSession, reason: SessionLossReason) => void; + onClosed: (session: SshRuntimeSession) => void; + onPaneExit: (session: SshRuntimeSession, subshellId: string, exitCode: number | null, at: string) => void; + onReport: ( + session: SshRuntimeSession, + rows: { subshellId: string; alive: boolean; exitCode: number | null }[], + ) => void; + /** + * The allowlist decision (the matcher is the pure module; null = refuse 403). + * `framePaneId` is the frame's per-connection attribution (task 25): set when + * the runtime accepted the callback on that pane's OWN door, absent for the + * shared door. A paneId the session never issued refuses like any other + * forgery; the shared door keeps the slice's one-pane rule. + */ + resolveCallbackPane: ( + session: SshRuntimeSession, + path: string, + method: string, + framePaneId?: string, + ) => string | null; + /** + * The allowlisted execution as the pane's own token. The `reqId` rides in + * so the executor sizes its answer against the frame that will carry it + * (the frame-cap fit is measured with the actual id; see callback-executor). + */ + executeCallback: ( + session: SshRuntimeSession, + reqId: string, + paneId: string, + method: string, + path: string, + body: string | undefined, + ) => Promise; +} + +/** The un-wired default: every hook is a no-op, and a callback answers 403 by refusing. */ +const UNWIRED_HOOKS: SessionEventHooks = { + onLost: () => {}, + onClosed: () => {}, + onPaneExit: () => {}, + onReport: () => {}, + resolveCallbackPane: () => null, + executeCallback: async () => ({ status: 500, body: JSON.stringify({ error: "not wired" }) }), +}; + +/** + * The base URL a runtime pane's env carries (task 25): the RFC-6761 reserved + * `.invalid` TLD, which NO resolver will ever answer. The destination must not + * need the plane's address as a network fact (design §5), but mcp-core's env + * contract always carries a base URL, and `runReport` composes its URL before + * the door transport decides where bytes go - a dead-by-DNS host means any + * path that ever bypasses the door branch fails loudly instead of quietly + * reaching a same-address server on the destination. It is inert text for the + * socket-routed half (Bun's `unix` transport never resolves the host). The + * pane-env bakes compose it next to `paneCallbackSockPath`; it never replaces + * a real URL in a NON-runtime pane's env. + */ +export const RUNTIME_PANE_BASE_URL = "http://subshell-callback.invalid"; + +export class SshRuntimeSession { + readonly id: string; + readonly ownerId: string; + readonly connectingNodeId: string; + readonly runtimeNodeId: string; + readonly target: SshSessionTargetWire; + /** The parsed hello (set once by the service immediately after construction). */ + hello: SshRuntimeHelloWire; + /** The runtime's SHARED callback door (composed from hello.dataDir; design §5's listener, the slice's manual-curl surface). */ + readonly callbackSockPath: string; + /** + * The env answers (`detect`-command round trips) this session's runtime gave + * for the plane's manifest-declared names (inversion §5's posture, one layer + * closer to the machine). In-memory by design: an ordinary node stashes them + * on its live WS connection facts, a session's live channel IS this object, + * and a lost session's answers retire with it (the next session re-asks). + */ + harnessEnv: Record = {}; + /** Registry-installed event wiring (see the interface). */ + hooks: SessionEventHooks = UNWIRED_HOOKS; + + #status: "active" | "lost" | "closed" = "active"; + /** Latch against double-close racing the in-flight `close` command (the runtime exits once; one frame per session). */ + #closeRequested = false; + readonly #decoder = new SshSessionFrameDecoder(); + readonly #pending = new Map(); + readonly #outputSubs = new Map(); + /** Pane id -> the token PLAINTEXT minted for it (never transmitted to the destination). */ + readonly #paneTokens = new Map(); + #sendChain: Promise = Promise.resolve(); + #callbacksInFlight = 0; + readonly #sendBytes?: (bytes: Uint8Array) => Promise; + + constructor(init: { + id: string; + ownerId: string; + connectingNodeId: string; + runtimeNodeId: string; + target: SshSessionTargetWire; + hello: SshRuntimeHelloWire; + sendBytes?: (bytes: Uint8Array) => Promise; + }) { + this.#sendBytes = init.sendBytes; + this.id = init.id; + this.ownerId = init.ownerId; + this.connectingNodeId = init.connectingNodeId; + this.runtimeNodeId = init.runtimeNodeId; + this.target = init.target; + this.hello = init.hello; + this.callbackSockPath = `${init.hello.dataDir}/callback.sock`; + } + + get status(): "active" | "lost" | "closed" { + return this.#status; + } + + /** Pane identity + its minted token, recorded when the service creates the pane (the callback's executing identity). */ + registerPane(subshellId: string, tokenPlaintext: string): void { + this.#paneTokens.set(subshellId, tokenPlaintext); + } + + /** + * A pane's OWN callback door on the destination: `/callbacks/ + * .sock` - the runtime-side template (`apps/node/agent/src/runtime/ + * callback-sock.ts`'s `paneCallbackSockPath`) composed plane-side from the + * hello the runtime itself reported. This is NOT a secret: the id is already + * in the pane's env and the launch frame; the door's 0600 mode and the + * destination OS boundary are its access control, exactly as for the shared + * door. Throws for a non-conforming id before composing (the + * `assertNodePathId` posture every destination-path composition obeys). + */ + paneCallbackSockPath(subshellId: string): string { + assertNodePathId(subshellId); + return `${this.hello.dataDir}/callbacks/${subshellId}.sock`; + } + + /** Forget one pane (terminate/delete): its token leaves memory with it. */ + unregisterPane(subshellId: string): void { + this.#paneTokens.delete(subshellId); + } + + /** The session's pane ids (the close sweep revokes tokens through the service). */ + paneIds(): string[] { + return [...this.#paneTokens.keys()]; + } + + /** The pane token for the callback executor (the ONLY holder of runtime pane plaintexts). */ + paneToken(subshellId: string): string | undefined { + return this.#paneTokens.get(subshellId); + } + + /** + * Feed one pumped chunk of child stdout (a `session_frame` event's bytes). + * The decoder is fail-closed, so any codec verdict marks the session lost + * rather than skipping bytes; an oversized batch (the plane cannot drain its + * own session) closes it too, design §2's overflow rule. + */ + ingestBytes(chunk: Uint8Array): void { + if (this.#status !== "active") return; // a dead channel drops late frames (the close report already answered) + const frames = this.#decoder.push(chunk); + if (this.#decoder.failed !== null) { + this.markLost("codec"); + return; + } + if (frames.length > SSH_SESSION_INBOUND_QUEUE_FRAMES) { + this.markLost("frame-overflow"); + return; + } + for (const raw of frames) this.#handleFrame(raw); + } + + /** The `ssh_session_lost` close report or the node link's own death: the channel is over, and nothing replays. */ + markLost(reason: SessionLossReason): void { + if (this.#status !== "active") return; + this.#status = "lost"; + this.#failPendings(`session ${this.id.slice(0, 8)} ${reason}`); + this.hooks.onLost(this, reason); + } + + /** + * Run the graceful close (design §6): send the `close` command WHILE the + * session is still active (flipping first would make `command` refuse to + * encode it - the frame is the whole point of the graceful path), await the + * runtime's answer, and settle regardless of it - an unreachable child's + * `closed` is still the user's act. + * + * The census is the `close` command's RESULT data (`finalReport` in + * `runtime/serve.ts` answers it before exiting): when it arrives it is + * delivered through `onReport` BEFORE `onClosed`, so the close settling sees + * panes as the destination reported them (dead panes carry their exit codes; + * the settle still flips survivors to `alive: 0` - a live pane with a dead + * channel is the design §6 unavailable reading, not a completed one). + * + * A mid-close death (the child dies while the result is in flight) leaves + * the `lost` settling as the owner of the transition: `markLost` wins and + * this method does nothing further. + */ + async close(): Promise { + if (this.#status !== "active" || this.#closeRequested) return; + this.#closeRequested = true; + let census: SshRuntimeReportRow[] | null = null; + try { + const data = await this.command({ type: "close", ref: crypto.randomUUID() }, 15_000); + census = parseSshRuntimeReportRows(data); + } catch (err) { + logger.debug(`ssh-runtime close of ${this.id.slice(0, 8)} answered nothing: ${String(err)}`); + } + if (this.#status !== "active") return; // died mid-close: the lost settle owns the transition + this.#status = "closed"; + if (census !== null) this.hooks.onReport(this, census); + this.#failPendings("session closed"); + this.hooks.onClosed(this); + } + + /** + * Send one command frame and await its `result{ref}`. The runtime ref IS + * the correlation id (the broker keys by the outer session ref, so inner + * collisions could only ever be plane-side, and one uuid per command keeps + * even that impossible). + */ + command(frame: Exclude, timeoutMs: number): Promise { + if (this.#status !== "active") { + return Promise.reject( + new SshRuntimeCommandError(`session ${this.id.slice(0, 8)} is ${this.#status}`, "session_unknown"), + ); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.#pending.delete(frame.ref)) { + reject(new SshRuntimeCommandError(`runtime command ${frame.type} timed out`, "timeout")); + } + }, timeoutMs); + timer.unref?.(); + this.#pending.set(frame.ref, { resolve, reject, timer }); + this.#enqueueBytes(encodeSshSessionFrame(frame), reject); + }); + } + + /** Subscribe to one `tail_start` pump's output events; returns the unsubscribe. */ + subscribeOutput(subId: string, subshellId: string, onChunk: (bytes: Uint8Array, next: number) => void): () => void { + this.#outputSubs.set(subId, { subshellId, onChunk }); + return () => { + this.#outputSubs.delete(subId); + }; + } + + /* ------------------------------------------------------------------ */ + /* internals */ + /* ------------------------------------------------------------------ */ + + /** + * Serialize outbound writes (module doc's ordering promise) and map a node + * failure to a session loss: if the frame cannot reach the child, the + * channel is gone, and a half-open session is never reported as open. + */ + #enqueueBytes(bytes: Uint8Array, onError: (err: Error) => void): void { + if (bytes.byteLength > SSH_SESSION_FRAME_MAX_BYTES) { + onError(new SshRuntimeCommandError("outbound runtime frame exceeds the session cap", "oversize")); + return; + } + const data_b64 = Buffer.from(bytes).toString("base64"); + this.#sendChain = this.#sendChain.then(async () => { + if (this.#status !== "active") { + onError(new SshRuntimeCommandError(`session ${this.id.slice(0, 8)} is ${this.#status}`, "session_unknown")); + return; + } + try { + if (this.#sendBytes) { + await this.#sendBytes(bytes); + return; + } + await sendCommand( + this.connectingNodeId, + { type: "ssh_session_send", ref: this.id, data_b64 }, + { timeoutMs: 15_000 }, + ); + } catch (err) { + this.markLost("send-failed"); + onError(err instanceof Error ? err : new Error(String(err))); + } + }); + } + + #handleFrame(raw: unknown): void { + const frame = parseSshRuntimeEventFrame(raw); + if (frame === null) { + // The codec accepted the bytes; the SESSION grammar refuses them. Same + // class as a codec failure: the other end is not speaking the agreed + // protocol, and fail-closed is the only honest reading. + this.markLost("codec"); + return; + } + switch (frame.type) { + case "hello": + // A second hello (the first one answered the open) is protocol noise. + this.markLost("codec"); + return; + case "result": { + const pending = this.#pending.get(frame.ref); + if (!pending) return; // late answer after a timeout: the caller already moved on + this.#pending.delete(frame.ref); + clearTimeout(pending.timer); + if (frame.ok) pending.resolve(frame.data); + else pending.reject(new SshRuntimeCommandError(`runtime refused: ${frame.error}`, frame.error)); + return; + } + case "output": { + const sub = this.#outputSubs.get(frame.subId); + if (sub && sub.subshellId === frame.subshellId) { + try { + sub.onChunk(Buffer.from(frame.data_b64, "base64"), frame.toByte); + } catch (err) { + logger.withError(err).debug(`ssh-runtime tail delivery failed for ${frame.subshellId}`); + } + } + return; + } + case "exit": + this.hooks.onPaneExit(this, frame.subshellId, frame.exitCode, frame.at); + return; + case "subshells_report": + this.hooks.onReport(this, frame.subshells); + return; + case "rest_request": + void this.#answerCallback(frame.reqId, frame.method, frame.path, frame.body, frame.paneId); + return; + } + } + + /** + * The callback round trip (design §5): allowlist, execute as the pane's own + * token, relay the answer as `rest_response`. Bound the in-flight count: a + * chatty (or prompt-injected) pane gets 8 callbacks in flight, then 503s - + * the honest refusal shape, and it keeps the session's RPC chain free for + * pane work. + */ + async #answerCallback( + reqId: string, + method: string, + path: string, + body: string | undefined, + framePaneId?: string, + ): Promise { + // Encoding here is already bounded by construction: `reqId` arrived + // through the frame grammar (length-capped, printable) and the executor + // sizes its body against a response frame carrying THAT id. The try/catch + // is the belt, and it is load-bearing for the call shape: this method is + // invoked `void`, so a throwing encode would otherwise leave the 502 + // re-settle throwing again and surface as an unhandled rejection - the + // pane's callback unanswered and the fact invisible. + const settle = (status: number, text: string): void => { + try { + this.#enqueueBytes( + encodeSshSessionFrame({ + type: "rest_response", + reqId, + status, + ...(text !== "" ? { body: text } : {}), + } satisfies Extract), + () => {}, + ); + } catch (err) { + logger.withError(err).warn(`ssh-runtime callback answer refused for ${path} (session ${this.id.slice(0, 8)})`); + } + }; + if (this.#callbacksInFlight >= 8) { + settle(503, JSON.stringify({ error: "too many callbacks in flight" })); + return; + } + // Normalize BEFORE matching, and execute the SAME normalized value + // (review C2): the executor's `new URL(path, loopback)` already resolves + // `.`/`..`, so a matcher working on the RAW string and an executor + // working on the normalized one disagree exactly where it hurts - a path + // like `/api/subshells//../../users` matched the own id and then + // EXECUTED `/api/users` with this pane's token. One parse, shared by the + // gate and the door. The sentinel base is inert here: only `pathname` + // and `search` are ever recomposed, onto the executor's own loopback. + let normalized: URL; + try { + normalized = new URL(path, RUNTIME_PANE_BASE_URL); + } catch { + // A path this parser cannot read is not a path to execute, matched or + // otherwise: fail closed with the same 403 every refusal answers. + logger.warn(`ssh-runtime callback refused an unparsable path for ${this.id.slice(0, 8)}`); + settle(403, JSON.stringify({ error: "forbidden" })); + return; + } + const paneId = this.hooks.resolveCallbackPane(this, normalized.pathname, method, framePaneId); + if (paneId === null) { + settle(403, JSON.stringify({ error: "forbidden" })); + return; + } + this.#callbacksInFlight += 1; + try { + const answer = await this.hooks.executeCallback( + this, + reqId, + paneId, + method, + `${normalized.pathname}${normalized.search}`, + body, + ); + settle(answer.status, answer.body); + } catch (err) { + logger.withError(err).warn(`ssh-runtime callback execution failed for ${path}`); + settle(502, JSON.stringify({ error: "callback execution failed" })); + } finally { + this.#callbacksInFlight -= 1; + } + } + + #failPendings(reason: string): void { + for (const [, p] of this.#pending) { + clearTimeout(p.timer); + p.reject(new SshRuntimeCommandError(reason, "lost")); + } + this.#pending.clear(); + // Tail subscribers stop via their own disposers; the subscriptions are + // meaningless now, so drop them (a later `tail_stop` relay from a disposer + // answers `session_unknown`, harmlessly). + this.#outputSubs.clear(); + } +} diff --git a/apps/server/api/src/services/ssh-runtime/sessions-lifecycle.ts b/apps/server/api/src/services/ssh-runtime/sessions-lifecycle.ts new file mode 100644 index 000000000..eb5608b71 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/sessions-lifecycle.ts @@ -0,0 +1,366 @@ +import { randomUUID } from "node:crypto"; +import { getHarness, tmuxSocketFor } from "@internal/pane-runtime"; +import { type NodeFsLsResult, normalizeLabel, parseNodeFsLsResult } from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { PresetsRepository } from "@/db/repositories/presets.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { audit } from "@/services/audit.js"; +import { publishLive } from "@/services/live-bus.js"; +import { lockdownEnabled } from "@/services/lockdown.js"; +import { nodeSelfInvoke, planRemoteSubshellMcp } from "@/services/mcp-launch.js"; +import { EMPTY_PRESET, parsePreset } from "@/services/preset-definition.js"; +import { PROMPT_POLL_MS, PROMPT_SETTLE_TIMEOUT_MS } from "@/services/subshell-manager.service.js"; +import { issueSubshellToken, revokeSubshellToken } from "@/services/subshell-tokens.js"; +import { resolveDestinationDir } from "./launch-dir.js"; +import { RuntimeSessionLauncher } from "./runtime-session-launcher.js"; +import { RUNTIME_PANE_BASE_URL, type SshRuntimeSession } from "./session.js"; +import { getSession } from "./session-registry.js"; +import { SshRuntimeSessionsRepository } from "./sessions.repository.js"; +import { closeBestEffort, SshRuntimeRefusal, type SshRuntimeSessionView, viewOfRow } from "./sessions.service.js"; + +/** + * The lifecycle half of the runtime-session service (design 2026-10-05 §4): + * the verbs that act ON a live session - browse its destination, launch its + * terminal pane, close it - and the owner's history reads. The open flow + * (gate, rows, broker, register) lives in `sessions.service.ts`, the settle + * writes in `session-settle.ts`; this file reaches a live session only + * through `requireOwnedSession`, so "not yours" and "never was" stay the + * same 404 reading everywhere. + * + * The ORDER rules that outlive the move: a launch writes row -> token -> + * frame (the guard's forgery check reads the row's `api_key_id`, so the row + * exists first) and a failed launch unrolls every write; a close sends the + * framed `close` before the broker's `ssh_session_close` and settles + * regardless of the runtime's answer (design §6). + */ + +const sessionsRepo = new SshRuntimeSessionsRepository(db); +const subshellsRepo = new SubshellsRepository(db); +const presetsRepo = new PresetsRepository(db); + +/** + * The live session for an owner, or the 404 reading (foreign ids never + * distinguish "gone" from "never was"). Exported for `harness-detect.ts` (the + * harnesses verbs share the exact door: same owner gate, same refusal). + */ +export function requireOwnedSession(sessionId: string, userId: string): SshRuntimeSession { + const session = getSession(sessionId); + if (!session || session.ownerId !== userId) { + throw new SshRuntimeRefusal(404, "session not found"); + } + return session; +} + +/** Ask the runtime for a directory listing (design §7's picker, §2's `list_dirs`). */ +export async function sessionListDirs(sessionId: string, userId: string, path: string): Promise { + const session = requireOwnedSession(sessionId, userId); + const data = await session.command({ type: "list_dirs", ref: crypto.randomUUID(), path }, 10_000); + const parsed = parseNodeFsLsResult(data); + if (parsed === null) throw new SshRuntimeRefusal(502, "the runtime answered the listing with a malformed payload"); + void sessionsRepo.touch(sessionId).catch(() => {}); + return parsed; +} + +/** + * Open a terminal pane on a live session (the `launch-terminal` verb): + * the ordinary row + the runtime frame, in `sshTerminalCreate`'s order (row, + * token, launch, settle), and a refusal deletes the row it just wrote. The + * ORDER IS LOAD-BEARING: `issueSubshellToken` stamps the key id onto the + * subshell row it already expects to exist (the guard's forgery check reads + * the row's `api_key_id`), so the row must be written first or the pane's own + * token authenticates as nothing. The token plaintext is held ONLY in the + * session (design §5: never transmitted); the pane env carries its id and the + * callback socket instead of the token and the plane URL. + */ +export async function sessionLaunchTerminal( + sessionId: string, + userId: string, + body: { cwd: string; cols?: number; rows?: number }, +): Promise<{ subshellId: string }> { + const session = requireOwnedSession(sessionId, userId); + if (await lockdownEnabled(db)) + throw new SshRuntimeRefusal(409, "This instance is in lockdown. No new subshells can be started."); + const harness = getHarness("terminal"); + if (harness === undefined) throw new SshRuntimeRefusal(500, "the built-in terminal harness is not available"); + // Stat-verified BEFORE the row (F3, design §7): a missing destination + // directory refuses by name instead of launching into the shell's fallback. + // It is the last cheap check before the first write - the guard refusals + // stay frameless, and the stat gate precedes the row/token/frame order. + const cwd = await resolveDestinationDir(session, body.cwd); + const id = randomUUID(); + const socket = tmuxSocketFor(id); + await subshellsRepo.create({ + id, + userId, + harnessId: "terminal", + name: normalizeLabel(`SSH ${session.target.alias}`, 120), + workingDir: cwd, + presetId: null, + nodeId: session.runtimeNodeId, + tmuxSocket: socket, + status: "running", + alive: 1, + startedAt: new Date().toISOString(), + notify: 1, + crossAgent: 0, + }); + let token: string; + try { + token = await issueSubshellToken(id, userId); + } catch (err) { + // the row must not exist without its minted key (the same posture as the + // failed launch below: the pane never existed) + await subshellsRepo.delete(id).catch(() => {}); + throw err; + } + session.registerPane(id, token); + const launcher = new RuntimeSessionLauncher(session); + try { + await launcher.launch({ + id, + socket, + harness, + binary: "", + cwd, + preset: { + name: "terminal", + description: null, + env: {}, + flags: [], + settings: null, + configIsolation: false, + }, + subshellName: id, + // The callback-socket contract (design §5): no token, no plane URL - + // this pane's OWN door (task 25: per-connection attribution on a + // multi-pane session), and the never-resolves sentinel base URL so any + // code path that bypasses the door's transport dies at DNS rather than + // reaching a same-address server on the destination. + subshellEnv: { + SUBSHELL_ID: id, + SUBSHELL_NAME: normalizeLabel(`SSH ${session.target.alias}`, 120), + SUBSHELL_RUNTIME_CALLBACK_SOCK: session.paneCallbackSockPath(id), + SUBSHELL_BASE_URL: RUNTIME_PANE_BASE_URL, + }, + }); + // Geometry is a request after the spawn (the node-link's own ordering: + // launch, then resize), and a refused fit must not fail a live pane. + if (body.cols !== undefined && body.rows !== undefined) { + await launcher.resize(socket, id, body.cols, body.rows).catch(() => {}); + } + } catch (err) { + // The row must not outlive the failed spawn (the create path's posture): + // the pane never existed. The token it minted goes with it. + session.unregisterPane(id); + await revokeSubshellToken(id).catch(() => {}); + await subshellsRepo.delete(id).catch(() => {}); + if (err instanceof Error && /harness binary missing/.test(err.message)) { + throw new SshRuntimeRefusal(409, err.message, "runtime_missing"); + } + throw err; + } + await audit({ + actorUserId: userId, + action: "ssh_runtime_session.pane_open", + targetType: "subshell", + targetId: id, + metadataJson: JSON.stringify({ sessionId, runtimeNodeId: session.runtimeNodeId }), + }); + return { subshellId: id }; +} + +/** + * Open a HARNESS pane on a live session (the `launch-harness` verb, the mount + * for the detect wave's seam): the same row -> token -> frame order as + * {@link sessionLaunchTerminal}, with the ordinary LaunchPlan composed here - + * harness resolution, optional preset (owner-checked, harness-matched), the + * MCP registration and the reporter hook command baked against the runtime's + * HELLO facts (`planRemoteSubshellMcp`/`nodeSelfInvoke` on `hello.dataDir` + + * `hello.selfInvoke`: the destination writes its own config file at its own + * path, and hooks re-enter the destination's own binary), and a fresh + * harness conversation id for resume-capable harnesses (the manager's + * start-mode allocation; a runtime pane is never resumed - the restart door + * seals runtime rows, so this id's only later reader is the census). + * `RuntimeSessionLauncher.launch`'s preset parity (mcp block + harnessSession + * + argv) carries it all to the frame. + * + * The HARD invariant stands unchanged (design §5): the pane env carries the + * id, the display name, the pane's OWN callback door, the destination data + * dir and the never-resolves sentinel - never the minted token, never the + * plane URL. The token lives only in `#paneTokens`; every callback executes + * plane-side as the pane. Pinned by the credential-scan test on this path. + * + * @throws SshRuntimeRefusal 404 (foreign/absent session or preset), 409 + * (unknown harness, harness/preset mismatch, harness not installed on + * the destination - the terminal verb's `runtime_missing` family) + */ +export async function sessionLaunchHarness( + sessionId: string, + userId: string, + body: { harnessId: string; presetId?: string | null; cwd: string; cols?: number; rows?: number; prompt?: string }, +): Promise<{ subshellId: string; promptDelivered?: boolean }> { + const session = requireOwnedSession(sessionId, userId); + if (await lockdownEnabled(db)) + throw new SshRuntimeRefusal(409, "This instance is in lockdown. No new subshells can be started."); + const harness = getHarness(body.harnessId); + if (harness === undefined) throw new SshRuntimeRefusal(409, `unknown harness: ${body.harnessId}`); + const presetRow = body.presetId ? await presetsRepo.findById(body.presetId) : undefined; + if (body.presetId && (!presetRow || presetRow.userId !== userId)) { + // A foreign preset and an absent one answer the same 404 (the invisible- + // resource convention this whole surface obeys; the manager's create door + // states the same rule for ordinary rows). + throw new SshRuntimeRefusal(404, "preset not found"); + } + if (presetRow && presetRow.harnessId !== harness.id) { + throw new SshRuntimeRefusal(409, "preset harness mismatch"); + } + const preset = presetRow ? parsePreset(presetRow) : EMPTY_PRESET; + // Stat-verified before any plane write (the terminal verb's rule, F3): the + // last check before the row/token/frame order begins, and no guard refusal + // above it ever dials the destination. + const cwd = await resolveDestinationDir(session, body.cwd); + // Fresh conversation id for resume-capable harnesses, start mode (module + // doc: never resumed on this surface); pinned on the row at insert, the + // manager's own ordering, so the census and any later lineage read see it. + const harnessSession = harness.resume + ? { id: harness.resume.allocateHarnessSessionId(), mode: "start" as const } + : undefined; + const id = randomUUID(); + const socket = tmuxSocketFor(id); + const paneName = normalizeLabel(`SSH ${session.target.alias}`, 120); + await subshellsRepo.create({ + id, + userId, + harnessId: harness.id, + name: paneName, + workingDir: cwd, + presetId: presetRow?.id ?? null, + nodeId: session.runtimeNodeId, + tmuxSocket: socket, + status: "running", + alive: 1, + startedAt: new Date().toISOString(), + notify: 1, + crossAgent: 0, + harnessSessionId: harnessSession?.id ?? null, + }); + let token: string; + try { + token = await issueSubshellToken(id, userId); + } catch (err) { + await subshellsRepo.delete(id).catch(() => {}); + throw err; + } + session.registerPane(id, token); + const launcher = new RuntimeSessionLauncher(session); + try { + // The registration and the hook command compose against the HELLO facts, + // not the node registry: a runtime's dataDir and self-invoke come from its + // own hello (task 25's field), exactly as an agent's come from its ready + // frame. Nothing here touches disk on the plane. They run INSIDE the + // guarded scope (M-2): `planRemoteSubshellMcp` executes the harness + // plugin's `mcpRegistration` dialect, and a throwing dialect must unroll + // the row, the token, and the registration exactly like a failed send - + // never orphan them as a pane that can never launch. + const planned = planRemoteSubshellMcp(harness, id, { + dataDir: session.hello.dataDir, + selfInvoke: session.hello.selfInvoke, + }); + const reporter = nodeSelfInvoke({ selfInvoke: session.hello.selfInvoke }, "report"); + await launcher.launch({ + id, + socket, + harness, + binary: "", + cwd, + preset, + // The destination tmux session name is the pane id (the terminal verb's + // rule, and the reconcile's match key: the census reports destination + // session names and the plane matches them to row ids). + subshellName: id, + // Door + sentinel + destination data dir, no token, no plane URL - + // design §5's bake, identical to the terminal verb's plus the data dir + // the `subshell mcp` child persists its keypairs under. + subshellEnv: { + SUBSHELL_ID: id, + SUBSHELL_NAME: paneName, + SUBSHELL_RUNTIME_CALLBACK_SOCK: session.paneCallbackSockPath(id), + SUBSHELL_DATA_DIR: session.hello.dataDir, + SUBSHELL_BASE_URL: RUNTIME_PANE_BASE_URL, + }, + mcp: planned?.reg, + mcpConfigPath: planned?.configPath, + harnessSession, + reporter, + }); + if (body.cols !== undefined && body.rows !== undefined) { + await launcher.resize(socket, id, body.cols, body.rows).catch(() => {}); + } + } catch (err) { + session.unregisterPane(id); + await revokeSubshellToken(id).catch(() => {}); + await subshellsRepo.delete(id).catch(() => {}); + // The destination's own resolve ladder refused the harness binary: the + // honest remedy is the HARNESS on the destination (the terminal verb + // points at the Subshell binary; a named harness's missing binary is a + // different install). + if (err instanceof Error && /harness binary missing/.test(err.message)) { + throw new SshRuntimeRefusal(409, `Harness "${harness.name}" is not installed on the destination.`); + } + throw err; + } + await audit({ + actorUserId: userId, + action: "ssh_runtime_session.pane_open", + targetType: "subshell", + targetId: id, + metadataJson: JSON.stringify({ + sessionId, + runtimeNodeId: session.runtimeNodeId, + harnessId: harness.id, + ...(presetRow ? { presetId: presetRow.id } : {}), + }), + }); + publishLive({ kind: "subshell.changed", id }); + let promptDelivered = false; + if (body.prompt?.trim()) { + promptDelivered = await launcher + .deliverPrompt(socket, id, body.prompt, PROMPT_SETTLE_TIMEOUT_MS, PROMPT_POLL_MS) + .catch(() => false); + } + return { subshellId: id, promptDelivered }; +} + +/** + * Close a session (the user's act, design §6's Close). Two frames, in order: + * the `close` COMMAND reaches the runtime (graceful exit, final census - see + * `SshRuntimeSession.close`), and `ssh_session_close` reaches the BROKER so + * the supervisor group-kills the SSH child and records `closed` on disk + * (either half may find its target already gone - a runtime that exited + * cleanly takes the child with it; a runtime that ignored the command is why + * the broker's kill exists). Panes on the destination keep running. + */ +export async function closeSession(sessionId: string, userId: string): Promise { + const session = requireOwnedSession(sessionId, userId); + await session.close(); + await closeBestEffort(session.connectingNodeId, sessionId); + await audit({ + actorUserId: userId, + action: "ssh_runtime_session.close", + targetType: "ssh_runtime_session", + targetId: sessionId, + metadataJson: JSON.stringify({ connectingNodeId: session.connectingNodeId }), + }); +} + +/** A session view for the routes; owner-only (foreign id reads 404 like every invisible resource). */ +export async function getSessionView(sessionId: string, userId: string): Promise { + const row = await sessionsRepo.findById(sessionId); + if (!row || row.ownerUserId !== userId) throw new SshRuntimeRefusal(404, "session not found"); + return viewOfRow(row); +} + +/** The owner's sessions, newest first (the recent-destinations history; design §6's "rows history"). */ +export async function listSessions(userId: string): Promise { + return (await sessionsRepo.listByOwner(userId)).map(viewOfRow); +} diff --git a/apps/server/api/src/services/ssh-runtime/sessions.repository.ts b/apps/server/api/src/services/ssh-runtime/sessions.repository.ts new file mode 100644 index 000000000..f8cfa3d02 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/sessions.repository.ts @@ -0,0 +1,155 @@ +import { type SqlBool, sql } from "kysely"; +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { + NewSshRuntimeSession, + SshRuntimeSessionStatus, + SshRuntimeSessionTable, +} from "@/db/types/ssh-runtime-sessions.db-types.js"; + +/** + * Persistence for `ssh_runtime_sessions` (design 2026-10-05 §4, migration + * 0049): the plane's session record. The in-memory registry owns the byte + * channel; this table owns the history and the facts a restart must recover + * from (which runtime node rows are hidden leftovers of dead sessions the + * boot reconcile sweeps - see `session-settle.ts`). + */ +export class SshRuntimeSessionsRepository extends BaseRepository { + async create(session: NewSshRuntimeSession): Promise { + const now = new Date().toISOString(); + return this.db + .insertInto("sshRuntimeSessions") + .values({ + ...session, + status: session.status ?? "opening", + helloJson: session.helloJson ?? null, + createdAt: session.createdAt ?? now, + lastSeenAt: null, + closedAt: null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async findById(id: string): Promise { + return await this.db.selectFrom("sshRuntimeSessions").selectAll().where("id", "=", id).executeTakeFirst(); + } + + async listByOwner(ownerUserId: string): Promise { + return await this.db + .selectFrom("sshRuntimeSessions") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .orderBy("createdAt", "desc") + .execute(); + } + + /** The status transition write; terminal states are final (a `lost` never re-opens - the registry is the only door, and it refuses a closed row). */ + async settle(id: string, status: SshRuntimeSessionStatus, helloJson: string | null): Promise { + const now = new Date().toISOString(); + await this.db + .updateTable("sshRuntimeSessions") + .set({ + status, + ...(helloJson !== null ? { helloJson } : {}), + lastSeenAt: now, + ...(status === "lost" || status === "closed" ? { closedAt: now } : {}), + }) + .where("id", "=", id) + .where("status", "!=", "closed") + .execute(); + } + + /** Heartbeat-ish stamp on any proof of life (the honest "last heard", not a poll). */ + async touch(id: string): Promise { + await this.db + .updateTable("sshRuntimeSessions") + .set({ lastSeenAt: new Date().toISOString() }) + .where("id", "=", id) + .where("status", "in", ["opening", "active"]) + .execute(); + } + + /** + * The hidden runtime node ids of the caller's SETTLED sessions (lost or + * closed) on one destination - the candidates the reopen reconcile + * (`session-adopt.ts`) sweeps pane rows off. The destination identity is + * the same material the deterministic tmux socket hashes from + * (host/port/user; the alias is display text and deliberately not part of + * it - the same destination reached under two aliases is one destination), + * and `user` matches with SQL `IS` so the "connecting account's default" + * (null) equals itself. + */ + async settledRuntimeNodeIdsForDestination(q: { + ownerUserId: string; + host: string; + port: number; + user: string | null; + }): Promise { + const rows = await this.db + .selectFrom("sshRuntimeSessions") + .select("runtimeNodeId") + .where("ownerUserId", "=", q.ownerUserId) + .where("host", "=", q.host) + .where("port", "=", q.port) + // `IS` (not `=`): null must equal null here - a plain `=` would drop + // every default-account destination out of the reconcile. Bound value, + // never interpolated. + .where(sql`user IS ${q.user}`) + .where("status", "in", ["lost", "closed"]) + .execute(); + return rows.map((r) => r.runtimeNodeId); + } + + /** Active sessions brokered by one connecting node (the plane-side mirror of the node's own quota count). */ + async countActiveForNode(connectingNodeId: string): Promise { + const row = await this.db + .selectFrom("sshRuntimeSessions") + .select((eb) => eb.fn.countAll().as("n")) + .where("connectingNodeId", "=", connectingNodeId) + .where("status", "in", ["opening", "active"]) + .executeTakeFirstOrThrow(); + return Number(row.n); + } + + /** + * Boot sweep: `opening`/`active` rows with no live registry session (this + * process just started) are `lost` - the child died with the old plane + * process's broker link, exactly like the node-side supervisor's own boot + * reconcile - and the hidden runtime node rows go offline with them. Panes + * on those rows keep `status: running` with their `alive` left as found: + * the design §6 unavailable reading comes from the offline node, and a + * sweep must not rewrite pane outcomes it never witnessed. Never a + * reconnect, never a replay. + * + * @returns the ids of the sessions swept (the service announces them) + */ + async reconcileAtBoot(): Promise { + const stale = await this.db + .selectFrom("sshRuntimeSessions") + .select(["id", "runtimeNodeId"]) + .where("status", "in", ["opening", "active"]) + .execute(); + if (stale.length === 0) return []; + const now = new Date().toISOString(); + await this.db + .updateTable("sshRuntimeSessions") + .set({ status: "lost", closedAt: now }) + .where( + "id", + "in", + stale.map((r) => r.id), + ) + .execute(); + await this.db + .updateTable("nodes") + .set({ status: "offline" }) + .where( + "id", + "in", + stale.map((r) => r.runtimeNodeId), + ) + .where("kind", "=", "runtime") + .execute(); + return stale.map((r) => r.id); + } +} diff --git a/apps/server/api/src/services/ssh-runtime/sessions.service.ts b/apps/server/api/src/services/ssh-runtime/sessions.service.ts new file mode 100644 index 000000000..93ed56841 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/sessions.service.ts @@ -0,0 +1,504 @@ +import { randomUUID } from "node:crypto"; +import { + normalizeLabel, + parseNodeSshSessionOpenResult, + parseSshRuntimeHello, + parseSshSessionTarget, + SSH_RUNTIME_PROTOCOL, + SSH_SESSION_OPEN_DEADLINE_MS, + SSH_SESSIONS_PER_NODE, + type SshErrorCode, + type SshRuntimeHelloWire, + type SshSessionTargetWire, + sshRuntimeProtocolSupported, +} from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID, NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; +import type { SshRuntimeSessionStatus } from "@/db/types/ssh-runtime-sessions.db-types.js"; +import { audit } from "@/services/audit.js"; +import { announceNodePresence } from "@/services/nodes/node-presence-announce.js"; +import { getLive } from "@/services/nodes/node-registry.js"; +import { sendCommand } from "@/services/nodes/node-rpc.js"; +import { logger } from "@/utils/logger.js"; +import { desktopCommand, desktopOwnerLive, getDesktopLink, isDesktopBroker } from "./desktop-broker.js"; +import { sshNodeGate } from "./discovery.service.js"; +import { closeLocalBroker, openLocalBroker, sendLocalBroker } from "./local-broker.js"; +import { SshRuntimeSession } from "./session.js"; +import { adoptReconciledPanes } from "./session-adopt.js"; +import { deliverSessionFrame, deliverSessionLost, registerSession, sessionHooks } from "./session-registry.js"; +import { installSessionSettlers } from "./session-settle.js"; +import { SshRuntimeSessionsRepository } from "./sessions.repository.js"; +import { buildSshCaller } from "./ssh-actor.js"; + +/** + * The control-plane half of SSH runtime sessions (design 2026-10-05 §4): + * open a brokered session through a node the caller OWNS and keep its row. + * This is the LAUNCH PATH DECISION the slice was asked to make, recorded in + * code: the hidden `nodes` row (kind `runtime`) plus the registry branch in + * `launcher-registry.ts` is what the pane plumbing needs, and it cost one + * column-value, one insert, and a three-line registry branch - nothing + * heavier, and the alternative (session rows carrying node facts ad hoc) + * would have forked every pane surface instead of reusing it. + * + * One concern per file: this module is the OPEN door (gate, rows, broker, + * register) and the shared refusal/view surface the sibling modules read + * through it - the in-session lifecycle (launch/close/list/view/browse) lives + * in `sessions-lifecycle.ts`, and the settling (lost/closed/exit/census, and + * the boot sweep that marks rows lost when this process restarts) lives in + * `session-settle.ts`; this module installs those handlers at load, before + * any session can exist. + * + * Authorization is the salvaged rule, restated rather than reached around: + * opening requires the caller to OWN the connecting node - real ownership, + * no admin boost, no share (the `sshNodeGate` agent arm's exact posture); + * the server account uses an admin-only in-process broker under the same + * supervisor, with authority checked on open and each outgoing frame. + * + * Version honesty (design §2): the runtime's `runtimeProtocol` is checked + * against {@link SSH_RUNTIME_PROTOCOL} HERE, at the plane, and the refusal + * names both numbers; the broker never interprets runtime frames beyond the + * hello it was built to hand back. + */ + +/** The settle handlers ride the shared hook object; installed once, before any session exists. */ +installSessionSettlers(); + +const sessionsRepo = new SshRuntimeSessionsRepository(db); +const nodesRepo = new NodesRepository(db); + +/** A refusal the routes map to a named API error: the transport/eligibility facts are never guessed at the handler. */ +export class SshRuntimeRefusal extends Error { + readonly status: number; + /** + * The named code when there is one (frozen `SshErrorCode`, `session_unknown`, + * `detect_unsupported` - the task-25 capability refusal the harnesses verbs + * answer 409 with when the destination runtime predates `"detect"` - + * `dir_missing` - the F3 launch refusal, the destination's own `stat_dir` + * saying the chosen working directory is not there - or `dir_relative`, + * its plane-side sibling: a relative cwd refuses BEFORE the frame, because + * the frame grammar's absolute-path rule would otherwise read as a protocol + * violation on the destination and close the session (review I6)). + */ + readonly code: SshErrorCode | "session_unknown" | "detect_unsupported" | "dir_missing" | "dir_relative" | null; + constructor(status: number, message: string, code: SshRuntimeRefusal["code"] = null) { + super(message); + this.name = "SshRuntimeRefusal"; + this.status = status; + this.code = code; + } +} + +/** The session view the routes serialize: facts, never refs to key material. */ +export interface SshRuntimeSessionView { + id: string; + /** + * The machine that brokered the child, or null when that row has since been + * deleted (migration 0049's SET NULL: the session history outlives the + * connecting machine). + */ + connectingNodeId: string | null; + runtimeNodeId: string; + alias: string; + host: string; + port: number; + user: string | null; + /** The lifecycle union (the route's response schema is the same four literals). */ + status: SshRuntimeSessionStatus; + hello: SshRuntimeHelloWire | null; + createdAt: string; + lastSeenAt: string | null; + closedAt: string | null; +} + +/** + * Open one session. The order is the contract: gate (real ownership, live, + * not maintenance) -> quota mirror -> DB rows (`opening` session + hidden + * runtime node) -> brokered `ssh_session_open` (whose RPC timeout OUTLASTS the + * node's own hello deadline, the pattern the run read long-poll set) -> + * version check -> register + settle active. Every failure before `active` + * unrolls what it wrote (the node's own supervisor does the same on its disk). + */ +export async function openSession( + userId: string, + body: { connectingNodeId: string; target: SshSessionTargetWire; runtimeCommand?: string }, +): Promise { + // Target grammar BEFORE any write: the protocol's parser is the single + // source of truth (the node re-runs it as defense in depth), and a refused + // target must never leave an `opening` row or a hidden node behind. The + // refusal carries no named code: this is review refusing the shape, not a + // destination refusing an arrival. + const target = parseSshSessionTarget(body.target); + if (target === null) { + throw new SshRuntimeRefusal( + 409, + "the destination target is not plain session material (host, user, alias, and port all have a grammar)", + ); + } + const node = await nodesRepo.findById(body.connectingNodeId); + if ( + !node || + (node.id !== LOCAL_NODE_ID && + ((node.kind !== "agent" && !(isDesktopBroker(node.id) && node.kind === "runtime")) || + node.ownerUserId !== userId)) + ) { + // One refusal for every shape of "that is not your enrolled machine": + // 404-not-403, the non-enumerating convention the ssh gate kept. + throw new SshRuntimeRefusal(404, "the connecting node was not found or is not yours"); + } + if (node.id === LOCAL_NODE_ID) await assertLocalSshAccess(userId); + if (node.maintenance === 1) throw new SshRuntimeRefusal(409, "the connecting node is in a maintenance window"); + if (isDesktopBroker(node.id) && !(await desktopOwnerLive(node.id, userId))) + throw new SshRuntimeRefusal(404, "Desktop broker not found or unavailable."); + if (node.id !== LOCAL_NODE_ID && !isDesktopBroker(node.id) && !getLive(node.id)) + throw new SshRuntimeRefusal(409, "the connecting node is offline"); + if ((await sessionsRepo.countActiveForNode(node.id)) >= SSH_SESSIONS_PER_NODE) { + throw new SshRuntimeRefusal(409, "the connecting node carries its share of open sessions", "session_quota"); + } + + const sessionId = randomUUID(); + const runtimeNodeId = randomUUID(); + const nowIso = new Date().toISOString(); + await nodesRepo.create({ + id: runtimeNodeId, + ownerUserId: userId, + // The suffix keeps the per-owner name uniqueness rule true across + // repeated sessions on the same destination (the row is hidden from + // listings; the name only ever appears in pane tooltips). + name: normalizeLabel(`SSH ${target.alias} ${sessionId.slice(0, 4)}`, 60), + kind: NODE_KIND_RUNTIME, + os: null, + arch: null, + hostname: null, + status: "online", + lastSeenAt: nowIso, + }); + await sessionsRepo.create({ + id: sessionId, + ownerUserId: userId, + connectingNodeId: node.id, + runtimeNodeId, + alias: target.alias, + host: target.host, + port: target.port, + user: target.user, + }); + + let openData: unknown; + let localLost = false; + // The connection tag (round-3 review MAJOR): captured in the SAME + // synchronous tick as `sendCommand` resolves its own target (it rejects + // outright if that changes before the frame is queued), so the tag names + // exactly the link the open RPC rode. When a later `ready` lands on a + // different connection, the sweep reads this tag and knows the session + // rode the link that died - and a session brokered on the live connection + // is never clobbered. + const brokeredOn = getLive(node.id); + const desktopLink = getDesktopLink(node.id); + try { + // The node's own open answer is bounded by its hello deadline (spawn + + // probe + hello); the RPC must outlast it and not race it (the read + // long-poll's slack pattern). + openData = + node.id === LOCAL_NODE_ID + ? await openLocalBroker(sessionId, target, body.runtimeCommand, { + emitBytes: async (bytes) => { + deliverSessionFrame(LOCAL_NODE_ID, sessionId, Buffer.from(bytes).toString("base64")); + }, + onLost: () => { + localLost = true; + deliverSessionLost(LOCAL_NODE_ID, sessionId); + }, + }) + : isDesktopBroker(node.id) + ? await desktopCommand( + node.id, + { + type: "ssh_session_open", + ref: sessionId, + target, + ...(body.runtimeCommand ? { runtimeCommand: body.runtimeCommand } : {}), + }, + SSH_SESSION_OPEN_DEADLINE_MS + 10_000, + ) + : await sendCommand( + node.id, + { + type: "ssh_session_open", + ref: sessionId, + target: target, + ...(body.runtimeCommand !== undefined && body.runtimeCommand !== "" + ? { runtimeCommand: body.runtimeCommand } + : {}), + }, + { timeoutMs: SSH_SESSION_OPEN_DEADLINE_MS + 10_000 }, + ); + if ( + isDesktopBroker(node.id) && + (!(await desktopOwnerLive(node.id, userId)) || + getDesktopLink(node.id) !== desktopLink || + !desktopLink?.refs.has(sessionId)) + ) + throw new Error("Desktop broker disconnected while opening."); + if (node.id === LOCAL_NODE_ID) { + await assertLocalSshAccess(userId); + if (localLost) throw new Error("SSH connection ended while opening."); + } + } catch (err) { + await closeBestEffort(node.id, sessionId); + await unrollOpening(sessionId, runtimeNodeId, err); + throw mapOpenError(err); + } + const result = parseNodeSshSessionOpenResult(openData); + if (result === null) { + await closeBestEffort(node.id, sessionId); + await unrollOpening(sessionId, runtimeNodeId, new Error("malformed open answer")); + throw new SshRuntimeRefusal(502, "the node answered the session open with a malformed payload"); + } + if (!sshRuntimeProtocolSupported(result.hello)) { + await closeBestEffort(node.id, sessionId); + await unrollOpening(sessionId, runtimeNodeId, new Error("runtime protocol mismatch")); + throw new SshRuntimeRefusal( + 409, + `the destination runtime speaks protocol ${result.hello.runtimeProtocol}; this plane speaks ${SSH_RUNTIME_PROTOCOL} - update the subshell binary on the destination`, + ); + } + + const session = new SshRuntimeSession({ + id: sessionId, + ownerId: userId, + connectingNodeId: node.id, + runtimeNodeId, + target: target, + hello: result.hello, + ...(isDesktopBroker(node.id) + ? { + sendBytes: async (bytes: Uint8Array) => { + if (!(await desktopOwnerLive(node.id, userId))) throw new Error("Desktop broker unavailable."); + if (!desktopLink) throw new Error("Desktop broker unavailable."); + await desktopLink.rpc( + { type: "ssh_session_send", ref: sessionId, data_b64: Buffer.from(bytes).toString("base64") }, + 60_000, + ); + }, + } + : node.id === LOCAL_NODE_ID + ? { + sendBytes: async (bytes: Uint8Array) => { + await assertLocalSshAccess(userId); + await sendLocalBroker(sessionId, bytes); + }, + } + : {}), + }); + const hooks = sessionHooks(); + session.hooks = isDesktopBroker(node.id) + ? { + ...hooks, + onLost: (s, reason) => { + void desktopLink?.rpc({ type: "ssh_session_close", ref: sessionId }, 10_000).catch(() => {}); + hooks.onLost(s, reason); + }, + } + : node.id === LOCAL_NODE_ID + ? { + ...hooks, + onLost: (s, reason) => { + closeLocalBroker(sessionId); + hooks.onLost(s, reason); + }, + } + : hooks; + if (isDesktopBroker(node.id) && (getDesktopLink(node.id) !== desktopLink || !desktopLink?.refs.has(sessionId))) { + await closeBestEffort(node.id, sessionId); + await unrollOpening(sessionId, runtimeNodeId, new Error("Desktop SSH session ended while opening.")); + throw new SshRuntimeRefusal(409, "Desktop SSH session ended while opening."); + } + registerSession(session, brokeredOn); + await sessionsRepo.settle(sessionId, "active", JSON.stringify(result.hello)); + await announceNodePresence(runtimeNodeId); + await audit({ + actorUserId: userId, + action: "ssh_runtime_session.open", + targetType: "ssh_runtime_session", + targetId: sessionId, + metadataJson: JSON.stringify({ connectingNodeId: node.id, host: result.host, port: result.port }), + }); + // Design §6's idempotent restore, on every open: a destination whose + // earlier sessions (lost or closed) left panes running re-adopts them onto + // THIS session - rows repointed, tokens rotated on the same row, census + // deaths settled (see `session-adopt.ts`). Best-effort by rule: the open + // already succeeded, and a reconcile that cannot ask the runtime leaves + // every row at the honest settled reading; the next open reconciles again. + await adoptReconciledPanes(session).catch((err: unknown) => + logger.withError(err).warn(`ssh-runtime reconcile after open failed for ${sessionId.slice(0, 8)}`), + ); + return viewOf(sessionId); +} + +/* ------------------------------------------------------------------ */ +/* open-flow helpers */ +/* ------------------------------------------------------------------ */ + +/** Undo the DB rows an open attempt wrote, when the attempt itself failed (the node-side supervisor's `#dropRecord`, plane-side). */ +async function unrollOpening(sessionId: string, runtimeNodeId: string, cause: unknown): Promise { + if (cause instanceof Error) logger.debug(`ssh-runtime open ${sessionId.slice(0, 8)} failed: ${cause.message}`); + await sessionsRepo.settle(sessionId, "lost", null).catch(() => {}); + await nodesRepo.setStatus(runtimeNodeId, "offline").catch(() => {}); + await nodesRepo.deleteById(runtimeNodeId).catch(() => {}); + await sessionsRepoDeleteRow(sessionId).catch(() => {}); +} + +/** The full row delete (history rows OUTLIVE lost sessions, but a never-spawned opening must leave nothing). */ +async function sessionsRepoDeleteRow(sessionId: string): Promise { + await db.deleteFrom("sshRuntimeSessions").where("id", "=", sessionId).execute(); +} + +/** + * Ask the node to forget a session - best effort, every answer swallowed. + * Two callers, same shape: the protocol-mismatch unroll (the child must not + * linger for a runtime that will never speak) and the user close in + * `sessions-lifecycle.ts` (the supervisor's group-kill; the runtime may have + * exited already, in which case the answer is `session_unknown` and there is + * nothing to kill). + */ +export async function closeBestEffort(connectingNodeId: string, sessionId: string): Promise { + try { + if (connectingNodeId === LOCAL_NODE_ID) { + closeLocalBroker(sessionId); + return; + } + if (isDesktopBroker(connectingNodeId)) { + await desktopCommand(connectingNodeId, { type: "ssh_session_close", ref: sessionId }, 10_000); + return; + } + await sendCommand(connectingNodeId, { type: "ssh_session_close", ref: sessionId }, { timeoutMs: 10_000 }); + } catch { + // the node's own boot reconcile will read its `lost` records; this is hygiene, not correctness + } +} + +/** Map the brokered-open failure onto the named refusal (equality on `detail`, the house rule). */ +function mapOpenError(err: unknown): SshRuntimeRefusal { + if (err instanceof Error && "detail" in err && typeof err.detail === "string") { + const detail = err.detail; + if (detail === "runtime_missing") return new SshRuntimeRefusal(409, detail, "runtime_missing"); + // The occupied destination (review m2): the broker read the serve's busy + // exit, so the refusal is the OTHER session - named here rather than + // left to the generic arm, whose prose would stand where the copy table + // branches by code. + if (detail === "session_in_use") return new SshRuntimeRefusal(409, detail, "session_in_use"); + if (detail === "session_quota") + return new SshRuntimeRefusal(409, "the connecting node carries its share of open sessions", "session_quota"); + if (detail === "session_protocol") return new SshRuntimeRefusal(409, detail, "session_protocol"); + if (detail === "run_conflict") + return new SshRuntimeRefusal(409, "a session ref collided; nothing was spawned twice", "run_conflict"); + if (detail === "host_key_unknown" || detail === "host_key_changed" || detail === "host_key_revoked") { + return new SshRuntimeRefusal(409, `host trust: ${detail}`, detail as SshErrorCode); + } + if (detail === "auth_mode_unsupported" || detail === "key_unavailable") { + return new SshRuntimeRefusal(409, detail, detail as SshErrorCode); + } + return new SshRuntimeRefusal(409, `the destination refused the session: ${detail}`, null); + } + return new SshRuntimeRefusal(502, err instanceof Error ? err.message : "the brokered session failed"); +} + +async function viewOf(id: string): Promise { + const row = await sessionsRepo.findById(id); + if (!row) throw new SshRuntimeRefusal(500, "session row vanished"); + return viewOfRow(row); +} + +/** The row -> view projection (the DB facts, the stored hello re-parsed or null). @internal shared with `sessions-lifecycle.ts` */ +export function viewOfRow( + row: Awaited> extends infer R ? NonNullable : never, +): SshRuntimeSessionView { + return { + id: row.id, + connectingNodeId: row.connectingNodeId, + runtimeNodeId: row.runtimeNodeId, + alias: row.alias, + host: row.host, + port: row.port, + user: row.user, + status: row.status, + hello: helloFromJson(row.helloJson), + createdAt: row.createdAt, + lastSeenAt: row.lastSeenAt, + closedAt: row.closedAt, + }; +} + +function helloFromJson(json: string | null): SshRuntimeHelloWire | null { + if (json === null) return null; + try { + return parseSshRuntimeHello(JSON.parse(json)); + } catch { + return null; + } +} + +/* ------------------------------------------------------------------ */ +/* pane identity (the terminal page's trusted line, design §7) */ +/* ------------------------------------------------------------------ */ + +/** What the pane page renders as the trusted identity line, from the rows only. */ +export interface SshPaneIdentityView { + sessionId: string; + status: SshRuntimeSessionStatus; + alias: string; + host: string; + port: number; + user: string | null; + /** The broker's machine NAME (null when that node row has since been deleted). */ + connectingNodeName: string | null; +} + +/** + * Resolve a pane to the SSH session that launched it, for the trusted + * identity line. The pane's ordinary row names its node; a runtime-session + * pane's `nodeId` IS the session's hidden runtime node, so one lookup joins + * the two, and the connecting machine's name joins the three. Every shape of + * "this pane did not come through a session of yours" (an ordinary pane, a + * foreign pane, a foreign session, a deleted row) answers the same 404, the + * subshells' non-enumerating convention: the caller cannot tell which, and + * the line simply does not render. + */ +export async function getPaneIdentityView(subshellId: string, userId: string): Promise { + // The PANE's own ownership axis first (the subshells' convention: a foreign + // pane is invisible, 404 - its identity line is not the session owner's to + // read either); then the session row's owner, which is belt-and-braces on + // the per-session runtime node id but states the rule where a reviewer can + // see it. + const pane = await new SubshellsRepository(db).findById(subshellId); + if (pane === undefined || pane.userId !== userId) throw new SshRuntimeRefusal(404, "session not found"); + const session = await db + .selectFrom("sshRuntimeSessions") + .selectAll() + .where("runtimeNodeId", "=", pane.nodeId) + .orderBy("createdAt", "desc") + .executeTakeFirst(); + if (session === undefined || session.ownerUserId !== userId) throw new SshRuntimeRefusal(404, "session not found"); + const connecting = session.connectingNodeId ? await nodesRepo.findById(session.connectingNodeId) : undefined; + return { + sessionId: session.id, + status: session.status, + alias: session.alias, + host: session.host, + port: session.port, + user: session.user, + connectingNodeName: connecting?.name ?? null, + }; +} + +/** Recheck the server account's authority on open and every outgoing command. */ +async function assertLocalSshAccess(userId: string): Promise { + const decision = await sshNodeGate(await buildSshCaller({ actor: "cookie", user: { id: userId } }), LOCAL_NODE_ID); + if (!decision.allow) + throw new SshRuntimeRefusal( + decision.code === "not_found" ? 404 : 409, + "The server machine is not available for SSH from this account.", + ); +} diff --git a/apps/server/api/src/services/ssh-runtime/ssh-actor.ts b/apps/server/api/src/services/ssh-runtime/ssh-actor.ts new file mode 100644 index 000000000..bb2ce8395 --- /dev/null +++ b/apps/server/api/src/services/ssh-runtime/ssh-actor.ts @@ -0,0 +1,102 @@ +import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; +import type { GuardActor } from "@/api/auth-guard.js"; +import { db } from "@/db/index.js"; +import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; +import { originRegistry } from "@/services/trusted-origins.js"; + +/** + * The caller-side plumbing the `/api/ssh-runtime` node verbs share + * (re-homed from the retired `services/ssh/ssh-actor.ts` by Workstream C's + * retirement, design 2026-10-05 §7: the old doors retire, the live seams + * move). The plain-data {@link SshCaller} the gates read, and the explicit + * origin validation every cookie-session SSH WRITE carries. + * + * What did NOT move: the coarse `ssh` token permission (that scope retired + * with the product; these verbs are human-only and refuse machine + * credentials outright) and the grant/pane decision types. + */ + +/** + * A caller's resolved identity as the guard saw it, in plain data so the + * gates never re-read the request. `userId` follows the guard's resolution + * (a subshell key resolves to its OWNER); owner identity is necessary and + * NOT sufficient here - this surface admits cookie actors only, and the + * fields stay because the eligibility arms read them exactly as the retired + * policy's did (auth semantics are unchanged by the move). + */ +export interface SshCaller { + /** Credential kind as auth-guard derived it (node keys never reach REST). */ + actor: GuardActor; + /** The resolved user id behind the credential. */ + userId: string; + /** `sess:` principal for a subshell key; null for cookie and system-key actors. */ + principal: string | null; + /** The presenting api-key's id for bearer actors; null for cookie. */ + apiKeyId: string | null; + /** The subshell a subshell-key is bound to; null otherwise. */ + subshellId: string | null; + /** Admin flag from the guard (never an override: it only answers the built-in `local` node's admin arm). */ + isAdmin: boolean; +} + +/** The guard-derived request fields {@link buildSshCaller} consumes. */ +export interface SshGuardFacts { + actor: GuardActor; + user: { id: string }; + principal?: string | null; + apiKeyId?: string | null; +} + +/** + * Resolves the guard's context into the gate's plain-data caller. + * `isAdmin` is the ONE read this helper adds - needed only to answer the + * built-in `local` node's admin arm; admin status is never an override on + * an enrolled machine. + */ +export async function buildSshCaller(guard: SshGuardFacts): Promise { + const principal = guard.principal ?? null; + return { + actor: guard.actor, + userId: guard.user.id, + principal, + apiKeyId: guard.apiKeyId ?? null, + subshellId: + guard.actor === "subshell-key" && principal !== null && principal.startsWith("sess:") + ? principal.slice("sess:".length) + : null, + isAdmin: guard.actor === "cookie" ? (await new UserMetaRepository(db).getRole(guard.user.id)) === "admin" : false, + }; +} + +/** + * Explicit origin validation for cookie-session SSH WRITES (the retired + * spec's "new ground" item, kept verbatim: the house precedent for the + * human door is `requireCookieActor`, and no existing surface validates + * Origin itself). + * + * Rules, in order, each refusing less than the last would: + * - Non-cookie actors never reach this check: a bearer credential has no + * ambient authority to forge a cross-site request with (its key IS the + * request). No-op here. + * - No `Origin` header at all -> allowed. That is curl/scripts and the + * same-origin `fetch` some browsers omit; the WRITE routes already + * required a cookie, and SameSite=Lax (the cookie's floor) still never + * attaches one to a cross-site POST. + * - `Origin` present -> it must be a member of the LIVE origin registry + * (`originRegistry()`, the four-derived-source allowlist CORS itself uses), + * which by construction includes this instance's own origins. A cross-site + * attacker's origin is not, and the DNS-rebinding rule is preserved: the + * registry is derived from configuration, never from the request's Host. + */ +export function assertCookieWriteOrigin(actor: GuardActor, request: Request): void { + if (actor !== "cookie") return; + const origin = request.headers.get("origin"); + if (origin === null) return; + if (!originRegistry().has(origin)) { + throwApiError({ + code: BackendErrorCodes.ACCESS_DENIED, + message: "This write must come from a trusted origin (the instance's own address or a configured one)", + doNotLog: true, + }); + } +} diff --git a/apps/server/api/src/services/subshell-tokens.ts b/apps/server/api/src/services/subshell-tokens.ts index 70681ed06..bb469f240 100644 --- a/apps/server/api/src/services/subshell-tokens.ts +++ b/apps/server/api/src/services/subshell-tokens.ts @@ -40,6 +40,9 @@ export async function issueSubshellToken(subshellId: string, userId: string): Pr subshells: ["read", "write"], prompts: ["read", "write"], transfers: ["read", "write"], + // The `ssh` scope retired with the destination product (design + // 2026-10-05 §7): a fresh map carries no `ssh` key, and the guard's + // read of an old key's stale map finds no `ssh` route to act on. }, }, })) as unknown as CreatedApiKey; diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index 56bd8ca6f..468700c99 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -13,7 +13,8 @@ import type { GuardActor } from "@/api/auth-guard.js"; import { HttpError } from "@/api/auth-guard.js"; import { harnessUsable } from "@/api/harness-utils.js"; import type { ShareEntry } from "@/db/repositories/subshell-shares.repository.js"; -import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { summarizeSubshells } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID, NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; import type { PresetTable } from "@/db/types/presets.db-types.js"; import type { SubshellSharePermission } from "@/db/types/subshell-shares.db-types.js"; import type { SubshellTable } from "@/db/types/subshells.db-types.js"; @@ -33,6 +34,8 @@ import { execSentinelToken, execTimeoutMs, probeQuiet, + releasePane, + tryHoldPane, waitSentinel, } from "@/services/nodes/pane-exec.js"; import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; @@ -224,6 +227,23 @@ export async function resolveLaunchNode( if (!row || !nodeCanLaunch(access)) { throw new SubshellCreateError("node_not_found", "Node not found", 404); } + // The runtime seal (design 2026-10-05 §4, review M4): a `runtime` row is a + // session's hidden destination, not a machine anyone launches an ordinary + // subshell onto. Without this the launch would die LATER on + // `resolveBinary` answering null - accident-shaped, and the day a runtime + // launcher gains a real resolver the accident stops: the manager bakes + // SUBSHELL_API_KEY/SUBSHELL_BASE_URL into the launch env, and this node's + // launcher forwards the plan verbatim, which would push the pane's bearer + // token and the plane URL onto a machine that must never see either + // (design §5's one sentence worth of threat model). Refuse it BY NAME, + // here, before anything composes. + if (row.kind === NODE_KIND_RUNTIME) { + throw new SubshellCreateError( + "runtime_session_only", + "Runtime sessions launch through the SSH runtime path only", + 409, + ); + } // Maintenance BEFORE the share reading and before liveness, because it is // the only one of the three the caller can do something about and the // only one that is true of everybody: a window refuses the owner, every @@ -349,20 +369,6 @@ export interface ExecAnswer { nextByte: number; } -/** - * exec's per-pane lease (spec 2026-10-02 §2), shared process-wide by SUBSHELL ID. - * - * `SubshellsService` is constructed FRESH per HTTP request (contextPlugin's - * resolve), so a per-instance map would be inert, exactly the failure - * `restartInFlight` in `subshell-manager.service.ts` exists to avoid: two - * concurrent execs (two tabs, list + detail, web + MCP) would each hold their - * own map, both proceed, and their interleaved sentinels would corrupt each - * other. Living at module scope, every instance shares the one lease and a - * second caller on a held id answers 409 EXEC_IN_FLIGHT. The entry is added - * synchronously before the first await and removed in `finally`. - */ -const execInFlight = new Map>(); - /** * Business logic behind `/api/subshells`, one method per endpoint. * @@ -631,18 +637,18 @@ export class SubshellsService extends BaseService { */ async listSubshells(viewerId: string, opts: { previews?: boolean } = {}): Promise { const isAdmin = (await this.repos.userMeta.getRole(viewerId)) === "admin"; - const rows = await this.repos.subshells.listVisibleTo(viewerId, isAdmin); - const sharesBy = await this.repos.subshellShares.listForSubshells(rows.map((r) => r.id)); + const rowsShown = await this.repos.subshells.listVisibleTo(viewerId, isAdmin); + const sharesBy = await this.repos.subshellShares.listForSubshells(rowsShown.map((r) => r.id)); // Resolve access per row (needs the owner id, which the view doesn't carry), // keyed by id so the view mapping stays a plain lookup. A visible row always // resolves to view/edit/owner; "none" is impossible here but the type // carries it, so the fallback names the weakest real access. const accessBy = new Map>(); - for (const row of rows) { + for (const row of rowsShown) { const access = resolveSubshellAccess(viewerId, isAdmin, row.userId, sharesBy.get(row.id) ?? []); accessBy.set(row.id, access === "none" ? "view" : access); } - const views = await this.#manager.toViews(rows, opts); + const views = await this.#manager.toViews(rowsShown, opts); return views.map((view) => ({ ...view, access: accessBy.get(view.id) ?? ("view" as const), @@ -681,6 +687,9 @@ export class SubshellsService extends BaseService { if (ids.length === 0) return new Map(); const isAdmin = (await this.repos.userMeta.getRole(viewerId)) === "admin"; const wanted = new Set(ids); + // The screen IS the pane's output: the captured lines are the most + // sensitive bytes this app moves, and they reach only callers whose + // ordinary visibility math includes the row. const visible = (await this.repos.subshells.listVisibleTo(viewerId, isAdmin)).filter((row) => wanted.has(row.id)); return await this.#manager.previewsFor(visible); } @@ -728,12 +737,16 @@ export class SubshellsService extends BaseService { * badge numbers for the native tab and push payloads. The blessed * `isNodeOffline` predicate is passed so a waiting subshell behind an * unreachable node does not count as waiting (F1); `running`/`total` are - * unaffected. + * unaffected. The counts ride the SAME rows the list returns (one + * `listVisibleTo`), so badge numbers and list can never disagree. * @param viewerId - The signed-in user whose visible set to count */ async summarySubshells(viewerId: string): Promise<{ total: number; running: number; waiting: number }> { const isAdmin = (await this.repos.userMeta.getRole(viewerId)) === "admin"; - return await this.repos.subshells.countsVisibleTo(viewerId, isAdmin, isNodeOffline); + const rows = await this.repos.subshells.listVisibleTo(viewerId, isAdmin); + // Same single source as the list, and the shared reduction + // (`summarizeSubshells` is the ONE formula, imported rather than restated). + return summarizeSubshells(rows, isNodeOffline); } /** @@ -976,24 +989,21 @@ export class SubshellsService extends BaseService { doNotLog: true, }); } - const running = execInFlight.get(id); - if (running) { + // The reservation is taken SYNCHRONOUSLY here (nothing between the check + // and the claim awaits), so two racing calls never both proceed; it lives + // exactly as long as this call's wait. + if (!tryHoldPane(id)) { throwApiError({ code: BackendErrorCodes.EXEC_IN_FLIGHT, message: "Another exec is already waiting on this pane; retry once it finishes", doNotLog: true, }); } - // The lease spans the whole call - gate already passed, nothing between - // the get and the set awaits - so two racing calls never both proceed. - // A caller that abandons the HTTP request mid-wait still holds it until - // the deadline: this plane plumbs no request-abort anywhere (the channels - // long-poll has the same property), and the wait is bounded by the clamp, - // so the worst case is EXEC_TIMEOUT_MAX of EXEC_IN_FLIGHT refusals, - // self-healing at the deadline. - const call = this.#execInner(row, id, command, execTimeoutMs(timeoutMs)).finally(() => execInFlight.delete(id)); - execInFlight.set(id, call); - return await call; + try { + return await this.#execInner(row, id, command, execTimeoutMs(timeoutMs)); + } finally { + releasePane(id); + } } async #execInner(row: SubshellTable, id: string, command: string, timeoutMs: number): Promise { @@ -1028,9 +1038,20 @@ export class SubshellsService extends BaseService { }, }); const tail = execOutputTail(waited.outputLines, EXEC_MAX_OUTPUT_BYTES); + if (waited.status === "completed") { + return { + status: "completed", + exitCode: waited.rc, + output: tail.text, + truncated: tail.truncated, + nextByte: waited.nextByte, + }; + } + // timed_out: the REPORT-ONLY rule stands (no automatic Ctrl-C, ruling 2); + // the command keeps running and this call just stops watching. return { - status: waited.status, - exitCode: waited.rc, + status: "timed_out", + exitCode: null, output: tail.text, truncated: tail.truncated, nextByte: waited.nextByte, @@ -1233,6 +1254,19 @@ export class SubshellsService extends BaseService { // node's own fail-closed file would refuse it a second time; on `local` // there is no agent and no file, so THIS is the only gate that exists. const node = await this.repos.nodes.findById(row.nodeId); + // The runtime seal again (review M4): a restart IS a launch, so the row's + // node gets the same refusal `gate` gives a create - and a runtime pane + // dies honestly here instead of accidentally, downstream, where the + // runtime launcher's null resolveBinary would surface "Harness is not + // installed on this machine" (true of the DESTINATION from the plane's + // seat, and useless to whoever clicked restart). + if (node?.kind === NODE_KIND_RUNTIME) { + throw new SubshellCreateError( + "runtime_session_only", + "Runtime sessions launch through the SSH runtime path only; a runtime pane is ended with terminate, not restarted", + 409, + ); + } if (node?.maintenance === 1) { throwApiError({ code: BackendErrorCodes.NODE_IN_MAINTENANCE, diff --git a/apps/server/api/src/test-helpers/runtime-session.ts b/apps/server/api/src/test-helpers/runtime-session.ts new file mode 100644 index 000000000..0ee90e0aa --- /dev/null +++ b/apps/server/api/src/test-helpers/runtime-session.ts @@ -0,0 +1,54 @@ +import { SSH_RUNTIME_PROTOCOL, type SshRuntimeHelloWire, type SshSessionTargetWire } from "@internal/subshell-protocol"; +import { SshRuntimeSession } from "@/services/ssh-runtime/session.js"; + +/** + * A bare {@link SshRuntimeSession} for plane-side suites (cross-suite + * fixture, hence `test-helpers/`). Deliberately DB-free: constructing and + * registering a session is all the liveness-predicate and view tests need - + * they assert on the REGISTRY's answer, not on rows (the open flow and its + * settle writes are covered by the ssh-runtime suites, which use the real + * scripted-node harness instead). + */ + +/** The hello a session needs to exist (override any fact per test). */ +export function runtimeHello(over: Partial = {}): SshRuntimeHelloWire { + return { + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: ["ssh-runtime", "callback-sock", "detect", "pane-callback-sock"], + homeDir: "/home/dst", + dataDir: "/home/dst/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-fixture0000", + paneCount: 0, + ...over, + }; +} + +/** The reviewed destination facts the session carries. */ +export function runtimeTarget(over: Partial = {}): SshSessionTargetWire { + return { alias: "fixture", host: "127.0.0.1", port: 22, user: null, identityFile: null, ...over }; +} + +/** A constructed (unregistered) live session with fresh ids unless named. */ +export function mkRuntimeSession( + over: Partial<{ + id: string; + ownerId: string; + connectingNodeId: string; + runtimeNodeId: string; + target: SshSessionTargetWire; + hello: SshRuntimeHelloWire; + }> = {}, +): SshRuntimeSession { + return new SshRuntimeSession({ + id: over.id ?? crypto.randomUUID(), + ownerId: over.ownerId ?? "u-fixture-owner", + connectingNodeId: over.connectingNodeId ?? "n-fixture-broker", + runtimeNodeId: over.runtimeNodeId ?? crypto.randomUUID(), + target: over.target ?? runtimeTarget(), + hello: over.hello ?? runtimeHello(), + }); +} diff --git a/apps/server/api/src/ws/__tests__/input-hold.test.ts b/apps/server/api/src/ws/__tests__/input-hold.test.ts index 57d4a575b..0d33d6433 100644 --- a/apps/server/api/src/ws/__tests__/input-hold.test.ts +++ b/apps/server/api/src/ws/__tests__/input-hold.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "bun:test"; +import { beforeAll, describe, expect, it } from "bun:test"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import type { NodeLauncher } from "@/services/nodes/node-launcher.js"; import { refireInputHoldsForNode } from "@/ws/input-hold.js"; @@ -6,6 +7,13 @@ import { inputWindowHas } from "@/ws/input-window.js"; import { cleanupSubshellWs, handleSubshellMessage } from "@/ws/subshell-ws.js"; import { registerViewer, resetLiveViewersForTests, type WsSocket } from "@/ws/viewers.js"; +// The suite's fake rows read through the migrated schema like every other +// server test DB; the ssh fence branch this handler once consulted retired +// with the destination product (design 2026-10-05 §7). +beforeAll(async () => { + await ensureMigratedTestDb(); +}); + /** * The plane→node input hold (spec 2026-09-21 Wave D): a failed write is held * per attached browser session and re-fired through the node's launcher when diff --git a/apps/server/api/src/ws/__tests__/local-attach-grid.test.ts b/apps/server/api/src/ws/__tests__/local-attach-grid.test.ts index cb0ffcef4..159c78ac5 100644 --- a/apps/server/api/src/ws/__tests__/local-attach-grid.test.ts +++ b/apps/server/api/src/ws/__tests__/local-attach-grid.test.ts @@ -184,9 +184,13 @@ describe("local attach cleanup — the ws.data wiring (pre-existing leak)", () = const pocketed = await attach(row.userId, row.id, "&cols=100&rows=20&hidden=1"); // The hidden joiner takes no part: the pane stays at the laptop's size. + // The view is sliced to THIS join - the shared recorder can carry + // legitimate resizes from earlier cases (and earlier files), and the + // claim is about the join that follows the laptop's attach. + const joinResizes = resizeCalls.slice(resizedByLaptop); expect(resizeCalls.at(-1)).toEqual({ cols: 100, rows: 50 }); - expect(resizeCalls.some((c) => c.rows === 20)).toBe(false); - expect(resizeCalls.length).toBe(resizedByLaptop); + expect(joinResizes.some((c) => c.rows === 20)).toBe(false); + expect(joinResizes.length).toBe(0); const presence = laptop.sent .filter((f) => f.includes('"type":"viewers"')) .map((f) => JSON.parse(f) as { viewers: Array<{ hidden: boolean; capacity: { rows: number } | null }> }) diff --git a/apps/server/api/src/ws/__tests__/remote-attach-runtime-session.test.ts b/apps/server/api/src/ws/__tests__/remote-attach-runtime-session.test.ts new file mode 100644 index 000000000..032a8f1d0 --- /dev/null +++ b/apps/server/api/src/ws/__tests__/remote-attach-runtime-session.test.ts @@ -0,0 +1,376 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + encodeSshSessionFrame, + SSH_RUNTIME_PROTOCOL, + type SshRuntimeHelloWire, + SshSessionFrameDecoder, + type SshSessionTargetWire, +} from "@internal/subshell-protocol"; +import { until } from "@/__tests__/helpers/until.js"; +import { db } from "@/db/index.js"; +import { runMigrations } from "@/db/migrate.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; +import { NODE_KIND_RUNTIME } from "@/db/types/nodes.db-types.js"; +import { RuntimeSessionLauncher } from "@/services/ssh-runtime/runtime-session-launcher.js"; +import { SshRuntimeSession } from "@/services/ssh-runtime/session.js"; +import { + registerSession, + resetSessionRegistryForTests, + sessionHooks, +} from "@/services/ssh-runtime/session-registry.js"; +import { installSessionSettlers } from "@/services/ssh-runtime/session-settle.js"; +import { SshRuntimeSessionsRepository } from "@/services/ssh-runtime/sessions.repository.js"; +import { attachScriptedNode, ok, type ScriptedNode } from "@/test-helpers/scripted-node.js"; +import { type AttachParams, UNNAMED_DEVICE } from "@/ws/attach-params.js"; +import { attachRemoteSubshellWs, type RemoteAttachRow } from "@/ws/remote-subshell-ws.js"; +import { cleanupSubshellWs, handleSubshellMessage } from "@/ws/subshell-ws.js"; +import { paneStreams, resetLiveViewersForTests, type WsSocket } from "@/ws/viewers.js"; + +/** + * F1: the live-terminal relay serving an SSH-RUNTIME pane (design 2026-10-05 + * §4 - "list/detail/log/live/ws all stay normal"). The old refusal: the + * relay's liveness probe consulted `getLive`, and the hidden runtime node is + * deliberately never dialable, so every browser attach to a runtime pane + * died at `4004 node offline` while REST input/log/capture worked (the + * acceptance run's F1). The relay now speaks the `NodeLauncher` interface, + * and the liveness authority for a runtime row is the LIVE SESSION. + * + * The loop is as real as this seam gets: the scripted connecting node carries + * the framed commands over the real `sendCommand` chain, and the runtime + * answers each inner frame the way `runtime/dispatch.ts` does. Pinned: + * - replay first, then live bytes, keystrokes reaching the pane's input + * frame with the ack, and the tail torn down on close (the browser contract + * the agent-node relay already had, now on the session twin); + * - no live session (never opened / settled lost) -> 4004, unchanged; + * - a session that goes lost mid-attach CLOSES the viewer's socket (1012, + * the retry convention, reason "session lost") - the socket learns + * unavailability the way every channel death teaches it, never a stream + * pretending to still be live. + */ + +installSessionSettlers(); // the REAL settle writes the loss test asserts through + +const SID = "7d0f2c34-11aa-4b5c-8d9e-0f1a2b3c4d5e"; +const OWNER_UID = "u-rts-owner"; + +const b64 = (s: string): string => Buffer.from(s, "utf8").toString("base64"); + +interface Rts { + session: SshRuntimeSession; + /** Inner runtime frames the scripted destination saw, in wire order. */ + innerTypes: string[]; + /** The `subId` the relay handed to `tail_start` (the output subscription). */ + tailSubId: () => string; + detach: () => void; +} + +async function mkRuntimeSession(opts: { register: boolean }): Promise { + const target: SshSessionTargetWire = { alias: "rts", host: "127.0.0.1", port: 22, user: null, identityFile: null }; + const hello: SshRuntimeHelloWire = { + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: ["ssh-runtime", "callback-sock", "detect", "pane-callback-sock"], + homeDir: "/home/dst", + dataDir: "/home/dst/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-rts00000000", + paneCount: 0, + }; + const connectingNodeId = crypto.randomUUID(); + const runtimeNodeId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + const nodesRepo = new NodesRepository(db); + const sessionsRepo = new SshRuntimeSessionsRepository(db); + await nodesRepo.create({ + id: connectingNodeId, + ownerUserId: OWNER_UID, + name: `rts-${sessionId.slice(0, 8)}`, + kind: "agent", + }); + await nodesRepo.create({ + id: runtimeNodeId, + ownerUserId: OWNER_UID, + name: `rts-rt-${sessionId.slice(0, 8)}`, + kind: NODE_KIND_RUNTIME, + status: "online", + lastSeenAt: new Date().toISOString(), + }); + await sessionsRepo.create({ + id: sessionId, + ownerUserId: OWNER_UID, + connectingNodeId, + runtimeNodeId, + alias: target.alias, + host: target.host, + port: target.port, + user: target.user, + status: "active", + }); + await sessionsRepo.settle(sessionId, "active", JSON.stringify(hello)); + // One fixed pane id across cases in this shared file-DB: clear any prior + // case's row first (the relay contract is per-id, the id is reused like + // `remote-subshell-ws.test.ts` does for its viewer-reset rule). + await new SubshellsRepository(db).delete(SID).catch(() => {}); + await new SubshellsRepository(db).create({ + id: SID, + userId: OWNER_UID, + harnessId: "terminal", + name: "rts pane", + workingDir: "/home/dst/work", + presetId: null, + nodeId: runtimeNodeId, + tmuxSocket: `sock-${SID}`, + status: "running", + alive: 1, + startedAt: new Date().toISOString(), + notify: 1, + crossAgent: 0, + }); + const session = new SshRuntimeSession({ + id: sessionId, + ownerId: OWNER_UID, + connectingNodeId, + runtimeNodeId, + target, + hello, + }); + session.hooks = sessionHooks(); + if (opts.register) { + // The launch verb's registration (row -> token -> frame order): the + // loss-settle walks `paneIds()`, so the pane must be carried to have the + // viewer drop exercised. The plaintext is test-local memory, as in + // production it would be. + session.registerPane(SID, "dc0e1f2a-3b4c-4d5e-8f60-rts-fake-mint"); + registerSession(session); + } + + const innerTypes: string[] = []; + const innerFrames: Record[] = []; + let tailRef: { subId: string } | undefined; + const sim: ScriptedNode = attachScriptedNode(connectingNodeId, { + ssh_session_close: ok, + ssh_session_send: (cmd) => { + if (cmd.type !== "ssh_session_send") throw new Error("wrong cmd"); + for (const frame of new SshSessionFrameDecoder().push(new Uint8Array(Buffer.from(cmd.data_b64, "base64")))) { + const inner = frame as { + type?: string; + ref?: string; + subId?: string; + fromByte?: number; + maxBytes?: number; + }; + if (inner.ref === undefined || inner.type === undefined) continue; + innerTypes.push(inner.type); + innerFrames.push(inner as Record); + const answer = (data?: unknown): void => + session.ingestBytes( + encodeSshSessionFrame({ + type: "result", + ref: inner.ref as string, + ok: true, + ...(data !== undefined ? { data: data as never } : {}), + }), + ); + switch (inner.type) { + case "probe": + answer([{ subshellId: SID, alive: true, exitCode: null }]); + break; + case "log_read": { + const full = "ab\ncd\n"; + answer( + inner.maxBytes === 1 + ? { bytes_b64: b64(full.slice(0, 1)), next: Math.min(1, full.length), size: full.length } + : { bytes_b64: b64(full), next: full.length, size: full.length }, + ); + break; + } + case "capture": + answer("SCREEN"); + break; + case "tail_start": + tailRef = { subId: inner.subId as string }; + answer(); + break; + case "pane_size": + answer({ cols: 80, rows: 24 }); + break; + case "pane_cursor": + answer({ x: 0, y: 0 }); + break; + default: + answer(); + } + } + return undefined; + }, + }); + + return { + session, + innerTypes, + tailSubId: () => { + if (tailRef === undefined) throw new Error("tail_start never reached the wire"); + return tailRef.subId; + }, + detach: () => sim.detach(), + }; +} + +interface FakeBrowser { + ws: WsSocket; + sent: string[]; + closed: { code?: number; reason?: string }[]; +} + +function fakeBrowser(): FakeBrowser { + const sent: string[] = []; + const closed: { code?: number; reason?: string }[] = []; + const ws: WsSocket = { + data: {}, + send: (d: string) => { + sent.push(d); + return 0; + }, + // Production closes always land a `close` event, and the plugin's handler + // runs `cleanupSubshellWs` - the fake mirrors the chain so disposal (the + // tail_stop on a settle-driven drop) is proven, not assumed. + close: (code?: number, reason?: string) => { + closed.push({ code, reason }); + cleanupSubshellWs(ws); + }, + raw: {}, + } as unknown as WsSocket; + return { ws, sent, closed }; +} + +function attachParams(over: Partial = {}): AttachParams { + return { size: null, deviceLabel: UNNAMED_DEVICE, hidden: false, build: "MISSING", wireMode: "json", ...over }; +} + +function attachRow(nodeId: string): RemoteAttachRow { + return { id: SID, nodeId, tmuxSocket: `sock-${SID}`, userId: OWNER_UID, startedAt: new Date().toISOString() }; +} + +const framesOf = (sent: string[], type: string): Record[] => + sent.map((s) => JSON.parse(s) as Record).filter((f) => f.type === type); + +beforeAll(async () => { + await runMigrations(); // the relay's persistOutput lastOutputAt write + the rows this fixture creates +}); + +beforeEach(async () => { + resetSessionRegistryForTests(); + resetLiveViewersForTests(); + paneStreams.resetForTests(); + await new UserMetaRepository(db).setTerminalReplayLines(OWNER_UID, null); +}); + +afterEach(() => { + resetSessionRegistryForTests(); + resetLiveViewersForTests(); + paneStreams.resetForTests(); +}); + +describe("runtime-pane live attach (F1)", () => { + it("replays, streams session output, takes the keystroke through the same writer, acks it", async () => { + const rts = await mkRuntimeSession({ register: true }); + const { ws, sent, closed } = fakeBrowser(); + try { + await attachRemoteSubshellWs( + ws, + attachRow(rts.session.runtimeNodeId), + new RuntimeSessionLauncher(rts.session), + "owner", + attachParams(), + ); + await until(() => rts.innerTypes.includes("tail_start"), "tail_start on the wire"); + expect(closed).toEqual([]); // the session-backed liveness passed - NO 4004 + + // The relay's flow on the framed channel: probe (liveness) first, the + // JOIN size probe second, then the tail, the capture, the cursor and + // the geometry read (the async tail's exact interleaving with the + // capture is the launcher's business, not the browser's). + expect(rts.innerTypes.slice(0, 2)).toEqual(["probe", "log_read"]); + for (const t of ["tail_start", "capture", "pane_cursor", "pane_size"]) { + expect(rts.innerTypes, `${t} on the wire`).toContain(t); + } + const replay = framesOf(sent, "replay"); + expect(replay.length).toBe(1); + expect(String(replay[0]?.data)).toContain("SCREEN"); + + // Live bytes: an `output` frame for the relay's tail subscription rides + // the session's pump out to the browser as an `output` frame. The + // offsets continue the JOIN point exactly (the log's 6 bytes at attach) + // - a frame starting before the cursor would be partially duplicate. + const subId = rts.tailSubId(); + rts.session.ingestBytes( + encodeSshSessionFrame({ type: "output", subId, subshellId: SID, fromByte: 6, toByte: 8, data_b64: b64("hi") }), + ); + await until(() => framesOf(sent, "output").some((f) => String(f.data).includes("hi")), "output frame delivered"); + + // A keystroke through the SHARED message handler reaches the session's + // `input` frame - the same writer REST input uses - and is acked. + handleSubshellMessage(ws, { type: "input", data: "x", id: 7 }); + await until(() => rts.innerTypes.includes("input"), "input frame on the wire"); + await until(() => framesOf(sent, "ack").some((f) => f.id === 7), "ack for the committed write"); + + cleanupSubshellWs(ws); + await until(() => rts.innerTypes.includes("tail_stop"), "tail_stop on close"); + } finally { + rts.detach(); + } + }); + + it("refuses a row whose session is not live with the 4004 it always gave", async () => { + const rts = await mkRuntimeSession({ register: false }); + const { ws, sent, closed } = fakeBrowser(); + try { + await attachRemoteSubshellWs( + ws, + attachRow(rts.session.runtimeNodeId), + new RuntimeSessionLauncher(rts.session), + "owner", + attachParams(), + ); + expect(closed).toEqual([{ code: 4004, reason: "node offline" }]); + expect(sent).toEqual([]); + } finally { + rts.detach(); + } + }); + + it("a session that goes lost closes the live viewer with 1012, never a dead stream", async () => { + const rts = await mkRuntimeSession({ register: true }); + const { ws, closed } = fakeBrowser(); + try { + await attachRemoteSubshellWs( + ws, + attachRow(rts.session.runtimeNodeId), + new RuntimeSessionLauncher(rts.session), + "owner", + attachParams(), + ); + await until(() => rts.innerTypes.includes("tail_start"), "attached"); + expect(closed).toEqual([]); + + rts.session.markLost("child-lost"); // the honest death: hooks -> settleLost -> viewer drop + await until( + () => closed.some((c) => c.code === 1012 && c.reason === "session lost"), + "the viewer socket closed on the loss", + ); + // The row went unavailable through the same settle, and the pump is + // gone with the viewer (the disposer's `tail_stop` is fire-and-forget + // and the dead session's command refuses it - correctly: there is no + // channel left to stop. What must not survive is the local stream). + const row = await new SubshellsRepository(db).findById(SID); + expect(row?.alive).toBe(0); + await until(() => paneStreams.viewerCount(SID) === 0, "the pump disposed with the dropped viewer"); + } finally { + rts.detach(); + } + }); +}); diff --git a/apps/server/api/src/ws/__tests__/remote-attach-ws.integration.test.ts b/apps/server/api/src/ws/__tests__/remote-attach-ws.integration.test.ts index ab1a8ac64..9d9a200db 100644 --- a/apps/server/api/src/ws/__tests__/remote-attach-ws.integration.test.ts +++ b/apps/server/api/src/ws/__tests__/remote-attach-ws.integration.test.ts @@ -230,10 +230,17 @@ describe("double cleanup parity — the local path absorbs it identically (T11 p // The local branch reaches the real tmux CLI; stub the two pane-touching // members of the SHARED defaultLocalLauncher (the local-attach suite's // technique) and let fs.watch / the tail pump / the DB run for real. - const originals = { hasSubshell: defaultLocalLauncher.hasSubshell, capture: defaultLocalLauncher.capture }; + const originals = { + hasSubshell: defaultLocalLauncher.hasSubshell, + capture: defaultLocalLauncher.capture, + paneSize: defaultLocalLauncher.paneSize, + paneCursor: defaultLocalLauncher.paneCursor, + }; afterEach(() => { defaultLocalLauncher.hasSubshell = originals.hasSubshell; defaultLocalLauncher.capture = originals.capture; + defaultLocalLauncher.paneSize = originals.paneSize; + defaultLocalLauncher.paneCursor = originals.paneCursor; }); it("two cleanupSubshellWs calls on a local attach: no throw, and the stream stays dead", async () => { @@ -252,6 +259,15 @@ describe("double cleanup parity — the local path absorbs it identically (T11 p }); defaultLocalLauncher.hasSubshell = async () => true; defaultLocalLauncher.capture = async () => "SCREEN"; + // The announce-before-replay `geometry` frame is measured through + // `paneSize` right before the replay ships; this case pins the + // replay/cleanup contract, so the pane answers "no size" and the socket + // sees replay first. Declared HERE rather than trusted from a previous + // file's harness state (the serial process shares the launcher + // singleton, and a sibling suite's stub outliving its own file is a + // hazard this test used to ride by accident). + defaultLocalLauncher.paneSize = async () => null; + defaultLocalLauncher.paneCursor = async () => null; const logFile = subshellLogPath(id); await Bun.write(logFile, "old\n"); // log exists ⇒ startLogTail branch (fs.watch path) diff --git a/apps/server/api/src/ws/__tests__/subshell-ws.test.ts b/apps/server/api/src/ws/__tests__/subshell-ws.test.ts index 4dc6dd475..f8901904a 100644 --- a/apps/server/api/src/ws/__tests__/subshell-ws.test.ts +++ b/apps/server/api/src/ws/__tests__/subshell-ws.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import { setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; import { db } from "@/db/index.js"; import { getRequestlessContext } from "@/lib/context.js"; @@ -7,6 +8,13 @@ import { parseClientBuild } from "@/ws/attach-params.js"; import { handleSubshellMessage } from "@/ws/subshell-ws.js"; import { registerViewer, resetGeometryQueueForTests, resetLiveViewersForTests, type WsSocket } from "@/ws/viewers.js"; +// The suite touches the database (migrated before anything runs, the +// per-file isolation rule - this file's own `beforeAll` calls below describe +// the auth-table needs that sit on top). +beforeAll(async () => { + await ensureMigratedTestDb(); +}); + // stripSyncMarkers / SyncStreamStripper moved to ws/sync-stripper.ts — // pinned there by __tests__/sync-stripper.test.ts. @@ -69,31 +77,39 @@ function fakeSocket( const tick = () => new Promise((r) => setTimeout(r, 0)); describe("handleSubshellMessage", () => { - it("forwards a keystroke without submitting it", () => { - const { ws, inputs } = fakeSocket(); + // The keystroke dispatch runs the write asynchronously; frame ORDER + // survives because the per-frame chain is entered in call order. `tick()` + // lets those microtasks resolve. + + it("forwards a keystroke without submitting it", async () => { + const { ws, inputs } = fakeSocket({ subshellId: "fwd-keystrokes" }); for (const ch of "hello") handleSubshellMessage(ws, JSON.stringify({ type: "input", data: ch })); + await tick(); expect(inputs).toEqual(["h", "e", "l", "l", "o"]); }); - it("forwards a multi-line paste as a single unmodified chunk", () => { - const { ws, inputs } = fakeSocket(); + it("forwards a multi-line paste as a single unmodified chunk", async () => { + const { ws, inputs } = fakeSocket({ subshellId: "fwd-paste" }); const data = "line one\nline two\n\nline four"; handleSubshellMessage(ws, JSON.stringify({ type: "input", data })); + await tick(); expect(inputs).toEqual([data]); }); - it("forwards control bytes verbatim", () => { - const { ws, inputs } = fakeSocket(); + it("forwards control bytes verbatim", async () => { + const { ws, inputs } = fakeSocket({ subshellId: "fwd-control" }); for (const data of ["\x04", "\x1b[A", "\r"]) { handleSubshellMessage(ws, JSON.stringify({ type: "input", data })); } + await tick(); expect(inputs).toEqual(["\x04", "\x1b[A", "\r"]); }); - it("delivers a pasted JSON object verbatim instead of eating it as a control frame", () => { - const { ws, inputs, resizes } = fakeSocket(); + it("delivers a pasted JSON object verbatim instead of eating it as a control frame", async () => { + const { ws, inputs, resizes } = fakeSocket({ subshellId: "fwd-pasted-json" }); const pasted = '{"type":"resize","cols":1,"rows":1}'; handleSubshellMessage(ws, JSON.stringify({ type: "input", data: pasted })); + await tick(); expect(inputs).toEqual([pasted]); expect(resizes).toEqual([]); }); @@ -323,14 +339,6 @@ describe("parseClientBuild", () => { }); }); -/** - * Typing into a pane answers its unseen push (operator report 2026-09-25): - * the two prior answering events were both BOUNDARIES (the detail read, the - * attach), so a push that arrived while the owner was already attached, - * already looking, kept its bell until the next reload — exactly the "the - * needs-attention flag won't clear while I'm interacting with it" complaint. - * The shared input handler is now the third answering event. - */ describe("handleSubshellMessage answers the unseen push on the owner's keystroke", () => { const seen: string[] = []; beforeAll(async () => { diff --git a/apps/server/api/src/ws/desktop-broker.plugin.ts b/apps/server/api/src/ws/desktop-broker.plugin.ts new file mode 100644 index 000000000..88b7c5a30 --- /dev/null +++ b/apps/server/api/src/ws/desktop-broker.plugin.ts @@ -0,0 +1,42 @@ +import { Elysia } from "elysia"; +import { + attachDesktopBroker, + authenticateDesktopBroker, + type DesktopIdentity, + type DesktopLink, + type DesktopSocket, +} from "@/services/ssh-runtime/desktop-broker.js"; + +interface BrokerSocket extends DesktopSocket { + data: { desktopIdentity: DesktopIdentity; desktopLink?: DesktopLink; desktopOpening?: Promise }; +} +/** This narrow bearer is accepted only here; REST authGuard never recognizes it. */ +export const desktopBrokerWsPlugin = new Elysia().ws("/api/ssh-runtime/desktop-brokers/attach", { + async upgrade(context) { + const request = (context as { request: Request }).request; + const identity = await authenticateDesktopBroker(request.headers.get("authorization")); + Object.assign(context as Record, { desktopIdentity: identity }); + }, + open(ws) { + const socket = ws as unknown as BrokerSocket; + socket.data.desktopOpening = attachDesktopBroker(socket.data.desktopIdentity, socket) + .then((link) => { + socket.data.desktopLink = link; + }) + .catch(() => { + socket.close(4401, "Desktop pairing refused"); + }); + }, + async message(ws, frame) { + const socket = ws as unknown as BrokerSocket; + await socket.data.desktopOpening; + await socket.data.desktopLink?.message(frame).catch(() => socket.data.desktopLink?.close()); + }, + async close(ws) { + const socket = ws as unknown as BrokerSocket; + await socket.data.desktopOpening; + socket.data.desktopLink?.close(); + }, + maxPayloadLength: 1024 * 1024, + idleTimeout: 60, +}); diff --git a/apps/server/api/src/ws/input-hold.ts b/apps/server/api/src/ws/input-hold.ts index 5fef146b2..3b929d53b 100644 --- a/apps/server/api/src/ws/input-hold.ts +++ b/apps/server/api/src/ws/input-hold.ts @@ -44,6 +44,16 @@ * - **A re-fire failure re-enters the hold.** The drain stops at the first * rejection, leaving that frame at the head and every later one behind it; * the next trigger re-enters. No clock anywhere in this module. + * - **A re-adopted runtime pane's old holds stay orphaned until the socket + * closes.** Review note: holds are keyed by the node id of the socket's + * row, and a hidden runtime node never `ready`s again; after a session + * loss + reopen the row reparents to a NEW runtime node id, so backlog + * held under the OLD key has no re-fire trigger (new writes arrive under + * the new one). The leak is bounded by rule three (at most one client + * pending queue per socket) and dies with the socket's + * {@link dropInputHolds}; the client's own retry is the delivery path + * (rule one), so the orphan is wasted memory for a socket's lifetime, + * never a lost keystroke beyond what the browser already re-ships. * * Ordering: holds re-fire in id order, and because an arrival while holds * exist JOINS the queue instead of dispatching past them, no ARRIVING frame diff --git a/apps/server/api/src/ws/pane-sources.ts b/apps/server/api/src/ws/pane-sources.ts index cc4b7b682..a2d22745c 100644 --- a/apps/server/api/src/ws/pane-sources.ts +++ b/apps/server/api/src/ws/pane-sources.ts @@ -1,6 +1,5 @@ import { type FSWatcher, watch } from "node:fs"; import type { NodeLauncher } from "@/services/nodes/node-launcher.js"; -import type { RemoteLauncher } from "@/services/nodes/remote-launcher.js"; import { captureToTerminalText } from "@/ws/capture-text.js"; import type { PaneSource } from "@/ws/pane-stream.js"; import { SyncStreamStripper } from "@/ws/sync-stripper.js"; @@ -187,10 +186,15 @@ export function createPanePollSource(options: PanePollSourceOptions): PaneSource }; } -/** What a source needs to tail a subshell running on a remote node. */ +/** What a source needs to tail a subshell running on a remote machine. */ export interface RemoteTailSourceOptions { - /** The node's launcher — the relay to the agent that owns the pane. */ - launcher: RemoteLauncher; + /** + * The pane's launcher — the relay to the machine that owns it. The source + * speaks only `NodeLauncher`'s `tailStart`, which the agent + * (`RemoteLauncher`) and the SSH-runtime session (`RuntimeSessionLauncher`) + * both implement with the same ordering contract. + */ + launcher: NodeLauncher; /** Subshell id. */ subshellId: string; /** diff --git a/apps/server/api/src/ws/remote-subshell-ws.ts b/apps/server/api/src/ws/remote-subshell-ws.ts index 195511836..a8740b4a3 100644 --- a/apps/server/api/src/ws/remote-subshell-ws.ts +++ b/apps/server/api/src/ws/remote-subshell-ws.ts @@ -1,8 +1,9 @@ import { getRequestlessContext } from "@/lib/context.js"; import { type Access, accessAtLeast } from "@/lib/subshell-access.js"; import { replayLineCap } from "@/services/nodes/log-tail.js"; +import type { NodeLauncher } from "@/services/nodes/node-launcher.js"; import { getLive } from "@/services/nodes/node-registry.js"; -import type { RemoteLauncher } from "@/services/nodes/remote-launcher.js"; +import { liveSessionForRuntimeNode } from "@/services/ssh-runtime/session-registry.js"; import { logger } from "@/utils/logger.js"; import { forensicsEnabled, recordAttachPaint } from "@/ws/attach-forensics.js"; import type { AttachParams } from "@/ws/attach-params.js"; @@ -42,7 +43,7 @@ import { * the agent's `tail_start` RESULT can resolve before its initial catch-up * bytes flow (never assume "caught up" at resolve — the launcher's relay * queues those bytes itself), gap backfill/dup-clamp live inside - * {@link RemoteLauncher.tailStart}, so `onChunk` here just ships bytes + * `tailStart` on both launchers, so `onChunk` here just ships bytes * monotonically. Client input/resize/teardown ride the EXISTING * `handleSubshellMessage`/`cleanupSubshellWs` — attach sets `ws.data` to the * same {@link WsData} shape the local path builds. @@ -94,16 +95,28 @@ const CLIENT_LAG_LIMIT_BYTES = 4 * 1024 * 1024; * `1011`; a browser whose send queue exceeds {@link CLIENT_LAG_LIMIT_BYTES} * is closed `1011 client too slow` mid-tail. * + * Design 2026-10-05 §4 widens this relay to SSH-runtime panes: a row on a + * hidden `runtime`-kind node relays through the session's + * `RuntimeSessionLauncher` (the same {@link NodeLauncher} twin every pane + * surface already calls), and the liveness check reads the SESSION, not a + * node link the runtime kind never has. Liveness authority, per row: agent + * node → live `/ws/node` connection; runtime node → live session. A session + * that goes lost closes the viewers of its panes (the settle path calls + * `closeViewersForSubshell`), so the socket learns unavailability the way + * every channel death teaches it: the stream ends and nothing completes. + * * Disposal is EXACTLY-ONCE by construction: `ws.data.cleanup` is installed * before the first await (so a close during any round-trip lands on it), it * flags `detached` and calls the late-bound disposer, and the disposer - * itself is idempotent ({@link RemoteLauncher.tailStart}). If the browser + * itself is idempotent (`tailStart`). If the browser * vanishes before `tailStart` resolves, no zombie stream survives: the * pending attach disposes immediately on resolve (or returns before arming). * * @param ws - the browser socket (Elysia WS, narrowed to {@link WsSocket}) * @param row - the subshell row (must name a non-local node) - * @param launcher - the node's cached {@link RemoteLauncher} (registry-resolved) + * @param launcher - the row's registry-resolved launcher ({@link NodeLauncher}): + * the agent node's cached `RemoteLauncher` or a runtime session's + * `RuntimeSessionLauncher` — the relay speaks only interface members * @param access - the caller's effective access; only `edit`/`owner` may type * @param size - the client's fitted geometry; when present the pane is * resized (and given {@link RESIZE_SETTLE_MS} to repaint) BEFORE the @@ -112,7 +125,7 @@ const CLIENT_LAG_LIMIT_BYTES = 4 * 1024 * 1024; export async function attachRemoteSubshellWs( ws: WsSocket, row: RemoteAttachRow, - launcher: RemoteLauncher, + launcher: NodeLauncher, access: Access, /** * Everything the client declared on the connect URL, as ONE value. @@ -126,7 +139,13 @@ export async function attachRemoteSubshellWs( params: AttachParams, ): Promise { const { size, deviceLabel, hidden, wireMode } = params; - if (!getLive(row.nodeId)) { + // Liveness, per the row's machine class: an agent node answers through its + // `/ws/node` connection; a hidden runtime node (design 2026-10-05 §4) is + // deliberately never dialable, so its authority is the LIVE SESSION - the + // same predicate `launcherFor` resolves by. A settled (lost/closed) session + // is absent there, and the pane reads the same 4004 an offline node gives: + // unavailable, not completed. + if (!getLive(row.nodeId) && liveSessionForRuntimeNode(row.nodeId) === undefined) { ws.close(4004, "node offline"); return; } @@ -153,8 +172,13 @@ export async function attachRemoteSubshellWs( launcher, socket: row.tmuxSocket ?? "", subshellId: row.id, - // Always an agent node on this path — the local twin sets `local`. Wave D - // keys the plane→node input hold by it. + // The row's machine: an agent node's id or a hidden runtime node's id — + // both classes reach this handler (the local twin sets `local`). Wave D + // keys the plane→node input hold by it; the hold's node-`ready` re-fire + // trigger never refires for a runtime node (it has no `/ws/node` connection + // to become ready again), but the other trigger — a new write arriving and + // draining through this row's launcher — still applies, and that launcher + // speaks the session channel (ws/input-hold.ts). nodeId: row.nodeId, logFile: "", // Only `edit`/`owner` may type into the pane; a `view` grantee watches. @@ -195,7 +219,11 @@ export async function attachRemoteSubshellWs( // desync a diff-rendering TUI can never heal; skipped-overlap it replays // is idempotent repaint). A missing/empty log reads size 0 and the tail // still arms (the agent tolerates a log pipe-pane has not created yet). - const logStart = (await launcher.readLogSized(row.id, 0, 1)).size; + // `readLogWindow` (the interface name; `RemoteLauncher`'s own + // `readLogSized` is that method under its class name) — the runtime twin + // answers the identical `log_read` frame, so the join point is the same + // byte on both machine classes. + const logStart = (await launcher.readLogWindow(row.id, 0, 1)).size; if (detached) return; // The local twin's rule: no readable bytes at the join, or residual bytes @@ -247,7 +275,7 @@ export async function attachRemoteSubshellWs( let repainted = false; let nudged = false; if (size) { - const sizeOf = async (): Promise => (await launcher.readLogSized(row.id, 0, 1)).size; + const sizeOf = async (): Promise => (await launcher.readLogWindow(row.id, 0, 1)).size; try { // The pane is fitted to what EVERY viewer can display, not to the // joiner's own size — the local twin's rule, and the remote path was diff --git a/apps/server/api/src/ws/subshell-ws.ts b/apps/server/api/src/ws/subshell-ws.ts index b47c0ea41..23d11c409 100644 --- a/apps/server/api/src/ws/subshell-ws.ts +++ b/apps/server/api/src/ws/subshell-ws.ts @@ -4,7 +4,6 @@ import { getRequestlessContext } from "@/lib/context.js"; import { accessAtLeast } from "@/lib/subshell-access.js"; import { launcherFor } from "@/services/nodes/launcher-registry.js"; import { replayLineCap } from "@/services/nodes/log-tail.js"; -import type { RemoteLauncher } from "@/services/nodes/remote-launcher.js"; import { subshellLogPath } from "@/services/nodes/subshell-paths.js"; import { logger } from "@/utils/logger.js"; import { forensicsEnabled, recordAttachPaint } from "@/ws/attach-forensics.js"; @@ -107,15 +106,16 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { // spec §6.5: the launcher resolves PER ROW — `local` (the schema default; // `nodeId` is NOT NULL) keeps the untouched path below, an agent-node row - // relays over its node socket and returns. `launcherFor` caches a - // RemoteLauncher for every non-local id, so the cast restates that registry - // invariant rather than guessing at the instance. + // relays over its node socket and returns. A `runtime`-kind row (design + // 2026-10-05 §4) resolves to the SAME relay through its session's + // `RuntimeSessionLauncher` — the relay speaks the `NodeLauncher` interface, + // so both machine classes ride this one call with no cast. const launcher = launcherFor(row.nodeId); if (row.nodeId !== LOCAL_NODE_ID) { // The WHOLE params struct, not a hand-picked few: this call site is where // `hidden` went missing for node panes, because it took each input as its // own positional argument and one of them was simply never added. - await attachRemoteSubshellWs(ws, row, launcher as RemoteLauncher, access, params); + await attachRemoteSubshellWs(ws, row, launcher, access, params); return; } // Split out so the rest of this function sees a non-null socket, and so the @@ -474,88 +474,101 @@ export function handleSubshellMessage(ws: WsSocket, message: string | object): v // retry branches, because a RETRIED frame is still a frame the human // typed. A `view` grantee never reaches this line. if (data.attendsPush) void answerUnseenPush(data.subshellId); - // Idempotent input (spec 2026-09-21 Wave A): a frame carrying an id is - // retried by its client, so the write must be deduped against what has - // ALREADY landed, and the client must learn when the write DID land. Both - // are keyed by the client's attach session (`&sid=`, read from the same - // upgrade-query channel `attachUrlFromQuery` feeds), per session and not - // per socket, so a retry arriving on the RECONNECTED socket is still - // recognized, and not per subshell alone, so two viewers on one shared - // pane, each counting ids from 1, never collide. - const sessionId = sessionIdOf(data) ?? data.viewerId; - const id = frame.id; - if (id !== undefined) { - if (inputWindowHas(data.subshellId, sessionId, id)) { - // Already written. Drop WITHOUT touching the pane, but still ack: the - // client must be able to retire an id the server has processed, or it - // would re-send it on every reconnect forever. - sendToSocket(ws, { type: "ack", id }); - return; - } - // Wave D: once this session holds failed plane→node writes, a newer id - // JOINS the queue instead of dispatching past them — a held id must - // re-fire BEFORE anything newer is written, or the user's keystrokes - // land reordered. The append is itself a re-fire trigger, so a backlog - // arriving on a quietly-recovered node ships immediately. - if (hasHeldInput(data.nodeId, data.subshellId, sessionId)) { - holdFailedInput({ - nodeId: data.nodeId, - subshellId: data.subshellId, - sessionId, - id, - payload: frame.data, - ws, - sendAck: () => sendToSocket(ws, { type: "ack", id }), - }); - return; - } - void data.launcher - .sendInput(data.socket, data.subshellId, frame.data) - .then(() => { - // Committed only on SUCCESS: a failed write must stay re-writable, - // because the client's reconnect re-send is the only thing that - // would carry the keystroke. RESIDUAL AMBIGUITY (accepted, - // at-least-once): a write still in flight when the retry arrives is - // not yet in the window, so the retry writes too: the keystroke can - // land twice, never zero times. - // The commit happens BEFORE the ack, on the write resolving — so a - // resolved write is already in the window when anything (a client - // re-send, a Wave D re-fire) can consult it, and only a write whose - // result never came back is re-writable. - inputWindowAdd(data.subshellId, sessionId, id); - sendToSocket(ws, { type: "ack", id }); - }) - .catch((err) => { - // Wave D: a failed plane→node write is HELD, not dropped — the - // plane-side hold re-fires it when the node's connection is live - // again (ws/input-hold.ts), because the browser socket lives on and - // the client's own retry ships only on reconnect or ack-drain. - // Scoped to agent nodes: the local leg has no node-ready moment to - // re-fire from, and a local failure keeps today's drop-and-log. - if (data.nodeId !== LOCAL_NODE_ID) { - holdFailedInput({ - nodeId: data.nodeId, - subshellId: data.subshellId, - sessionId, - id, - payload: frame.data, - ws, - sendAck: () => sendToSocket(ws, { type: "ack", id }), - }); - return; - } - logFailure(err); - }); + void routeKeystroke(ws, data, frame.data, frame.id, logFailure); + } +} + +/** + * The keystroke dispatch: idempotent dedupe, Wave D hold, drop-and-log. + */ +async function routeKeystroke( + ws: WsSocket, + data: WsData, + text: string, + id: number | undefined, + logFailure: (err: unknown) => void, +): Promise { + // Idempotent input (spec 2026-09-21 Wave A): a frame carrying an id is + // retried by its client, so the write must be deduped against what has + // ALREADY landed, and the client must learn when the write DID land. Both + // are keyed by the client's attach session (`&sid=`, read from the same + // upgrade-query channel `attachUrlFromQuery` feeds), per session and not + // per socket, so a retry arriving on the RECONNECTED socket is still + // recognized, and not per subshell alone, so two viewers on one shared + // pane, each counting ids from 1, never collide. The dedupe runs on both + // seams; the hold machinery belongs to the ordinary one only. + const sessionId = sessionIdOf(data) ?? data.viewerId; + if (id !== undefined) { + if (inputWindowHas(data.subshellId, sessionId, id)) { + // Already written. Drop WITHOUT touching the pane, but still ack: the + // client must be able to retire an id the server has processed, or it + // would re-send it on every reconnect forever. + sendToSocket(ws, { type: "ack", id }); return; } - // No id: nothing to hold, and this dispatches PAST any held ids of the - // session (Wave D). Unreachable with the engaged client — the queue - // either carries ids from its first frame or was disengaged, and - // disengage drops its own backlog first — so the only bare frame that - // can land behind a hold is a client that downgraded mid-session, whose - // own retry machinery has already given up. Today's drop-and-log. - void data.launcher.sendInput(data.socket, data.subshellId, frame.data).catch(logFailure); + // Wave D: once this session holds failed plane→node writes, a newer id + // JOINS the queue instead of dispatching past them — a held id must + // re-fire BEFORE anything newer is written, or the user's keystrokes + // land reordered. The append is itself a re-fire trigger, so a backlog + // arriving on a quietly-recovered node ships immediately. + if (hasHeldInput(data.nodeId, data.subshellId, sessionId)) { + holdFailedInput({ + nodeId: data.nodeId, + subshellId: data.subshellId, + sessionId, + id, + payload: text, + ws, + sendAck: () => sendToSocket(ws, { type: "ack", id }), + }); + return; + } + void data.launcher + .sendInput(data.socket, data.subshellId, text) + .then(() => { + // Committed only on SUCCESS: a failed write must stay re-writable, + // because the client's reconnect re-send is the only thing that + // would carry the keystroke. RESIDUAL AMBIGUITY (accepted, + // at-least-once): a write still in flight when the retry arrives is + // not yet in the window, so the retry writes too: the keystroke can + // land twice, never zero times. + // The commit happens BEFORE the ack, on the write resolving — so a + // resolved write is already in the window when anything (a client + // re-send, a Wave D re-fire) can consult it, and only a write whose + // result never came back is re-writable. + inputWindowAdd(data.subshellId, sessionId, id); + sendToSocket(ws, { type: "ack", id }); + }) + .catch((err) => { + // Wave D: a failed plane→node write is HELD, not dropped — the + // plane-side hold re-fires it when the node's connection is live + // again (ws/input-hold.ts), because the browser socket lives on and + // the client's own retry ships only on reconnect or ack-drain. + // Scoped to agent nodes: the local leg has no node-ready moment to + // re-fire from, and a local failure keeps today's drop-and-log. + if (data.nodeId !== LOCAL_NODE_ID) { + holdFailedInput({ + nodeId: data.nodeId, + subshellId: data.subshellId, + sessionId, + id, + payload: text, + ws, + sendAck: () => sendToSocket(ws, { type: "ack", id }), + }); + return; + } + logFailure(err); + }); + return; } + // No id: nothing to hold, and this dispatches PAST any held ids of the + // session (Wave D). Unreachable with the engaged client — the queue + // either carries ids from its first frame or was disengaged, and + // disengage drops its own backlog first — so the only bare frame that + // can land behind a hold is a client that downgraded mid-session, whose + // own retry machinery has already given up. Today's drop-and-log. + void data.launcher.sendInput(data.socket, data.subshellId, text).catch(logFailure); } /** diff --git a/apps/server/api/src/ws/viewers.ts b/apps/server/api/src/ws/viewers.ts index 406154917..be1de6533 100644 --- a/apps/server/api/src/ws/viewers.ts +++ b/apps/server/api/src/ws/viewers.ts @@ -614,6 +614,42 @@ export function dropTerminalSocketsFor(userId: string, reason = "account disable return closed; } +/** + * Close every terminal socket ATTACHED TO ONE PANE, with one code. + * + * Its callers are the SSH runtime session settles (`session-settle.ts`, the + * first real ones): when a session goes lost or closed, every pane's relay + * streams over a channel that no longer exists, and a socket authenticates at + * connect and is never re-checked - the same §11.5 property every other path + * has - so the settling is the only thing that can end the stream. Each + * reconnection afterwards passes the attach gate fresh and meets whatever the + * row/registry says then (a re-adopted live pane, or the 4004 of a session + * that stayed dead): unavailable while the channel is gone, never completed. + * + * Same BELOW-4000 close convention as {@link dropTerminalSocketsFor}: 1012 is + * the retry convention, so a watching client reconnects rather than reads the + * drop as a refusal. Teardown bookkeeping stays in each socket's close handler + * (`detachViewer`); the maps are not touched here. + * + * @param subshellId - whose pane's sockets to drop + * @param reason - the close reason the sockets carry + * @returns how many sockets were asked to close + */ +export function closeViewersForSubshell(subshellId: string, reason = "pane control changed"): number { + const viewers = liveViewers.get(subshellId); + if (!viewers) return 0; + let closed = 0; + for (const ws of [...viewers.values()]) { + try { + ws.close(1012, reason); + closed++; + } catch { + // A socket already gone is the outcome this wanted anyway. + } + } + return closed; +} + /** * Removes a viewer and re-settles everything that depended on it. * diff --git a/apps/server/web/docs/launch-form-picker.md b/apps/server/web/docs/launch-form-picker.md index a8c468ec4..a190971f8 100644 --- a/apps/server/web/docs/launch-form-picker.md +++ b/apps/server/web/docs/launch-form-picker.md @@ -154,3 +154,37 @@ Mobile's New screen does NOT mirror this tier (operator scope call: web only; `agent-default.ts` keeps its "change one, change both" for the AGENT rule only). + +**SSH is a work location in the shared launch form** (2026-10-06). +`NewSubshellForm` selects the ordinary machine fields or `SshLaunchForm`. +`sshSessionId: undefined` means an ordinary machine; an empty string means +SSH selection is in progress and cannot submit. The creation hook dispatches +to the session launch endpoint and returns the usual `{ id }` so workspace +attachment stays with the caller. Switching locations clears the directory. +Remote presets copy agent settings and prompts without replacing the host or +folder. The folder browser starts compact and expands on Browse. + +`/connect` is secondary connection management, reachable from launch and +unavailable panes. It has no primary navigation row. Connection history is +also the display-name source for runtime node ids: `useExecutionLabels` +merges names only for the rail, home grouping/filter, and diagnostics, never +for the node registry or launch authorization. A runtime row remains hidden +from node management and cannot be passed to the ordinary create endpoint. + +Saved SSH locations are owner-scoped server records. The launch form saves only +canonical remote directories and reviewed origin/destination facts, never keys. +Selecting one reuses the exact live destination or re-resolves its alias and +refuses changed host/port/user before opening. A desktop origin is a personal +paired broker, not a node enrollment; its label comes from the broker list. + +The connection setup panel lives in personal Settings > Connections. First-use +empty states must keep desktop pairing, node selection, config examples, and +Refresh hosts reachable. Recovery uses the same journey inside a pane dialog, +including workspace panes, and cannot navigate away or replace placement state. + +SSH connection setup hides the launch action until a session is selected. +The three launch dialogs pass their footer separately to `DialogContent`, so +forms scroll without moving Cancel and launch out of view. Connection review +shows destination facts first; only a failed probe renders the corresponding +host-trust or runtime-install instructions. Copied SSH commands shell-quote +aliases and end option parsing; they never disable host-key verification. diff --git a/apps/server/web/docs/sidebar.md b/apps/server/web/docs/sidebar.md index 05c36b347..16efc9e04 100644 --- a/apps/server/web/docs/sidebar.md +++ b/apps/server/web/docs/sidebar.md @@ -112,3 +112,19 @@ view can see (a capped or comms-led machine may order differently between the two, accepted, order is a scan hint). `Segmented` grew `tooltip` and `dense` as opt-ins with byte-identity pinned for every other caller; the ONE clock tick still lives in `AppSidebar`. + +SSH runtime ids are named from the current viewer's connection history via +`useExecutionLabels`. This is a display-only union with the node names; it +must never add runtime sessions to the enrolled-node registry or authorize +ordinary node launches. SSH connection management is reached from launch or +a disconnected pane, rather than its own primary rail row. + +Personal Settings > SSH Connections is available to every signed-in person, separate +from the admin-only Server Settings group. The compatibility `/connect` route +redirects there. Desktop pairing never grants the server-loaded desktop window +new native IPC; consent and local broker startup live in the bundled Client page. + +Nodes, Presets, and Prompts live under personal Settings alongside Connections. +Their routes and access rules are unchanged. Settings also stays open on their +detail pages, such as `/nodes/local`; General matches only `/settings` so it +does not activate Server Settings for personal connection pages. diff --git a/apps/server/web/src/components/__tests__/app-sidebar-group.test.tsx b/apps/server/web/src/components/__tests__/app-sidebar-group.test.tsx index 82717cad4..6eeeeadb0 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar-group.test.tsx +++ b/apps/server/web/src/components/__tests__/app-sidebar-group.test.tsx @@ -159,6 +159,7 @@ describe("the Server Settings group's open/close wiring", () => { await waitFor(() => expect(expanded()).toBe("false")); fireEvent.click(header()); await waitFor(() => expect(expanded()).toBe("true")); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); fireEvent.click(screen.getByRole("link", { name: "Nodes" })); await waitFor(() => expect(expanded()).toBe("false")); }); @@ -172,6 +173,7 @@ describe("the Server Settings group's open/close wiring", () => { await waitFor(() => expect(expanded()).toBe("true")); // Leaving again shuts it, proving the open above came from the route // rather than from the press that survived. + fireEvent.click(screen.getByRole("button", { name: "Settings" })); fireEvent.click(screen.getByRole("link", { name: "Presets" })); await waitFor(() => expect(expanded()).toBe("false")); }); diff --git a/apps/server/web/src/components/__tests__/connect-journey.test.tsx b/apps/server/web/src/components/__tests__/connect-journey.test.tsx new file mode 100644 index 000000000..02fc56918 --- /dev/null +++ b/apps/server/web/src/components/__tests__/connect-journey.test.tsx @@ -0,0 +1,409 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { Node } from "@internal/node-admin"; +import { SSH_ERROR_DESCRIPTIONS } from "@internal/subshell-protocol"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ConnectJourney } from "@/components/connect/connect-journey"; +import { resetDesktopShellForTests } from "@/lib/desktop"; + +/** + * The wizard's refusal and empty-state branches (wave-2 review M2): machines + * the caller does not OWN never appear; ineligible machines are shown disabled + * with the reason; a failed config read is never the empty-list copy; and a + * named resolve refusal renders the SHIPPED sentence from + * `SSH_ERROR_DESCRIPTIONS` plus the blocked settings, never the wire code. + */ + +const BASE: Node = { + id: "n1", + name: "Laptop", + kind: "agent", + os: "linux", + arch: "x86_64", + hostname: "laptop-01", + status: "online", + lastSeenAt: new Date().toISOString(), + agentVersion: null, + protocolVersion: null, + access: "owner", + canManage: true, + canLaunch: true, + allowedDirs: [], + capabilities: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + held: null, +}; + +interface JourneyFetchOptions { + nodes?: Node[]; + brokers?: { id: string; name: string; online: boolean }[]; + admin?: boolean; + serverEnabled?: boolean; + aliases?: string[]; + /** Set to make discovery answer non-OK (the failure-vs-empty branch). */ + discoveryStatus?: number; + resolveBody?: unknown; + brokersStatus?: number; + brokerRead?: Promise; +} + +function mockFetch(over: JourneyFetchOptions = {}) { + const original = globalThis.fetch; + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + seen.push(`${init?.method ?? "GET"} ${url.pathname}`); + if (url.pathname === "/api/settings/public") + return json({ viewerIsAdmin: over.admin ?? false, allowServerSubshells: over.serverEnabled ?? true }); + if (url.pathname === "/api/ssh-runtime/desktop-brokers") { + await over.brokerRead; + return over.brokersStatus + ? json({ message: "Computer connections unavailable" }, over.brokersStatus) + : json({ brokers: over.brokers ?? [] }); + } + if (url.pathname === "/api/nodes") return json({ nodes: over.nodes ?? [] }); + if (url.pathname === "/api/presets") return json([]); + if (url.pathname === "/api/ssh-runtime/discovery") { + if (over.discoveryStatus !== undefined && over.discoveryStatus !== 200) { + return json({ message: "The node refused the read." }, over.discoveryStatus); + } + return json({ aliases: over.aliases ?? [], includeCycle: false, truncated: false }); + } + if (url.pathname === "/api/ssh-runtime/resolve") + return json(over.resolveBody ?? { accepted: false, code: "config_missing", settings: [] }); + return json({}); + }) as typeof fetch; + return { + seen, + restore: () => { + globalThis.fetch = original; + }, + }; +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +/** Flush pending query/effect updates inside act() (the repo-wide settle pattern). */ +async function settle(): Promise { + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); +} + +/** The journey is mounted as the index route of a throwaway router (it calls useNavigate). */ +async function renderJourney(prefill: { nodeId: string; alias: string } | null = null): Promise { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const rootRoute = createRootRoute(); + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => {}} />, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + defaultPreload: false, + }); + await router.load(); + render( + + + , + ); + await settle(); +} + +afterEach(cleanup); + +/** Open the machine picker (a `SearchableSelect`) the way the mouse does. */ +async function openMachinePopup(): Promise { + const input = screen.getByPlaceholderText("Choose a computer") as HTMLInputElement; + fireEvent.mouseDown(input); + fireEvent.click(input); + await screen.findAllByRole("option"); + return input; +} + +/** Choose a connecting machine from the picker, then let the host step settle. */ +async function pickMachineByName(name: RegExp | string): Promise { + const input = await openMachinePopup(); + void input; + fireEvent.click(await screen.findByRole("option", { name })); + await settle(); +} + +describe("ConnectJourney machine step", () => { + it("an empty owned-machine list names the need and the remedy, not a broken list", async () => { + const m = mockFetch({ nodes: [{ ...BASE, id: "other", access: "edit" }] }); + try { + await renderJourney(); + expect(screen.getByText(/You haven’t connected a computer for SSH yet/)).toBeTruthy(); + expect(screen.getByRole("link", { name: /open Settings/ }).getAttribute("href")).toBe("/settings/connections"); + // Nothing to pick, so the picker itself is absent; the shared machine is + // never offered because broker rights are ownership, not access. + expect(screen.queryByPlaceholderText("Choose a computer")).toBeNull(); + } finally { + m.restore(); + } + }); + + it("ineligible machines are shown DISABLED with the reason, never hidden", async () => { + const m = mockFetch({ + nodes: [ + BASE, + { ...BASE, id: "n2", name: "Docking", status: "offline" }, + { ...BASE, id: "n3", name: "Workshop", maintenance: true }, + ], + }); + try { + await renderJourney(); + await openMachinePopup(); + // A disabled row is still listed and explains itself; greying explains, + // never hides. Enabled rows carry no data-disabled marker. + expect(screen.getByRole("option", { name: /Laptop/ }).getAttribute("data-disabled")).toBeNull(); + const offline = screen.getByRole("option", { name: /Docking/ }); + expect(offline.getAttribute("data-disabled")).toBe(""); + expect(offline.textContent).toContain("offline"); + const maint = screen.getByRole("option", { name: /Workshop/ }); + expect(maint.getAttribute("data-disabled")).toBe(""); + expect(maint.textContent).toContain("in maintenance"); + } finally { + m.restore(); + } + }); + + it("a hostname a row shows is searchable even though the detail line is `reason`", async () => { + const m = mockFetch({ nodes: [BASE, { ...BASE, id: "n2", name: "Docking", hostname: "dock-77" }] }); + try { + await renderJourney(); + const input = await openMachinePopup(); + fireEvent.change(input, { target: { value: "dock-77" } }); + await waitFor(() => expect(screen.queryByRole("option", { name: /Laptop/ })).toBeNull()); + expect(screen.getByRole("option", { name: /Docking/ })).toBeTruthy(); + } finally { + m.restore(); + } + }); +}); + +describe("ConnectJourney host step", () => { + it("an empty alias list says so; a failed read is never that sentence", async () => { + const m = mockFetch({ nodes: [BASE], aliases: [] }); + try { + await renderJourney({ nodeId: "n1", alias: "staging" }); + expect(await screen.findByText(/No SSH hosts found/)).toBeTruthy(); + expect(screen.queryByText(/could not be read/)).toBeNull(); + expect(screen.getByText(/HostName host.example.com/)).toBeTruthy(); + const readsBefore = m.seen.filter((url) => url.includes("/discovery")).length; + fireEvent.click(screen.getByRole("button", { name: "Refresh hosts" })); + await settle(); + expect(m.seen.filter((url) => url.includes("/discovery")).length).toBeGreaterThan(readsBefore); + } finally { + m.restore(); + } + }); + + it("a failed config read is the failure row, never the empty-list sentence", async () => { + const m = mockFetch({ nodes: [BASE], discoveryStatus: 502 }); + try { + await renderJourney({ nodeId: "n1", alias: "staging" }); + // The read's own error (the failure alert carries it verbatim) plus a + // Retry; the empty copy must stay silent, that is the whole distinction. + expect(await screen.findByText(/The node refused the read\./)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy(); + expect(screen.queryByText(/No SSH hosts found/)).toBeNull(); + } finally { + m.restore(); + } + }); +}); + +describe("ConnectJourney review step", () => { + it("renders the shipped sentence for a named refusal, the blocked settings, and never the wire code", async () => { + const m = mockFetch({ + nodes: [BASE], + aliases: ["staging"], + resolveBody: { + accepted: false, + code: "unsupported_setting", + settings: ["ProxyCommand", "LocalForward"], + }, + }); + try { + await renderJourney(); + await pickMachineByName(/Laptop/); + const alias = await screen.findByRole("button", { name: "staging" }); + fireEvent.click(alias); + // The equality mapping, mirrored from the old editor: the package's own + // sentence, rendered verbatim. + expect(await screen.findByText(SSH_ERROR_DESCRIPTIONS.unsupported_setting)).toBeTruthy(); + expect(screen.getByText(/Unsupported SSH settings: ProxyCommand, LocalForward/)).toBeTruthy(); + // The raw wire code is not human copy (finding I1): nothing renders it. + expect(screen.queryByText(/unsupported_setting/)).toBeNull(); + // The refusal leaves Connect inert: the review has nothing to open. + expect(screen.getByRole("button", { name: "Connect" }).hasAttribute("disabled")).toBe(true); + } finally { + m.restore(); + } + }); + + it("an accepted resolve shows the destination and the connecting account", async () => { + const m = mockFetch({ + nodes: [BASE], + aliases: ["staging"], + resolveBody: { + accepted: true, + snapshot: { alias: "staging", host: "app-02", user: "deploy", port: 22, identityFiles: [] }, + connectingAccount: "theo", + }, + }); + try { + await renderJourney(); + await pickMachineByName(/Laptop/); + fireEvent.click(await screen.findByRole("button", { name: "staging" })); + expect(await screen.findByText("deploy@app-02:22")).toBeTruthy(); + expect(screen.getByText(/Laptop as theo/)).toBeTruthy(); + expect(screen.queryByRole("link", { name: "Download the Subshell CLI" })).toBeNull(); + expect(screen.getByText(/Connect to this host, then choose a folder/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Connect" }).hasAttribute("disabled")).toBe(false); + } finally { + m.restore(); + } + }); +}); + +describe("server connection origin", () => { + it("offers the server account only to admins and explains a disabled server", async () => { + const m = mockFetch({ + admin: true, + serverEnabled: false, + nodes: [{ ...BASE, id: "local", kind: "local", name: "Server" }], + }); + try { + await renderJourney(); + await openMachinePopup(); + const server = screen.getByRole("option", { name: /Server/ }); + expect(server.getAttribute("data-disabled")).toBe(""); + expect(server.textContent).toContain("server launching disabled"); + } finally { + m.restore(); + } + }); + it("never offers server SSH credentials to a member", async () => { + const m = mockFetch({ nodes: [{ ...BASE, id: "local", kind: "local", name: "Server" }] }); + try { + await renderJourney(); + expect(screen.queryByPlaceholderText("Choose a computer")).toBeNull(); + expect(screen.queryByRole("option", { name: /Server/ })).toBeNull(); + } finally { + m.restore(); + } + }); +}); + +describe("connecting computer discovery", () => { + it("waits for computer connections before declaring no machines", async () => { + let release = () => {}; + const m = mockFetch({ + brokerRead: new Promise((resolve) => { + release = resolve; + }), + }); + try { + await renderJourney(); + expect(screen.queryByText(/You haven’t connected a computer for SSH yet/)).toBeNull(); + release(); + await settle(); + expect(screen.getByText(/You haven’t connected a computer for SSH yet/)).toBeTruthy(); + } finally { + release(); + m.restore(); + } + }); + it("reports computer discovery failures with retry instead of an empty verdict", async () => { + const m = mockFetch({ brokersStatus: 503 }); + try { + await renderJourney(); + expect(screen.queryByText(/You haven’t connected a computer for SSH yet/)).toBeNull(); + expect(screen.getByRole("alert")).toBeTruthy(); + const before = m.seen.filter((url) => url.includes("desktop-brokers")).length; + fireEvent.click(screen.getByRole("button", { name: "Retry computer connections" })); + await settle(); + expect(m.seen.filter((url) => url.includes("desktop-brokers")).length).toBeGreaterThan(before); + } finally { + m.restore(); + } + }); +}); + +describe("this computer SSH identity", () => { + const originalUA = navigator.userAgent; + afterEach(() => { + Object.defineProperty(navigator, "userAgent", { configurable: true, value: originalUA }); + Reflect.deleteProperty(window, "__TAURI__"); + resetDesktopShellForTests(); + }); + function desktop(ids: string[]) { + Object.defineProperty(navigator, "userAgent", { configurable: true, value: "SubshellClient/1.0.0 (linux; p=1)" }); + Object.defineProperty(window, "__TAURI__", { + configurable: true, + value: { + core: { + invoke: async (command: string) => { + expect(command).toBe("desktop_ssh_identity"); + return ids; + }, + }, + }, + }); + resetDesktopShellForTests(); + } + it("identifies this computer by pairing ID rather than a matching name", async () => { + desktop(["mine"]); + const f = mockFetch({ + brokers: [ + { id: "other", name: "Laptop", online: true }, + { id: "mine", name: "Laptop", online: true }, + ], + }); + try { + await renderJourney(); + await pickMachineByName(/This computer/); + expect(screen.getByText("Work on")).toBeTruthy(); + } finally { + f.restore(); + } + }); + it("offers pairing when the desktop identity is not visible to this account", async () => { + desktop(["another-account"]); + const f = mockFetch({ brokers: [{ id: "other", name: "Laptop", online: true }] }); + try { + await renderJourney(); + fireEvent.click(await screen.findByRole("button", { name: "This computer · set up SSH" })); + expect(await screen.findByText(/Your node registration stays unchanged/)).toBeTruthy(); + } finally { + f.restore(); + } + }); + it("does not offer this computer in a browser", async () => { + const f = mockFetch(); + try { + await renderJourney(); + expect(screen.queryByRole("button", { name: /This computer/ })).toBeNull(); + } finally { + f.restore(); + } + }); +}); diff --git a/apps/server/web/src/components/__tests__/desktop-broker-setup.test.tsx b/apps/server/web/src/components/__tests__/desktop-broker-setup.test.tsx new file mode 100644 index 000000000..d88690071 --- /dev/null +++ b/apps/server/web/src/components/__tests__/desktop-broker-setup.test.tsx @@ -0,0 +1,51 @@ +import { afterEach, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { DESKTOP_BROKERS_QUERY_KEY, DesktopBrokerSetup } from "@/components/connect/desktop-broker-setup"; + +const originalFetch = globalThis.fetch; +afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; +}); + +it("names a paired computer, clears its code when online, and revokes access", async () => { + const requests: { method: string; body?: string }[] = []; + let brokers: { id: string; name: string; online: boolean }[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? "GET"; + requests.push({ method, body: init?.body as string | undefined }); + const data = + method === "POST" + ? { + id: "desktop:d1", + name: "Travel laptop", + pairingToken: "secret-pair-code", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } + : method === "DELETE" + ? { ok: true } + : { brokers }; + return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + fireEvent.change(screen.getByLabelText("Computer name (optional)"), { target: { value: "Travel laptop" } }); + fireEvent.click(screen.getByRole("button", { name: "Add computer" })); + await screen.findByText("secret-pair-code"); + expect(JSON.parse(requests.find((request) => request.method === "POST")?.body ?? "{}").name).toBe("Travel laptop"); + expect(screen.getByRole("button", { name: "Copy pairing code" })).toBeTruthy(); + brokers = [{ id: "desktop:d1", name: "Travel laptop", online: true }]; + await act(async () => { + await client.invalidateQueries({ queryKey: DESKTOP_BROKERS_QUERY_KEY }); + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + await waitFor(() => expect(screen.queryByText("secret-pair-code")).toBeNull()); + fireEvent.click(screen.getByRole("button", { name: "Revoke access" })); + await waitFor(() => expect(requests.some((request) => request.method === "DELETE")).toBe(true)); + client.clear(); +}); diff --git a/apps/server/web/src/components/__tests__/launch-step.test.tsx b/apps/server/web/src/components/__tests__/launch-step.test.tsx new file mode 100644 index 000000000..046150e40 --- /dev/null +++ b/apps/server/web/src/components/__tests__/launch-step.test.tsx @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { type LaunchPick, LaunchStep } from "@/components/connect/launch-step"; +import type { SshRuntimeHarnessEntry, SshRuntimeSessionView } from "@/lib/ssh-runtime"; +import type { PresetRow } from "@/types/preset"; + +/** + * The launch seam (design 2026-10-05 §7, wave-2 review M2). What the step + * exists to prove: the terminal row is never held hostage by the detect round + * trip (a failed check still leaves the shell), the harness and preset rows + * come ONLY from the detected mirror, and each row's pick carries the right + * arguments upstream. + */ + +const SESSION: SshRuntimeSessionView = { + id: "s1", + connectingNodeId: "n1", + runtimeNodeId: "r1", + alias: "staging", + host: "app-02", + port: 22, + user: "deploy", + status: "active", + hello: null, + createdAt: new Date().toISOString(), + lastSeenAt: null, + closedAt: null, +}; + +function harness(over: Partial & { harnessId: string }): SshRuntimeHarnessEntry { + return { + harnessName: over.harnessId, + installed: true, + binaryPath: null, + rawVersion: null, + reason: null, + checkedAt: null, + ...over, + }; +} + +function preset(over: Partial & { id: string; harnessId: string; name: string }): PresetRow { + return { + description: null, + envJson: null, + flagsJson: null, + settingsJson: null, + configIsolation: 0, + restartOnExit: 0, + crossCommEnabled: 0, + nodeId: null, + workingDir: null, + promptBlocks: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...over, + }; +} + +function renderStep(over: Partial[0]> = {}, onPick?: (picks: LaunchPick[]) => void) { + const picks: LaunchPick[] = []; + render( + {}} + onPick={(p) => { + picks.push(p); + onPick?.(picks); + }} + {...over} + />, + ); + return picks; +} + +// Cleanup must be registered BY THIS FILE: bun test caches modules across +// files in serial mode, so RTL's auto-cleanup lands only on the first file +// that imports it, and a later file's renders would pile up in the shared +// happy-dom document (measured: /Terminal/ matched 3 rows). +afterEach(cleanup); + +describe("LaunchStep", () => { + it("a failed detect says so, keeps the terminal row, and offers the retry", () => { + renderStep({ detectError: true }); + expect(screen.getByText(/The destination could not say which agents it has installed\./)).toBeTruthy(); + // The sentence that follows is the honesty half: the shell still works. + expect(screen.getByText(/The terminal still opens/)).toBeTruthy(); + expect(screen.getByRole("button", { name: /Terminal/ })).toBeTruthy(); + expect(screen.queryByText("Claude")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Check installed agents" })); + // (no throw, and the button exists: re-ask is wired) + }); + + it("a quiet mirror shows no detect sentence", () => { + renderStep(); + expect(screen.queryByText(/could not say which agents/)).toBeNull(); + }); + + it("only installed non-terminal harnesses become rows, and picks name the harness", () => { + const picks = renderStep({ + harnesses: [ + harness({ harnessId: "claude", harnessName: "Claude", rawVersion: "2.1.0" }), + harness({ harnessId: "terminal", harnessName: "Terminal" }), + harness({ harnessId: "opencode", harnessName: "OpenCode", installed: false }), + ], + }); + expect(screen.getByRole("button", { name: /Claude/ })).toBeTruthy(); + // The terminal harness rides its own row above, never a second one below. + expect(screen.getAllByRole("button", { name: /Terminal/ }).length).toBe(1); + // Not installed on the destination: no row that would only fail. + expect(screen.queryByRole("button", { name: /OpenCode/ })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /Claude/ })); + expect(picks.at(-1)).toEqual({ kind: "harness", harnessId: "claude" }); + expect(screen.getByText("2.1.0 on this destination.")).toBeTruthy(); + }); + + it("a preset becomes a row only when its harness detected installed here", () => { + const picks = renderStep({ + harnesses: [harness({ harnessId: "claude", harnessName: "Claude" })], + presets: [ + preset({ id: "p1", harnessId: "claude", name: "Work" }), + preset({ id: "p2", harnessId: "opencode", name: "Absence" }), + ], + }); + expect(screen.getByRole("button", { name: /Work/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Absence/ })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /Work/ })); + expect(picks.at(-1)).toEqual({ kind: "preset", harnessId: "claude", presetId: "p1" }); + }); +}); diff --git a/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx b/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx index 2e316fa2c..a32d97734 100644 --- a/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx +++ b/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx @@ -305,7 +305,7 @@ describe("NewSubshellForm agent/preset defaults", () => { const restore = mockFetch([LOCAL, ENROLLED_ONLINE, ENROLLED_OFFLINE], [CLAUDE]); try { const { latest } = await renderForm(); - expect(screen.getByPlaceholderText("Choose a node")).toBeDefined(); + expect(screen.getByPlaceholderText("Choose a machine")).toBeDefined(); expect(screen.getByPlaceholderText("Choose an agent")).toBeDefined(); await waitFor(() => expect(latest().nodeId).toBe("local")); await waitFor(() => expect(latest().harnessId).toBe("claude-code")); diff --git a/apps/server/web/src/components/__tests__/sidebar-nav.test.ts b/apps/server/web/src/components/__tests__/sidebar-nav.test.ts index e33e015fa..3dd7b9af4 100644 --- a/apps/server/web/src/components/__tests__/sidebar-nav.test.ts +++ b/apps/server/web/src/components/__tests__/sidebar-nav.test.ts @@ -36,7 +36,8 @@ describe("visibleNavItems", () => { for (const flag of [false, undefined]) { const paths = visibleNavItems(flag).map((i) => i.to); for (const page of GROUP_PAGES) expect(paths).not.toContain(page); - expect(paths).toEqual(["/", "/workspaces", "/nodes", "/presets", "/prompts"]); + // SSH is a launch location. Connection management has no primary rail row. + expect(paths).toEqual(["/", "/workspaces", "/nodes", "/presets", "/prompts", "/settings/connections"]); } }); @@ -47,16 +48,29 @@ describe("visibleNavItems", () => { "/nodes", "/presets", "/prompts", + "/settings/connections", ...GROUP_PAGES, ]); }); }); describe("visibleNavEntries", () => { - it("carries exactly one group, gated as a whole", () => { + it("keeps personal settings separate from the gated server group", () => { const groups = visibleNavEntries(true).filter(isNavGroup); - expect(groups.map((g) => g.label)).toEqual(["Server Settings"]); - expect(groups[0]?.requiresAdmin).toBe(true); + expect(groups.map((g) => g.label)).toEqual(["Settings", "Server Settings"]); + expect(groups[0]?.requiresAdmin).toBeUndefined(); + expect(groups[0]?.children.map((item) => item.to)).toEqual([ + "/nodes", + "/presets", + "/prompts", + "/settings/connections", + ]); + expect( + visibleNavEntries(false) + .filter((entry) => !isNavGroup(entry)) + .map((entry) => entry.label), + ).toEqual(["Subshells", "Workspaces"]); + expect(groups[1]?.requiresAdmin).toBe(true); }); it("puts the gate on the group and nowhere else", () => { @@ -68,7 +82,12 @@ describe("visibleNavEntries", () => { }); it("drops the group whole while the flag is false or unknown", () => { - for (const flag of [false, undefined]) expect(visibleNavEntries(flag).filter(isNavGroup)).toEqual([]); + for (const flag of [false, undefined]) + expect( + visibleNavEntries(flag) + .filter(isNavGroup) + .map((group) => group.id), + ).toEqual(["personal-settings"]); }); }); @@ -97,3 +116,9 @@ describe("groupOpen", () => { expect(groupOpen(true, false)).toBe(true); }); }); + +it("offers personal connections to members while retaining the admin settings gate", () => { + expect(visibleNavItems(false).some((item) => item.to === "/settings/connections")).toBe(true); + expect(visibleNavItems(undefined).some((item) => item.to === "/settings/connections")).toBe(true); + expect(visibleNavItems(false).some((item) => item.to === "/settings/users")).toBe(false); +}); diff --git a/apps/server/web/src/components/__tests__/ssh-runtime-pane-identity.test.tsx b/apps/server/web/src/components/__tests__/ssh-runtime-pane-identity.test.tsx new file mode 100644 index 000000000..5aa06cda9 --- /dev/null +++ b/apps/server/web/src/components/__tests__/ssh-runtime-pane-identity.test.tsx @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { SshRuntimePaneIdentity } from "@/components/connect/ssh-runtime-pane-identity"; +import { SSH_PANE_IDENTITY_QUERY_KEY } from "@/hooks/use-ssh-runtime"; +import type { SshRuntimePaneIdentity as IdentityFacts } from "@/lib/ssh-runtime"; + +/** + * The qualified identity line (wave-2 review M4/R2-1): a pane whose session + * channel is gone must never read as a live destination. Both departures get + * their suffix - `connection lost` for a dropped link (design §6: + * unavailable, not completed) and `closed` for an ended one - while a live + * session renders the bare line. + */ + +function facts(status: IdentityFacts["status"]): IdentityFacts { + return { + sessionId: "s1", + status, + alias: "staging", + host: "app-02", + port: 22, + user: "deploy", + connectingNodeName: "Laptop", + }; +} + +const savedFetch = globalThis.fetch; +afterEach(() => { + cleanup(); + globalThis.fetch = savedFetch; +}); + +function stubIdentity(data: IdentityFacts | null) { + globalThis.fetch = (async () => + new Response(JSON.stringify(data), { + headers: { "content-type": "application/json" }, + status: data === null ? 404 : 200, + })) as never; +} + +async function renderLine(status: IdentityFacts["status"] | null) { + stubIdentity(status === null ? null : facts(status)); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 60_000 } }, + }); + // Seed nothing; the component's own read carries the state. The query key + // is asserted once to pin the shared prefix the close mutation invalidates + // (the R2-1 invalidation half rides exactly that key). + const root = createRootRoute(); + const route = createRoute({ + getParentRoute: () => root, + path: "/", + component: () => , + }); + const router = createRouter({ + routeTree: root.addChildren([route]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); + render( + + + , + ); + return client; +} + +describe("SshRuntimePaneIdentity qualification (M4)", () => { + it("renders the bare line for a live session", async () => { + await renderLine("active"); + await waitFor(() => expect(screen.getByText(/SSH ·/)).toBeTruthy()); + expect(screen.getByText(/SSH ·/).textContent).not.toContain("lost"); + expect(screen.getByText(/SSH ·/).textContent).not.toContain("closed"); + }); + + it("qualifies a dropped link as connection lost, never a completed pane", async () => { + await renderLine("lost"); + await waitFor(() => expect(screen.getByText(/SSH ·/).textContent ?? "").toContain("connection lost")); + }); + + it("opens recovery in the affected pane without replacing its identity", async () => { + await renderLine("lost"); + await waitFor(() => expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy()); + globalThis.fetch = (async () => + new Response(JSON.stringify({ sessions: [] }), { headers: { "content-type": "application/json" } })) as never; + fireEvent.click(screen.getByRole("button", { name: "Reconnect" })); + await waitFor(() => expect(screen.getByRole("dialog")).toBeTruthy()); + expect(screen.getByText("Reconnect to SSH host")).toBeTruthy(); + expect(screen.getByText(/SSH ·/).textContent).toContain("connection lost"); + expect(screen.getByText(/Your pane and workspace stay open/)).toBeTruthy(); + }); + + it("qualifies an ended session as closed", async () => { + await renderLine("closed"); + await waitFor(() => expect(screen.getByText(/SSH ·/).textContent ?? "").toContain(" · closed")); + }); + + it("renders nothing on the ordinary-pane 404", async () => { + await renderLine(null); + await waitFor(() => expect(screen.queryByText(/SSH ·/)).toBeNull()); + }); + + it("the read rides the shared key the close mutation invalidates", async () => { + // Pin the coupling rather than trust the comment: the component's OWN + // query must register under SSH_PANE_IDENTITY_QUERY_KEY, or the close + // mutation's prefix invalidation silently stops re-asking a mounted pane + // line. Reading the cache the component filled (not one we filled) is + // what makes a divergent literal key fail this test. + const client = await renderLine("active"); + await waitFor(() => + expect( + client + .getQueryCache() + .getAll() + .some((q) => JSON.stringify(q.queryKey) === JSON.stringify([...SSH_PANE_IDENTITY_QUERY_KEY, "p1"])), + ).toBe(true), + ); + }); +}); + +it("polls known SSH identities so a lost connection exposes recovery without navigation", async () => { + await renderLine("active"); + await waitFor(() => expect(screen.getByText(/SSH ·/)).toBeTruthy()); + expect(screen.queryByRole("button", { name: "Reconnect" })).toBeNull(); + stubIdentity(facts("lost")); + await waitFor(() => expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy(), { timeout: 7000 }); +}, 8000); diff --git a/apps/server/web/src/components/__tests__/ssh-setup-help.test.tsx b/apps/server/web/src/components/__tests__/ssh-setup-help.test.tsx new file mode 100644 index 000000000..e3b6b538d --- /dev/null +++ b/apps/server/web/src/components/__tests__/ssh-setup-help.test.tsx @@ -0,0 +1,35 @@ +import { afterEach, expect, test } from "bun:test"; +import { cleanup, render, screen } from "@testing-library/react"; +import { SshSetupHelp, sshLoginCommand } from "@/components/connect/ssh-setup-help"; + +afterEach(cleanup); + +test("ready hosts get no installation instructions", () => { + const { container } = render(); + expect(container.textContent).toBe(""); +}); + +test("unknown host recovery identifies the connecting account and requires fingerprint verification", () => { + render(); + expect(screen.getByText(/Server as service-user/)).toBeTruthy(); + expect(screen.getByText(/check that the SHA256 fingerprint matches/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Copy SSH login command" })).toBeTruthy(); + expect(screen.queryByRole("link", { name: "Download the Subshell CLI" })).toBeNull(); +}); + +test("changed identities never get instructions to blindly accept or remove trust", () => { + render(); + expect(screen.getByText(/Keep host-key checking enabled/)).toBeTruthy(); + expect(screen.queryByText(/before accepting it/)).toBeNull(); +}); + +test("runtime recovery provides platform, install and noninteractive verification commands", () => { + render(); + expect(screen.getByRole("button", { name: "Copy platform check" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Copy runtime install command" })).toBeTruthy(); + expect(screen.getByText("ssh -- 'dev' 'subshell --version; tmux -V'")).toBeTruthy(); +}); + +test("copied SSH commands quote shell metacharacters and terminate option parsing", () => { + expect(sshLoginCommand("-oProxyCommand=$(touch /tmp/oops)'")).toBe("ssh -- '-oProxyCommand=$(touch /tmp/oops)'\\'''"); +}); diff --git a/apps/server/web/src/components/__tests__/subshell-pane.test.tsx b/apps/server/web/src/components/__tests__/subshell-pane.test.tsx index 997d06143..6be838955 100644 --- a/apps/server/web/src/components/__tests__/subshell-pane.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-pane.test.tsx @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it, spyOn } from "bun:test"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { SubshellPane } from "@/components/subshell-pane"; +import { SUBSHELLS_QUERY_KEY } from "@/lib/query-keys"; +import type { SubshellView } from "@/types/subshell"; import type { WorkspacePaneRow } from "@/types/workspace"; /** @@ -52,6 +54,56 @@ function renderPane(el: ReactNode) { afterEach(cleanup); +it("recreates the workspace terminal when SSH adoption changes its runtime node, without remounting on ordinary updates", async () => { + const row: SubshellView = { + id: "s1", + nodeId: "ssh-runtime-before", + nodeOffline: false, + presetId: null, + harnessId: "terminal", + name: "Remote work", + nameLocked: false, + workingDir: "/tmp", + status: "running", + createdAt: "2026-10-06T00:00:00Z", + startedAt: "2026-10-06T00:00:00Z", + endedAt: null, + lastOutputAt: null, + activity: "idle", + alive: true, + exitCode: null, + backoffCount: 0, + restartOnExit: false, + nextRestartAt: null, + notify: false, + waitingSince: null, + unseenPush: false, + access: "owner", + }; + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData(SUBSHELLS_QUERY_KEY, [row]); + const view = render( + + {}} onRemovePane={() => {}} /> + , + ); + await waitFor(() => expect(view.container.querySelector(".xterm")).toBeTruthy()); + const original = view.container.querySelector(".xterm"); + await act(async () => { + client.setQueryData(SUBSHELLS_QUERY_KEY, [{ ...row, name: "Renamed remote work" }]); + }); + expect(view.container.querySelector(".xterm")).toBe(original); + await act(async () => { + // Adoption deliberately preserves startedAt and the workspace's pane id. + client.setQueryData(SUBSHELLS_QUERY_KEY, [{ ...row, nodeId: "ssh-runtime-after" }]); + }); + await waitFor(() => { + expect(view.container.querySelector(".xterm")).toBeTruthy(); + expect(view.container.querySelector(".xterm")).not.toBe(original); + expect(original?.isConnected).toBe(false); + }); +}); + describe("SubshellPane touch key bar", () => { it("allows the key bar to scroll while the terminal's touch scroll guard is engaged", () => { const spy = stubCoarsePointer(true); @@ -173,3 +225,40 @@ describe("SubshellPane node-offline state (spec §5.6 precedence)", () => { expect(screen.queryByRole("button", { name: /restart/i })).toBeNull(); }); }); + +it("offers SSH recovery inside an offline workspace pane", async () => { + const original = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => + new Response( + JSON.stringify( + String(input).includes("/by-pane/") + ? { + sessionId: "ssh1", + status: "lost", + alias: "work", + host: "host.example.com", + port: 22, + user: "dev", + connectingNodeName: "Laptop", + } + : [], + ), + { headers: { "content-type": "application/json" } }, + )) as typeof fetch; + try { + renderPane( + {}} + onRemovePane={() => {}} + />, + ); + await waitFor(() => expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy()); + expect(screen.getByText("dev@host.example.com:22")).toBeTruthy(); + expect(screen.getByRole("button", { name: /Remove pane/ })).toBeTruthy(); + } finally { + cleanup(); + globalThis.fetch = original; + } +}); diff --git a/apps/server/web/src/components/app-sidebar.tsx b/apps/server/web/src/components/app-sidebar.tsx index 4c44c2be4..6f45347e9 100644 --- a/apps/server/web/src/components/app-sidebar.tsx +++ b/apps/server/web/src/components/app-sidebar.tsx @@ -336,7 +336,11 @@ export function AppSidebar({ ); } - const childActive = entry.children.some((child) => location.pathname === child.to); + const childActive = entry.children.some( + (child) => + location.pathname === child.to || + (child.to !== "/settings" && location.pathname.startsWith(`${child.to}/`)), + ); const open = groupOpen(overrides[entry.id], childActive); const listId = `nav-group-${entry.id}`; return ( @@ -417,7 +421,7 @@ export function AppSidebar({
{/* The whole subshell section — mode control, filter, the Needs Attention spotlight, the machine groups and the - cross-agent comms — is `RailSubshells`, which owns that + Agent-created section — is `RailSubshells`, which owns that shape and its localStorage prefs. The ONE thing staying here is the filter query: this mount unmounts on collapse, and the query outlives it (see its prop JSDoc). It stays diff --git a/apps/server/web/src/components/connect/connect-journey.tsx b/apps/server/web/src/components/connect/connect-journey.tsx new file mode 100644 index 000000000..baa5cb8b9 --- /dev/null +++ b/apps/server/web/src/components/connect/connect-journey.tsx @@ -0,0 +1,432 @@ +import { Button, errMessage, Label } from "@internal/node-admin"; +import { SSH_ERROR_DESCRIPTIONS } from "@internal/subshell-protocol"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { Server, ServerOff } from "lucide-react"; +import { useState } from "react"; +import { useDesktopBrokers } from "@/components/connect/desktop-broker-setup"; +import { SshSetupHelp } from "@/components/connect/ssh-setup-help"; +import { CopyCommandRow } from "@/components/copy-command-row"; +import { type ComboboxOption, SearchableSelect } from "@/components/ui/combobox"; +import { useNodes } from "@/hooks/use-nodes"; +import { usePublicSettings } from "@/hooks/use-public-settings"; +import { useSshDiscovery, useSshOpen, useSshResolve } from "@/hooks/use-ssh-runtime"; +import { desktopInvokeStrict, desktopShell } from "@/lib/desktop"; +import { + SshApiError, + type SshRuntimeResolveView, + type SshRuntimeSessionView, + sshRuntimeErrorCopy, +} from "@/lib/ssh-runtime"; + +/** Connection selection inside the ordinary launch form. No pane is launched here. */ +type Step = "machine" | "host" | "review"; + +interface Prefill { + nodeId: string; + alias: string; +} + +export function ConnectJourney({ + prefill, + onConnected, + requiredTarget, +}: { + prefill: Prefill | null; + requiredTarget?: { host: string; port: number; user: string | null }; + onConnected: (session: SshRuntimeSessionView) => void; +}) { + const settings = usePublicSettings(); + const nodesQ = useNodes(); + const brokersQ = useDesktopBrokers(); + const isClient = desktopShell()?.app === "client"; + const identity = useQuery({ + queryKey: ["desktop-ssh-identity", window.location.origin], + queryFn: () => desktopInvokeStrict("desktop_ssh_identity"), + enabled: isClient, + retry: false, + refetchInterval: 5000, + }); + const localBrokers = (brokersQ.data?.brokers ?? []).filter((b) => identity.data?.includes(b.id)); + const localOnline = localBrokers.find((b) => b.online); + const [showLocalHelp, setShowLocalHelp] = useState(false); + const [step, setStep] = useState(prefill ? "host" : "machine"); + const [nodeId, setNodeId] = useState(prefill?.nodeId ?? ""); + const [alias, setAlias] = useState(prefill?.alias ?? ""); + const [resolved, setResolved] = useState(null); + const [openCode, setOpenCode] = useState(null); + const [openError, setOpenError] = useState(null); + + const resolve = useSshResolve(); + const open = useSshOpen(); + const discovery = useSshDiscovery(step === "host" ? nodeId : null); + const machine = (nodesQ.data?.nodes ?? []).find((n) => n.id === nodeId); + const broker = brokersQ.data?.brokers?.find((row) => row.id === nodeId); + const machineLabel = machine?.name ?? broker?.name ?? "the connecting machine"; + + const ownedAgents = (nodesQ.data?.nodes ?? []).filter( + (n) => + (n.kind === "agent" && n.access === "owner") || (n.kind === "local" && settings.data?.viewerIsAdmin === true), + ); + + function pickMachine(id: string) { + setNodeId(id); + setAlias(""); + setStep("host"); + } + + function pickHost(name: string) { + setAlias(name); + setResolved(null); + setOpenError(null); + setOpenCode(null); + setStep("review"); + // The answer rides onSuccess (not resolve.data): a re-pick must never + // render the PREVIOUS alias's review facts while its own resolve is in + // flight, and the per-call callback clears exactly then. + resolve.mutate( + { nodeId, alias: name }, + { + // Transport failures stay on the mutation object (the step renders + // `resolve.isError`); only an ANSWER (accepted or named refusal) + // becomes review facts. + onSuccess: (view) => { + if ( + view.accepted && + requiredTarget && + (view.snapshot.host !== requiredTarget.host || + view.snapshot.port !== requiredTarget.port || + view.snapshot.user !== requiredTarget.user) + ) { + setResolved(null); + setOpenError( + "This SSH alias now points to a different account or host. Open a new subshell to review that destination; this pane cannot reconnect there.", + ); + return; + } + setResolved(view); + }, + }, + ); + } + + function connect() { + if (resolved === null || !resolved.accepted) return; + setOpenError(null); + setOpenCode(null); + open.mutate( + { + connectingNodeId: nodeId, + target: { + alias: resolved.snapshot.alias, + host: resolved.snapshot.host, + port: resolved.snapshot.port, + user: resolved.snapshot.user, + identityFile: resolved.snapshot.identityFiles[0] ?? null, + }, + }, + { + onSuccess: (view) => { + onConnected(view); + }, + onError: (err) => { + setOpenCode(err instanceof SshApiError ? (err.sshCode ?? null) : null); + setOpenError(sshRuntimeErrorCopy(err, { host: resolved.snapshot.host, machine: machineLabel })); + }, + }, + ); + } + + const back = (to: Step) => { + setStep(to); + setResolved(null); + setOpenError(null); + setOpenCode(null); + }; + + // The connecting machine is a picker like every other launch choice (the + // Quick connect selects below it, the node and agent pickers). A card stack + // was readable at one machine and a scan at ten, with the live one buried + // among greyed ones; a searchable list keeps the primary step one field no + // matter how many computers the account owns. + const serverIcon = ; + const offlineIcon = ; + const machineOptions: ComboboxOption[] = []; + if (isClient && localOnline) { + machineOptions.push({ + value: localOnline.id, + label: "This computer", + reason: `${localOnline.name} · Subshell Client SSH`, + searchText: `${localOnline.name} this computer`, + icon: serverIcon, + }); + } + const brokerOptions = (brokersQ.data?.brokers ?? []) + .filter((b) => !localOnline || b.id !== localOnline.id) + .map((b) => ({ + value: b.id, + label: b.name, + reason: b.online ? "Subshell Client SSH" : "Subshell Client SSH · offline", + searchText: "Subshell Client SSH", + disabled: !b.online, + icon: b.online ? serverIcon : offlineIcon, + })); + const agentOptions = ownedAgents.map((n) => { + const why = n.maintenance + ? "in maintenance" + : n.kind === "local" && settings.data?.allowServerSubshells === false + ? "server launching disabled" + : n.status !== "online" + ? "offline" + : null; + const detail = [n.hostname, n.os, n.arch].filter(Boolean).join(" · "); + return { + value: n.id, + label: n.name, + reason: why === null ? detail : detail ? `${detail} · ${why}` : why, + // The detail line is `reason`, which the shared filter does NOT search, + // so hostnames go in `searchText` to stay findable by what the row shows. + searchText: [n.hostname, n.os, n.arch].filter(Boolean).join(" "), + disabled: why !== null, + icon: why === null ? serverIcon : offlineIcon, + }; + }); + machineOptions.push( + ...brokerOptions.filter((o) => !o.disabled), + ...agentOptions.filter((o) => !o.disabled), + ...brokerOptions.filter((o) => o.disabled), + ...agentOptions.filter((o) => o.disabled), + ); + + return ( +
+ {step === "machine" && ( + <> +
+ +

+ Choose the computer that will make the SSH connection, using its SSH settings and keys. Your agent or + terminal runs on the remote host you choose next. +

+
+ {nodesQ.isError && ( +

+ {errMessage(nodesQ.error, "We couldn’t load your computers. Please try again.")} +

+ )} + {brokersQ.isError && ( +
+

+ {errMessage(brokersQ.error, "We couldn’t load your Subshell Client connections.")} +

+ +
+ )} + {isClient && !localOnline && ( +
+ + {showLocalHelp && ( +

+ {identity.isError + ? "Update Subshell Client to identify this computer automatically. You can still choose a named connection below." + : localBrokers.length > 0 + ? "In Subshell Client, open SSH Connections and reconnect to this server." + : "Open Settings → SSH Connections on this server to get a pairing code. Then open SSH Connections in Subshell Client and pair with this server. Your node registration stays unchanged."} +

+ )} +
+ )} + {machineOptions.length > 0 ? ( + { + if (id) pickMachine(id); + }} + /> + ) : ( + !nodesQ.isLoading && + !brokersQ.isLoading && + !nodesQ.isError && + !brokersQ.isError && ( +

+ You haven’t connected a computer for SSH yet. To use the SSH keys on your computer, + + open Settings → SSH Connections + {" "} + and pair it with Subshell Client. You can also add a computer you own on the Nodes page. +

+ ) + )} + + )} + + {step === "host" && ( + <> +
+

Work on

+

+ Choose where you want to work. {machineLabel} SSHes into one of these hosts, listed in its SSH config. +

+
+ {discovery.isError && ( +
+

+ {errMessage(discovery.error, `The SSH config on ${machineLabel} could not be read.`)} +

+ +
+ )} + {discovery.isLoading &&

Reading the config…

} + {discovery.data !== undefined && discovery.data.aliases.length === 0 && ( +
+

+ No SSH hosts found on {machineLabel}. Add a host to ~/.ssh/config for the account running Subshell. + Here’s an example you can adapt: +

+ +

+ Replace the example address, username, and key path with yours. For a container, use its published SSH + port. The address must be reachable from {machineLabel}; localhost refers to that computer. Give each + host a name rather than a wildcard like *. Then run ssh work on {machineLabel}, verify the host’s + fingerprint, and select Refresh hosts here. +

+
+ )} + {discovery.data !== undefined && discovery.data.aliases.length > 0 && ( +
+ {discovery.data.aliases.map((a) => ( + + ))} + {discovery.data.includeCycle && ( +

+ Some SSH config files include each other in a loop, so a few hosts may be missing. +

+ )} + {discovery.data.truncated && ( +

+ There are too many hosts to show them all. Narrow down your SSH config to find the one you need. +

+ )} +
+ )} +
+ + +
+ + )} + + {step === "review" && ( + <> +
+

{alias}

+

+ Connect to this host, then choose a folder to work in. We’ll help you with any setup along the way. +

+
+ {resolve.isPending &&

Checking host settings…

} + {resolve.isError && ( +

+ {errMessage( + resolve.error, + "We couldn’t read the settings for this host. Check its SSH config and try again.", + )} +

+ )} + {resolved !== null && !resolved.accepted && ( +
+ {/* The shipped sentence for the named code, by EQUALITY - never + the wire code itself (the old editor's mapping, same package). */} +

{SSH_ERROR_DESCRIPTIONS[resolved.code]}

+ {resolved.settings.length > 0 && ( +

+ Unsupported SSH settings: {resolved.settings.join(", ")} +

+ )} +
+ )} + {resolved?.accepted && ( +
+
+
Destination
+
+ {resolved.snapshot.user ?? ""} + {resolved.snapshot.user !== null ? "@" : ""} + {resolved.snapshot.host}:{resolved.snapshot.port} +
+
+
+
Connect from
+
+ {machineLabel} + {resolved.connectingAccount !== undefined ? ` as ${resolved.connectingAccount}` : ""} +
+
+
+ )} + {openError !== null && ( +

+ {openError} +

+ )} + +
+ + +
+ + )} +
+ ); +} diff --git a/apps/server/web/src/components/connect/desktop-broker-setup.tsx b/apps/server/web/src/components/connect/desktop-broker-setup.tsx new file mode 100644 index 000000000..e4abf3029 --- /dev/null +++ b/apps/server/web/src/components/connect/desktop-broker-setup.tsx @@ -0,0 +1,138 @@ +import { Button, errMessage, Input } from "@internal/node-admin"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { sshRuntimeFetch } from "@/lib/ssh-runtime"; + +export const DESKTOP_BROKERS_QUERY_KEY = ["ssh-desktop-brokers"] as const; +export interface DesktopBroker { + id: string; + name: string; + online: boolean; +} +export function useDesktopBrokers() { + return useQuery({ + queryKey: DESKTOP_BROKERS_QUERY_KEY, + queryFn: () => sshRuntimeFetch<{ brokers: DesktopBroker[] }>("/api/ssh-runtime/desktop-brokers"), + refetchInterval: 5000, + }); +} + +/** Pairing is personal and explicitly performed in the trusted native node window. */ +export function DesktopBrokerSetup() { + const brokers = useDesktopBrokers(); + const client = useQueryClient(); + const [name, setName] = useState(""); + const [copyError, setCopyError] = useState(false); + const pair = useMutation({ + mutationFn: (id?: string) => + sshRuntimeFetch<{ id: string; name: string; pairingToken: string; expiresAt: string }>( + "/api/ssh-runtime/desktop-brokers", + { + method: "POST", + body: JSON.stringify({ + name: name.trim() || brokers.data?.brokers.find((broker) => broker.id === id)?.name || "This computer", + ...(id ? { id } : {}), + }), + }, + ), + onSuccess: () => { + void client.invalidateQueries({ queryKey: DESKTOP_BROKERS_QUERY_KEY }); + }, + }); + const revoke = useMutation({ + mutationFn: (id: string) => + sshRuntimeFetch(`/api/ssh-runtime/desktop-brokers/${encodeURIComponent(id)}`, { method: "DELETE" }), + onSuccess: () => { + pair.reset(); + void client.invalidateQueries({ queryKey: DESKTOP_BROKERS_QUERY_KEY }); + }, + }); + useEffect(() => { + if (!pair.data) return; + if (brokers.data?.brokers.some((broker) => broker.id === pair.data?.id && broker.online)) { + pair.reset(); + return; + } + const expiry = setTimeout(() => pair.reset(), Math.max(0, new Date(pair.data.expiresAt).getTime() - Date.now())); + return () => clearTimeout(expiry); + }, [pair.data, brokers.data, pair.reset]); + return ( +
+

Connect this computer

+

+ Use your computer’s SSH settings and keys through Subshell Client. Add your computer below, then open SSH + Connections → Connect this computer in the app to pair it. You only need to pair once; your SSH keys stay on + your computer. +

+ + Computer name (optional) + setName(event.target.value)} + placeholder="My laptop" + maxLength={100} + /> +

+ Give your computer a name you’ll recognize when connecting. Leave this blank to use “This computer”. +

+
+ + {pair.data && ( +
+

+ In Subshell Client, choose SSH Connections → Connect this computer and enter this server URL and pairing + code. Only paste the code into Subshell Client. +

+ {window.location.origin} + {pair.data.pairingToken} +

+ This code expires at {new Date(pair.data.expiresAt).toLocaleTimeString()}. You can create a new one if + needed. +

+ + {copyError && ( +

+ We couldn’t copy the code. You can select and copy it from above. +

+ )} +
+ )} + {(pair.isError || revoke.isError || brokers.isError) && ( +

+ {errMessage(pair.error ?? revoke.error ?? brokers.error, "The connection could not be updated.")} +

+ )} + {brokers.data?.brokers.map((broker) => ( +
+ + {broker.name} · {broker.online ? "online" : "offline"} + + + +
+ ))} +
+ ); +} diff --git a/apps/server/web/src/components/connect/dir-browser.tsx b/apps/server/web/src/components/connect/dir-browser.tsx new file mode 100644 index 000000000..3ce967802 --- /dev/null +++ b/apps/server/web/src/components/connect/dir-browser.tsx @@ -0,0 +1,167 @@ +import { Button, errMessage, Input } from "@internal/node-admin"; +import { ArrowUp, ChevronRight, Folder } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useSshListDirs } from "@/hooks/use-ssh-runtime"; + +/** + * The remote folder picker over a LIVE session (design §7's browse step): + * one-level listings through the session's `list_dirs`, the absolute path + * always shown, `Up` walking to the parent the server names. It lists the + * destination, never the connecting machine - the path line says whose disk + * this is. Empty and failure read differently on purpose: a failure says the + * listing did not answer, an empty says the folder simply has no subfolders. + */ +export function DirBrowser({ + sessionId, + host, + value, + onChange, +}: { + sessionId: string; + /** Destination host, for the line that says whose disk is being browsed. */ + host: string; + /** The chosen working directory ("" = the home the session starts in). */ + value: string; + onChange: (path: string) => void; +}) { + const listDirs = useSshListDirs(); + // The open panel path, for Retry: "" asks the destination account's home, + // which the server resolves and answers with its realpath - the path shown + // is the one that came back, never a typed one. + const [askedPath, setAskedPath] = useState(""); + const [typedPath, setTypedPath] = useState(value); + const [expanded, setExpanded] = useState(false); + + const browse = (path: string) => { + setAskedPath(path); + listDirs.mutate( + { sessionId, path }, + { + // The home request ("" = the destination account's home) SELECTS its + // realpath when nothing is chosen yet: the server's answer, never a + // guess. Later browses leave the chosen row's value alone. + onSuccess: (listing) => { + if (path === "" && valueRef.current === "") onChange(listing.path); + setTypedPath(listing.path); + }, + }, + ); + }; + // The current value at SUCCESS time, read through a ref so the callback + // above never closes over a stale one (and the effect below stays simple). + const valueRef = useRef(value); + valueRef.current = value; + // First paint browses the chosen value (the wizard starts at the home, "" + // until the server's realpath lands); a session change re-browses. The + // mutation object and `value` are deliberately NOT deps: naming them would + // re-fire this effect on every listing (the home selection changes `value`) + // and ping-pong the destination forever. + // biome-ignore lint/correctness/useExhaustiveDependencies: browse re-arms only for a new session + useEffect(() => { + browse(value); + }, [sessionId]); + + const failed = listDirs.isError; + const listing = listDirs.isSuccess ? listDirs.data : null; + return ( +
+
+

Choose the folder

+

+ Disk on {host} +

+
+
+ { + setTypedPath(event.target.value); + onChange(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onChange(typedPath); + setExpanded(true); + browse(typedPath); + } + }} + /> + +
+ {/* The absolute path the picker stands on, stated as the fact it is. */} +

+ Current folder: {listing?.path ?? "Home"} +

+ {failed && ( +
+

+ {errMessage(listDirs.error, "This folder could not be read on the destination.")} +

+ +
+ )} + {!failed && listDirs.isPending &&

Loading…

} + {expanded && listing !== null && ( +
+ {listing.parent !== null && ( + + )} + {listing.entries.length === 0 && !listDirs.isPending && ( +

+ This folder has no subfolders. You can still launch in it. +

+ )} + {listing.entries.map((entry) => ( + + ))} + {listing.truncated && ( +

+ The listing is capped; more folders exist here. +

+ )} +
+ )} +
+ ); +} diff --git a/apps/server/web/src/components/connect/launch-step.tsx b/apps/server/web/src/components/connect/launch-step.tsx new file mode 100644 index 000000000..05da36847 --- /dev/null +++ b/apps/server/web/src/components/connect/launch-step.tsx @@ -0,0 +1,160 @@ +import { Bot, SlidersHorizontal, TerminalSquare } from "lucide-react"; +import type { SshRuntimeHarnessEntry, SshRuntimeSessionView } from "@/lib/ssh-runtime"; +import { destinationLabel } from "@/lib/ssh-runtime"; +import type { PresetRow } from "@/types/preset"; + +/** + * The wizard's last step: WHAT to open in the chosen folder. Three row kinds, + * one list, the same call signature for all: + * + * - **Terminal**: always available (the destination's shell needs nothing but + * the runtime). + * - **Harnesses**: the destination's detected binaries (the detect mirror; + * `installed` rows only, and the terminal harness rides its own row above). + * A presetless harness launch, the same launch an unnamed create performs. + * - **Presets**: the caller's saved presets whose harness DETECTED as + * installed here. A preset for a harness the destination lacks stays hidden + * - the row would only fail, and hiding names nothing the operator needs. + * + * `detecting`/`detectError` surface the round trip: while it is in flight or + * failed, only the terminal row is offered (and a failure says so in one + * quiet sentence - the mirror is cacheable, a retry is a button). + */ + +/** What a row asks for: the terminal, a harness presetless, or a preset. */ +export type LaunchPick = + | { kind: "terminal" } + | { kind: "harness"; harnessId: string } + | { kind: "preset"; harnessId: string; presetId: string }; + +/** One harness the destination has (the terminal harness never doubles as a row here). */ +function isSelectableHarness(h: SshRuntimeHarnessEntry): boolean { + return h.installed && h.harnessId !== "terminal"; +} + +/** The preset rows: the caller's presets whose harness detected installed there. */ +function selectablePresets( + presets: PresetRow[], + harnesses: SshRuntimeHarnessEntry[], +): { preset: PresetRow; harnessName: string }[] { + const installed = harnesses.filter(isSelectableHarness); + return presets + .filter((p) => p.harnessId !== "terminal") + .map((p) => ({ preset: p, harnessName: installed.find((h) => h.harnessId === p.harnessId)?.harnessName ?? null })) + .filter((r): r is { preset: PresetRow; harnessName: string } => r.harnessName !== null); +} + +export function LaunchStep({ + session, + cwd, + busy, + harnesses, + presets, + detecting, + detectError, + onDetect, + onPick, +}: { + session: SshRuntimeSessionView; + /** The folder chosen in the browser above this step. */ + cwd: string; + /** A launch is in flight; the rows are inert until it answers. */ + busy: boolean; + /** The destination's harness mirror (empty until the first detect). */ + harnesses: SshRuntimeHarnessEntry[]; + /** The caller's presets; only those on installed harnesses become rows. */ + presets: PresetRow[]; + /** The detect round trip is in flight. */ + detecting: boolean; + /** The detect round trip failed (rows fall back to terminal-only). */ + detectError: boolean; + /** Re-ask the destination. */ + onDetect: () => void; + /** The chosen entry: terminal, a detected harness, or an installed preset. */ + onPick: (pick: LaunchPick) => void; +}) { + const harnessRows = harnesses.filter(isSelectableHarness); + const presetRows = selectablePresets(presets, harnesses); + return ( +
+
+

Open in this folder

+ +
+ {detectError && !detecting && ( +

+ The destination could not say which agents it has installed. The terminal still opens; retry when the runtime + there is updated. +

+ )} + {/* A real button, not a click-handler div: these are the step's only + controls, and a control has to take the keyboard. */} + + {harnessRows.map((h) => ( + + ))} + {presetRows.map(({ preset, harnessName }) => ( + + ))} +
+ ); +} diff --git a/apps/server/web/src/components/connect/sessions-table.tsx b/apps/server/web/src/components/connect/sessions-table.tsx new file mode 100644 index 000000000..1fa377a6d --- /dev/null +++ b/apps/server/web/src/components/connect/sessions-table.tsx @@ -0,0 +1,137 @@ +import { Button, confirmAction, errMessage } from "@internal/node-admin"; +import { Link } from "@tanstack/react-router"; +import { ArrowUpCircle, XCircle } from "lucide-react"; +import { useState } from "react"; +import { useNodes } from "@/hooks/use-nodes"; +import { useSshClose } from "@/hooks/use-ssh-runtime"; +import { destinationLabel, type SshRuntimeSessionView } from "@/lib/ssh-runtime"; + +/** + * The personal history (design §7's Connections-like list): the caller's own + * sessions, newest first, each row naming the destination and the machine + * that brokered it. Statuses read in user words, and the honesty sentence is + * part of the LOST row, not a footnote: a lost session means the destination + * is unavailable, never that the work completed. Closing ends the session; + * launched panes keep running on the destination until ended themselves + * (design §6), and the confirm dialog says so before the act. + */ + +/** The status word a row shows beside its dot-free truth. */ +const STATUS_WORDS: Record = { + opening: "Opening", + active: "Connected", + lost: "Lost", + closed: "Closed", +}; + +export function SessionsTable({ + sessions, + loading, + onReopen, +}: { + sessions: SshRuntimeSessionView[]; + loading: boolean; + /** Re-run the journey with this machine and alias pre-chosen. */ + onReopen: (session: SshRuntimeSessionView) => void; +}) { + const nodesQ = useNodes(); + const close = useSshClose(); + const [error, setError] = useState(null); + + const machineName = (id: string | null): string | null => + id === null ? null : (nodesQ.data?.nodes.find((n) => n.id === id)?.name ?? null); + + async function handleClose(session: SshRuntimeSessionView) { + setError(null); + const ok = await confirmAction({ + title: "Close session?", + description: `The connection to ${destinationLabel(session)} ends. Panes you launched there keep running until you end them yourself.`, + confirmLabel: "Close", + danger: true, + }); + if (!ok) return; + try { + await close.mutateAsync(session.id); + } catch (err) { + setError(errMessage(err, "The session could not be closed.")); + } + } + + if (loading) { + return

Loading…

; + } + if (sessions.length === 0) { + return ( +

+ No SSH connections yet. Choose SSH host when opening a new subshell or adding a workspace pane. +

+ ); + } + + return ( +
+ {error !== null && ( +

+ {error} +

+ )} + {sessions.map((s) => { + const via = machineName(s.connectingNodeId); + return ( +
+
+ + {destinationLabel(s)} + + {s.alias} + {via !== null ? ` · via ${via}` : s.connectingNodeId === null ? " · connecting machine removed" : ""} + {" · "} + {STATUS_WORDS[s.status]} + {" · "} + {new Date(s.createdAt).toLocaleString()} + + + + {(s.status === "lost" || s.status === "closed") && ( + + )} + {(s.status === "active" || s.status === "opening") && ( + + )} + +
+ {s.status === "lost" && ( +

+ The connection dropped; the destination is unavailable, not completed. Reconnect to continue working. +

+ )} + {s.status === "closed" && ( +

+ The session ended. Panes launched on it keep running on the destination. +

+ )} + {s.hello !== null && ( +

+ Runtime {s.hello.agentVersion} on{" "} + + {s.hello.os}/{s.hello.arch} + +

+ )} +
+ ); + })} +

+ Looking for a past pane?{" "} + + Subshells + {" "} + keeps every row a session launched. +

+
+ ); +} diff --git a/apps/server/web/src/components/connect/ssh-runtime-pane-identity.tsx b/apps/server/web/src/components/connect/ssh-runtime-pane-identity.tsx new file mode 100644 index 000000000..13fb2d972 --- /dev/null +++ b/apps/server/web/src/components/connect/ssh-runtime-pane-identity.tsx @@ -0,0 +1,96 @@ +import { Button } from "@internal/node-admin"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { ConnectJourney } from "@/components/connect/connect-journey"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import { SSH_PANE_IDENTITY_QUERY_KEY, useSshPaneIdentity, useSshSessions } from "@/hooks/use-ssh-runtime"; +import { SUBSHELLS_QUERY_KEY } from "@/lib/query-keys"; +import { destinationLabel } from "@/lib/ssh-runtime"; + +/** + * The trusted identity line on a pane opened through an SSH session: the + * destination and the machine that brokered it, read from the SERVER's rows + * by pane id (the by-pane route), never composed from anything the pane's + * own output claims, and never from terminal bytes. On an ordinary pane the + * read answers the ordinary 404 and the component renders nothing. + * + * The tier mirrors the old managed-terminal chrome and the cross-agent + * marker: `detail`, muted, the destination in monospace. A session that is no + * longer standing is qualified, in either of its two ways: `connection lost` + * for a dropped link (design §6: unavailable, not completed) and `closed` for + * an ended one; a pane whose channel is gone must never read as a live + * destination. A close known client-side invalidates this read (the close + * action in use-ssh-runtime), so the qualification lands without a reload. + */ +export function SshRuntimePaneIdentity({ subshellId, compact = false }: { subshellId: string; compact?: boolean }) { + const [recovering, setRecovering] = useState(false); + const identity = useSshPaneIdentity(subshellId); + if (identity.data === null || identity.data === undefined || identity.isError) return null; + const line = identity.data; + return ( + + SSH ·{" "} + + {destinationLabel(line)} + + {!compact && <> · via {line.connectingNodeName ?? "a machine since removed"}} + {line.status === "lost" && " · connection lost"} + {line.status === "closed" && " · closed"} + {line.status !== "active" && ( + + )} + {recovering && setRecovering(false)} />} + + ); +} + +function PaneRecovery({ sessionId, onClose }: { sessionId: string; onClose: () => void }) { + const sessions = useSshSessions(); + const client = useQueryClient(); + const session = sessions.data?.sessions.find((row) => row.id === sessionId); + return ( + { + if (!open) onClose(); + }} + > + + Reconnect to SSH host + + Your pane and workspace stay open. Reconnecting restores panes still running on the host. + + {sessions.isLoading &&

Loading connection…

} + {sessions.isError && ( + + )} + {session && session.connectingNodeId !== null && ( + { + void client.invalidateQueries({ queryKey: SSH_PANE_IDENTITY_QUERY_KEY }); + void client.invalidateQueries({ queryKey: SUBSHELLS_QUERY_KEY }); + onClose(); + }} + /> + )} + {!sessions.isLoading && !sessions.isError && (!session || session.connectingNodeId === null) && ( +

+ The connecting machine is no longer available. Choose another machine from New subshell to reach this host. +

+ )} +
+
+ ); +} diff --git a/apps/server/web/src/components/connect/ssh-setup-help.tsx b/apps/server/web/src/components/connect/ssh-setup-help.tsx new file mode 100644 index 000000000..7d53c6a08 --- /dev/null +++ b/apps/server/web/src/components/connect/ssh-setup-help.tsx @@ -0,0 +1,80 @@ +import { SUBSHELL_REPO_SLUG } from "@internal/subshell-protocol"; +import { CopyCommandRow } from "@/components/copy-command-row"; + +/** Shell-quote display commands too: an SSH alias is configuration data. */ +export function sshLoginCommand(alias: string): string { + return `ssh -- '${alias.replaceAll("'", "'\\''")}'`; +} + +/** Recovery instructions name both accounts; commands are copied, never executed by the page. */ +export function SshSetupHelp({ + code, + alias, + machine, + account, +}: { + code: string | null; + alias: string; + machine: string; + account?: string; +}) { + const login = sshLoginCommand(alias); + const origin = `${machine}${account ? ` as ${account}` : ", using the account running Subshell"}`; + if (code === "host_key_unknown" || code === "host_key_changed" || code === "host_key_revoked") { + return ( +
+

Open a terminal on {origin} and run:

+ +

+ {code === "host_key_unknown" + ? "Before accepting the connection, check that the SHA256 fingerprint matches the one shown in the host’s console, or ask its administrator to confirm it. Then leave the SSH shell and try connecting here again." + : "This host’s SSH key has changed or was revoked. Ask its administrator to confirm the new fingerprint before updating this account’s known_hosts file. Keep host-key checking enabled, then try again."} +

+
+ ); + } + if (code === "runtime_missing" || code === "session_protocol") { + return ( +
+

You can reach this host. Let’s get Subshell ready to run there:

+
    +
  1. + Open a terminal on {origin}, then log in to the remote host: + +
  2. +
  3. + Check which operating system and processor the remote host uses: + + Choose Linux for Linux or macOS for Darwin. For the processor, choose x64 for x86_64, or arm64 for aarch64 + or arm64. +
  4. +
  5. + + Download the Subshell CLI + {" "} + for that operating system and processor. Copy the downloaded program to the remote host and name it{" "} + subshell. Choose a release with SSH support. +
  6. +
  7. + In the remote folder where you saved the download, run this command to install it. You may need an + administrator’s help: + + You’ll also need tmux on the remote host (on Ubuntu: sudo apt-get install tmux). +
  8. +
  9. + Back on {origin}, check that SSH can find both programs: + + Once both version numbers appear, select Retry connection here. You can start with Terminal and install an + agent later. There’s no need to enroll this host as a node or set up a service. +
  10. +
+
+ ); + } + return null; +} diff --git a/apps/server/web/src/components/sidebar/SubshellRecentRow.tsx b/apps/server/web/src/components/sidebar/SubshellRecentRow.tsx index 272d3dcc9..662029e84 100644 --- a/apps/server/web/src/components/sidebar/SubshellRecentRow.tsx +++ b/apps/server/web/src/components/sidebar/SubshellRecentRow.tsx @@ -62,7 +62,7 @@ export function SubshellRecentRow({ presetLabel?: string; /** * Text for the row's muted second line, REPLACING the working directory. - * The "Cross-agent comms" section uses it to name the MACHINE each pane runs + * The "Agent-created" section uses it to name the MACHINE each pane runs * on: its rows span every machine, so the header cannot say which one a row * is on the way a node group's header can, and the path is less useful there * than the machine. Every other rail row leaves it undefined (the path). diff --git a/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx b/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx index 636dc0216..b6d8461b3 100644 --- a/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx +++ b/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx @@ -70,21 +70,27 @@ export function LaunchSubshellDialog({ if (!next) reset(); })} > - + + + {form.sshSessionId !== "" && ( + + )} + + } + className="sm:max-w-xl" + > New subshell Launch an agent in a working directory. onOpenChange(false)} /> {error &&

{error}

} - - - -
); diff --git a/apps/server/web/src/components/sidebar/new-workspace-dialog.tsx b/apps/server/web/src/components/sidebar/new-workspace-dialog.tsx index 394a1c8a4..57eff6816 100644 --- a/apps/server/web/src/components/sidebar/new-workspace-dialog.tsx +++ b/apps/server/web/src/components/sidebar/new-workspace-dialog.tsx @@ -152,7 +152,30 @@ export function NewWorkspaceDialog({ if (!next) reset(); })} > - + + {createdId ? ( + + ) : ( + <> + + {mode === "new" && form.sshSessionId !== "" && ( + + )} + + + )} + + } + className="sm:max-w-2xl" + > New workspace Start it with subshells already tiled in, or empty. @@ -191,26 +214,6 @@ export function NewWorkspaceDialog({ {selected.length === 1 ? "1 subshell" : `${selected.length} subshells`} to add

)} - - - {createdId ? ( - - ) : ( - <> - - {mode === "new" && ( - - )} - - - )} -
); diff --git a/apps/server/web/src/components/sidebar/rail-subshells.tsx b/apps/server/web/src/components/sidebar/rail-subshells.tsx index 495d610a7..87bce1e03 100644 --- a/apps/server/web/src/components/sidebar/rail-subshells.tsx +++ b/apps/server/web/src/components/sidebar/rail-subshells.tsx @@ -7,6 +7,7 @@ import { SubshellRecentRow } from "@/components/sidebar/SubshellRecentRow"; import { flatCellRows, nodeTintBucket, paneInitial, SubshellCellGrid } from "@/components/sidebar/subshell-cell-grid"; import { TippedIconButton } from "@/components/tipped-icon-button"; import { Segmented } from "@/components/ui/segmented"; +import { useExecutionLabels } from "@/hooks/use-execution-labels"; import { useInstancePlugins } from "@/hooks/use-instance-plugins"; import { useNodes } from "@/hooks/use-nodes"; import { useOrderedSubshells } from "@/hooks/use-ordered-subshells"; @@ -56,7 +57,7 @@ const OTHERS_GROUP_ID = "others"; /** * The rail's whole subshell section: the mode control, the filter box, the - * Needs Attention spotlight, the Cross-agent comms section and the machine + * Needs Attention spotlight, the Agent-created section and the machine * groups, in one of THREE renderings behind a single choice. * * - `rows` (the default): text rows — the shape the rail has always had. @@ -110,6 +111,7 @@ export function RailSubshells({ // Same query keys the home page and every mutation already share — these // reads are cache hits for a rail that lives in the same tree. const { data: nodeData } = useNodes(); + const nodeLabels = useExecutionLabels(nodeData?.nodes); // Names only, over the catalog the launch pickers already cache. An // unresolvable harness degrades to its id, which is a readable slug // ("claude-code") — see the clone dialog, which makes the same trade. @@ -142,7 +144,7 @@ export function RailSubshells({ // than two identical expressions kept in sync by a comment. const railRows = q ? filterSubshells(byStatus, query) : byStatus; // Panes an AGENT opened over MCP leave the machine groups entirely - // (operator ask 2026-09-25): they are internal cross-agent comms, filed in + // (operator ask 2026-09-25; the SSH-wave rename calls them Agent-created), filed in // one section of their own ABOVE the machine groups (moved from below on // operator ask 2026-09-26), each row naming the machine it runs on since // the section spans them. The partition runs on the FILTERED list, so a @@ -150,7 +152,7 @@ export function RailSubshells({ const { human: railHuman, comms: railComms } = partitionCrossAgent(railRows); const commsGroup = { nodeId: CROSS_AGENT_GROUP_ID, - label: "Cross-agent comms", + label: "Agent-created", // The header's hover line is the whole explanation; two sentences max, // per the design system's rule for UI text. title: @@ -165,7 +167,7 @@ export function RailSubshells({ // between feed frames and undo the liveliest-member ordering the 20 s tick // exists to maintain. The pass is O(rows) with a Map, per tick and per // keystroke — the frame it costs is one the rail re-renders for anyway. - const nodeGroups = groupSubshellsByNode(railHuman, nodeData?.nodes, { + const nodeGroups = groupSubshellsByNode(railHuman, nodeLabels, { limit: q ? undefined : RECENT_LIMIT, // "Unanswered" means NO successful read has ever committed: in flight, or // failed with nothing cached. It cannot be `isPending || isError` — a @@ -226,8 +228,8 @@ export function RailSubshells({ // from the header's `label`; the comms and spotlight rows span machines). // Same ladder the headers use — one answer per machine per render. const machineLabel = useCallback( - (sub: SubshellView) => nodeLabelFor(sub.nodeId || FALLBACK_NODE_ID, nodeData?.nodes, nodeData === undefined).label, - [nodeData], + (sub: SubshellView) => nodeLabelFor(sub.nodeId || FALLBACK_NODE_ID, nodeLabels, nodeData === undefined).label, + [nodeData, nodeLabels], ); // SELECTED is the SET of open panes — the viewed `/subshells/:id` plus every // pane the current `/workspaces/:id` holds open (the SAME cached query the diff --git a/apps/server/web/src/components/sidebar/sidebar-nav.ts b/apps/server/web/src/components/sidebar/sidebar-nav.ts index 264fe4689..090381e59 100644 --- a/apps/server/web/src/components/sidebar/sidebar-nav.ts +++ b/apps/server/web/src/components/sidebar/sidebar-nav.ts @@ -16,7 +16,9 @@ import { Shield, SlidersHorizontal, TerminalSquare, + UserRoundCog, Users, + Waypoints, } from "lucide-react"; /** Sidebar item: route target + icon. */ @@ -57,10 +59,18 @@ const NAV_ENTRIES: NavEntry[] = [ // not a grid (the grid icon belongs to the tiles/list view toggle). { to: "/", label: "Subshells", icon: TerminalSquare }, { to: "/workspaces", label: "Workspaces", icon: LayoutDashboard, short: "Wksp" }, - { to: "/nodes", label: "Nodes", icon: Server, short: "Nodes" }, - { to: "/presets", label: "Presets", icon: SlidersHorizontal, short: "Preset" }, - // Beside Presets: both are saved launch material, one is settings, one is text. - { to: "/prompts", label: "Prompts", icon: MessageSquareText, short: "Prompts" }, + { + id: "personal-settings", + label: "Settings", + icon: UserRoundCog, + children: [ + { to: "/nodes", label: "Nodes", icon: Server, short: "Nodes" }, + { to: "/presets", label: "Presets", icon: SlidersHorizontal, short: "Preset" }, + // Beside Presets: both are saved launch material, one is settings, one is text. + { to: "/prompts", label: "Prompts", icon: MessageSquareText, short: "Prompts" }, + { to: "/settings/connections", label: "SSH Connections", icon: Waypoints, short: "SSH" }, + ], + }, // On the label (spec 2026-09-11 §2.1). The single entry here used to read // "Instance", not "Server", because the control-plane host's own NODE is // named Server by default and on /nodes an admin saw that word twice, on two diff --git a/apps/server/web/src/components/sidebar/subshell-cell-grid.tsx b/apps/server/web/src/components/sidebar/subshell-cell-grid.tsx index 08874682b..76d6e0499 100644 --- a/apps/server/web/src/components/sidebar/subshell-cell-grid.tsx +++ b/apps/server/web/src/components/sidebar/subshell-cell-grid.tsx @@ -87,7 +87,7 @@ const NODE_TINT_CLASS = [ /** * The flat grid's cell set, ordered in FOUR bands (operator order * 2026-09-26): the Needs Attention spotlight, then machine clusters that hold a - * SELECTED pane, then every other machine cluster, then cross-agent comms. + * SELECTED pane, then every other machine cluster, then the Agent-created section. * * The four arguments are the pieces grouped mode draws: each machine group's * CAPPED rows, the comms section's rows, the Needs Attention spotlight, and the @@ -129,7 +129,7 @@ export function flatCellRows( const machines = clusters .sort((a, b) => Number(b.selected) - Number(a.selected) || a.rank - b.rank) .flatMap((cluster) => sortByStatus(cluster.members)); - // Band 4: cross-agent comms last. + // Band 4: Agent-created panes last. return [...notifications, ...machines, ...sortByStatus(comms)]; } diff --git a/apps/server/web/src/components/subshell-pane.tsx b/apps/server/web/src/components/subshell-pane.tsx index 6fe11a22a..a87f5c126 100644 --- a/apps/server/web/src/components/subshell-pane.tsx +++ b/apps/server/web/src/components/subshell-pane.tsx @@ -2,6 +2,7 @@ import { Button } from "@internal/node-admin"; import type { ViewersState } from "@internal/subshell-protocol"; import { RotateCcw, Trash2 } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { SshRuntimePaneIdentity } from "@/components/connect/ssh-runtime-pane-identity"; import { LogTail } from "@/components/log-tail"; import { InjectPromptDialog } from "@/components/prompts/inject-prompt-dialog"; import { SubshellDevices } from "@/components/subshell-devices"; @@ -117,6 +118,10 @@ export function SubshellPane({ onDispose?.(); }, [onDispose]); + // Only SSH panes render this owner-scoped line; ordinary panes resolve to + // null. Both phone tabs and dockview share this recovery surface. + const sshIdentity = ; + // Shared two-button row the exited and ended panels both offer. const paneActions = ( <> @@ -137,6 +142,7 @@ export function SubshellPane({ return (
+ {sshIdentity}

Node offline, reconnecting

This subshell may still be running on its node. Output and input resume when the node reconnects to the @@ -156,6 +162,7 @@ export function SubshellPane({ if (exited) { return ( + {sshIdentity} {paneActions} ); @@ -166,6 +173,7 @@ export function SubshellPane({ return (

+ {sshIdentity}

Subshell ended

{paneActions}
@@ -179,9 +187,10 @@ export function SubshellPane({ } const terminal = ( {terminal} {devices} +
+
{sshIdentity}
+
); } @@ -239,6 +251,9 @@ export function SubshellPane({
{terminal} {devices} +
+
{sshIdentity}
+
- + + + {form.sshSessionId !== "" && ( + + )} + + ) + } + className="sm:max-w-2xl" + > {title ?? "Add a subshell"} {description ?? "Pick one you already have, or launch a new one."} @@ -196,17 +212,6 @@ export function AddSubshellDialog({ )} {error &&

{error}

} - - {mode === "new" && ( - - - - - )}
); diff --git a/apps/server/web/src/components/subshell-picker/launch-form-rules.ts b/apps/server/web/src/components/subshell-picker/launch-form-rules.ts index 62156f821..26de62b9a 100644 --- a/apps/server/web/src/components/subshell-picker/launch-form-rules.ts +++ b/apps/server/web/src/components/subshell-picker/launch-form-rules.ts @@ -25,6 +25,8 @@ export interface NewSubshellFormValue { * nodes around), which blocks submit until the user picks one. */ nodeId: string; + /** undefined = ordinary machine; empty = choosing SSH; otherwise the live connection id. */ + sshSessionId?: string; workingDir: string; /** * The prompt stack, in the order it will be typed (spec 2026-09-28, @@ -57,7 +59,13 @@ export function canSubmit(value: NewSubshellFormValue): boolean { // A checked "Save as preset" without a name is an unnamed promise; the // field is required exactly while the box is checked (ruling 2026-09-30). const presetNamed = value.saveAsPreset !== true || (value.presetName ?? "").trim() !== ""; - return Boolean(value.harnessId) && Boolean(value.workingDir.trim()) && Boolean(value.nodeId) && presetNamed; + return ( + (value.sshSessionId === undefined || value.sshSessionId !== "") && + Boolean(value.harnessId) && + Boolean(value.workingDir.trim()) && + Boolean(value.nodeId) && + presetNamed + ); } /** diff --git a/apps/server/web/src/components/subshell-picker/new-subshell-form.tsx b/apps/server/web/src/components/subshell-picker/new-subshell-form.tsx index 0b9952c26..76989a1f6 100644 --- a/apps/server/web/src/components/subshell-picker/new-subshell-form.tsx +++ b/apps/server/web/src/components/subshell-picker/new-subshell-form.tsx @@ -1,5 +1,5 @@ import type { Node } from "@internal/node-admin"; -import { Input, Label } from "@internal/node-admin"; +import { Button, Input, Label } from "@internal/node-admin"; import { Link } from "@tanstack/react-router"; import type { JSX } from "react"; import { useState } from "react"; @@ -13,6 +13,7 @@ import { type NewSubshellFormValue, } from "@/components/subshell-picker/launch-form-rules"; import { NoLaunchTargets } from "@/components/subshell-picker/no-launch-targets"; +import { SshLaunchForm } from "@/components/subshell-picker/ssh-launch-form"; import { useLaunchFormDefaults } from "@/components/subshell-picker/use-launch-form-defaults"; import { type ComboboxOption, SearchableSelect } from "@/components/ui/combobox"; import { RequiredMark } from "@/components/ui/required-mark"; @@ -74,7 +75,44 @@ import { buildAgentOptions, buildNodeOptions, launchableNodes } from "@/lib/subs * Renaming stays a deliberate act on the subshell itself — "Edit title" in * its actions menu, which is also the title pin. */ -export function NewSubshellForm({ +export function NewSubshellForm(props: Parameters[0]): JSX.Element { + const ssh = props.value.sshSessionId !== undefined; + return ( +
+ {!props.firstRun && ( +
+

Where do you want to work?

+
+ + +
+
+ )} + {ssh ? : } +
+ ); +} + +function MachineSubshellForm({ value, onChange, ids = DIALOG_IDS, @@ -332,7 +370,7 @@ export function NewSubshellForm({ nodeId !== "" && onChange({ ...value, nodeId })} /> diff --git a/apps/server/web/src/components/subshell-picker/ssh-launch-form.tsx b/apps/server/web/src/components/subshell-picker/ssh-launch-form.tsx new file mode 100644 index 000000000..f2dfb65f6 --- /dev/null +++ b/apps/server/web/src/components/subshell-picker/ssh-launch-form.tsx @@ -0,0 +1,372 @@ +import { Button, errMessage, Label } from "@internal/node-admin"; +import { Link } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import { ConnectJourney } from "@/components/connect/connect-journey"; +import { useDesktopBrokers } from "@/components/connect/desktop-broker-setup"; +import { DirBrowser } from "@/components/connect/dir-browser"; +import { PromptStackSection } from "@/components/prompts/prompt-stack-section"; +import { + DIALOG_IDS, + type NewSubshellFormIds, + type NewSubshellFormValue, +} from "@/components/subshell-picker/launch-form-rules"; +import { SearchableSelect } from "@/components/ui/combobox"; +import { useNodes } from "@/hooks/use-nodes"; +import { usePresets } from "@/hooks/use-presets"; +import { useConnectSshLocation, useSaveSshLocation, useSshLocations } from "@/hooks/use-ssh-locations"; +import { useSshDetectHarnesses, useSshSessionHarnesses, useSshSessions } from "@/hooks/use-ssh-runtime"; +import { makeForm, useSubmitDisabled } from "@/lib/form"; +import { wireToPresetBlocks } from "@/lib/prompt-stack"; +import { sshLocationProblems } from "@/lib/ssh-location-form"; +import { destinationLabel, type SshRuntimeSessionView } from "@/lib/ssh-runtime"; + +/** Remote location selection inside the shared standalone/workspace launch form. */ +export function SshLaunchForm({ + value, + onChange, + ids = DIALOG_IDS, + onLeave, +}: { + value: NewSubshellFormValue; + onChange: (value: NewSubshellFormValue) => void; + ids?: NewSubshellFormIds; + onLeave?: () => void; +}) { + const sessions = useSshSessions(true); + const locations = useSshLocations(); + const reconnect = useConnectSshLocation(); + const choice = useRef(0); + useEffect( + () => () => { + choice.current += 1; + }, + [], + ); + const nodes = useNodes(); + const desktopBrokers = useDesktopBrokers(); + const originName = (id: string | null) => + nodes.data?.nodes.find((n) => n.id === id)?.name ?? + desktopBrokers.data?.brokers.find((b) => b.id === id)?.name ?? + "connecting machine"; + const [opened, setOpened] = useState(null); + const [connecting, setConnecting] = useState(false); + const [prefill, setPrefill] = useState<{ nodeId: string; alias: string } | null>(null); + const session = + sessions.data?.sessions.find((s) => s.id === value.sshSessionId) ?? + (opened?.id === value.sshSessionId ? opened : null); + + useEffect(() => { + if (session && session.status !== "active" && value.nodeId) onChange({ ...value, nodeId: "", workingDir: "" }); + }, [session, value, onChange]); + + function choose(s: SshRuntimeSessionView, workingDir = "") { + choice.current += 1; + setOpened(s); + setConnecting(false); + onChange({ ...value, sshSessionId: s.id, nodeId: s.runtimeNodeId, workingDir, harnessId: "", presetId: null }); + } + + if (session?.status === "active") { + return ( +
+
+
+

{session.alias}

+

+ {destinationLabel(session)} · via {originName(session.connectingNodeId)} +

+
+ +
+ + + Manage SSH connections + +
+ ); + } + + const recent = sessions.data?.sessions.filter((s) => s.status !== "active" && s.connectingNodeId !== null) ?? []; + const active = sessions.data?.sessions.filter((s) => s.status === "active") ?? []; + const savedCount = locations.data?.locations.length ?? 0; + // The shortcuts are the secondary path; the machine-and-destination journey is + // the primary first read. The group appears only when there is something to + // jump back into, so a fresh host with no history shows just the journey. + const showQuick = active.length > 0 || savedCount > 0 || (recent.length > 0 && !connecting); + return ( +
+ {session && ( +

+ This connection has ended. Reconnect below before starting a subshell. +

+ )} + {sessions.isError && ( +

+ {errMessage(sessions.error, "Could not load SSH connections.")}{" "} + +

+ )} + + {/* Primary: the machine that makes the SSH connection, then its host and folder. */} + {active.length > 0 && !connecting ? ( + + ) : ( + + )} + + {/* Secondary: one-click shortcuts back into something already known. */} + {showQuick && ( +
+
+

Quick connect

+

+ Reconnect to a host or folder you have used before. Choosing one sets up the connection for you. +

+
+ {active.length > 0 && ( +
+ + ({ + value: s.id, + label: s.alias, + reason: `${destinationLabel(s)} · via ${originName(s.connectingNodeId)}`, + }))} + onValueChange={(id) => { + const s = active.find((s) => s.id === id); + if (s) choose(s); + }} + /> +
+ )} + {savedCount > 0 && ( +
+ + ({ + value: l.id, + label: `${l.alias} · ${l.path}`, + reason: `${destinationLabel(l)} · via ${originName(l.originId)}`, + disabled: reconnect.isPending, + }))} + onValueChange={(id) => { + const request = ++choice.current; + reconnect.mutate(id, { + onSuccess: ({ session, location }) => { + if (choice.current === request) choose(session, location.path); + }, + }); + }} + /> +
+ )} + {recent.length > 0 && !connecting && ( +
+ + ({ value: s.id, label: s.alias, reason: destinationLabel(s) }))} + onValueChange={(id) => { + const s = recent.find((s) => s.id === id); + if (s?.connectingNodeId) { + choice.current += 1; + setPrefill({ nodeId: s.connectingNodeId, alias: s.alias }); + setConnecting(true); + } + }} + /> +
+ )} + {(locations.isError || reconnect.isError) && ( +

+ {errMessage(reconnect.error ?? locations.error, "Could not load saved locations.")} +

+ )} +
+ )} + + + Manage SSH connections and reconnect + +
+ ); +} + +/** The selected destination owns its folder and detected agents, never the connecting node. */ +function RemoteLaunchFields({ + session, + value, + onChange, + ids, +}: { + session: SshRuntimeSessionView; + value: NewSubshellFormValue; + onChange: (value: NewSubshellFormValue) => void; + ids: NewSubshellFormIds; +}) { + const save = useSaveSshLocation(); + const saveForm = makeForm({ + defaultValues: { path: value.workingDir }, + validator: sshLocationProblems, + onSubmit: ({ path }) => { + if (save.isPending || Object.keys(sshLocationProblems({ path })).length > 0) return; + save.mutate({ sessionId: session.id, path }); + }, + }); + useEffect(() => { + saveForm.setFieldValue("path", value.workingDir); + }, [saveForm, value.workingDir]); + const saveDisabled = useSubmitDisabled(saveForm, save.isPending); + const savePathProblem = sshLocationProblems({ path: value.workingDir }).path; + const harnesses = useSshSessionHarnesses(session.id); + const detect = useSshDetectHarnesses(session.id); + const presets = usePresets(); + const detected = useRef(false); + useEffect(() => { + if (!detected.current) { + detected.current = true; + detect.mutate(undefined); + } + }, [detect.mutate]); + const available = harnesses.data?.harnesses ?? []; + const chosen = available.find((h) => h.harnessId === value.harnessId); + // A carried-over local agent is never a valid choice until this destination answers. + useEffect(() => { + if (value.harnessId && harnesses.data && !chosen?.installed) onChange({ ...value, harnessId: "", presetId: null }); + }, [chosen, harnesses.data, value, onChange]); + return ( +
+ onChange({ ...value, workingDir })} + /> +
+ + {save.isSuccess && ( +

+ Location saved. +

+ )} +
+ {savePathProblem &&

{savePathProblem}

} + {save.isError && ( +

+ {errMessage(save.error, "Could not save this location.")} +

+ )} +
+ + ({ + value: p.id, + label: p.name, + disabled: !available.some((h) => h.harnessId === p.harnessId && h.installed), + reason: p.harnessId, + }))} + onValueChange={(id) => { + const preset = presets.data?.find((p) => p.id === id); + if (preset) + onChange({ + ...value, + harnessId: preset.harnessId, + presetId: preset.id, + promptBlocks: wireToPresetBlocks(preset.promptBlocks), + }); + }} + /> + {value.presetId && ( +

+ Settings copied from {presets.data?.find((p) => p.id === value.presetId)?.name}. The SSH host and folder + stay selected. +

+ )} +
+
+ + ({ + value: h.harnessId, + label: h.harnessName, + disabled: !h.installed, + reason: h.installed ? undefined : "Not installed on this host", + }))} + onValueChange={(harnessId) => onChange({ ...value, harnessId, presetId: null })} + /> +

+ Runs on {session.alias}. Agent sign-in and files stay on that host. +

+ {detect.isPending && ( +

+ Checking installed agents… +

+ )} + {(detect.isError || harnesses.isError) && ( +

+ Could not check installed agents.{" "} + +

+ )} +
+ onChange({ ...value, promptBlocks })} + addButtonId={ids.prompt} + /> +
+ ); +} diff --git a/apps/server/web/src/components/ui/dialog.tsx b/apps/server/web/src/components/ui/dialog.tsx index 0a4a36aa1..03f4623d2 100644 --- a/apps/server/web/src/components/ui/dialog.tsx +++ b/apps/server/web/src/components/ui/dialog.tsx @@ -1,7 +1,7 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { cn } from "@internal/node-admin"; import { X } from "lucide-react"; -import type { HTMLAttributes } from "react"; +import type { HTMLAttributes, ReactNode } from "react"; /** * Modal dialog on Base UI's `Dialog` parts (migrated from Radix; export @@ -42,7 +42,12 @@ export function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop. * pushed off the edge) instead of letting the row clip. `minmax(0,…)` lets the * column shrink to the dialog width, which is what the ellipsis needs to bind. */ -export function DialogContent({ className, children, ...props }: DialogPrimitive.Popup.Props) { +export function DialogContent({ + className, + children, + footer, + ...props +}: DialogPrimitive.Popup.Props & { footer?: ReactNode }) { return ( @@ -54,9 +59,16 @@ export function DialogContent({ className, children, ...props }: DialogPrimitive )} {...props} > -
- {children} -
+ {footer ? ( +
+
{children}
+
{footer}
+
+ ) : ( +
+ {children} +
+ )} { url: url.pathname, body: init?.body ? (JSON.parse(String(init.body)) as Call["body"]) : undefined, }); + if (url.pathname.endsWith("/launch-harness")) + return Promise.resolve(new Response(JSON.stringify({ subshellId: "remote-pane", promptDelivered: true }))); if (url.pathname === "/api/presets" && method === "GET") { return Promise.resolve(new Response(JSON.stringify([]))); } @@ -189,3 +193,33 @@ describe("useCreateSubshell save-as-preset (review round 3)", () => { } }); }); + +it("launches an SSH pane through its connection and returns the ordinary caller contract", async () => { + const m = mockFetch(); + try { + const result = await launch({ + harnessId: "terminal", + workingDir: "/remote/project", + nodeId: "runtime-node", + sshSessionId: "session-1", + }); + expect(result.err).toBeUndefined(); + const writes = m.calls.filter((c) => c.method === "POST"); + expect(writes).toHaveLength(1); + expect(writes[0]?.url).toBe("/api/ssh-runtime/sessions/session-1/launch-harness"); + expect(writes[0]?.body).toMatchObject({ harnessId: "terminal", cwd: "/remote/project", prompt: "" }); + } finally { + m.restore(); + } +}); + +it("never falls back to a local launch while choosing an SSH host", async () => { + const m = mockFetch(); + try { + const result = await launch({ harnessId: "terminal", workingDir: "/remote/project", sshSessionId: "" }); + expect(result.err).toBeDefined(); + expect(m.calls.filter((c) => c.method === "POST")).toHaveLength(0); + } finally { + m.restore(); + } +}); diff --git a/apps/server/web/src/hooks/use-create-subshell.ts b/apps/server/web/src/hooks/use-create-subshell.ts index e26aabe36..57674a45c 100644 --- a/apps/server/web/src/hooks/use-create-subshell.ts +++ b/apps/server/web/src/hooks/use-create-subshell.ts @@ -1,8 +1,10 @@ import { apiFetch, apiPost } from "@internal/node-admin"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { SSH_SESSIONS_QUERY_KEY } from "@/hooks/use-ssh-runtime"; import { joinPromptBlocks, type PromptBlock, presetBlocksToWire, promptLaunchMissed } from "@/lib/prompt-stack"; import { SUBSHELLS_QUERY_KEY } from "@/lib/query-keys"; +import { sshRuntimeFetch } from "@/lib/ssh-runtime"; import type { PresetRow } from "@/types/preset"; /** The fields the shared new-subshell form collects. */ @@ -27,6 +29,8 @@ export interface CreateSubshellInput { * Node picked in the form; "" = no valid choice yet (blocks submit upstream). */ nodeId?: string; + /** Selected live SSH connection; never sent to the ordinary node launch endpoint. */ + sshSessionId?: string; /** The prompt stack, joined into `prompt` when non-empty (spec * 2026-09-28; the launch form's checkbox was retired 2026-09-30 - the * blocks ARE the switch). */ @@ -99,6 +103,22 @@ export function useCreateSubshell() { // `promptDelivered: false` is honest "started, prompt did not land": the // caller can point at the inject action instead of assuming the task went in. mutationFn: async (input: CreateSubshellInput) => { + if (input.sshSessionId !== undefined) { + if (!input.sshSessionId) throw new Error("Choose an SSH host and connect before starting."); + const created = await sshRuntimeFetch<{ subshellId: string; promptDelivered?: boolean }>( + `/api/ssh-runtime/sessions/${encodeURIComponent(input.sshSessionId)}/launch-harness`, + { + method: "POST", + body: JSON.stringify({ + harnessId: input.harnessId, + presetId: input.presetId ?? undefined, + cwd: input.workingDir, + prompt: joinPromptBlocks(input.promptBlocks ?? []), + }), + }, + ); + return { id: created.subshellId, promptDelivered: created.promptDelivered }; + } // The checkbox's promise first, so a failed preset POST never launches // an unremembered subshell (ruling 2026-09-30). SAVE-AS always creates // a NEW row - presets are never written back - and it is a FAITHFUL @@ -182,6 +202,7 @@ export function useCreateSubshell() { toast.warning('The prompt did not land. Use "Inject prompt" to type it in.'); } void queryClient.invalidateQueries({ queryKey: SUBSHELLS_QUERY_KEY }); + if (input.sshSessionId) void queryClient.invalidateQueries({ queryKey: SSH_SESSIONS_QUERY_KEY }); // The new preset row belongs in every preset list the app has open. if (input.saveAsPreset === true) void queryClient.invalidateQueries({ queryKey: ["presets"] }); // The create touched the recent-paths row for its launch node — every diff --git a/apps/server/web/src/hooks/use-execution-labels.ts b/apps/server/web/src/hooks/use-execution-labels.ts new file mode 100644 index 000000000..cd74e697a --- /dev/null +++ b/apps/server/web/src/hooks/use-execution-labels.ts @@ -0,0 +1,20 @@ +import type { Node } from "@internal/node-admin"; +import { useMemo } from "react"; +import { useSshSessions } from "@/hooks/use-ssh-runtime"; + +/** Display-only names for enrolled machines and the viewer's SSH destinations. Never used for launch authorization. */ +export function useExecutionLabels( + nodes: readonly Node[] | undefined, +): readonly Pick[] | undefined { + const sessions = useSshSessions(); + return useMemo(() => { + if (nodes === undefined && sessions.data === undefined) return undefined; + return [ + ...(nodes ?? []), + ...(sessions.data?.sessions ?? []).map((session) => ({ + id: session.runtimeNodeId, + name: `SSH · ${session.alias}`, + })), + ]; + }, [nodes, sessions.data]); +} diff --git a/apps/server/web/src/hooks/use-ssh-locations.ts b/apps/server/web/src/hooks/use-ssh-locations.ts new file mode 100644 index 000000000..8ea5f33e3 --- /dev/null +++ b/apps/server/web/src/hooks/use-ssh-locations.ts @@ -0,0 +1,54 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { SSH_SESSIONS_QUERY_KEY } from "@/hooks/use-ssh-runtime"; +import { type SshRuntimeSessionView, sshRuntimeFetch } from "@/lib/ssh-runtime"; + +export interface SshSavedLocation { + id: string; + originKind: "node" | "desktop"; + originId: string; + alias: string; + host: string; + port: number; + user: string | null; + path: string; +} +const KEY = ["ssh-saved-locations"] as const; +export function useSshLocations() { + return useQuery({ + queryKey: KEY, + queryFn: () => sshRuntimeFetch<{ locations: SshSavedLocation[] }>("/api/ssh-runtime/locations"), + }); +} +export function useSaveSshLocation() { + const cache = useQueryClient(); + return useMutation({ + mutationFn: (body: { sessionId: string; path: string }) => + sshRuntimeFetch("/api/ssh-runtime/locations", { method: "POST", body: JSON.stringify(body) }), + onSuccess: () => { + void cache.invalidateQueries({ queryKey: KEY }); + }, + }); +} +export function useConnectSshLocation() { + const cache = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + sshRuntimeFetch<{ location: SshSavedLocation; session: SshRuntimeSessionView }>( + `/api/ssh-runtime/locations/${encodeURIComponent(id)}/connect`, + { method: "POST", body: "{}" }, + ), + onSuccess: () => { + void cache.invalidateQueries({ queryKey: SSH_SESSIONS_QUERY_KEY }); + }, + }); +} +export function useDeleteSshLocation() { + const cache = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + sshRuntimeFetch(`/api/ssh-runtime/locations/${encodeURIComponent(id)}`, { method: "DELETE" }), + onSuccess: () => { + void cache.invalidateQueries({ queryKey: KEY }); + }, + }); +} diff --git a/apps/server/web/src/hooks/use-ssh-runtime.ts b/apps/server/web/src/hooks/use-ssh-runtime.ts new file mode 100644 index 000000000..49dc0ac7a --- /dev/null +++ b/apps/server/web/src/hooks/use-ssh-runtime.ts @@ -0,0 +1,212 @@ +import { ApiError } from "@internal/node-admin"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { SUBSHELLS_QUERY_KEY } from "@/lib/query-keys"; +import { + type SshRuntimeDiscoveryView, + type SshRuntimeHarnessesView, + type SshRuntimeListDirsResult, + type SshRuntimePaneIdentity, + type SshRuntimeResolveView, + type SshRuntimeSessionView, + sshRuntimeFetch, +} from "@/lib/ssh-runtime"; + +/** + * The personal Connect-over-SSH surface (design 2026-10-05 §1/§7): the + * caller's own sessions, the wizard's pre-session reads (discovery, resolve), + * and the session verbs. Every endpoint is cookie-scoped server-side, and the + * server has already filtered to the caller's own rows and own nodes - these + * hooks mirror the contract, they do not re-derive it. + * + * `list-dirs` is a mutation, not a query: each browse click asks for the path + * named in the body, and the answer is a moment's listing on a live session, + * not a cacheable resource. + */ + +export const SSH_SESSIONS_QUERY_KEY = ["ssh-runtime-sessions"] as const; + +/** The discovery read's key, per node. */ +export const SSH_DISCOVERY_QUERY_KEY = (nodeId: string) => ["ssh-runtime-discovery", nodeId] as const; + +/** + * The pane identity read's key prefix; one pane's row is + * `[...SSH_PANE_IDENTITY_QUERY_KEY, subshellId]`. The prefix alone is what a + * session close invalidates: every launched pane's identity line changes + * meaning the moment its session closes, and the pane ids are not known here. + */ +export const SSH_PANE_IDENTITY_QUERY_KEY = ["ssh-runtime-pane-identity"] as const; + +/** The caller's sessions, newest first. */ +export function useSshSessions(polling = false) { + return useQuery({ + queryKey: SSH_SESSIONS_QUERY_KEY, + queryFn: () => sshRuntimeFetch<{ sessions: SshRuntimeSessionView[] }>("/api/ssh-runtime/sessions"), + refetchInterval: polling ? 5000 : false, + }); +} + +/** Alias names on one node (enabled only while the wizard has chosen a machine). */ +export function useSshDiscovery(nodeId: string | null, enabled = true) { + return useQuery({ + queryKey: SSH_DISCOVERY_QUERY_KEY(nodeId ?? ""), + queryFn: () => + sshRuntimeFetch(`/api/ssh-runtime/discovery?nodeId=${encodeURIComponent(nodeId ?? "")}`), + enabled: enabled && nodeId !== null && nodeId !== "", + staleTime: 30_000, + retry: false, + }); +} + +/** One alias on one node, resolved into the concrete destination the open will carry. */ +export function useSshResolve() { + return useMutation({ + mutationFn: (body: { nodeId: string; alias: string }) => + sshRuntimeFetch("/api/ssh-runtime/resolve", { + method: "POST", + body: JSON.stringify(body), + }), + }); +} + +/** Open a session through a node the caller owns; invalidates the history list. */ +export function useSshOpen() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: { + connectingNodeId: string; + target: { alias: string; host: string; port: number; user: string | null; identityFile: string | null }; + }) => + sshRuntimeFetch("/api/ssh-runtime/sessions", { + method: "POST", + body: JSON.stringify(body), + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: SSH_SESSIONS_QUERY_KEY }); + }, + }); +} + +/** Close a session (the runtime exits; destination panes keep running - the server says so). */ +export function useSshClose() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + sshRuntimeFetch<{ ok: true }>(`/api/ssh-runtime/sessions/${id}/close`, { method: "POST", body: "{}" }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: SSH_SESSIONS_QUERY_KEY }); + // The client now KNOWS this session is closed: every pane identity read + // must re-ask, or a still-mounted pane page keeps saying "connected" + // for a staleTime nobody can wait out from here. + void queryClient.invalidateQueries({ queryKey: SSH_PANE_IDENTITY_QUERY_KEY }); + }, + }); +} + +/** One directory listing on a live session (the remote folder picker's read). */ +export function useSshListDirs() { + return useMutation({ + mutationFn: (body: { sessionId: string; path: string }) => + sshRuntimeFetch(`/api/ssh-runtime/sessions/${body.sessionId}/list-dirs`, { + method: "POST", + body: JSON.stringify({ path: body.path }), + }), + }); +} + +/** Launch the terminal pane; the rail's subshell list must learn the new row. */ +export function useSshLaunchTerminal() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: { sessionId: string; cwd: string; cols?: number; rows?: number }) => + sshRuntimeFetch<{ subshellId: string }>(`/api/ssh-runtime/sessions/${body.sessionId}/launch-terminal`, { + method: "POST", + body: JSON.stringify({ + cwd: body.cwd, + ...(body.cols !== undefined ? { cols: body.cols } : {}), + ...(body.rows !== undefined ? { rows: body.rows } : {}), + }), + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: SUBSHELLS_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: SSH_SESSIONS_QUERY_KEY }); + }, + }); +} + +/** The harnesses mirror's key, per session. */ +export const SSH_HARNESS_QUERY_KEY = (sessionId: string) => ["ssh-runtime-harnesses", sessionId] as const; + +/** + * The cached harness mirror for one session (a pure row read: works offline, + * never a round trip - the detect mutation below is what asks the machine). + */ +export function useSshSessionHarnesses(sessionId: string | null) { + return useQuery({ + queryKey: SSH_HARNESS_QUERY_KEY(sessionId ?? ""), + queryFn: () => sshRuntimeFetch(`/api/ssh-runtime/sessions/${sessionId}/harnesses`), + enabled: sessionId !== null && sessionId !== "", + retry: false, + }); +} + +/** Ask the destination what is installed NOW; the answer lands in the cached query. */ +export function useSshDetectHarnesses(sessionId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (harnessIds?: string[]) => + sshRuntimeFetch(`/api/ssh-runtime/sessions/${sessionId}/harnesses/detect`, { + method: "POST", + body: JSON.stringify(harnessIds ? { harnessIds } : {}), + }), + onSuccess: (view) => { + queryClient.setQueryData(SSH_HARNESS_QUERY_KEY(sessionId), view); + }, + }); +} + +/** Launch a harness (optionally a preset) on a live session; the rail's list learns the new row. */ +export function useSshLaunchHarness() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: { sessionId: string; harnessId: string; presetId?: string | null; cwd: string }) => + sshRuntimeFetch<{ subshellId: string }>(`/api/ssh-runtime/sessions/${body.sessionId}/launch-harness`, { + method: "POST", + body: JSON.stringify({ + harnessId: body.harnessId, + ...(body.presetId !== undefined && body.presetId !== null ? { presetId: body.presetId } : {}), + cwd: body.cwd, + }), + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: SUBSHELLS_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: SSH_SESSIONS_QUERY_KEY }); + }, + }); +} + +/** + * The pane page's trusted identity read. A 404 is the ORDINARY answer for a + * non-SSH pane (the route refuses foreign and unknown alike), so it resolves + * to null instead of an error - the line renders on SSH panes and nowhere + * else, and no pane page ever shows a fetch failure for being a normal pane. + */ +export function useSshPaneIdentity(subshellId: string) { + return useQuery({ + queryKey: [...SSH_PANE_IDENTITY_QUERY_KEY, subshellId], + queryFn: async () => { + try { + return await sshRuntimeFetch( + `/api/ssh-runtime/sessions/by-pane/${encodeURIComponent(subshellId)}`, + ); + } catch (err) { + if (err instanceof ApiError && err.status === 404) return null; + throw err; + } + }, + retry: false, + staleTime: 60_000, + // SSH sessions can disappear without a pane-list event. Ordinary panes + // resolve to null once and never poll this owner-scoped identity endpoint. + refetchInterval: (query) => (query.state.data ? 5000 : false), + }); +} diff --git a/apps/server/web/src/lib/__tests__/ssh-location-form.test.ts b/apps/server/web/src/lib/__tests__/ssh-location-form.test.ts new file mode 100644 index 000000000..6bfb44193 --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh-location-form.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "bun:test"; +import { sshLocationProblems } from "@/lib/ssh-location-form"; + +describe("saved remote folder readiness", () => { + it("requires an absolute path, preserving spaces and destination spelling", () => { + expect(sshLocationProblems({ path: "" }).path).toContain("absolute"); + expect(sshLocationProblems({ path: "project" }).path).toContain("absolute"); + expect(sshLocationProblems({ path: "/home/user/My project" })).toEqual({}); + expect(sshLocationProblems({ path: "/" })).toEqual({}); + }); + it("rejects forbidden path material and request-size overflow", () => { + expect(sshLocationProblems({ path: "/bad\0path" }).path).toBeDefined(); + expect(sshLocationProblems({ path: `/${"x".repeat(4096)}` }).path).toBeDefined(); + }); +}); diff --git a/apps/server/web/src/lib/__tests__/ssh-runtime.test.ts b/apps/server/web/src/lib/__tests__/ssh-runtime.test.ts new file mode 100644 index 000000000..e34c0c391 --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh-runtime.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "bun:test"; +import { destinationLabel, SshApiError, sshRuntimeErrorCopy } from "@/lib/ssh-runtime"; + +/** + * The refusal copy table (design 2026-10-05 §7): every named code renders a + * remedy, the runtime guidance names the BINARY only (never enrollment, never + * `subshell setup`), and no sentence carries an em dash (the voice rule). + */ + +describe("sshRuntimeErrorCopy", () => { + const facts = { host: "app-02", machine: "Laptop" }; + const errFor = (sshCode?: string) => new SshApiError(409, "probe", { sshCode }); + + it("names the binary install for a missing runtime", () => { + const copy = sshRuntimeErrorCopy(errFor("runtime_missing"), facts); + expect(copy).toContain("SSH connected to app-02"); + expect(copy).not.toMatch(/setup|enroll/i); + }); + + it("names the updater for a protocol mismatch and the machine for host trust", () => { + expect(sshRuntimeErrorCopy(errFor("session_protocol"), facts)).toContain("Update Subshell"); + expect(sshRuntimeErrorCopy(errFor("host_key_changed"), facts)).toContain("Laptop"); + }); + + it("names the machine for quota and the destination for connection failure", () => { + expect(sshRuntimeErrorCopy(errFor("session_quota"), facts)).toContain("Laptop"); + expect(sshRuntimeErrorCopy(errFor("connection_failed"), facts)).toContain("app-02"); + }); + + it("names the OTHER session for an occupied destination, never the binary (m2)", () => { + const copy = sshRuntimeErrorCopy(errFor("session_in_use"), facts); + expect(copy.toLowerCase()).toContain("session"); + expect(copy.toLowerCase()).toMatch(/close/); // the remedy: close the live session, then retry + // The whole m2 defect was reading an occupied destination as a missing + // binary; the copy must not repeat that lie. + expect(copy.toLowerCase()).not.toContain("binary"); + expect(copy.toLowerCase()).not.toContain("install"); + }); + + it("keeps the server message for unnamed refusals and stays within two sentences", () => { + const named = new SshApiError(409, "the destination refused the session: something odd", {}); + expect(sshRuntimeErrorCopy(named, facts)).toContain("something odd"); + // Built by code point so this file carries no U+2014 byte itself: a repo + // grep for the glyph stays honest, and the test still refuses the glyph. + const emDash = String.fromCodePoint(0x2014); + for (const code of ["runtime_missing", "session_protocol", "session_quota", "host_key_unknown"]) { + const copy = sshRuntimeErrorCopy(errFor(code), facts); + expect(copy).not.toContain(emDash); // the voice rule + const sentences = copy.split(/[.!?](\s|$)/).filter((s) => s.trim() !== ""); + expect(sentences.length).toBeLessThanOrEqual(2); + } + }); +}); + +describe("destinationLabel", () => { + it("omits the account when the connecting default applies", () => { + expect(destinationLabel({ host: "10.0.0.2", port: 22, user: null })).toBe("10.0.0.2:22"); + expect(destinationLabel({ host: "10.0.0.2", port: 2222, user: "deploy" })).toBe("deploy@10.0.0.2:2222"); + }); +}); diff --git a/apps/server/web/src/lib/sidebar-node-group-pref.ts b/apps/server/web/src/lib/sidebar-node-group-pref.ts index f41249ff2..85a46c182 100644 --- a/apps/server/web/src/lib/sidebar-node-group-pref.ts +++ b/apps/server/web/src/lib/sidebar-node-group-pref.ts @@ -65,7 +65,7 @@ export function toggleNodeGroup(collapsed: readonly string[], nodeId: string): s } /** - * The rail's "Cross-agent comms" section has the OPPOSITE default to the + * The rail's "Agent-created" section has the OPPOSITE default to the * machine groups (operator ask 2026-09-25): it is CLOSED until opened. A * machine's groups default open because a human's own live work should greet * them; comms panes are internal chatter that can multiply without warning diff --git a/apps/server/web/src/lib/ssh-location-form.ts b/apps/server/web/src/lib/ssh-location-form.ts new file mode 100644 index 000000000..fe99a9b54 --- /dev/null +++ b/apps/server/web/src/lib/ssh-location-form.ts @@ -0,0 +1,7 @@ +import type { FieldProblems } from "@/lib/form"; + +export function sshLocationProblems(value: { path: string }): FieldProblems { + return value.path.startsWith("/") && !value.path.includes("\0") && value.path.length <= 4096 + ? {} + : { path: "Choose an absolute folder path on the remote host before saving this location." }; +} diff --git a/apps/server/web/src/lib/ssh-runtime.ts b/apps/server/web/src/lib/ssh-runtime.ts new file mode 100644 index 000000000..f79909687 --- /dev/null +++ b/apps/server/web/src/lib/ssh-runtime.ts @@ -0,0 +1,187 @@ +import { ApiError, parseErrorBody } from "@internal/node-admin"; +import type { SshErrorCode } from "@internal/subshell-protocol"; + +/** + * The SPA-side mirror of the `/api/ssh-runtime` family (design 2026-10-05 + * §1/§7) plus the refusal-copy map the wizard renders. The wire shapes are + * the route schemas; the named codes ride `metadataSafe.sshCode`, which the + * shared `apiFetch` error path drops - so this file carries its own thin + * fetch that keeps the code for the copy table. Everything else (cookies, + * the JSON contract) matches `apiFetch` exactly. The named codes are the + * frozen `@internal/subshell-protocol` set (imported, never restated, the + * `lib/ssh.ts` precedent), so a refusal can be mapped by EQUALITY into + * `SSH_ERROR_DESCRIPTIONS`. + */ + +/** One session as the server answers it (`SshRuntimeSessionView`). */ +export interface SshRuntimeSessionView { + id: string; + /** The broker's node id; null once that machine row has been deleted */ + connectingNodeId: string | null; + runtimeNodeId: string; + alias: string; + host: string; + port: number; + user: string | null; + status: "opening" | "active" | "lost" | "closed"; + hello: { + runtimeProtocol: number; + agentVersion: string; + os: string; + arch: string; + capabilities: string[]; + homeDir: string; + dataDir: string; + tmuxSocket: string; + paneCount: number; + } | null; + createdAt: string; + lastSeenAt: string | null; + closedAt: string | null; +} + +/** `GET /api/ssh-runtime/discovery` - alias NAMES on a node, never config contents. */ +export interface SshRuntimeDiscoveryView { + aliases: string[]; + includeCycle: boolean; + truncated: boolean; +} + +/** `POST /api/ssh-runtime/resolve` - a named refusal is a 200, not a transport failure. */ +export type SshRuntimeResolveView = + | { + accepted: true; + snapshot: { + alias: string; + host: string; + user: string | null; + port: number; + identityFiles: string[]; + }; + connectingAccount?: string; + } + | { + accepted: false; + /** Named limitation; the route schema admits only the frozen set (the ssh-api-types convention). */ + code: SshErrorCode; + /** Config keywords that blocked, when the code names several. */ + settings: string[]; + }; + +/** `GET /api/ssh-runtime/sessions/by-pane/:id` - the pane's trusted identity line. */ +export interface SshRuntimePaneIdentity { + sessionId: string; + status: SshRuntimeSessionView["status"]; + alias: string; + host: string; + port: number; + user: string | null; + connectingNodeName: string | null; +} + +/** `POST /:id/list-dirs` - one destination directory, dirs only. */ +export interface SshRuntimeListDirsResult { + path: string; + parent: string | null; + entries: { name: string; path: string; kind: "dir" }[]; + truncated: boolean; +} + +/** One cached/just-detected harness on a session's destination (`GET/POST /:id/harnesses`). */ +export interface SshRuntimeHarnessEntry { + harnessId: string; + /** Display name from the plane's manifest (the id when the plugin is unknown). */ + harnessName: string; + /** The destination's binary answer: found and executable there. */ + installed: boolean; + binaryPath: string | null; + rawVersion: string | null; + reason: string | null; + checkedAt: string | null; +} + +/** `GET /:id/harnesses` and `POST /:id/harnesses/detect` - the destination's harness mirror. */ +export interface SshRuntimeHarnessesView { + sessionId: string; + runtimeNodeId: string; + /** Whether a live session backs this view right now. */ + online: boolean; + harnesses: SshRuntimeHarnessEntry[]; + env: Record; +} + +/** An ApiError that kept the named SSH code (the copy table branches on it). */ +export class SshApiError extends ApiError { + readonly sshCode: string | undefined; + constructor(status: number, body: string, meta: { code?: string; errId?: string; sshCode?: string }) { + super(status, body, meta); + this.sshCode = meta.sshCode; + } +} + +/** `apiFetch` with the sshCode preserved out of `metadataSafe` (its own error parse; same success path). */ +export async function sshRuntimeFetch(path: string, init?: RequestInit): Promise { + let res: Response; + try { + res = await fetch(path, { + credentials: "include", + ...init, + headers: { "content-type": "application/json", ...init?.headers }, + }); + } catch (err) { + throw new ApiError(0, err instanceof Error ? err.message : "Server unreachable"); + } + if (!res.ok) { + const body = await res.text().catch(() => ""); + const { message, code, errId } = parseErrorBody(body); + let sshCode: string | undefined; + try { + const parsed = JSON.parse(body) as { metadataSafe?: { sshCode?: unknown } }; + const candidate = parsed?.metadataSafe?.sshCode; + if (typeof candidate === "string") sshCode = candidate; + } catch { + // a non-JSON body has no code to keep + } + throw new SshApiError(res.status, message, { code, errId, sshCode }); + } + return (await res.json()) as T; +} + +/** The destination as review copy spells it: `user@host:port`, account omitted when it is the connecting default. */ +export function destinationLabel(view: { host: string; port: number; user: string | null }): string { + return `${view.user === null ? "" : `${view.user}@`}${view.host}:${view.port}`; +} + +/** + * The wizard's refusal sentence (design §7: errors name the remedy). The key + * is equality on the named code, never prose parsing; the fallback is the + * server's own message. Two sentences max, no em dashes, and the runtime + * guidance names the BINARY only - never enrollment, never `subshell setup`. + */ +export function sshRuntimeErrorCopy(err: unknown, facts: { host: string; machine: string }): string { + const code = err instanceof SshApiError ? err.sshCode : undefined; + switch (code) { + case "runtime_missing": + return `SSH connected to ${facts.host}, but Subshell isn’t installed there yet. Follow the steps below, then try again.`; + case "session_protocol": + return `The Subshell version on ${facts.host} isn’t compatible with this server. Update Subshell on that host, then try again.`; + case "session_quota": + return `${facts.machine} has reached its limit of open SSH connections. Close one you’re no longer using, then try again.`; + case "session_in_use": + return `There’s already an active session on ${facts.host}. Close that session before connecting again.`; + case "host_key_unknown": + case "host_key_changed": + case "host_key_revoked": + return `${facts.machine} can’t verify the SSH identity of ${facts.host}. Follow the steps below to check it before connecting.`; + case "auth_mode_unsupported": + return `${facts.host} is asking for a sign-in method Subshell can’t use. Set up SSH key access from ${facts.machine}, then try again.`; + case "key_unavailable": + return `Subshell can’t read the SSH key file on ${facts.machine}. Check that the file exists and the account running Subshell can read it.`; + case "connection_failed": + return `We couldn’t reach ${facts.host} from ${facts.machine}. Check the host address and network connection, then try again.`; + default: + return err instanceof ApiError + ? err.message.slice(0, 300) + : "We couldn’t connect. Please try again, and check the connecting computer’s network if the problem continues."; + } +} diff --git a/apps/server/web/src/lib/subshell-node-groups.ts b/apps/server/web/src/lib/subshell-node-groups.ts index c1837545f..654bdc018 100644 --- a/apps/server/web/src/lib/subshell-node-groups.ts +++ b/apps/server/web/src/lib/subshell-node-groups.ts @@ -72,7 +72,7 @@ export const FALLBACK_NODE_ID = "local"; */ export function nodeLabelFor( nodeId: string, - nodes: readonly Node[] | undefined, + nodes: readonly Pick[] | undefined, unanswered: boolean, ): { label: string; title: string } { const known = nodes?.find((n) => n.id === nodeId); @@ -110,7 +110,7 @@ export function nodeLabelFor( */ export function groupSubshellsByNode( subshells: readonly SubshellView[], - nodes: readonly Node[] | undefined, + nodes: readonly Pick[] | undefined, { limit, unanswered = false }: { limit?: number; unanswered?: boolean } = {}, ): SubshellNodeGroup[] { // Insertion-ordered, so the discovery order IS the input's status order and @@ -143,7 +143,7 @@ export function groupSubshellsByNode( } /** - * The pseudo-node id that keys the rail's "Cross-agent comms" section — its + * The pseudo-node id that keys the rail's "Agent-created" section — its * collapse preference, its DOM id, and its React key. A real node id is a * uuid and can never collide with this string. */ diff --git a/apps/server/web/src/routeTree.gen.ts b/apps/server/web/src/routeTree.gen.ts index d7ae6c85e..c2e443c87 100644 --- a/apps/server/web/src/routeTree.gen.ts +++ b/apps/server/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as AccountRouteImport } from './routes/account' +import { Route as ConnectRouteImport } from './routes/connect' import { Route as LoginRouteImport } from './routes/login' import { Route as NewRouteImport } from './routes/new' import { Route as NodesRouteImport } from './routes/nodes' @@ -26,6 +27,7 @@ import { Route as PresetsIdRouteImport } from './routes/presets_.$id' import { Route as SettingsApiKeysRouteImport } from './routes/settings_.api-keys' import { Route as SettingsAuthRouteImport } from './routes/settings_.auth' import { Route as SettingsBackupsRouteImport } from './routes/settings_.backups' +import { Route as SettingsConnectionsRouteImport } from './routes/settings_.connections' import { Route as SettingsLogsRouteImport } from './routes/settings_.logs' import { Route as SettingsNetworkingRouteImport } from './routes/settings_.networking' import { Route as SettingsPluginsRouteImport } from './routes/settings_.plugins' @@ -48,6 +50,11 @@ const AccountRoute = AccountRouteImport.update({ path: '/account', getParentRoute: () => rootRouteImport, } as any) +const ConnectRoute = ConnectRouteImport.update({ + id: '/connect', + path: '/connect', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -123,6 +130,11 @@ const SettingsBackupsRoute = SettingsBackupsRouteImport.update({ path: '/settings/backups', getParentRoute: () => rootRouteImport, } as any) +const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ + id: '/settings_/connections', + path: '/settings/connections', + getParentRoute: () => rootRouteImport, +} as any) const SettingsLogsRoute = SettingsLogsRouteImport.update({ id: '/settings_/logs', path: '/settings/logs', @@ -182,6 +194,7 @@ const NodesIdServiceRoute = NodesIdServiceRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/account': typeof AccountRoute + '/connect': typeof ConnectRoute '/login': typeof LoginRoute '/new': typeof NewRoute '/nodes': typeof NodesRoute @@ -197,6 +210,7 @@ export interface FileRoutesByFullPath { '/settings/api-keys': typeof SettingsApiKeysRoute '/settings/auth': typeof SettingsAuthRoute '/settings/backups': typeof SettingsBackupsRoute + '/settings/connections': typeof SettingsConnectionsRoute '/settings/logs': typeof SettingsLogsRoute '/settings/networking': typeof SettingsNetworkingRoute '/settings/plugins': typeof SettingsPluginsRoute @@ -212,6 +226,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/account': typeof AccountRoute + '/connect': typeof ConnectRoute '/login': typeof LoginRoute '/new': typeof NewRoute '/nodes': typeof NodesRoute @@ -227,6 +242,7 @@ export interface FileRoutesByTo { '/settings/api-keys': typeof SettingsApiKeysRoute '/settings/auth': typeof SettingsAuthRoute '/settings/backups': typeof SettingsBackupsRoute + '/settings/connections': typeof SettingsConnectionsRoute '/settings/logs': typeof SettingsLogsRoute '/settings/networking': typeof SettingsNetworkingRoute '/settings/plugins': typeof SettingsPluginsRoute @@ -243,6 +259,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/account': typeof AccountRoute + '/connect': typeof ConnectRoute '/login': typeof LoginRoute '/new': typeof NewRoute '/nodes': typeof NodesRoute @@ -258,6 +275,7 @@ export interface FileRoutesById { '/settings_/api-keys': typeof SettingsApiKeysRoute '/settings_/auth': typeof SettingsAuthRoute '/settings_/backups': typeof SettingsBackupsRoute + '/settings_/connections': typeof SettingsConnectionsRoute '/settings_/logs': typeof SettingsLogsRoute '/settings_/networking': typeof SettingsNetworkingRoute '/settings_/plugins': typeof SettingsPluginsRoute @@ -275,6 +293,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/account' + | '/connect' | '/login' | '/new' | '/nodes' @@ -290,6 +309,7 @@ export interface FileRouteTypes { | '/settings/api-keys' | '/settings/auth' | '/settings/backups' + | '/settings/connections' | '/settings/logs' | '/settings/networking' | '/settings/plugins' @@ -305,6 +325,7 @@ export interface FileRouteTypes { to: | '/' | '/account' + | '/connect' | '/login' | '/new' | '/nodes' @@ -320,6 +341,7 @@ export interface FileRouteTypes { | '/settings/api-keys' | '/settings/auth' | '/settings/backups' + | '/settings/connections' | '/settings/logs' | '/settings/networking' | '/settings/plugins' @@ -335,6 +357,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/account' + | '/connect' | '/login' | '/new' | '/nodes' @@ -350,6 +373,7 @@ export interface FileRouteTypes { | '/settings_/api-keys' | '/settings_/auth' | '/settings_/backups' + | '/settings_/connections' | '/settings_/logs' | '/settings_/networking' | '/settings_/plugins' @@ -366,6 +390,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AccountRoute: typeof AccountRoute + ConnectRoute: typeof ConnectRoute LoginRoute: typeof LoginRoute NewRoute: typeof NewRoute NodesRoute: typeof NodesRoute @@ -381,6 +406,7 @@ export interface RootRouteChildren { SettingsApiKeysRoute: typeof SettingsApiKeysRoute SettingsAuthRoute: typeof SettingsAuthRoute SettingsBackupsRoute: typeof SettingsBackupsRoute + SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsLogsRoute: typeof SettingsLogsRoute SettingsNetworkingRoute: typeof SettingsNetworkingRoute SettingsPluginsRoute: typeof SettingsPluginsRoute @@ -410,6 +436,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AccountRouteImport parentRoute: typeof rootRouteImport } + '/connect': { + id: '/connect' + path: '/connect' + fullPath: '/connect' + preLoaderRoute: typeof ConnectRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -515,6 +548,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsBackupsRouteImport parentRoute: typeof rootRouteImport } + '/settings_/connections': { + id: '/settings_/connections' + path: '/settings/connections' + fullPath: '/settings/connections' + preLoaderRoute: typeof SettingsConnectionsRouteImport + parentRoute: typeof rootRouteImport + } '/settings_/logs': { id: '/settings_/logs' path: '/settings/logs' @@ -598,6 +638,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AccountRoute: AccountRoute, + ConnectRoute: ConnectRoute, LoginRoute: LoginRoute, NewRoute: NewRoute, NodesRoute: NodesRoute, @@ -613,6 +654,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsApiKeysRoute: SettingsApiKeysRoute, SettingsAuthRoute: SettingsAuthRoute, SettingsBackupsRoute: SettingsBackupsRoute, + SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsLogsRoute: SettingsLogsRoute, SettingsNetworkingRoute: SettingsNetworkingRoute, SettingsPluginsRoute: SettingsPluginsRoute, diff --git a/apps/server/web/src/routes/connect.tsx b/apps/server/web/src/routes/connect.tsx new file mode 100644 index 000000000..b922f8bb5 --- /dev/null +++ b/apps/server/web/src/routes/connect.tsx @@ -0,0 +1,73 @@ +import { createFileRoute, Link, redirect } from "@tanstack/react-router"; +import { Waypoints } from "lucide-react"; +import { useRef, useState } from "react"; +import { ConnectJourney } from "@/components/connect/connect-journey"; +import { DesktopBrokerSetup } from "@/components/connect/desktop-broker-setup"; +import { SessionsTable } from "@/components/connect/sessions-table"; +import { EmptyState } from "@/components/empty-state"; +import { PageHeader } from "@/components/page-header"; +import { useSshSessions } from "@/hooks/use-ssh-runtime"; +import type { SshRuntimeSessionView } from "@/lib/ssh-runtime"; + +/** Secondary connection recovery and disconnect controls. Launch lives in the shared subshell form. */ +export const Route = createFileRoute("/connect")({ + beforeLoad: () => { + throw redirect({ to: "/settings/connections" }); + }, +}); + +export function ConnectionsPage() { + const sessions = useSshSessions(true); + const [prefill, setPrefill] = useState<{ nodeId: string; alias: string } | null>(null); + // A second Reopen while the wizard already holds this machine+alias still + // deserves a jump: the ref keys the remount below so each request re-seeds + // the wizard at the host step. + const prefillSeq = useRef(0); + const [prefillKey, setPrefillKey] = useState(0); + + const reopen = (session: SshRuntimeSessionView) => { + if (session.connectingNodeId === null) return; + prefillSeq.current += 1; + setPrefill({ nodeId: session.connectingNodeId, alias: session.alias }); + setPrefillKey(prefillSeq.current); + }; + + return ( +
+ + + + + Open a subshell on an SSH host + + {prefill !== null && ( + { + setPrefill(null); + void sessions.refetch(); + }} + /> + )} + +
+

Connections

+ {sessions.isError ? ( + void sessions.refetch()} + /> + ) : ( + + )} +
+
+ ); +} diff --git a/apps/server/web/src/routes/index.tsx b/apps/server/web/src/routes/index.tsx index 141e8445a..a3602935b 100644 --- a/apps/server/web/src/routes/index.tsx +++ b/apps/server/web/src/routes/index.tsx @@ -15,6 +15,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useCardPreviews } from "@/hooks/use-card-previews"; import { useClockTick } from "@/hooks/use-clock-tick"; +import { useExecutionLabels } from "@/hooks/use-execution-labels"; import { useNodes } from "@/hooks/use-nodes"; import { usePublicSettings } from "@/hooks/use-public-settings"; import { useLiveSubshells } from "@/hooks/useLiveSubshells"; @@ -108,6 +109,7 @@ function SubshellsPage() { // No per-group cap here: the rail caps a group at 8 rows because it is a // rail; the page shows what it has. const { data: nodeData } = useNodes(); + const nodeLabels = useExecutionLabels(nodeData?.nodes); // Unanswered is `nodeData === undefined`, not `isError` — the ladder's own // rule: a failed BACKGROUND refresh keeps the cache, and relabeling resolved // names on a blip is the bug that shape caused once in the sidebar. @@ -128,7 +130,7 @@ function SubshellsPage() { // already mean: the table drops its bands, the tiles one heading-less grid. const sections = groupBy === "machine" - ? sectionsByNode(groupSubshellsByNode(sorted, nodeData?.nodes, { unanswered })) + ? sectionsByNode(groupSubshellsByNode(sorted, nodeLabels, { unanswered })) : groupBy === "status" ? sectionsByStatus(sorted) : null; @@ -150,7 +152,7 @@ function SubshellsPage() { // list so the control never blanks itself out from under its own value. const machines = machineIds(subshells); const machineOptions = (machine !== "all" && !machines.includes(machine) ? [...machines, machine] : machines).map( - (id) => ({ id, label: nodeLabelFor(id, nodeData?.nodes, unanswered).label }), + (id) => ({ id, label: nodeLabelFor(id, nodeLabels, unanswered).label }), ); // The combobox closed state and the list both print the OPTION LABEL, and // the value is a node id while the reader needs a machine name — the id→ diff --git a/apps/server/web/src/routes/settings_.connections.tsx b/apps/server/web/src/routes/settings_.connections.tsx new file mode 100644 index 000000000..e44be828b --- /dev/null +++ b/apps/server/web/src/routes/settings_.connections.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ConnectionsPage } from "./connect"; + +/** Personal connections: cookie ownership is enforced by every SSH endpoint. */ +export const Route = createFileRoute("/settings_/connections")({ component: ConnectionsPage }); diff --git a/apps/server/web/src/routes/subshells_.$id.tsx b/apps/server/web/src/routes/subshells_.$id.tsx index f968c071b..152a9b77d 100644 --- a/apps/server/web/src/routes/subshells_.$id.tsx +++ b/apps/server/web/src/routes/subshells_.$id.tsx @@ -6,6 +6,7 @@ import type { SearchAddon } from "@xterm/addon-search"; import type { Terminal } from "@xterm/xterm"; import { SlidersHorizontal } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { SshRuntimePaneIdentity } from "@/components/connect/ssh-runtime-pane-identity"; import { DetailBackHeader } from "@/components/detail-back-header"; import { EditableText } from "@/components/editable-text"; import { SubshellNotFoundCard } from "@/components/not-found-page"; @@ -22,6 +23,7 @@ import { TranscriptSearch } from "@/components/transcript-search"; import { TrustIndicators } from "@/components/trust-indicators"; import { TrustNoticeBanner } from "@/components/trust-notice-banner"; import { useClockTick } from "@/hooks/use-clock-tick"; +import { useExecutionLabels } from "@/hooks/use-execution-labels"; import { useIsCoarsePointer } from "@/hooks/use-is-coarse-pointer"; import { useIsStackedHeader } from "@/hooks/use-is-stacked-header"; import { useNodes } from "@/hooks/use-nodes"; @@ -113,11 +115,12 @@ function SubshellPage() { // a failed background refresh keeps the cache, and relabeling resolved // names on a blip is the bug that shape caused once. const { data: nodesData } = useNodes(); + const nodeLabels = useExecutionLabels(nodesData?.nodes); const nodeLabel = useMemo(() => { if (!subshell) return null; const nodeId = subshell.nodeId || FALLBACK_NODE_ID; - return nodeLabelFor(nodeId, nodesData?.nodes, nodesData === undefined).label; - }, [subshell, nodesData]); + return nodeLabelFor(nodeId, nodeLabels, nodesData === undefined).label; + }, [subshell, nodesData, nodeLabels]); /** Renames this subshell in place (the header title edits itself). */ async function saveName(name: string): Promise { @@ -309,7 +312,11 @@ function SubshellPage() { not a human at the UI (operator ask 2026-09-25). A fact about the pane stated where its own name is, worded the way the rail's section labels it. */} - {subshell?.crossAgent && Cross-agent comms} + {subshell?.crossAgent && Agent-created} + {/* The trusted line for a pane launched through a Connect-over-SSH + session (design §7): destination and broker, read from the + server's rows by pane id. Renders nothing on an ordinary pane. */} + {/* Permanent disclosure: whose machine this runs on, and who else can read it. Never suppressible — see components/trust-indicators.tsx. */} @@ -370,10 +377,12 @@ function SubshellPage() { every birth — including a LIVE restart, where the client never sees `alive:false` (the POST returns after the pane is already respawned) and an aliveness key would leave the socket wedged on the transient - 4004 it hits during the kill→respawn gap. */} + 4004 it hits during the kill→respawn gap. SSH recovery also changes + nodeId while preserving startedAt: that new runtime needs a fresh + terminal and attachment even though the pane itself survives. */} {(subshell || isError) && ( = 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], - "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], + "compression": ["compression@1.8.2", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ=="], "compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], @@ -2996,7 +2996,7 @@ "serialize-error": ["serialize-error@13.0.1", "", { "dependencies": { "non-error": "^0.1.0", "type-fest": "^5.4.1" } }, "sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA=="], - "seroval": ["seroval@1.6.2", "", {}, "sha512-mPT+SD2TrlB6wvte1KkYOYUkubaTbd6pZ/6Kk3C9nxzrHmCZyhxOO7XGAeL7f+yLKZglzGtM9odUVvg/EhO+vQ=="], + "seroval": ["seroval@1.6.8", "", {}, "sha512-HlSgSAkTk4EqHcje1ptJjfZi1YDv5KbhVJ/d3P7T/nAXua2VmDu+AKDX5VTdFfZf48nDkWB2TKYt0DrCSa+3wg=="], "seroval-plugins": ["seroval-plugins@1.6.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-TfxuUjlbBESzUOWdTkTKqvSmav0ABym+itetDXLK6mDz8SmrpdI30aF8RTXE8Bvq+tH/1yIDkvy3W0lfQb1ipQ=="], @@ -3038,7 +3038,7 @@ "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], diff --git a/docs/security.md b/docs/security.md index dd9d0beb8..9acace8f7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -59,6 +59,7 @@ Everything below assumes that perimeter holds. | A malicious node path escaping into host paths | Structural id/path guards on both sides of the link | | DNS rebinding | A static origin allowlist, never "trust the requesting host" | | Terminal output injecting markup into the UI | PTY bytes are rendered only by xterm, never as HTML | +| A person brokering an SSH session through a machine that is not theirs | Sessions open only on nodes the cookie owner OWNS (no admin boost, no shares), and every `/api/ssh-runtime` door refuses machine credentials with the origin asserted on writes (§4b) | ### Explicitly NOT defended against @@ -71,6 +72,7 @@ Everything below assumes that perimeter holds. | **A malicious admin** | Admins hold instance-wide edit access and can mint full-access system keys. There is no separation of duties | | **Denial of service** | Only login is rate-limited | | **Supply chain of the harnesses themselves** | Subshell launches whatever `claude`/`opencode`/`codex` binary is on the host's PATH and does not verify it | +| **What an SSH runtime session can do on the destination** | The destination account's permissions ARE the remote boundary. Subshell refuses to sandbox beyond its own APIs: the runtime child rides the link brokered BY the connecting OS account, with whatever that account already had (§4b) | ## 2. Identity and credentials @@ -729,6 +731,94 @@ so the argv posture above is its posture too (accepted, §11), and the sentinel lands in the pane's own log as random noise, not data; every refusal precedes the typing, and a `timed_out` answer signals nothing and types nothing further. +## 4b. SSH runtime sessions (design 2026-10-05; the destination-execution product retired per its §7) + +A signed-in person picks one of their own machines as the point of departure, +a destination it can reach with its own SSH account, and a directory there. +The plane brokers an ssh child on the connecting machine and starts the +`subshell runtime-serve` mode of the destination's own `subshell` binary +through that link; panes opened in a session are ordinary subshell rows, and +the session ends when the link does. Nothing is installed as a service, no +node key exists, no port listens on the destination - the SSH link IS the +connection. Human-facing `/api/ssh-runtime/*` routes answer cookie sessions +only, machine API credentials are refused, and writes carry explicit origin +validation. The desktop transport has a separate WebSocket authentication path +that accepts only scoped desktop-broker credentials, never ordinary API keys. + +**Desktop connection boundary (2026-10-06).** A user creates a five-minute, +single-use pairing code from personal Connections settings. Subshell Client's +bundled page explicitly confirms the server origin and account trust before +starting its broker child; the server-loaded main window retains only +`desktop_open_in_browser`. Codes travel to the CLI on stdin, never argv or a +URL. The CLI exchanges the code for a broker-only credential, stored 0600 in +an owner-only directory and keyed by canonical origin and broker id. The server +stores only its digest. Revoke clears that digest and drops the live connection; +normal requests recheck ownership and active account status. HTTPS is required +except on loopback. Server redirects cannot retarget the saved connection. + +Desktop brokers are hidden `runtime` node records with a reserved `desktop:` +id. They cannot receive normal node commands or launch local panes. Their +transport permits only SSH discovery, resolution, open, send, and close, through +the existing bounded supervisor. EOF of the native parent's stdin or a dropped +transport drains SSH children. Reconnect is explicit and does not replay pending +mutations. Native metadata contains no credential. Saved remote locations are +owner-scoped snapshots of origin, destination, and canonical directory; reconnect +rejects changed host, port, or user rather than silently trusting a moved alias. + +**SSH private keys never enter Subshell.** Discovery answers alias NAMES only; +resolution evaluates one alias with the connecting account's own `ssh -G` and +returns a NORMALIZED approved snapshot (destination, ports, identity and +certificate PATHS, trust-file paths, a bounded jump chain) - never key +material, never config file contents, in either direction. The wizard discloses +before resolving that trusted local configuration (`Match exec` and friends) +can run helpers on the node. §1's OS-user boundary still applies to the node: +whoever owns that account owns the credentials this feature points at. + +**The every-hop policy is compiled-in, not caller-shaped.** The ssh argv is +rendered from the approved snapshot under a mandatory config the request cannot +override: BatchMode, strict host checking against the snapshot's pinned trust +files, no agent or X11 forwarding, no port forwards or tunnels, no local or +remote commands, no escape characters, no control sockets or multiplexing, +noninteractive key/certificate auth only, and a bounded `ProxyJump` chain as +the one routing mechanism - the jump children read the same rendered file. +Anything the normalization cannot represent safely is refused with a NAMED +limitation rather than silently weakened. Unknown, changed, and revoked host +keys fail closed; there is no trust-acceptance endpoint. + +**The connecting account is the boundary.** Only nodes the caller owns broker a +session - no admin boost, no share reaches it - and the destination account's +permissions are what the work can do, exactly as the NOT-defended table says. +A foreign session id reads the non-enumerating 404. + +**A remote pane acts as its own token and nothing wider.** A pane launched on +a destination carries the ordinary subshell token; its callbacks (the MCP door, +token self-extension) ride back to the plane as allowlisted `rest_request` +frames through its own session and are executed by the plane's callback +executor AS THAT PANE'S TOKEN - the same §4 altitude an ordinary pane holds, +never a broader credential. The input-generation fence (the additive +`inputGeneration` frame field, the node's per-pane store, the frozen stale +refusal) stays exactly where the destination product put it: the NEXT writer's +generation fences queued input at the machine. + +**Limits are frozen and enforced before the wire.** Eight active brokered +sessions per connecting node across all owners; an open must spawn the child +AND receive its parsed hello within 30 seconds or the session fails closed - +never half-open, never auto-retried; inbound runtime frames queue at most 128 +per session and overflow CLOSES the session; a session ref the node does not +know answers the bare `run_unknown`, and a re-used ref with a different digest +answers `run_conflict` with nothing spawned twice. + +**Audit names acts, never content.** `ssh.discover` and `ssh.resolve` record +the node and the accepted fact (a refusal names its code); +`ssh_runtime_session.open` records the connecting node plus host and port, +`...pane_open` the session, runtime node, harness, and preset, `...close` the +connecting node. No row carries command text, config contents, or pane output. + +**The retirement.** The 2026-10-04 product - connection rows, per-pane grants, +structured runs, managed SSH terminals, and their five `ssh_*` tables - +retired with design §7; migration 0050 drops those tables forward-only, and +the surviving surfaces above are all the wire and the API still answer. + ## 5. Sharing A subshell is private by default. The owner may grant, via @@ -2183,6 +2273,14 @@ Audit events are written, grouped by family: up is exactly the event an operator reads the trail for. A gate refusal (foreign node, `local`, maintenance, offline) writes neither - the trail records acts, and a pre-gate 403 is not one. +- **SSH** (design 2026-10-05; the destination product's rows retired with + it): `ssh.discover` and `ssh.resolve` name the node, and resolve carries the + accepted fact or the refusal's named code; `ssh_runtime_session.open` names + `{ connectingNodeId, host, port }`, `ssh_runtime_session.pane_open` + `{ sessionId, runtimeNodeId, harnessId?, presetId? }`, and + `ssh_runtime_session.close` `{ connectingNodeId }`. The §3 hygiene rule + holds for every one of them: NEVER command text, config contents, or + output, a sentence route suites enforce by scanning the whole audit table. - **Nodes**: `node.enroll`, `node.delete`, `node.rename`, `node.key_rotate`, `node.reregister_key`, `node.reregister`, `node.allowed_dirs.update`, `node.config.update`, `node.maintenance.update`, `node.logging.update`, `node.update`, @@ -2400,6 +2498,16 @@ Recorded so they are decisions rather than surprises: 7. **No DoS protection** beyond login backoff. 8. **The post bus is in-process**, so a multi-process deployment would silently lose cross-process wakeups. +9. **SSH runtime sessions reuse the connecting account's existing trust** + (design 2026-10-05, §4b): the account's SSH configuration, keys, agent, and + known hosts decide what a destination really is, and a human who edits that + account's config outside Subshell widens or moves the ground under the next + resolution. That is the same OS-user boundary as item 1, restated because + the feature makes the account's trust decisions load-bearing for the + session's work. The mitigation is procedural and shipped: the human-only + discover/resolve/review loop shows the resolved destination, the connecting + account, and the route before a session opens, and the reviewed snapshot is + re-evaluated at every open - nothing saved can drift. ## 11.9 Plugins run in the control-plane process diff --git a/docs/superpowers/specs/2026-10-05-ssh-remote-runtime-design.md b/docs/superpowers/specs/2026-10-05-ssh-remote-runtime-design.md new file mode 100644 index 000000000..66569e51e --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-ssh-remote-runtime-design.md @@ -0,0 +1,198 @@ +# SSH remote runtime: Gate A design (contract freeze) + +Revised direction from `SSH-SUPPORT.md` (2026-10-05): select an SSH host, start the +Subshell runtime on that host through SSH, choose a remote directory, and work there +with ordinary panes. This document freezes the contracts §6 requires before parallel +implementation. Supersedes the destination-execution model shipped on this branch +(PR #330's product shape); the salvage list is §5 of the plan. + +## 1. Shape + +``` +browser -> control plane -> connecting node (enrolled, owner's machine) + -> ssh child (policy-rendered) -> `subshell runtime-serve` on the destination +``` + +The runtime is a session-scoped mode of the existing `subshell` node binary. It +behaves like a node whose link is the SSH child's stdio: it drives tmux on the +destination, owns pane logs, and answers the same command/event semantics. No +enrollment config, no node key, no service, no network listener on the destination. +The SSH child dies, the session ends; the tmux server and its panes survive on the +destination (tmux outlives its clients) and the next session reconciles them. + +**Both origins are covered by one broker.** The web path selects an eligible +connecting node. Subshell Client's own machine is exactly such a node, so the +laptop experience is the same flow with the client's node chosen; nothing new is +exposed to any server-loaded webview (desktop privilege separation untouched). The +Server assistant gains no SSH commands. + +## 2. Framing and negotiation + +Transport: the SSH child's stdin/stdout, 4-byte big-endian length prefix + one JSON +frame, `MAX_SESSION_FRAME_BYTES` = 262144, inbound queue bounded (128 frames; +overflow closes the session fail-closed). stdout is protocol-only; stderr is +diagnostic text forwarded to logs without interpretation. Login banners and +malformed leading bytes fail the open, never a silent skip. + +Hello (first runtime frame): `{ type: "hello", runtimeProtocol: 1, agentVersion, +os, arch, capabilities, homeDir, dataDir, tmuxSocket, paneCount }`. +Plane refuses a major mismatch by name. `runtimeProtocol` is its own version +namespace; the node link family is unchanged in content, but the new commands and +events bump `NODE_PROTOCOL_VERSION` to 17 with `MIN_NODE_VERSION` raised in step +(exact-match gate, both sides released together). + +Plane-to-runtime command frames mirror the node command bodies the runtime needs, +minus crypto: `launch` (terminal or presetless harness argv under the runtime's +tmux), `input`, `terminate`, `kill`, `capture`, `log_read`, `tail_start/stop`, +`list_dirs`, `subshells_report`, `close`. Runtime-to-plane event frames mirror +`NodeEvent`: `result{ref}`, `output`, `exit`, `subshells_report`, `rest_response`. +The runtime reuses `TmuxRunner`, `pane-log` self-invoke, exit watcher, and the +executors in `apps/node/agent/src/commands/` under a shared runtime core extracted +from the agent; the daemon keeps its own dispatch over the WS link. + +## 3. Broker (connecting node) + +New node command family `ssh_session_open/send/close` (protocol 17): + +- `ssh_session_open { ref, target: { alias, host, port, user, identityFile? } }`: + the agent first probes the runtime (`sh -lc "command -v subshell"`, short RPC over + the same policy render); absent answers `SSH_RUNTIME_MISSING` with install + guidance for the binary only. Present: spawn via the salvaged + `buildSshInvocation` + `renderSshConfigContents` (mandatory deny-by-default + policy, `HostKeyAlias`, ProxyJump, BatchMode, keys-only) with the remote command + `subshell runtime-serve --session ` composed through the existing + `remoteCommandLine` quoting. The child leads its process group + (salvaged `killGroup`). Bounded pump: child stdout frames sealed into new + `NodeEvent` arm `session_frame { ref, data_b64 }` (size-capped, backpressured by + `bufferedAmount` like the tail pump); plane sends `ssh_session_send { ref, + data_b64 }` for runtime stdin. The supervisor lives beside `SshRunSupervisor` + with the same durable-accept, deadline, and boot-reconcile posture; at agent + exit the child goes with it. +- Open result carries the parsed hello plus the real `host:port` and account. + Identity files, config contents, and key material never enter any frame or row. + +Frame routing is by `ref` (plane-minted uuid). A frame naming another node's or +another session's ref is dropped; the node keys children by ref, one SSH child per +session. + +## 4. Plane: sessions as execution targets + +New tables (migration 0049): `ssh_runtime_sessions` (id, owner_id, +connecting_node_id, alias, host, port, user, status `opening|active|lost|closed`, +hello facts JSON, created/last_seen/closed) and hidden `nodes` rows with +`kind = 'runtime'` (owner-only, no node key, never listed by `GET /api/nodes`, +never dialable, never enrolled). Panes keep the ordinary `subshells` row with +`nodeId` set to the runtime node, so list/detail/log/live/ws all stay normal. + +`launcher-registry.ts` gains a branch: a `runtime`-kind node resolves to a new +`RuntimeSessionLauncher` implementing the existing `NodeLauncher` interface; it +serializes launch/input/tail through §3's frames instead of signed WS commands. +`RemoteLauncher` stays untouched. + +Authorization: opening a session requires the caller to **own** the connecting +node (salvaged `sshNodeGate`: real owner, no admin boost, no share; `local` keeps +its admin gate in-process). The session row carries `owner_id`; every pane created +through it belongs to that user; cross-account frames are impossible because refs +resolve only within the caller's sessions. Sharing follows ordinary pane sharing, +nothing session-level. + +## 5. MCP and reporter callbacks through the session + +The runtime serves its panes' callbacks on a private unix socket +`/callback.sock` (0600, mode-pinned, no network bind): the one +listener class permitted by "no inbound network listener", inside the destination +OS-account boundary. + +- `subshell mcp` on the destination detects `SUBSHELL_RUNTIME_CALLBACK_SOCK` and + routes its single `ToolApi.req` seam over the socket to the runtime as + `rest_request { reqId, method, path, body }` frames. The runtime forwards them + bounded; the plane executes the path **as the pane's own subshell token**, which + the plane minted at launch and never transmits. Only paths the pane could call + with its own token pass (`/api/subshells//*`, extend, identities, + channels): a fixed allowlist of prefixes with the pane's id substituted, not a + general proxy. Responses ride `rest_response` back. +- Exit and reporter hooks (`subshell report`, exit-hook) ride the same pipe; they + already speak the same token. +- The token itself never leaves the plane; the destination never learns the plane + URL as a network requirement. Blocking destination-to-plane connectivity is a + supported configuration and must be tested. + +## 6. Lifecycle + +- **Disconnect**: SSH child dies (network, user, reboot) -> broker reports close, + plane marks the session `lost` and its runtime node offline. Panes read + `alive:0` with status intact: unavailable, not completed. No launch, input, or + mutating frame is ever replayed; uncertain results surface as `unknown` + (salvaged exit-255 honesty posture). +- **Reconnect**: user reopens the same destination (recent aliases are rows + history); runtime reconciles by listing the destination tmux socket named + deterministically per destination (`subshell-ssh-`), so a + second session on the same machine finds the first session's panes; plane matches + reported pane ids to existing rows (idempotent restore, duplicates impossible + because pane ids come from the rows). +- **Close**: `subshell runtime-serve` exits after forwarding its final report; + tmux server and panes keep running under the destination user. Killing panes is + an explicit terminate, never an implicit close side effect. +- Coexistence: the runtime's tmux socket namespace, dataDir subdirectory + (`/runtime/`), and log dir are isolated from any separately + enrolled daemon's artifacts on the same machine. + +## 7. Selection UX and the missing runtime + +One personal flow, not an admin surface: **Connect over SSH** -> pick the +connecting node (labeled with its machine name) -> pick an alias (salvaged +`discoverSshAliases` on that node) -> Resolve shows the concrete `host:port +user`, names the connecting OS account, and Test proves reachability + runtime +presence -> pick a remote directory via `list_dirs` browsing on the live +session -> launch (preset or terminal) exactly as on any node. Saved +destinations are personal conveniences. Errors name the remedy: +`SSH_RUNTIME_MISSING` prints the binary install pointer (never `subshell +setup`/enrollment), a version mismatch names the compatible range, a host-key +refusal says who must fix it. Old admin SSH page, grants ceremony, run API, and +the ssh MCP family retire in the same change that lands the replacement +(inventory in §5 of the plan; migrations 0047/0048 dropped by 0050 after +inspecting dev instances; this instance base is single-user, so no +data-preservation burden, but the drop ships, it is not a silent revert). + +Amendment (2026-10-05, wave-2 UX review): this paragraph first labeled the +pick-time choice with "its machine name and OS account". The node view serves +no OS-account fact (`GET /api/nodes` answers name, hostname, os, arch), so the +picker labels machines by name alone; the connecting account is a resolve +answer (`connectingAccount`) and surfaces at review, where the server actually +serves it. Stating it at pick would have meant a label with no fact behind it. + +## 8. Security invariants carried over + +Keys, agents, and config stay on the connecting node; host-key verification is +mandatory (policy render unchanged); frames bounded and fail-closed; explicit +argv with per-token quoting through the login-shell boundary; remote output is +untrusted data; no server credential on the destination; per-pane token identity +for MCP preserved; authority to open a session grants nothing beyond the caller's +own panes. + +## 9. Gate A proof (vertical slice, in-repo, fixture sshd) + +Extend `e2e/fixtures/sshd.ts` destination: no enrollment config, plane unreachable +from the destination's network namespace expectation. Scenario: open session +through the connecting node -> list dirs -> launch a terminal pane -> attach and +type (output bytes land in the runtime pane log on the destination) -> MCP round +trip: from inside the pane, one call through `callback.sock` (a `curl --unix-socket` +in a terminal, or the runtime-side test harness) resolving as the pane's own token +-> terminate. Protocol 17 handshake assertion on the fixture link. This slice is +the acceptance gate for workstreams R/B/C; it does not require the UX. + +## 10. Workstream contracts + +- **R (remote runtime)**: `runtime-serve` verb on the node CLI extracting the + shared runtime core (tmux, executors, logs, exit watcher) from `apps/node/agent`; + framed stdio + hello + callback socket. Owns: `apps/node/agent/src/runtime/`. +- **B (broker)**: `ssh_session_*` commands, session supervisor, protocol 17. + Owns: `packages/subshell-protocol/src/ssh-session-frames.ts`, + `apps/node/agent/src/commands/ssh-session.ts`. +- **C (control plane)**: session tables/registry, `RuntimeSessionLauncher`, + callback execution allowlist, retirement of old doors (routes, MCP tools, + tables, policy scopes, docs). Owns: `apps/server/api/src/services/ssh-runtime/`, + migrations 0049/0050, `packages/mcp-core` ssh removal. +- **U (UX/docs)**: Connect-over-SSH flow in the SPA on frozen APIs, docs rewrite. + Only the coordinator edits shared registries (`routes.ts`, `commands/index.ts`, + `node-frames.ts` unions, `migrate.ts`) by applying worker-supplied hunks. diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 6349bff93..eaa8c45b4 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -54,6 +54,21 @@ Every fixed port in `ports.ts` takes an env override (`E2E_PORT_BACKEND`, committed defaults unchanged, the escape hatch for two suites running side by side on one machine, which the defaults collide on. +Spec `21` adds a real SSH destination: `e2e/fixtures/sshd.ts` generates +passphrase-less ed25519 keys, starts one loopback ephemeral-port `sshd` (its +own pid tracked; `ssh`/`ssh-keygen`/`sshd` probed on PATH with an +`E2E_SSHD_BIN` override), and hands the real node agent a `HOME` at the +fixture's config dir so `ssh -G`, structured runs, and managed-terminal panes +all resolve the alias `e2edest` against fixture bytes, never a developer's +`~/.ssh`. Like specs 14/19 it is a third-party process, but started IN the +Node worker (the Playwright runner is Node, so the in-repo Bun sshd fixture +recipe is ported to `child_process`/`net`). The whole file skips with a +reason naming the missing binaries (`missingSshBins()` → `test.skip(…)`) on +a host without ssh/ssh-keygen/sshd, never a silent green. The builder-image +e2e container carries none of them, so the job's ensure-step installs +`openssh-client`/`openssh-server` and CI executes the fixture rather than +skipping it. Keep that step and this sentence in agreement. + Spec `12` extends the stack itself: it spawns the **real `subshell` from source** (`bun apps/node/agent/src/main.ts enroll|run` via `stub/client.ts`, with `SUBSHELL_CONFIG_HOME` and `TMUX_TMPDIR` pointed at temp dirs so its config and its @@ -70,7 +85,7 @@ against ONE shared database: - `01-setup-wizard` creates the admin (`.test` email TLD: better-auth rejects digit TLDs like `.e2e`) and writes `.auth/admin.json`. - The specs after it load that storage state via `ADMIN_STATE` from - `helpers.ts` (`04`–`19`, less `15`, which boots its own clean + `helpers.ts` (`04`–`21`, less `15`, which boots its own clean machine and loads no state; `02` deliberately stays anonymous: it pins the 401 boundary itself; `19` reads it through an admin `APIRequestContext` for its provider seeding, while every browser context it drives stays diff --git a/e2e/fixtures/sshd.ts b/e2e/fixtures/sshd.ts new file mode 100644 index 000000000..f9c00e87b --- /dev/null +++ b/e2e/fixtures/sshd.ts @@ -0,0 +1,298 @@ +/** + * The e2e sshd fixture (specs 22 and 23): a REAL isolated sshd, the port of + * `packages/pane-runtime/src/ssh/__tests__/sshd-fixture.test.ts`'s recipe + * from Bun APIs to Node ones (the Playwright runner is Node). It owns and + * cleans only its own pieces: generated host + client keys, one sshd on a + * loopback ephemeral port (own pid tracked), and an `sshConfigHome` the node + * agent runs as its HOME. The developer's `~/.ssh` is never touched — every + * byte lives under the caller's temp root. + * + * Trust choice (one pick, documented per the brief): a PRE-TRUSTED + * `known_hosts` at `/.ssh/known_hosts` — exactly where the + * node's `ssh -G` resolves the default `UserKnownHostsFile` (the child HOME + * is this dir), so the approved snapshot carries the fixture's trusted file + * and the runtime's mandatory `StrictHostKeyChecking yes` passes on the + * pinned key. No `accept-new` first-contact pinning: the feature requires + * existing verified trust, and the fixture enters that contract already + * trusted rather than exercising a mode production never uses. + */ +import { spawn, spawnSync } from "node:child_process"; +import { accessSync, chmodSync, closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { createServer } from "node:net"; +import { userInfo } from "node:os"; +import path from "node:path"; + +/** The alias every consumer of this fixture references (`~/.ssh/config`). */ +export const SSH_FIXTURE_ALIAS = "e2edest"; + +/** sshd's known install locations beyond PATH (probe fallbacks, the ladder's shape). */ +const SSHD_KNOWN_PATHS = ["/usr/sbin/sshd"]; + +/** PATH probe (the Node stand-in for `Bun.which`); sshd also checks the known install paths and honors `E2E_SSHD_BIN`. */ +function which(bin: string, extraPaths: readonly string[] = []): string | null { + const override = bin === "sshd" ? process.env["E2E_SSHD_BIN"] : undefined; + if (override !== undefined && override !== "") return override; + const dirs = (process.env.PATH ?? "").split(path.delimiter).filter((d) => d !== ""); + for (const dir of [...dirs, ...extraPaths]) { + try { + accessSync(path.join(dir, bin)); + return path.join(dir, bin); + } catch { + // not here + } + } + return null; +} + +/** The missing pieces, empty when the host can run the real-daemon suite. */ +export function missingSshBins(): string[] { + const missing: string[] = []; + if (which("ssh") === null) missing.push("ssh"); + if (which("ssh-keygen") === null) missing.push("ssh-keygen"); + if (which("sshd", SSHD_KNOWN_PATHS) === null) missing.push("sshd"); + return missing; +} + +/** A loopback ephemeral port, freed the instant it is named (the Node stand-in for `Bun.listen`). */ +async function freePort(): Promise { + const srv = createServer(); + await new Promise((resolve, reject) => { + srv.once("error", reject); + srv.listen(0, "127.0.0.1", () => resolve()); + }); + const { port } = srv.address() as AddressInfo; + await new Promise((resolve) => srv.close(() => resolve())); + return port; +} + +/** The probe asks "is anything listening"; StrictHostKeyChecking=no is the probe's own trust, never the feature's (the real trust is the pre-trusted known_hosts). `-F /dev/null` seals the ambient `~/.ssh/config` out of a probe meant to be ambient-free. */ +const PROBE_OPTS = [ + "BatchMode=yes", + "ConnectTimeout=1", + "StrictHostKeyChecking=no", + "UserKnownHostsFile=/dev/null", +].flatMap((o) => ["-o", o]); + +/** sshd's readiness, probed through ssh itself: "not refused" means the daemon answers (the bun recipe's rule). */ +async function waitListening(sshBin: string, port: number, logPath: string): Promise { + for (let i = 0; i < 100; i++) { + const probe = spawnSync(sshBin, ["-F", "/dev/null", "-p", String(port), ...PROBE_OPTS, "127.0.0.1", "true"], { + stdio: ["ignore", "ignore", "pipe"], + }); + const err = probe.stderr?.toString() ?? ""; + if (!/Connection refused|connect to host 127\.0\.0\.1 port|Connection timed out/i.test(err)) return; + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`sshd did not come up on 127.0.0.1:${port}\n${readFileSync(logPath, "utf8").slice(-2000)}`); +} + +export interface SshFixture { + /** The loopback port the fixture sshd answers on. */ + port: number; + /** A loopback port THIS fixture holds with an accept-then-destroy listener: the dead-run target answers with an immediate connection reset (a freed port could be silently rebound between free and dial, turning the honest 255 into a ssh-connect timeout). */ + deadPort: number; + /** The fixture user — the host's own account (sshd refuses passwords; keys only). */ + user: string; + /** The directory to hand the node agent as HOME (its `.ssh/` carries config + known_hosts). */ + sshConfigHome: string; + /** Absolute path of the passphrase-less client key sshd authorizes. */ + trustedKeyPath: string; + /** The daemon's own stderr log — a real "Accepted publickey" line is a connect truth no client-side fact gives. */ + logPath: string; + /** SIGTERM the daemon this fixture started (and only that one). */ + stop(): Promise; +} + +/** Live fixture daemons, killed on process exit so a crashed worker never strands an sshd. */ +const live = new Set(); +process.on("exit", () => { + for (const pid of live) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // already gone + } + } +}); + +/** + * Start the fixture under `root` (a caller-mkdtemp'd dir; the caller owns the + * bytes, this function owns the daemon). Generates ed25519 host + client keys, + * writes the account config alias `e2edest`, the pre-trusted `known_hosts`, + * and sshd's own config (loopback-only, key-only auth, no PAM). + * + * `envPath` (spec 23) adds one `SetEnv PATH=` line. sshd resets a session's + * PATH to the platform login default regardless of the daemon's own + * environment (measured: a PATH prepended before spawn never reaches the + * remote command), so a destination that must find a scratch binary on PATH + * needs the daemon to hand it out. Absent, the fixture is exactly the + * default-PATH world it has always been. + */ +export async function startSshFixture(root: string, options: { envPath?: string } = {}): Promise { + const sshBin = which("ssh"); + const keygenBin = which("ssh-keygen"); + const sshdBin = which("sshd", SSHD_KNOWN_PATHS); + if (sshBin === null || keygenBin === null || sshdBin === null) { + throw new Error(`startSshFixture called on a host without ssh/ssh-keygen/sshd (${missingSshBins().join(", ")})`); + } + // The connecting account NAME, deterministically. `os.userInfo().username` + // is env-driven on bun (the Playwright runner IS bun) and passwd-driven on + // node: in a container with no USER/LOGNAME env bun answers "unknown" while + // sshd, matching the uid not the env, logs the real account ("root"). + // uid 0 is root on every supported OS; otherwise env, then the passwd + // lookup. The specs hand the agent `USER`/`LOGNAME` (see 22-ssh-runtime) so + // the daemon's own `connectingAccount` says the same thing everywhere. + const user = process.getuid?.() === 0 ? "root" : (process.env.USER ?? process.env.LOGNAME ?? userInfo().username); + const home = path.join(root, "home"); + mkdirSync(home, { recursive: true, mode: 0o700 }); + mkdirSync(path.join(home, ".ssh"), { mode: 0o700 }); + chmodSync(home, 0o700); + + const hostKey = path.join(root, "hostkey"); + const clientKey = path.join(root, "clientkey"); + for (const f of [hostKey, clientKey]) { + const kg = spawnSync(keygenBin, ["-q", "-t", "ed25519", "-N", "", "-f", f], { stdio: "ignore" }); + if (kg.status !== 0) throw new Error(`ssh-keygen failed for ${f}: exit ${kg.status}`); + } + + const port = await freePort(); + // The dead-run target, HELD for the fixture's life (review M2): we bind the + // named-free port ourselves and destroy every inbound socket, so a dial is + // refused fast (ssh: "Connection closed", exit 255 - exactly what + // sshTransportFailure corroborates) and NO third party can re-bind the name + // between our freePort and the dead run's connect. + const deadPort = await freePort(); + const refuseHold = createServer((sock) => sock.destroy()); + await new Promise((resolve, reject) => { + refuseHold.once("error", reject); + refuseHold.listen(deadPort, "127.0.0.1", () => resolve()); + }); + + // Pre-trusted known_hosts, named ABSOLUTE in the config below. ssh resolves + // the DEFAULT `userknownhostsfile` against the passwd home, not the injected + // $HOME, so a bare `~/.ssh/known_hosts` default would make the approved + // snapshot point at the host user's real file and the mandatory + // `StrictHostKeyChecking yes` probe fail host_key_unknown. The bracketed + // `[host]:port` authority form is what the non-22 port matches. + const knownHosts = path.join(home, ".ssh", "known_hosts"); + const keyTypeAndBlob = readFileSync(`${hostKey}.pub`, "utf8").trim().split(" ").slice(0, 2).join(" "); + writeFileSync(knownHosts, `[127.0.0.1]:${port} ${keyTypeAndBlob}\n`); + + // The account's config: the alias every consumer references. `IdentityAgent + // none` keeps an ambient ssh-agent out of resolution (its keys could crowd + // the fixture's single trusted key against sshd's MaxAuthTries). + writeFileSync( + path.join(home, ".ssh", "config"), + [ + `Host ${SSH_FIXTURE_ALIAS}`, + " HostName 127.0.0.1", + ` Port ${port}`, + ` User ${user}`, + ` IdentityFile ${clientKey}`, + " IdentitiesOnly yes", + " IdentityAgent none", + ` UserKnownHostsFile ${knownHosts}`, + "", + ].join("\n"), + { mode: 0o600 }, + ); + + // sshd trusts exactly the fixture's client key; the host's own `~/.ssh` is + // never consulted (StrictModes off lets authorized_keys live at the root). + const authorizedKeys = path.join(root, "authorized_keys"); + writeFileSync(authorizedKeys, `${readFileSync(`${clientKey}.pub`, "utf8").trim()}\n`); + + const sshdDir = path.join(root, "sshd"); + mkdirSync(sshdDir, { recursive: true }); + const sshdConfig = path.join(sshdDir, "sshd_config"); + const sshdLog = path.join(sshdDir, "sshd.log"); + writeFileSync( + sshdConfig, + [ + `Port ${port}`, + "ListenAddress 127.0.0.1", + "AddressFamily inet", + `HostKey ${hostKey}`, + `PidFile ${path.join(sshdDir, "sshd.pid")}`, + `AuthorizedKeysFile ${authorizedKeys}`, + "StrictModes no", + "UsePAM no", + "UseDNS no", + "PasswordAuthentication no", + "KbdInteractiveAuthentication no", + "PubkeyAuthentication yes", + "PermitUserEnvironment no", + "PrintMotd no", + "LogLevel VERBOSE", + // The spec-23 seam: a session PATH the daemon hands out (the default + // login PATH would otherwise hide any scratch binary). One token, no + // spaces: sshd's SetEnv value grammar. + ...(options.envPath !== undefined && options.envPath !== "" ? [`SetEnv PATH=${options.envPath}`] : []), + ].join("\n"), + ); + // sshd's privilege-separation directory is compiled in (/run/sshd; no + // sshd_config keyword overrides it) and the daemon refuses to start without + // it. Measured: the CI builder image's openssh-server postinst does NOT + // create it in a non-systemd container (job run 37274033881 died exactly + // there), while dev hosts usually have one because sshd is installed and + // running. Ensure it opportunistically: an existing dir or a non-writable + // /run surfaces through sshd's own stderr in the readiness failure below. + try { + mkdirSync("/run/sshd", { mode: 0o755 }); + } catch { + // already there, or not ours to make - sshd's log will say which + } + // sshd -e means "log to stderr" and takes NO file argument: the fixture + // redirects the child's stderr into its own file (the bun recipe's rule). + const logFd = openSync(sshdLog, "w"); + const child = spawn(sshdBin, ["-D", "-f", sshdConfig, "-e"], { + stdio: ["ignore", "ignore", logFd], + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + }); + closeSync(logFd); + const pid = child.pid; + if (pid === undefined) throw new Error("sshd spawn returned no pid"); + live.add(pid); + child.once("exit", () => live.delete(pid)); + // A spawn-adjacent failure is async on a long-lived child: hold the message + // for the readiness failure text rather than letting it throw unhandled. + let spawnError: string | null = null; + child.once("error", (err) => { + spawnError = String(err); + }); + try { + await waitListening(sshBin, port, sshdLog); + } catch (err) { + throw new Error(`${String(err)}${spawnError === null ? "" : `\nspawn error: ${spawnError}`}`); + } + + return { + port, + deadPort, + user, + sshConfigHome: home, + trustedKeyPath: clientKey, + logPath: sshdLog, + async stop(): Promise { + await new Promise((resolve) => refuseHold.close(() => resolve())); + live.delete(pid); + for (const sig of ["SIGTERM", "SIGKILL"] as const) { + try { + process.kill(pid, sig); + } catch { + return; + } + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch { + return; + } + await new Promise((r) => setTimeout(r, 100)); + } + } + }, + }; +} diff --git a/e2e/stub/client.ts b/e2e/stub/client.ts index b6b2642ee..eaa60eb61 100644 --- a/e2e/stub/client.ts +++ b/e2e/stub/client.ts @@ -31,6 +31,8 @@ export interface StartNodeOptions { name: string; /** Control plane base URL (default: the e2e stack's). */ server?: string; + /** Extra env merged LAST over the defaults - the SSH specs point `HOME` at their fixture's config dir. */ + extraEnv?: Record; } /** A live `subshell run` daemon + its operator-facing surface. */ @@ -55,6 +57,9 @@ function nodeEnv(o: StartNodeOptions): NodeJS.ProcessEnv { SUBSHELL_CONFIG_HOME: o.home, PI_PATH: STUB_PI, TMUX_TMPDIR: o.tmuxBase, + // Extra env LAST so a caller can retarget HOME (an SSH fixture's + // home) or blank SSH_AUTH_SOCK, overriding the inherited process.env. + ...(o.extraEnv ?? {}), }; } diff --git a/e2e/tests/22-ssh-runtime.spec.ts b/e2e/tests/22-ssh-runtime.spec.ts new file mode 100644 index 000000000..ff9c6cef5 --- /dev/null +++ b/e2e/tests/22-ssh-runtime.spec.ts @@ -0,0 +1,884 @@ +import { spawnSync } from "node:child_process"; +import { accessSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { NODE_PROTOCOL_VERSION, SSH_RUNTIME_PROTOCOL } from "@internal/subshell-protocol"; +import { type APIRequestContext, expect, test } from "@playwright/test"; +import { missingSshBins, type SshFixture, startSshFixture } from "../fixtures/sshd"; +import { BASE_URL } from "../ports"; +import { shortTmuxBase } from "../stack"; +import { NODE_MAIN, type RunningNode, startNode } from "../stub/client"; +import { pollUntil, sleep, sweepTmuxServers } from "./helpers"; +import { type NodeRow, rmScratch, seedAdminApi } from "./ssh-support"; + +/** + * SSH RUNTIME SESSIONS end to end (design 2026-10-05 §9, Gate A): the whole + * chain with no fake - plane, real enrolled agent, policy-rendered ssh child, + * the repo's OWN `runtime-serve` running on the destination over a fixture + * sshd, a real terminal pane on the destination's tmux, typed bytes returning + * through the framed channel, one REST callback executed by the plane as the + * pane's own token, an ordinary REST terminate that kills the DESTINATION pane + * while the session survives, and a close that kills BOTH halves of the + * channel (the process-kill proof the review asked for: a leaked ssh child + * occupies the per-node quota forever). The negatives ride along: a foreign + * callback path is refused, the runtime binds no TCP listener, an absent + * destination runtime refuses by name, the ssh child dying under a live link + * marks the session LOST with the node still online, and losing the whole + * agent marks it LOST too (the pane unavailable, not completed). The harness + * wave rides along: detect answers from the destination's own lookup (a fake + * harness binary the wrapper hands out via CLAUDE_PATH), and launch-harness + * really spawns a pane on it. The acceptance-fixes wave added its own three + * network-truth proofs: a raw `/ws?subshell=` live attach (replay, a typed + * marker echoed back off the socket, and the socket closing when the child + * dies - F1), the invalid-directory launch refused BY NAME before any row is + * written (F3), and the reopen RE-ADOPT: after the kill, the SAME pane id + * reads alive on the new session, types over a fresh socket, and its + * callback door attributes again on the rotated token (F2). + * + * The destination IS this host (the fixture sshd dials loopback as the same + * account), so `runtimeCommand` is a wrapper that re-enters this checkout: + * `bun /apps/node/agent/src/main.ts runtime-serve ...` - the source-mode + * parity spec 12 established, one layer across an SSH hop. The wrapper pins + * the destination-side runtime data dir and TMUX_TMPDIR into scratch dirs the + * teardown owns. Terminal truth is REST/log truth (AGENTS.md: never xterm). + */ + +const missing = missingSshBins(); +test.skip(missing.length > 0, `real-ssh runtime e2e skipped: this host lacks ${missing.join(", ")}`); +// API-driven (the spec's proof is the chain, not chrome): the admin context +// comes from seedAdminApi, which also covers a focused run without spec 01. +test.describe.configure({ mode: "serial" }); + +const RUN = Math.random().toString(36).slice(2, 7); +const NODE_NAME = `e2e-rt-${RUN}`; + +/** The wrapper the destination runs instead of an installed `subshell`. */ +function resolveBun(): string { + for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter((d) => d !== "")) { + try { + accessSync(path.join(dir, "bun")); + return path.join(dir, "bun"); + } catch { + // not here + } + } + throw new Error("the e2e host has no bun on PATH (spec 12/21 would already have died)"); +} + +let fixture: SshFixture | undefined; +let agent: RunningNode | undefined; +let agentHome = ""; +let tmuxBase = ""; +let destTmuxBase = ""; +let fixtureRoot = ""; +let scratch = ""; +let wrapperPath = ""; +let fakeClaudePath = ""; +let nodeId = ""; +let setupKey = ""; +let setupKeyId = ""; +let api: APIRequestContext; + +const ssh = (): SshFixture => { + if (fixture === undefined) throw new Error("the sshd fixture never started"); + return fixture; +}; +const agentTail = (): string => agent?.logTail() ?? "(agent never started)"; + +/** The ordered chain's sessions (each test names the one it owns). */ +let sessionId = ""; +let paneId = ""; + +/** The runtime's process liveness for one session ref. The `runtime-serve + * --session ` argv is carried by BOTH halves of the channel - the + * connecting node's ssh child (the remote command line sits in its argv) and + * the destination's `runtime-serve` - so zero means neither survives, which + * is exactly the close promise. */ +function runtimePids(sessionRef: string): string[] { + const find = spawnSync("pgrep", ["-f", `runtime-serve --session ${sessionRef}`], { encoding: "utf8" }); + return find.stdout + .split("\n") + .map((l) => l.trim()) + .filter((l) => l !== ""); +} + +/** The CONNECTING side's ssh child for one session: its argv carries the + * rendered config path, which is named by the ref and by nothing else. */ +function sshChildPid(sessionRef: string): string { + const find = spawnSync("pgrep", ["-f", `${sessionRef}.config`], { encoding: "utf8" }); + const pids = find.stdout + .split("\n") + .map((l) => l.trim()) + .filter((l) => l !== ""); + expect(pids.length, `the ssh child for ${sessionRef.slice(0, 8)} should be live`).toBeGreaterThanOrEqual(1); + return pids[0] as string; +} + +/** The destination's tmux session names, read as the test host (the destination IS this machine, socket in the wrapper's TMUX_TMPDIR). */ +function destTmuxSessions(socket: string): string[] { + const r = spawnSync("tmux", ["-L", socket, "list-sessions", "-F", "#{session_name}"], { + encoding: "utf8", + env: { ...process.env, TMUX_TMPDIR: destTmuxBase }, + }); + if (r.error !== undefined) throw new Error(`tmux(1) unavailable: ${r.error.message}`); + return r.stdout + .split("\n") + .map((l) => l.trim()) + .filter((l) => l !== ""); +} + +interface SessionView { + id: string; + runtimeNodeId: string; + status: string; + hello: { + runtimeProtocol: number; + dataDir: string; + tmuxSocket: string; + paneCount: number; + capabilities: string[]; + } | null; +} + +async function openRuntimeSession( + runtimeCommand: string, +): Promise<{ res: Awaited> }> { + const f = ssh(); + const res = await api.post("/api/ssh-runtime/sessions", { + data: { + connectingNodeId: nodeId, + target: { alias: "e2edest", host: "127.0.0.1", port: f.port, user: f.user, identityFile: f.trustedKeyPath }, + runtimeCommand, + }, + }); + return { res }; +} + +const logText = async (id: string): Promise => { + const res = await api.get(`/api/subshells/${id}/log`); + if (!res.ok()) return ""; + return ((await res.json()) as { lines: string[] }).lines.join("\n"); +}; + +/** + * A browser-class live attach, driven as NETWORK truth (no xterm peek): the + * token comes from the same `POST /api/auth/ws-token` the SPA calls, the + * socket dials the same `/ws?subshell=…&token=…` URL the SPA composes, every + * TEXT frame the server ships is recorded, and the close code is exposed. + * Without the `&enc=cbor` half the SPA adds, the server answers in JSON - the + * same frames, readable. + */ +async function openLiveSocket(subshellId: string): Promise<{ + send: (frame: Record) => void; + frames: string[]; + opened: () => boolean; + closed: Promise<{ code: number; reason: string }>; + close: () => void; +}> { + const tok = await api.post("/api/auth/ws-token"); + expect(tok.ok(), await tok.text()).toBe(true); + const token = ((await tok.json()) as { token: string }).token; + const socket = new WebSocket( + `${BASE_URL.replace(/^http/, "ws")}/ws?subshell=${encodeURIComponent(subshellId)}&token=${token}`, + ); + const frames: string[] = []; + socket.addEventListener("message", (ev) => frames.push(String(ev.data))); + let isOpen = false; + socket.addEventListener("open", () => { + isOpen = true; + }); + const closed = new Promise<{ code: number; reason: string }>((resolve) => { + socket.addEventListener("close", (ev) => { + const e = ev as { code: number; reason: string }; + resolve({ code: e.code, reason: e.reason }); + }); + }); + // The upgrade is the first truth: a 4004-class refusal (pre-fix F1 closed + // every runtime-pane attach that way) lands as an immediate CLOSE, not an + // OPEN - so the race below fails LOUDLY on the old behavior. + const settled = await Promise.race([ + new Promise<"open">((resolve) => socket.addEventListener("open", () => resolve("open"))), + closed.then(() => "closed" as const), + ]); + expect(settled, `the /ws attach to ${subshellId.slice(0, 8)} must OPEN (a refused attach closes)`).toBe("open"); + return { + send: (frame) => socket.send(JSON.stringify(frame)), + frames, + opened: () => isOpen, + closed, + close: () => socket.close(), + }; +} + +test.beforeAll(async () => { + api = await seedAdminApi(); + + fixtureRoot = mkdtempSync(path.join(tmpdir(), "subshell-e2e-rt-sshd-")); + fixture = await startSshFixture(fixtureRoot); + + scratch = mkdtempSync(path.join(tmpdir(), "subshell-e2e-rt-")); + const runtimeData = path.join(scratch, "runtime-data"); + destTmuxBase = path.join(scratch, "tmux-dest"); + mkdirSync(runtimeData, { recursive: true, mode: 0o700 }); + mkdirSync(destTmuxBase, { recursive: true, mode: 0o700 }); + // A fake harness binary on the DESTINATION's lookup path: the detect wave's + // launch verb resolves the binary THERE (the inversion's late binding), and + // the fixture account has no real claude to point at. The CLAUDE_PATH env + // override is the detectSpec's own first rung, handed out by the wrapper so + // the runtime's probe and the launch resolve see the same shim. It answers + // --version fast (the version probe is bounded at 4 s) and otherwise prints + // a marker and lives, which is all the pane proof this spec needs. + fakeClaudePath = path.join(scratch, "fake-claude"); + writeFileSync( + fakeClaudePath, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then echo "fake-claude 9.9.9"; exit 0; fi', + "echo FAKE-CLAUDE-START", + "exec sleep 3600", + "", + ].join("\n"), + { mode: 0o755 }, + ); + chmodSync(fakeClaudePath, 0o755); + + wrapperPath = path.join(scratch, "runtime-wrapper.sh"); + writeFileSync( + wrapperPath, + [ + "#!/bin/sh", + `export SUBSHELL_RUNTIME_DATA_DIR=${runtimeData}`, + `export TMUX_TMPDIR=${destTmuxBase}`, + `export CLAUDE_PATH=${fakeClaudePath}`, + `exec ${resolveBun()} ${NODE_MAIN} "$@"`, + "", + ].join("\n"), + { mode: 0o755 }, + ); + chmodSync(wrapperPath, 0o755); + + const mint = await api.post("/api/nodes/setup-keys"); + expect(mint.ok(), await mint.text()).toBe(true); + const key = (await mint.json()) as { id: string; key: string }; + setupKeyId = key.id; + setupKey = key.key; + + agentHome = mkdtempSync(path.join(tmpdir(), "subshell-e2e-rt-node-")); + tmuxBase = shortTmuxBase(); // the CONNECTING side's tmux home (the sun_path limit) + agent = await startNode({ + home: agentHome, + dataDir: path.join(agentHome, "data"), + tmuxBase, + setupKey, + name: NODE_NAME, + extraEnv: { HOME: fixture.sshConfigHome, USER: fixture.user, LOGNAME: fixture.user, SSH_AUTH_SOCK: "" }, + }); + + await pollUntil( + `node "${NODE_NAME}" never came online`, + async () => { + const res = await api.get("/api/nodes"); + expect(res.ok(), await res.text()).toBe(true); + const row = ((await res.json()) as { nodes: NodeRow[] }).nodes.find((n) => n.name === NODE_NAME); + if (row) nodeId = row.id; + return row?.status === "online"; + }, + { tail: agentTail }, + ); +}); + +test.afterAll(async () => { + const leaks: string[] = []; + try { + await agent?.stop(); + } catch (err) { + leaks.push(`agent stop: ${String(err)}`); + } + sweepTmuxServers(tmuxBase); + sweepTmuxServers(destTmuxBase); // the DESTINATION's servers live under the wrapper's TMUX_TMPDIR + try { + await fixture?.stop(); + } catch (err) { + leaks.push(`sshd stop: ${String(err)}`); + } + if (api !== undefined) { + if (sessionId) { + // best-effort close of whatever session survived the run (a settled one answers 404/409, fine) + await api.post(`/api/ssh-runtime/sessions/${sessionId}/close`).catch(() => undefined); + } + if (nodeId) { + for (let i = 0; i < 10; i++) { + try { + const del = await api.delete(`/api/nodes/${nodeId}?force=true`); + if (del.ok() || del.status() === 404) break; + if (i === 9) leaks.push(`node delete: HTTP ${del.status()}`); + } catch (err) { + leaks.push(`node delete: ${String(err)}`); + break; + } + await sleep(500); + } + } + if (setupKeyId) { + const rev = await api.delete(`/api/nodes/setup-keys/${setupKeyId}`); + if (!rev.ok() && rev.status() !== 404) leaks.push(`setup-key revoke: HTTP ${rev.status()}`); + } + await api.dispose(); + } + rmScratch(agentHome); + rmScratch(fixtureRoot); + rmScratch(scratch); + rmScratch(path.dirname(tmuxBase)); + if (leaks.length > 0) console.error(`[22-ssh-runtime] cleanup problems: ${leaks.join("; ")}`); +}); + +test("open: probe, spawn, framed hello; the view carries destination truth", async () => { + test.setTimeout(180_000); + const { res } = await openRuntimeSession(wrapperPath); + expect(res.ok(), await res.text()).toBe(true); + const view = (await res.json()) as SessionView; + sessionId = view.id; + expect(view.status).toBe("active"); + expect(view.hello?.runtimeProtocol).toBe(SSH_RUNTIME_PROTOCOL); + // The hello's dataDir is the WRAPPER's env, not the connecting side's: the + // runtime really ran on the destination with its own namespace. + expect(view.hello?.dataDir).toBe(path.join(scratch, "runtime-data")); + expect(view.hello?.tmuxSocket).toMatch(/^subshell-ssh-[0-9a-f]{12}$/); + expect(view.hello?.paneCount, "a fresh socket on a fresh fixture account has no panes yet").toBe(0); + expect(view.hello?.capabilities.length).toBeGreaterThan(0); + + // The hidden runtime node row is NEVER listed: the session references it, + // the nodes surface must not show it (design §4's concealment). + const nodes = await api.get("/api/nodes"); + const listed = ((await nodes.json()) as { nodes: NodeRow[] }).nodes; + expect( + listed.some((n) => n.id === view.runtimeNodeId), + "runtime rows stay out of listings", + ).toBe(false); + + const get = await api.get(`/api/ssh-runtime/sessions/${sessionId}`); + expect(get.ok(), await get.text()).toBe(true); + expect(((await get.json()) as SessionView).status).toBe("active"); +}); + +test("pane lifecycle: terminal launch, a typed marker returns through the framed log read", async () => { + test.setTimeout(120_000); + const launch = await api.post(`/api/ssh-runtime/sessions/${sessionId}/launch-terminal`, { + data: { cwd: fixtureRoot, cols: 80, rows: 24 }, + }); + expect(launch.ok(), await launch.text()).toBe(true); + paneId = ((await launch.json()) as { subshellId: string }).subshellId; + + // The pane is an ordinary subshell row: list, detail and log all read it. + const row = await api.get(`/api/subshells/${paneId}`); + expect(row.ok(), await row.text()).toBe(true); + const pane = (await row.json()) as { status: string; alive: boolean }; + expect(pane.status).toBe("running"); + expect(pane.alive).toBe(true); + + const typed = `echo RT-MARKER-${RUN}`; + const input = await api.post(`/api/subshells/${paneId}/input`, { data: { text: typed, submit: true } }); + expect(input.ok(), await input.text()).toBe(true); + + // TWO occurrences are the proof, and they prove different halves: the echo + // of the typed line means the INPUT frame reached the pane; the marker + // standing ALONE (not glued to `echo`) means the pane RAN the command and + // its output came back through GET /log -> log_read frames. Counting beats + // line equality here: the destination's interactive shell redraws with + // control residue that survives ANSI stripping as non-whitespace bytes. + await pollUntil( + "the typed marker never appeared (echoed AND run) in the destination pane's log tail", + async () => { + const text = await logText(paneId); + const hits = text.split(`RT-MARKER-${RUN}`).length - 1; + return hits >= 2 && text.includes(typed); + }, + { budgetMs: 60_000, tail: agentTail }, + ); +}); + +test("invalid directory: the launch refuses BY NAME before any row exists (F3)", async () => { + test.setTimeout(60_000); + // The measured F3 was a lying 200: the row claimed a path the destination's + // shell had silently fallen back from. The gate is the destination's own + // `stat_dir`, and it precedes the row/token/frame order. + const bogus = `/no/such/dir-${RUN}`; + const launch = await api.post(`/api/ssh-runtime/sessions/${sessionId}/launch-terminal`, { data: { cwd: bogus } }); + expect(launch.status(), await launch.text()).toBe(409); + const text = await launch.text(); + expect(text).toContain("dir_missing"); // the named sshCode the UI branches on + expect(text).toContain(bogus); // names the refused value + expect(text).toContain("Choose an existing folder"); // names the remedy + // Nothing was written: no row anywhere (admin sees its own list; the + // refusal must not have minted a pane claiming the missing path). + const rows = (await (await api.get("/api/subshells")).json()) as { workingDir: string }[]; + expect(rows.some((r) => r.workingDir === bogus)).toBe(false); + // The session survives its refused launch (a refusal is not a loss). + const still = await api.get(`/api/ssh-runtime/sessions/${sessionId}`); + expect(((await still.json()) as SessionView).status).toBe("active"); +}); + +test("callback: the pane's curl through callback.sock executes as its own token; a foreign path is refused", async () => { + test.setTimeout(120_000); + if (spawnSync("curl", ["--version"]).error !== undefined) test.skip(true, "no curl(1) on this host"); + // The pane's own curl over the UNIX socket (design §5): the runtime forwards + // the request to the plane, which executes it with the pane's own bearer and + // relays the answer back. The response goes to a FILE the test reads (not + // the terminal stream): the destination shell's redraw is its own business, + // and the bytes proving the round trip deserve a deterministic read. + const cbDir = path.join(scratch, "cb"); + mkdirSync(cbDir, { recursive: true, mode: 0o700 }); + const ownBody = path.join(cbDir, "own.json"); + const foreignBody = path.join(cbDir, "foreign.json"); + + const read = (p: string): string => { + try { + return readFileSync(p, "utf8"); + } catch { + return ""; + } + }; + + const own = await api.post(`/api/subshells/${paneId}/input`, { + data: { + text: `curl -s --unix-socket "$SUBSHELL_RUNTIME_CALLBACK_SOCK" -o ${ownBody} http://unix/api/subshells/$SUBSHELL_ID`, + submit: true, + }, + }); + expect(own.ok(), await own.text()).toBe(true); + await pollUntil( + "the callback response never reached the destination file", + async () => read(ownBody).includes('"workingDir"'), + { budgetMs: 60_000, tail: agentTail }, + ); + + // Foreign path: the allowlist refuses it without ever fetching - the pane + // sees the named 403 body, and NOTHING executed as its token. + const foreign = await api.post(`/api/subshells/${paneId}/input`, { + data: { + text: `curl -s --unix-socket "$SUBSHELL_RUNTIME_CALLBACK_SOCK" -o ${foreignBody} http://unix/api/users`, + submit: true, + }, + }); + expect(foreign.ok(), await foreign.text()).toBe(true); + await pollUntil( + "the refused callback never answered forbidden", + async () => read(foreignBody).includes("forbidden"), + { budgetMs: 60_000, tail: agentTail }, + ); +}); + +test("harness launch: detect answers from the destination, launch-harness runs a pane on its binary", async () => { + test.setTimeout(120_000); + // The mount's end-to-end proof: the detect round trip reads the fake + // harness off the DESTINATION's lookup (the wrapper hands out CLAUDE_PATH, + // the detectSpec's own env-override rung), the cached mirror reads the same + // answer back with no round trip, and launch-harness really spawns the pane + // whose argv the plane composed from the HELLO facts. + const detect = await api.post(`/api/ssh-runtime/sessions/${sessionId}/harnesses/detect`, { + data: { harnessIds: ["claude-code"] }, + }); + expect(detect.ok(), await detect.text()).toBe(true); + const view = (await detect.json()) as { + online: boolean; + harnesses: { harnessId: string; harnessName: string; installed: boolean; binaryPath: string | null }[]; + }; + expect(view.online).toBe(true); + const claude = view.harnesses.find((h) => h.harnessId === "claude-code"); + expect(claude?.installed, "the destination's CLAUDE_PATH shim is the answer").toBe(true); + expect(claude?.binaryPath).toBe(fakeClaudePath); + expect(claude?.harnessName.length).toBeGreaterThan(0); + + const cached = await api.get(`/api/ssh-runtime/sessions/${sessionId}/harnesses`); + expect(cached.ok(), await cached.text()).toBe(true); + const mirror = (await cached.json()) as { + harnesses: { harnessId: string; installed: boolean }[]; + }; + expect(mirror.harnesses.some((h) => h.harnessId === "claude-code" && h.installed)).toBe(true); + + const launch = await api.post(`/api/ssh-runtime/sessions/${sessionId}/launch-harness`, { + data: { harnessId: "claude-code", cwd: fixtureRoot }, + }); + expect(launch.ok(), await launch.text()).toBe(true); + const harnessPaneId = ((await launch.json()) as { subshellId: string }).subshellId; + + // An ordinary row, on the hidden runtime node, reporting its own harness. + const row = await api.get(`/api/subshells/${harnessPaneId}`); + expect(row.ok(), await row.text()).toBe(true); + const pane = (await row.json()) as { status: string; alive: boolean; harnessId: string }; + expect(pane.status).toBe("running"); + expect(pane.alive).toBe(true); + expect(pane.harnessId).toBe("claude-code"); + + // The fake's own output came back through the framed log read: the pane + // really ran on the destination (no xterm peek, REST/log truth). + await pollUntil( + "the fake harness never printed its marker into the destination pane's log", + async () => (await logText(harnessPaneId)).includes("FAKE-CLAUDE-START"), + { budgetMs: 60_000, tail: agentTail }, + ); + + // Terminate is the pane's ending (design §6); the session survives it. + const term = await api.post(`/api/subshells/${harnessPaneId}/terminate`); + expect(term.ok(), await term.text()).toBe(true); + await pollUntil( + "the terminated harness pane never went unavailable", + async () => { + const r = await api.get(`/api/subshells/${harnessPaneId}`); + if (!r.ok()) return false; + return ((await r.json()) as { alive: boolean }).alive === false; + }, + { budgetMs: 30_000, tail: agentTail }, + ); + const still = await api.get(`/api/ssh-runtime/sessions/${sessionId}`); + expect(((await still.json()) as SessionView).status).toBe("active"); +}); + +test("containment: the runtime-serve on the destination holds no TCP listener", async () => { + test.setTimeout(60_000); + // `runtimePids` names both halves that carry the session ref in argv (the + // connecting side's ssh child and the destination runtime); the listener + // question is answered against the UNION, which is the stronger check. + const pids = runtimePids(sessionId); + expect(pids.length, "the runtime-serve child is live on the destination").toBeGreaterThan(0); + + const ss = spawnSync("ss", ["-ltnp"], { encoding: "utf8" }); + if (ss.error !== undefined) test.skip(true, "no ss(8) on this host"); + expect(ss.stdout, "no LISTEN socket belongs to the runtime pid (it serves stdio + the unix socket only)").not.toMatch( + new RegExp(`pid=(${pids.join("|")})[,)]`), + ); +}); + +test("absent runtime: the probe refuses by name before any child is kept", async () => { + test.setTimeout(60_000); + const { res } = await openRuntimeSession("/nonexistent/subshell-xyz"); + // 403 with the named code riding the body: the house SSH-refusal convention + // (a destination's answer is ACCESS_DENIED, quota/conflict facts are 409), + // and the e2e precedent asserts the code's presence, not the status alone. + expect(res.status(), await res.text()).toBe(403); + const text = await res.text(); + expect(text).toContain("runtime_missing"); + + // The refused open left no history behind that could be mistaken for a session. + const list = await api.get("/api/ssh-runtime/sessions"); + expect(list.ok(), await list.text()).toBe(true); + const sessions = ((await list.json()) as { sessions: SessionView[] }).sessions; + expect(sessions.filter((s) => s.status === "opening").length).toBe(0); +}); + +test("close: settles, the census arrives, BOTH halves of the channel die, the pane's tmux survives", async () => { + test.setTimeout(120_000); + const closedSessionRef = sessionId; + const close = await api.post(`/api/ssh-runtime/sessions/${sessionId}/close`); + expect(close.ok(), await close.text()).toBe(true); + await pollUntil( + "the closed session never settled", + async () => { + const res = await api.get(`/api/ssh-runtime/sessions/${sessionId}`); + if (!res.ok()) return false; + return ((await res.json()) as SessionView).status === "closed"; + }, + { budgetMs: 30_000, tail: agentTail }, + ); + + // The C1 proof, end to end: a graceful close must kill BOTH the destination + // `runtime-serve` (the close frame reached it) AND the connecting node's ssh + // child (the broker's group-kill reached it). Before the fix the close frame + // was never sent and both leaked until the agent restarted. + await pollUntil( + "the closed session's runtime/ssh processes never went away", + async () => runtimePids(closedSessionRef).length === 0, + { budgetMs: 20_000, tail: agentTail }, + ); + + // The pane SURVIVED on the destination's tmux (design §6 Close: panes keep + // running) while the plane's row reads the unavailable truth: running, alive + // false - the census told the plane what was alive, and a live pane with a + // dead session is exactly that reading (review NIT: pinned, not commented). + const row = await api.get(`/api/subshells/${paneId}`); + expect(row.ok(), await row.text()).toBe(true); + const pane = (await row.json()) as { status: string; alive: boolean }; + expect(pane.status).toBe("running"); + expect(pane.alive, "the plane's row says unavailable after a graceful close").toBe(false); + + // Reconciliation: the deterministic per-destination socket still holds the + // pane, so the NEW session's hello counts it. + const { res } = await openRuntimeSession(wrapperPath); + expect(res.ok(), await res.text()).toBe(true); + const second = (await res.json()) as SessionView; + expect(second.hello?.paneCount, "the second session finds the first session's pane").toBeGreaterThanOrEqual(1); + // Direct proof the destination's tmux kept the pane: its session name (the + // launch's `subshellName` = the pane id) is still listed on the socket. + expect(destTmuxSessions(second.hello?.tmuxSocket ?? "unset")).toContain(paneId); + sessionId = second.id; // the following tests drive the surviving session +}); + +test("terminate: an ordinary REST terminate reaches the pane through the frame and kills it on the destination", async () => { + test.setTimeout(120_000); + const launch = await api.post(`/api/ssh-runtime/sessions/${sessionId}/launch-terminal`, { + data: { cwd: fixtureRoot, cols: 80, rows: 24 }, + }); + expect(launch.ok(), await launch.text()).toBe(true); + const pane2 = ((await launch.json()) as { subshellId: string }).subshellId; + // A live pane first (the marker echo proves the pane ran): input it. + await api.post(`/api/subshells/${pane2}/input`, { data: { text: "true", submit: true } }); + await pollUntil( + "the terminated-session pane never appeared on the destination socket", + async () => destTmuxSessions((await sessionSocket()).tmuxSocket).includes(pane2), + { budgetMs: 60_000, tail: agentTail }, + ); + + const term = await api.post(`/api/subshells/${pane2}/terminate`); + expect(term.ok(), await term.text()).toBe(true); + // The row reflects it (the ordinary pane plumbing, unchanged). + await pollUntil( + "the terminated runtime pane's row never settled", + async () => { + const res = await api.get(`/api/subshells/${pane2}`); + if (!res.ok()) return false; + const row = (await res.json()) as { alive: boolean }; + return row.alive === false; + // (status rides the terminate path's own word; alive is the shared truth) + }, + { budgetMs: 30_000, tail: agentTail }, + ); + // The DESTINATION pane is gone: the terminate frame reached the runtime and + // killed exactly one pane - and the session survives (design §6: killing + // panes is terminate's job, and a terminate must not read as a session loss). + const socket = (await sessionSocket()).tmuxSocket; + expect(destTmuxSessions(socket), "the terminated pane left the destination tmux").not.toContain(pane2); + const still = await api.get(`/api/ssh-runtime/sessions/${sessionId}`); + expect(still.ok(), await still.text()).toBe(true); + expect(((await still.json()) as SessionView).status).toBe("active"); +}); + +/** The live session's destination facts (socket) via the session view. */ +async function sessionSocket(): Promise<{ tmuxSocket: string }> { + const res = await api.get(`/api/ssh-runtime/sessions/${sessionId}`); + expect(res.ok(), await res.text()).toBe(true); + const view = (await res.json()) as SessionView; + if (view.hello === null) throw new Error("session has no hello (not active?)"); + return view.hello; +} + +test("child dies with the link ALIVE: the ssh child's death marks the session lost and the node stays online", async () => { + test.setTimeout(120_000); + // The arm the disconnect test cannot exercise (it kills the whole agent): + // one child death, reported through the live link (`ssh_session_lost` -> + // `deliverSessionLost`), with everything else still standing. + const launch = await api.post(`/api/ssh-runtime/sessions/${sessionId}/launch-terminal`, { + data: { cwd: fixtureRoot }, + }); + expect(launch.ok(), await launch.text()).toBe(true); + const pane3 = ((await launch.json()) as { subshellId: string }).subshellId; + + const child = sshChildPid(sessionId); + process.kill(Number(child), "SIGTERM"); + + await pollUntil( + "the session never read lost after its ssh child died", + async () => { + const res = await api.get(`/api/ssh-runtime/sessions/${sessionId}`); + if (!res.ok()) return false; + return ((await res.json()) as SessionView).status === "lost"; + }, + { budgetMs: 30_000, tail: agentTail }, + ); + + // The pane reads the design §6 truth for a lost channel: alive false, + // status intact - unavailable, not completed (the child died; nobody + // watched the pane end). + const pane3Row = await api.get(`/api/subshells/${pane3}`); + expect(pane3Row.ok(), await pane3Row.text()).toBe(true); + const p3 = (await pane3Row.json()) as { status: string; alive: boolean }; + expect(p3.alive).toBe(false); + expect(p3.status).toBe("running"); + + // The link is untouched: the agent is still online (the child died, not the + // daemon) - the distinction the whole broker design rests on. + const nodes = await api.get("/api/nodes"); + const row = ((await nodes.json()) as { nodes: NodeRow[] }).nodes.find((n) => n.id === nodeId); + expect(row?.status, "losing one session child must not take the connecting node offline").toBe("online"); + + // Open the session the FINAL test consumes (the disconnect test needs a + // live session, and this one is now history). + const { res } = await openRuntimeSession(wrapperPath); + expect(res.ok(), await res.text()).toBe(true); + sessionId = ((await res.json()) as SessionView).id; +}); + +test("protocol 17 reality: the node registered on the exact-match handshake", async () => { + const res = await api.get("/api/nodes"); + expect(res.ok(), await res.text()).toBe(true); + const row = ((await res.json()) as { nodes: NodeRow[] }).nodes.find((n) => n.id === nodeId); + // The session verbs ride protocol 17 exactly (no fallback): the online row + // at NODE_PROTOCOL_VERSION IS the registration, and every test above is its + // functional half against the real daemon. + expect(row?.status).toBe("online"); + expect(row?.protocolVersion).toBe(NODE_PROTOCOL_VERSION); +}); + +test("live terminal: /ws attach, typed echo off the socket, loss closes it; reopen re-adopts (F1+F2)", async () => { + test.setTimeout(240_000); + const launch = await api.post(`/api/ssh-runtime/sessions/${sessionId}/launch-terminal`, { + data: { cwd: fixtureRoot, cols: 80, rows: 24 }, + }); + expect(launch.ok(), await launch.text()).toBe(true); + const livePaneId = ((await launch.json()) as { subshellId: string }).subshellId; + + // F1, network truth (AGENTS.md: never xterm). Pre-fix, this attach died at + // 4004 "node offline" in a retry loop - the socket never opened (walkthrough + // finding F1, `ui-typing-telemetry.txt`). Now: open, replay, type, echo. + const live = await openLiveSocket(livePaneId); + const firstFrame = await Promise.race([ + pollUntil("replay", async () => live.frames.some((f) => f.includes('"replay"')), { + budgetMs: 30_000, + tail: agentTail, + }).then(() => "replay" as const), + live.closed.then((c) => `closed ${c.code} ${c.reason}` as const), + ]); + expect(firstFrame, "the live attach must replay (a refusal closes; this says how)").toBe("replay"); + live.send({ type: "input", data: `echo WS-LIVE-${RUN}\r`, id: 1 }); + await pollUntil( + "the ws keystroke was never acked (the ack rides the pane write landing)", + async () => live.frames.some((f) => f.includes('"ack"') && f.includes('"id":1')), + { budgetMs: 30_000, tail: agentTail }, + ); + // TWO occurrences off the socket: the echoed typed line (input reached the + // pane) and the bare marker (the pane ran it and the output frame streamed + // back) - the same two-occurrence rigor the REST cell holds. + await pollUntil( + "the ws-typed marker never came back over the socket (echoed AND executed)", + async () => live.frames.join("|").split(`WS-LIVE-${RUN}`).length - 1 >= 2, + { budgetMs: 60_000, tail: agentTail }, + ); + + // The socket learns the death the way every channel loss teaches a live + // terminal: the settle closes it (1012, the retry convention) with the pane + // unavailable, not completed. No silent stream pretending to still be live. + process.kill(Number(sshChildPid(sessionId)), "SIGKILL"); + const shut = await Promise.race([live.closed, sleep(30_000).then(() => ({ code: -1, reason: "no close" }))]); + expect(shut.code, "the live socket must close when the session goes lost").toBe(1012); + expect(shut.reason).toContain("session lost"); + await pollUntil( + "the session never read lost after the SIGKILL", + async () => { + const res = await api.get(`/api/ssh-runtime/sessions/${sessionId}`); + if (!res.ok()) return false; + return ((await res.json()) as SessionView).status === "lost"; + }, + { budgetMs: 30_000, tail: agentTail }, + ); + const deadRow = await api.get(`/api/subshells/${livePaneId}`); + expect(((await deadRow.json()) as { alive: boolean; status: string }).alive).toBe(false); + + // F2: reopen the SAME destination. Design §6's idempotent restore: the + // fresh runtime's census names the surviving pane (tmux outlived the ssh + // child), and the plane flips the SAME row onto the new session - the row + // that a moment ago read alive:false. + const { res } = await openRuntimeSession(wrapperPath); + expect(res.ok(), await res.text()).toBe(true); + const reopened = (await res.json()) as SessionView; + const priorSessionId = sessionId; + sessionId = reopened.id; + await pollUntil( + "the reopen never re-adopted the surviving pane", + async () => { + const r = await api.get(`/api/subshells/${livePaneId}`); + if (!r.ok()) return false; + const row = (await r.json()) as { status: string; alive: boolean; nodeId: string }; + return row.status === "running" && row.alive === true && row.nodeId === reopened.runtimeNodeId; + }, + { budgetMs: 30_000, tail: agentTail }, + ); + // The retired node decision (kept, offline, as history - see + // `session-adopt.ts`) is invisible by design through REST: hidden runtime + // rows never list, and the old session row is still readable as `lost` + // with its runtimeNodeId intact (the history the retire choice preserves). + const oldView = (await (await api.get(`/api/ssh-runtime/sessions/${priorSessionId}`)).json()) as SessionView; + expect(oldView.status).toBe("lost"); + expect(oldView.runtimeNodeId).toBeTruthy(); + + // Live output again over a FRESH socket, typing again: the adopted pane is + // not a dead row that merely reads alive - the session carries it. + const adopted = await openLiveSocket(livePaneId); + await pollUntil( + "the re-adopted pane never replayed", + async () => adopted.frames.some((f) => f.includes('"replay"')), + { budgetMs: 30_000, tail: agentTail }, + ); + adopted.send({ type: "input", data: `echo WS-ADOPT-${RUN}\r`, id: 1 }); + await pollUntil( + "the re-adopted pane never echoed the ws-typed marker back", + async () => adopted.frames.join("|").split(`WS-ADOPT-${RUN}`).length - 1 >= 2, + { budgetMs: 60_000, tail: agentTail }, + ); + + // The in-pane callback path works again: the runtime rebuilt the + // destination-stable door at startup, and the ROTATED token registered on + // the new session is what lets the plane attribute the request as the pane. + if (spawnSync("curl", ["--version"]).error === undefined) { + const cbBody = path.join(scratch, "adopt-callback.json"); + adopted.send({ + type: "input", + data: `curl -s --unix-socket "$SUBSHELL_RUNTIME_CALLBACK_SOCK" -o ${cbBody} http://unix/api/subshells/$SUBSHELL_ID\r`, + id: 2, + }); + await pollUntil( + "the re-adopted pane's callback never answered its own row (door rebuilt + token rotated)", + async () => { + try { + return readFileSync(cbBody, "utf8").includes('"workingDir"'); + } catch { + return false; + } + }, + { budgetMs: 60_000, tail: agentTail }, + ); + } + adopted.close(); +}); + +test("disconnect: losing the link marks the session LOST and the pane unavailable, not completed", async () => { + test.setTimeout(120_000); + // A pane on the session THIS test kills (review M-A: the old assertion read + // the first session's pane, settled alive:false tests ago, so it proved + // nothing about the disconnect settling). + const launch = await api.post(`/api/ssh-runtime/sessions/${sessionId}/launch-terminal`, { + data: { cwd: fixtureRoot }, + }); + expect(launch.ok(), await launch.text()).toBe(true); + const disconnectPaneId = ((await launch.json()) as { subshellId: string }).subshellId; + const beforeRow = await api.get(`/api/subshells/${disconnectPaneId}`); + expect(beforeRow.ok(), await beforeRow.text()).toBe(true); + expect(((await beforeRow.json()) as { alive: boolean }).alive, "the pane is live before the kill").toBe(true); + + await agent?.stop(); + await pollUntil( + "the session never read lost after the connecting node died", + async () => { + const res = await api.get(`/api/ssh-runtime/sessions/${sessionId}`); + if (!res.ok()) return false; + return ((await res.json()) as SessionView).status === "lost"; + }, + { budgetMs: 30_000 }, + ); + // The honest pane state for THIS pane: unavailable (alive false) while its + // status stays what it last was (design §6: lost, not completed). Polled, + // not immediate: the lost settling writes the session row before it walks + // the pane rows. + await pollUntil( + "the disconnected session's pane never went unavailable", + async () => { + const res = await api.get(`/api/subshells/${disconnectPaneId}`); + if (!res.ok()) return false; + return ((await res.json()) as { alive: boolean }).alive === false; + }, + { budgetMs: 30_000 }, + ); + const row = await api.get(`/api/subshells/${disconnectPaneId}`); + expect(row.ok(), await row.text()).toBe(true); + const pane = (await row.json()) as { status: string; alive: boolean }; + expect(pane.alive).toBe(false); + expect(pane.status).toBe("running"); +}); diff --git a/e2e/tests/23-ssh-connect.spec.ts b/e2e/tests/23-ssh-connect.spec.ts new file mode 100644 index 000000000..3a394e11d --- /dev/null +++ b/e2e/tests/23-ssh-connect.spec.ts @@ -0,0 +1,642 @@ +import { spawn } from "node:child_process"; +import { accessSync, chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { type APIRequestContext, expect, type Page, request as pwRequest, test } from "@playwright/test"; +import { missingSshBins, SSH_FIXTURE_ALIAS, type SshFixture, startSshFixture } from "../fixtures/sshd"; +import { BASE_URL } from "../ports"; +import { shortTmuxBase } from "../stack"; +import { NODE_MAIN, type RunningNode, startNode } from "../stub/client"; +import { pollUntil, sweepTmuxServers } from "./helpers"; +import { rmScratch, seedAdminApi } from "./ssh-support"; + +/** + * CONNECT OVER SSH end to end (design 2026-10-05 §1/§7, workstream U): the + * personal journey from the SIDEBAR, driven by a NON-ADMIN who owns the + * connecting node. No direct URLs: sign in, press the rail's "Connect over + * SSH", pick the machine, discover the host aliases on it, review the + * resolved destination, connect (the probe IS the test), browse the + * destination's folders, launch a terminal pane, read the pane's trusted + * identity line, and SEE it live: an open streaming /ws with no + * "reconnecting…" pill and a browser-typed marker landing in the pane's log + * (the acceptance run's F1 was precisely this journey's attach loop). Then + * the session is closed from the sessions view, and the missing-runtime + * guidance path is proven against a second fixture sshd whose PATH carries no + * `subshell` shim. + * + * The destination IS this host (spec 22's posture): the fixture sshd accepts + * the same account, and the happy destination's PATH carries a `subshell` + * shim - a scratch script re-entering this checkout in source mode (spec 12's + * parity), pinning the runtime's data dir and TMUX_TMPDIR into scratch dirs + * the teardown owns. The guidance destination's daemon starts from the + * un-prepended PATH, so its probe answers absence for real. The connecting + * node's HOME is the fixture config dir, where a second alias + * `e2edest-nobin` is appended to point at the second daemon. + * + * Truth assertions are server/network truth and this app's own chrome text + * (e2e/AGENTS.md): every pane fact is read from the REST API; the xterm grid + * is never grepped. + */ + +const missing = missingSshBins(); +test.skip(missing.length > 0, `connect-over-ssh e2e skipped: this host lacks ${missing.join(", ")}`); +test.describe.configure({ mode: "serial" }); + +const RUN = Math.random().toString(36).slice(2, 7); +const NODE_NAME = `e2e-conn-${RUN}`; +const MEMBER_EMAIL = `conn23-${RUN}@subshell.test`; +const MEMBER_PW = `conn23-pass-${RUN}`; +/** The second destination's alias (this spec appends it to the fixture config). */ +const NOBIN_ALIAS = "e2edest-nobin"; + +function mkdtemp(prefix: string): string { + const base = path.join(tmpdir(), `${prefix}${RUN}-`); + mkdirSync(base, { recursive: true }); + return base; +} + +/** The wrapper the happy destination finds on PATH as `subshell`. */ +function resolveBun(): string { + for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter((d) => d !== "")) { + try { + accessSync(path.join(dir, "bun")); + return path.join(dir, "bun"); + } catch { + // not here + } + } + throw new Error("the e2e host has no bun on PATH (spec 12/21/22 would already have died)"); +} + +let admin: APIRequestContext; +let memberApi: APIRequestContext; +let fixtureA: SshFixture | undefined; // the happy destination (PATH carries the shim) +let fixtureB: SshFixture | undefined; // the runtime-less destination +let agent: RunningNode | undefined; +let agentHome = ""; +let tmuxBase = ""; +let destTmuxBase = ""; +let rootA = ""; +let rootB = ""; +let scratch = ""; +let nodeId = ""; +let setupKeyId = ""; +let sessionId = ""; +let paneId = ""; + +const sshA = (): SshFixture => { + if (fixtureA === undefined) throw new Error("fixture sshd A never started"); + return fixtureA; +}; +const sshB = (): SshFixture => { + if (fixtureB === undefined) throw new Error("fixture sshd B never started"); + return fixtureB; +}; +const agentTail = (): string => agent?.logTail() ?? "(agent never started)"; + +interface SessionRow { + id: string; + status: string; + runtimeNodeId: string; +} + +/** The member signs in through the login form (the rail journey starts here, and only here). */ +async function loginMember(page: Page): Promise { + await page.goto("/login"); + await page.fill("#email", MEMBER_EMAIL); + await page.fill("#password", MEMBER_PW); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await expect(page).toHaveURL(/\/$/, { timeout: 30_000 }); +} + +test.beforeAll(async () => { + admin = await seedAdminApi(); + + scratch = mkdtemp("subshell-e2e-conn-"); + const shimDir = path.join(scratch, "bin"); + const runtimeData = path.join(scratch, "runtime-data"); + destTmuxBase = path.join(scratch, "tmux-dest"); + mkdirSync(shimDir, { recursive: true }); + mkdirSync(runtimeData, { recursive: true, mode: 0o700 }); + mkdirSync(destTmuxBase, { recursive: true, mode: 0o700 }); + const shimPath = path.join(shimDir, "subshell"); + writeFileSync( + shimPath, + [ + "#!/bin/sh", + `export SUBSHELL_RUNTIME_DATA_DIR=${runtimeData}`, + `export TMUX_TMPDIR=${destTmuxBase}`, + `exec ${resolveBun()} ${NODE_MAIN} "$@"`, + "", + ].join("\n"), + { mode: 0o755 }, + ); + chmodSync(shimPath, 0o755); + + // sshd resets a session's PATH to the platform login default (measured), so + // the happy fixture HANDS OUT a session PATH that carries the shim via the + // fixture's SetEnv seam; the guidance fixture does not, which is exactly the + // missing-runtime world. The runner's own PATH is never mutated. + rootA = mkdtemp("subshell-e2e-conn-sshd-a-"); + fixtureA = await startSshFixture(rootA, { + envPath: `${shimDir}${path.delimiter}${process.env.PATH ?? "/usr/bin:/bin"}`, + }); + rootB = mkdtemp("subshell-e2e-conn-sshd-b-"); + fixtureB = await startSshFixture(rootB); + + // The connecting account's config gains the second alias, pointing at the + // shim-less daemon; its host key joins the same pre-trusted known_hosts + // (the session render consults the account's default file, spec 22's rule). + const hostB = readFileSync(path.join(rootB, "hostkey.pub"), "utf8").trim().split(" ").slice(0, 2).join(" "); + const knownHosts = path.join(fixtureA.sshConfigHome, ".ssh", "known_hosts"); + writeFileSync(knownHosts, `${readFileSync(knownHosts, "utf8")}[127.0.0.1]:${fixtureB.port} ${hostB}\n`, { + mode: 0o600, + }); + const configPath = path.join(fixtureA.sshConfigHome, ".ssh", "config"); + writeFileSync( + configPath, + `${readFileSync(configPath, "utf8")}Host ${NOBIN_ALIAS} + HostName 127.0.0.1 + Port ${fixtureB.port} + User ${fixtureA.user} + IdentityFile ${fixtureB.trustedKeyPath} + IdentitiesOnly yes + IdentityAgent none + UserKnownHostsFile ${knownHosts} +`, + { mode: 0o600 }, + ); + + // The member: created by the admin, and the node it enrolls is its OWN (the + // non-admin owner is the exact actor the auth widening is about). + const created = await admin.post("/api/users", { + data: { email: MEMBER_EMAIL, name: MEMBER_EMAIL, password: MEMBER_PW, role: "user" }, + }); + expect(created.ok(), await created.text()).toBe(true); + memberApi = await pwRequest.newContext({ + baseURL: BASE_URL, + extraHTTPHeaders: { origin: BASE_URL }, + storageState: { cookies: [], origins: [] }, + }); + const signInRes = await memberApi.post("/api/auth/sign-in/email", { + data: { email: MEMBER_EMAIL, password: MEMBER_PW }, + }); + expect(signInRes.ok(), await signInRes.text()).toBe(true); + + const mint = await memberApi.post("/api/nodes/setup-keys"); + expect(mint.ok(), await mint.text()).toBe(true); + const key = (await mint.json()) as { id: string; key: string }; + setupKeyId = key.id; + + agentHome = mkdtemp("subshell-e2e-conn-node-"); + tmuxBase = shortTmuxBase(); + agent = await startNode({ + home: agentHome, + dataDir: path.join(agentHome, "data"), + tmuxBase, + setupKey: key.key, + name: NODE_NAME, + // The fixture account, pinned (the fixture's account-name rule): the daemon's own + // USER/LOGNAME must agree with what sshd logs, across runtimes. + extraEnv: { HOME: fixtureA.sshConfigHome, USER: fixtureA.user, LOGNAME: fixtureA.user, SSH_AUTH_SOCK: "" }, + }); + + await pollUntil( + `node "${NODE_NAME}" never came online`, + async () => { + const res = await memberApi.get("/api/nodes"); + expect(res.ok(), await res.text()).toBe(true); + const row = ((await res.json()) as { nodes: { id: string; name: string; status: string }[] }).nodes.find( + (n) => n.name === NODE_NAME, + ); + if (row) nodeId = row.id; + return row?.status === "online"; + }, + { tail: agentTail }, + ); +}); + +test.afterAll(async () => { + const leaks: string[] = []; + try { + await agent?.stop(); + } catch (err) { + leaks.push(`agent stop: ${String(err)}`); + } + sweepTmuxServers(tmuxBase); + sweepTmuxServers(destTmuxBase); + try { + await fixtureA?.stop(); + } catch (err) { + leaks.push(`sshd A stop: ${String(err)}`); + } + try { + await fixtureB?.stop(); + } catch (err) { + leaks.push(`sshd B stop: ${String(err)}`); + } + if (memberApi !== undefined) { + if (sessionId) await memberApi.post(`/api/ssh-runtime/sessions/${sessionId}/close`).catch(() => undefined); + if (nodeId) { + const del = await memberApi.delete(`/api/nodes/${nodeId}?force=true`).catch(() => null); + if (del !== null && !del.ok() && del.status() !== 404) leaks.push(`node delete: HTTP ${del.status()}`); + } + if (setupKeyId) { + const rev = await memberApi.delete(`/api/nodes/setup-keys/${setupKeyId}`).catch(() => null); + if (rev !== null && !rev.ok() && rev.status() !== 404) leaks.push(`setup-key revoke: HTTP ${rev.status()}`); + } + await memberApi.dispose(); + } + await admin?.dispose(); + rmScratch(agentHome); + rmScratch(rootA); + rmScratch(rootB); + rmScratch(scratch); + rmScratch(path.dirname(tmuxBase)); + if (leaks.length > 0) console.error(`[23-ssh-connect] cleanup problems: ${leaks.join("; ")}`); +}); + +test("the shared launch journey: discover, review, connect, browse, launch, identity", async ({ page }) => { + test.setTimeout(240_000); + const f = sshA(); + + await loginMember(page); + + // The flow starts from the RAIL, never a URL. + await page.locator("aside").getByRole("button", { name: "New subshell", exact: true }).click(); + await expect(page.getByRole("button", { name: "Node", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Connect another machine…", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "SSH", exact: true }).click(); + + // Machine: the picker offers only the member's own node. + const machinePicker = page.getByPlaceholder("Choose a computer"); + await expect(machinePicker).toBeVisible({ timeout: 30_000 }); + await machinePicker.click(); + await page.getByRole("option", { name: new RegExp(NODE_NAME) }).click(); + + // Host: the real config on the node, discovered through the RPC. + await expect(page.getByRole("button", { name: SSH_FIXTURE_ALIAS, exact: true })).toBeVisible({ timeout: 60_000 }); + await page.getByRole("button", { name: SSH_FIXTURE_ALIAS, exact: true }).click(); + + // Review: the concrete destination and the connecting account, server facts. + await expect(page.getByText(`127.0.0.1:${f.port}`, { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(`as ${f.user}`)).toBeVisible(); + + // Connection setup has one primary act; ready hosts need no installation lecture. + await expect(page.getByRole("button", { name: "Start subshell", exact: true })).toHaveCount(0); + await expect(page.getByRole("link", { name: "Download the Subshell CLI" })).toHaveCount(0); + + // Connect = open the session (the probe is the test). + const openRespP = page.waitForResponse( + (r) => r.url().endsWith("/api/ssh-runtime/sessions") && r.request().method() === "POST", + ); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + const openResp = await openRespP; + expect(openResp.status(), await openResp.text()).toBe(200); + const opened = (await openResp.json()) as SessionRow; + sessionId = opened.id; + expect(opened.status).toBe("active"); + + // SSH uses the same focus-to-browse field as Node. + const directory = page.getByRole("textbox", { name: "Working directory", exact: true }); + await expect(directory).toHaveValue(/^\//, { timeout: 30_000 }); + const homeShown = await directory.inputValue(); + expect(path.isAbsolute(homeShown)).toBe(true); + await directory.focus(); + await expect(page.getByTitle("Go up one level")).toBeVisible(); + await page.getByTitle("Go up one level").click(); + await expect(directory).not.toHaveValue(homeShown); + await directory.fill(homeShown); + await expect(page.getByTitle("Go up one level")).toBeVisible(); + await directory.press("Escape"); + await expect(page.getByTitle("Go up one level")).toHaveCount(0); + + const savedResponse = page.waitForResponse( + (r) => r.url().endsWith("/api/ssh-runtime/locations") && r.request().method() === "POST", + ); + await page.getByRole("button", { name: "Remember this host and folder" }).click(); + expect((await savedResponse).ok()).toBe(true); + + // Launch: the terminal seam, and the pane it opens is an ordinary row. + // The F1 finding lived on this page: the pane's live-terminal socket looped + // `4004 node offline` and the "reconnecting…" pill stayed up, because the + // relay had no session-backed liveness for the hidden runtime node. The + // sockets the page dials are collected here and asserted as network truth + // below (e2e/AGENTS.md: the pill and the upgrade, never the xterm grid). + const terminalSockets: { url: string; frames: number; open: boolean }[] = []; + page.on("websocket", (w) => { + if (!w.url().includes("/ws?subshell=")) return; + const entry = { url: w.url(), frames: 0, open: true }; + terminalSockets.push(entry); + w.on("framereceived", () => { + entry.frames += 1; + }); + w.on("close", () => { + entry.open = false; + }); + }); + const launchRespP = page.waitForResponse((r) => r.url().endsWith("/launch-harness")); + await page.locator("#picker-agent").click(); + await page.getByRole("option", { name: "Terminal", exact: true }).click(); + const launchButton = page.getByRole("button", { name: "Start subshell", exact: true }); + await expect(launchButton).toBeInViewport(); + const originalViewport = page.viewportSize(); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(launchButton).toBeInViewport(); + if (originalViewport) await page.setViewportSize(originalViewport); + await launchButton.click(); + const launchResp = await launchRespP; + expect(launchResp.status(), await launchResp.text()).toBe(200); + paneId = ((await launchResp.json()) as { subshellId: string }).subshellId; + await expect(page).toHaveURL(new RegExp(`/subshells/${paneId}`), { timeout: 30_000 }); + + // The trusted identity line (design §7), assembled from server rows. + await expect(page.getByText(`SSH · 127.0.0.1:${f.port} · via ${NODE_NAME}`, { exact: true })).toBeVisible({ + timeout: 30_000, + }); + + await expect(page.locator("aside").getByText(`SSH · ${SSH_FIXTURE_ALIAS}`, { exact: true })).toBeVisible(); + + // Server truth behind the chrome: an ordinary running row on the hidden + // runtime node, in the folder the picker showed. The runtime row never + // appears in the nodes listing (design §4's concealment), and the by-pane + // identity read answers what the line claims. + await pollUntil( + "the runtime pane never read running on the API", + async () => { + const res = await memberApi.get(`/api/subshells/${paneId}`); + if (!res.ok()) return false; + const row = (await res.json()) as { status: string; alive: boolean; workingDir: string; nodeId: string }; + return ( + row.status === "running" && + row.alive === true && + row.workingDir === homeShown && + row.nodeId === opened.runtimeNodeId + ); + }, + { budgetMs: 30_000, tail: agentTail }, + ); + const identity = await memberApi.get(`/api/ssh-runtime/sessions/by-pane/${paneId}`); + expect(identity.ok(), await identity.text()).toBe(true); + const line = (await identity.json()) as { + host: string; + port: number; + user: string | null; + connectingNodeName: string; + }; + expect(line.host).toBe("127.0.0.1"); + expect(line.port).toBe(f.port); + expect(line.user, "resolve keeps the connecting default un-named").toBeNull(); + expect(line.connectingNodeName).toBe(NODE_NAME); + const listed = (await (await memberApi.get("/api/nodes")).json()) as { nodes: { id: string }[] }; + expect(listed.nodes.some((n) => n.id === opened.runtimeNodeId)).toBe(false); + + // F1 (the browser journey the acceptance run found broken): the pane page's + // live terminal actually ATTACHES a runtime pane. Network truth, not the + // grid: one /ws socket stays OPEN and keeps RECEIVING frames (pre-fix every + // attempt closed at 4004 and the pill stayed up), and the "reconnecting…" + // pill is absent on the settled page. + await pollUntil( + "the browser terminal never held an open streaming /ws for the runtime pane", + async () => terminalSockets.some((s) => s.open && s.frames > 0), + { budgetMs: 60_000, tail: agentTail }, + ); + await expect(page.getByText("reconnecting…")).toHaveCount(0, { timeout: 10_000 }); + + // Browser typing reaches the destination pane (pre-fix, keystrokes died in + // the 4004 loop while REST input worked). The proof is the REST log tail: + // echoed AND executed, spec 22's two-occurrence rigor. + const uiMarker = `WALK-UI-${RUN}`; + await page.locator(".xterm-helper-textarea").click(); + await page.keyboard.type(`echo ${uiMarker}`); + await page.keyboard.press("Enter"); + await pollUntil( + "the browser-typed marker never appeared (echoed AND run) in the destination pane's log", + async () => { + const res = await memberApi.get(`/api/subshells/${paneId}/log`); + if (!res.ok()) return false; + const text = ((await res.json()) as { lines: string[] }).lines.join("\n"); + return text.split(uiMarker).length - 1 >= 2 && text.includes(`echo ${uiMarker}`); + }, + { budgetMs: 60_000, tail: agentTail }, + ); + await page.screenshot({ path: "/tmp/ssh-terminal-render.png", fullPage: true }); +}); + +test("workspace pane uses an existing SSH connection without leaving the workspace", async ({ page }) => { + test.setTimeout(120_000); + await loginMember(page); + const created = await memberApi.post("/api/workspaces", { data: { name: `SSH workspace ${RUN}` } }); + expect(created.ok()).toBe(true); + const workspace = (await created.json()) as { id: string }; + try { + await page.goto(`/workspaces/${workspace.id}`); + await page.getByRole("button", { name: "Add a subshell to this workspace" }).click(); + await page.getByRole("dialog").getByRole("button", { name: "New subshell", exact: true }).click(); + await page.getByRole("button", { name: "SSH", exact: true }).click(); + const opens: string[] = []; + page.on("request", (r) => { + if (r.method() === "POST" && r.url().endsWith("/api/ssh-runtime/sessions")) opens.push(r.url()); + }); + await page.getByPlaceholder("Choose a saved host and folder").click(); + await page.getByRole("option", { name: new RegExp(SSH_FIXTURE_ALIAS) }).click(); + await expect(page.getByRole("textbox", { name: "Working directory", exact: true })).toHaveValue(/^\//); + await page.locator("#picker-agent").click(); + await page.getByRole("option", { name: "Terminal", exact: true }).click(); + await page.screenshot({ path: "/tmp/subshell-ssh-workspace-launch.png", fullPage: true }); + const response = page.waitForResponse((r) => r.url().endsWith("/launch-harness")); + await page.getByRole("button", { name: "Start subshell", exact: true }).click(); + const launched = await response; + expect(launched.ok(), await launched.text()).toBe(true); + const pane = (await launched.json()) as { subshellId: string }; + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page).toHaveURL(new RegExp(`/workspaces/${workspace.id}$`)); + expect(opens).toEqual([]); + const state = await memberApi.get(`/api/workspaces/${workspace.id}`); + expect(JSON.stringify(await state.json())).toContain(pane.subshellId); + await memberApi.post(`/api/subshells/${pane.subshellId}/terminate`); + } finally { + await memberApi.delete(`/api/workspaces/${workspace.id}`); + } +}); + +test("sessions view: the row closes from the page, and the history says so", async ({ page }) => { + test.setTimeout(120_000); + expect(sessionId, "the journey test owns the session").not.toBe(""); + + await loginMember(page); + await page.locator("aside").getByRole("button", { name: "New subshell", exact: true }).click(); + await page.getByRole("button", { name: "SSH", exact: true }).click(); + await page.getByRole("link", { name: "Manage SSH connections and reconnect" }).click(); + await expect(page).toHaveURL(/\/settings\/connections$/); + await expect(page.getByText(new RegExp(`127\\.0\\.0\\.1:${sshA().port}`))).toBeVisible({ timeout: 30_000 }); + + await page.getByRole("button", { name: "Close" }).first().click(); + // The dialog names the ACT; the body carries the destination's price. + await expect(page.getByRole("dialog")).toContainText("Close session?"); + const closeRespP = page.waitForResponse( + (r) => r.url().includes("/api/ssh-runtime/sessions/") && r.url().endsWith("/close"), + ); + // The dialog carries TWO accessible names "Close": the destructive confirm + // and the overlay's icon-only ×. The confirm is the one with the WORD; the + // × is an aria-label over an svg. + await page + .getByRole("dialog") + .getByRole("button", { name: "Close", exact: true }) + .filter({ hasText: "Close" }) + .click(); + const closeResp = await closeRespP; + expect(closeResp.status(), await closeResp.text()).toBe(200); + + await pollUntil( + "the session row never settled closed on the API", + async () => { + const res = await memberApi.get(`/api/ssh-runtime/sessions/${sessionId}`); + if (!res.ok()) return false; + return ((await res.json()) as SessionRow).status === "closed"; + }, + { budgetMs: 30_000, tail: agentTail }, + ); + // The honest history sentence for a closed row (design §6): the panes + // outlive the session, on the destination's own tmux. + await expect(page.getByText("Panes launched on it keep running on the destination")).toBeVisible({ + timeout: 30_000, + }); + // The pane row survives the close as the design promises (running, its + // channel gone): unavailable, not completed. + await pollUntil( + "the closed session's pane never read unavailable-with-status-intact", + async () => { + const res = await memberApi.get(`/api/subshells/${paneId}`); + if (!res.ok()) return false; + const row = (await res.json()) as { status: string; alive: boolean }; + return row.status === "running" && row.alive === false; + }, + { budgetMs: 30_000, tail: agentTail }, + ); +}); + +test("missing runtime: the probe's absence renders the binary-install guidance", async ({ page }) => { + test.setTimeout(120_000); + const fB = sshB(); + + await loginMember(page); + await page.locator("aside").getByRole("button", { name: "New subshell", exact: true }).click(); + await page.getByRole("button", { name: "SSH", exact: true }).click(); + await page.getByPlaceholder("Choose a computer").click(); + await page.getByRole("option", { name: new RegExp(NODE_NAME) }).click(); + await expect(page.getByRole("button", { name: NOBIN_ALIAS, exact: true })).toBeVisible({ timeout: 60_000 }); + await page.getByRole("button", { name: NOBIN_ALIAS, exact: true }).click(); + await expect(page.getByText(`127.0.0.1:${fB.port}`, { exact: true })).toBeVisible({ timeout: 30_000 }); + + const openRespP = page.waitForResponse( + (r) => r.url().endsWith("/api/ssh-runtime/sessions") && r.request().method() === "POST", + ); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + const refused = await openRespP; + expect(refused.status(), await refused.text()).toBe(403); + expect(await refused.text()).toContain("runtime_missing"); + + // The UI remedy names the BINARY and nothing else: never enrollment, never + // `subshell setup`. + await expect(page.getByText(/SSH connected to 127\.0\.0\.1/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Copy runtime verification command" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry connection", exact: true })).toBeVisible(); + const list = (await (await memberApi.get("/api/ssh-runtime/sessions")).json()) as { sessions: SessionRow[] }; + expect(list.sessions.some((s) => s.status === "opening")).toBe(false); +}); + +test("desktop SSH connects without enrollment and restores the same pane in place", async ({ page }) => { + test.setTimeout(120_000); + await loginMember(page); + await page.goto("/settings/connections"); + await page.getByLabel("Computer name").fill(`Laptop ${RUN}`); + const pairingResponse = page.waitForResponse( + (r) => r.url().endsWith("/api/ssh-runtime/desktop-brokers") && r.request().method() === "POST", + ); + await page.getByRole("button", { name: "Add computer", exact: true }).click(); + const pairing = (await (await pairingResponse).json()) as { id: string; pairingToken: string }; + const brokerHome = path.join(scratch, "desktop-broker"); + mkdirSync(brokerHome, { recursive: true }); + const child = spawn(resolveBun(), [NODE_MAIN, "ssh-broker", "--server", BASE_URL], { + env: { + ...process.env, + HOME: sshA().sshConfigHome, + USER: sshA().user, + LOGNAME: sshA().user, + SSH_AUTH_SOCK: "", + SUBSHELL_CONFIG_HOME: brokerHome, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + child.stdin.write(`${JSON.stringify({ pairingToken: pairing.pairingToken })}\n`); + let desktopPane = ""; + try { + await expect(page.getByText(`Laptop ${RUN} · online`, { exact: true })).toBeVisible({ timeout: 30_000 }); + await page.locator("aside").getByRole("button", { name: "New subshell", exact: true }).click(); + await page.getByRole("button", { name: "SSH", exact: true }).click(); + await page.getByPlaceholder("Choose a computer").click(); + await page.getByRole("option", { name: new RegExp(`Laptop ${RUN}`) }).click(); + await page.getByRole("button", { name: SSH_FIXTURE_ALIAS, exact: true }).click(); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Working directory", exact: true })).toHaveValue(/^\//); + await page.locator("#picker-agent").click(); + await page.getByRole("option", { name: "Terminal", exact: true }).click(); + const launchResponse = page.waitForResponse((r) => r.url().endsWith("/launch-harness")); + await page.getByRole("button", { name: "Start subshell", exact: true }).click(); + const launched = await launchResponse; + expect(launched.ok(), await launched.text()).toBe(true); + desktopPane = ((await launched.json()) as { subshellId: string }).subshellId; + await expect(page).toHaveURL(new RegExp(`/subshells/${desktopPane}`)); + await expect(page.getByText(new RegExp(`via Laptop ${RUN}`))).toBeVisible(); + const identity = (await (await memberApi.get(`/api/ssh-runtime/sessions/by-pane/${desktopPane}`)).json()) as { + sessionId: string; + }; + const closed = await memberApi.post(`/api/ssh-runtime/sessions/${identity.sessionId}/close`); + expect(closed.ok()).toBe(true); + await page.getByRole("button", { name: "Reconnect", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Reconnect to SSH host" })).toBeVisible(); + await page.getByRole("button", { name: SSH_FIXTURE_ALIAS, exact: true }).click(); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Reconnect to SSH host" })).not.toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(new RegExp(`/subshells/${desktopPane}`)); + await pollUntil("the reconnected desktop pane never became available", async () => { + const row = (await (await memberApi.get(`/api/subshells/${desktopPane}`)).json()) as { + alive: boolean; + status: string; + }; + return row.alive === true && row.status === "running"; + }); + await expect(page.getByText("Subshell exited", { exact: true })).not.toBeVisible({ timeout: 15_000 }); + await expect(page.getByText("reconnecting…")).toHaveCount(0, { timeout: 30_000 }); + const marker = `DESKTOP-RESUMED-${RUN}`; + await page.locator(".xterm-helper-textarea").click(); + await page.keyboard.type(`echo ${marker}`); + await page.keyboard.press("Enter"); + await pollUntil("typing after desktop reconnect did not reach the remote terminal", async () => { + const response = await memberApi.get(`/api/subshells/${desktopPane}/log`); + if (!response.ok()) return false; + const text = ((await response.json()) as { lines: string[] }).lines.join("\n"); + return text.split(marker).length >= 3; + }); + await page.screenshot({ path: "/tmp/subshell-ssh-desktop-reconnected.png", fullPage: true }); + } finally { + if (desktopPane) await memberApi.delete(`/api/subshells/${desktopPane}`); + await memberApi.delete(`/api/ssh-runtime/desktop-brokers/${encodeURIComponent(pairing.id)}`); + child.stdin.end(); + await new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve(); + }, 40_000); + if (child.exitCode !== null) { + clearTimeout(timer); + resolve(); + } else + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); + } +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 6a84b29ff..62b000f69 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -1,3 +1,6 @@ +import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +import path from "node:path"; import { expect, type Locator, type Page } from "@playwright/test"; /** @@ -174,3 +177,47 @@ export async function pickAgent(input: Locator, label: string): Promise { export async function dismissDirectoryPanel(page: Page, heading = "New subshell"): Promise { await page.getByRole("heading", { name: heading }).click(); } + +/** A wall-clock pause (shared: the SSH specs' polls and teardown beats use it). */ +export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +/** + * Polls `check` until it returns true or `budgetMs` elapses; on timeout throws + * `label` plus an optional `tail` (the agent log, so a stuck fixture says what + * to look at rather than timing out silently). `tickMs` widens for tmux- + * spawning checks (each probe is a blocking spawnSync; pane liveness moves on + * seconds, not 500 ms) while pure-API polls keep the tight default. + */ +export async function pollUntil( + label: string, + check: () => Promise, + opts: { tail?: () => string; budgetMs?: number; tickMs?: number } = {}, +): Promise { + const { tail, budgetMs = 60_000, tickMs = 500 } = opts; + const deadline = Date.now() + budgetMs; + for (;;) { + if (await check()) return; + if (Date.now() > deadline) { + throw new Error(`${label}${tail === undefined ? "" : `\n--- agent log tail ---\n${tail()}`}`); + } + await sleep(tickMs); + } +} + +/** + * Kill every tmux server the caller's isolated `TMUX_TMPDIR` holds, found by + * enumerating `$TMUX_TMPDIR/tmux-/` — the socket NAMES are the backend's + * hash, deliberately not recomputed here (enumerating the dir pins agent-side + * truth without coupling to the hashing scheme). Spec 12 and 14 keep their own + * copies; this is the shared home a new spec imports. + */ +export function sweepTmuxServers(tmuxBase: string): void { + const uidDir = path.join(tmuxBase, `tmux-${process.getuid?.() ?? 0}`); + let socks: string[] = []; + try { + socks = readdirSync(uidDir).map((s) => path.join(uidDir, s)); + } catch { + return; // no sockets, nothing to sweep + } + for (const sock of socks) spawnSync("tmux", ["-S", sock, "kill-server"]); +} diff --git a/e2e/tests/ssh-support.ts b/e2e/tests/ssh-support.ts new file mode 100644 index 000000000..0320cd6e5 --- /dev/null +++ b/e2e/tests/ssh-support.ts @@ -0,0 +1,71 @@ +import { rmSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { type APIRequestContext, expect, request as pwRequest } from "@playwright/test"; +import { BASE_URL } from "../ports"; +import { ADMIN, ADMIN_STATE } from "./helpers"; + +/** + * Shared machinery for the SSH-runtime end-to-end specs (22, 23). Kept out of + * the specs so the spec files stay the orchestrators of user-visible flows + * (the repo's thin-route rule applied to tests): this module owns the admin + * seed, the node-row shape, and the scratch-dir guard. The destination + * product's connection round-trip helpers retired with it (design + * 2026-10-05 §7); the wizard specs drive `/api/ssh-runtime` directly. + */ + +export interface NodeRow { + id: string; + name: string; + status: "online" | "offline"; + protocolVersion: number | null; + capabilities: string[]; +} + +/** Delete a scratch dir only when it is a real absolute path (never "" or "."). */ +export function rmScratch(dir: string): void { + if (isAbsolute(dir)) rmSync(dir, { recursive: true, force: true }); +} + +/** + * The admin, present-or-seeded, as an authenticated API context. A focused + * run never booted spec 01, so the admin, its storage state, AND its + * completed-onboarding bookmark are seeded here when absent; in the full + * suite 01 already did all three. `request` from @playwright/test inherits + * the file's `test.use` storageState, so the anon seed context passes an + * EXPLICIT empty state - a bare newContext would READ the (focused-run: + * not-yet-written) admin.json and ENOENT before the seed could create it. + */ +export async function seedAdminApi(): Promise { + const anon = await pwRequest.newContext({ + baseURL: BASE_URL, + extraHTTPHeaders: { origin: BASE_URL }, + storageState: { cookies: [], origins: [] }, + }); + const status = await anon.get("/api/setup/status"); + expect(status.ok(), await status.text()).toBe(true); + let seededAdmin = false; + if (((await status.json()) as { needsSetup: boolean }).needsSetup) { + const signUp = await anon.post("/api/auth/sign-up/email", { + data: { name: ADMIN.name, email: ADMIN.email, password: ADMIN.password }, + }); + expect(signUp.ok(), `seed sign-up: HTTP ${signUp.status()}`).toBe(true); + await anon.storageState({ path: ADMIN_STATE }); + seededAdmin = true; + } + await anon.dispose(); + const api = await pwRequest.newContext({ + baseURL: BASE_URL, + storageState: ADMIN_STATE, + extraHTTPHeaders: { origin: BASE_URL }, + }); + // A user minted through the raw sign-up endpoint has never walked the boot + // wizard, so its `setup-progress` bookmark is set and the root gate RESUMES + // it onto /setup - which would bounce every /settings/* navigation. Clearing + // the bookmark (step:null) is exactly the write the wizard's final step + // makes; only the seeded (focused-run) case needs it - spec 01 finished it. + if (seededAdmin) { + const progress = await api.patch("/api/setup/progress", { data: { step: null } }); + expect(progress.ok(), `clear seeded onboarding bookmark: HTTP ${progress.status()}`).toBe(true); + } + return api; +} diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index 3288ffaba..e757f0773 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -113,6 +113,25 @@ export enum BackendErrorCodes { EXEC_PANE_BUSY = "EXEC_PANE_BUSY", /** exec: another exec already holds this pane's lease. */ EXEC_IN_FLIGHT = "EXEC_IN_FLIGHT", + // The managed-SSH-terminal family (EXEC_SSH_UNSUPPORTED, UPLOAD_SSH_ + // UNSUPPORTED, EXEC_UNKNOWN_RECOVERY, SSH_ACCESS_DENIED, SSH_SHARING_ + // UNSUPPORTED, SSH_BACKEND_UNAVAILABLE) retired with the destination + // product (design 2026-10-05 §7): no route throws them any more, and the + // SSH-runtime surface names its refusals through the ordinary families. + // ONE SSH-runtime refusal needs its own member (review m3), below: the + // ordinary offline/unreachable codes all promise a remedy the runtime seal + // forbids, so an honest refusal needs an honest name. + /** + * `POST /api/subshells/:id/uploads` against a pane whose node is a `runtime` + * row (an SSH runtime session's hidden destination, design 2026-10-05 §4): + * the runtime is reached ONLY through its session's framed channel, and + * `write_file` is not a session frame (design §2's frame set), so no upload + * can reach this pane. Refused BY NAME before any RPC is composed - the + * offline/unreachable 409s that preceded this each promised a remedy + * ("start the node", "restart the subshell") that the runtime seal makes + * impossible. + */ + RUNTIME_UPLOAD_UNSUPPORTED = "RUNTIME_UPLOAD_UNSUPPORTED", /** * `POST /api/subshells`: the row was retired between its INSERT and its * spawn — a maintenance window opening on that node, or a plain terminate. @@ -415,4 +434,8 @@ export const BackendErrorCodeDefs = { message: "This network needs configuring first", statusCode: 409, }, + [BackendErrorCodes.RUNTIME_UPLOAD_UNSUPPORTED]: { + message: "File upload is not supported for panes on an SSH runtime session", + statusCode: 409, + }, }; diff --git a/packages/mcp-core/src/__tests__/api-client.test.ts b/packages/mcp-core/src/__tests__/api-client.test.ts index b3e5f6a40..95b339c04 100644 --- a/packages/mcp-core/src/__tests__/api-client.test.ts +++ b/packages/mcp-core/src/__tests__/api-client.test.ts @@ -83,4 +83,44 @@ describe("SubshellApi", () => { expect(err).toBeInstanceOf(ApiError); expect((err as ApiError).status).toBe(401); }); + + it("routes through the callback door when the config names one (task 25, design §5)", async () => { + // The runtime-pane contract: door mode never touches the network - the + // `unix` transport is set from the config, the sentinel host is never + // resolved by Bun - and no Authorization header rides (the plane + // authenticates by attribution; the pane holds no key BY DESIGN). + const door = new SubshellApi({ + apiKey: "", + baseUrl: "http://subshell-callback.invalid", + callbackSock: "/srv/runtime/callbacks/s1.sock", + }); + let seen: { url: string; init?: RequestInit } | undefined; + globalThis.fetch = (async (input: URL, init?: RequestInit) => { + seen = { url: String(input), init }; + return new Response("{}", { status: 200 }); + }) as never; + expect( + await door.req>("/api/subshells/s1/attention", { + method: "POST", + body: { kind: "resumed" }, + }), + ).toEqual({}); + const headers = (seen?.init?.headers ?? {}) as Record; + expect((seen?.init as { unix?: string } | undefined)?.unix).toBe("/srv/runtime/callbacks/s1.sock"); + expect(headers["authorization"]).toBeUndefined(); + expect(headers["content-type"]).toBe("application/json"); + expect(seen?.url).toBe("http://subshell-callback.invalid/api/subshells/s1/attention"); + }); + + it("without a door, the old behavior is byte-identical (header rides, no unix)", async () => { + let seen: { init?: RequestInit } | undefined; + globalThis.fetch = (async (_input: URL, init?: RequestInit) => { + seen = { init }; + return new Response("[]", { status: 200 }); + }) as never; + await api.req("/api/identities"); + const headers = (seen?.init?.headers ?? {}) as Record; + expect(headers["authorization"]).toBe("Bearer subshell_key123"); + expect((seen?.init as { unix?: string } | undefined)?.unix).toBeUndefined(); + }); }); diff --git a/packages/mcp-core/src/__tests__/env.test.ts b/packages/mcp-core/src/__tests__/env.test.ts new file mode 100644 index 000000000..5316f1552 --- /dev/null +++ b/packages/mcp-core/src/__tests__/env.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { readMcpEnv, resolveMcpDataDir } from "../env.js"; + +/** + * The pane-env contract, including the runtime-pane half (task 25, + * design 2026-10-05 §5): with a callback door the pane carries NO key by + * design (the plane executes callbacks as the pane from the door's + * attribution); without one, the key is as required as it has ever been. + */ +describe("readMcpEnv", () => { + it("requires the key exactly as before when no door is set", () => { + expect(() => readMcpEnv({ SUBSHELL_ID: "s1" } as NodeJS.ProcessEnv)).toThrow("SUBSHELL_API_KEY"); + const full = readMcpEnv({ SUBSHELL_ID: "s1", SUBSHELL_API_KEY: "k" } as NodeJS.ProcessEnv); + expect(full.callbackSock).toBeNull(); + expect(full.apiKey).toBe("k"); + expect(full.baseUrl).toBe("http://127.0.0.1:3080"); + }); + + it("accepts a keyless pane when the runtime callback door is named", () => { + const pane = readMcpEnv({ + SUBSHELL_ID: "s1", + SUBSHELL_RUNTIME_CALLBACK_SOCK: "/srv/runtime/callbacks/s1.sock", + SUBSHELL_BASE_URL: "http://subshell-callback.invalid", + } as NodeJS.ProcessEnv); + expect(pane.callbackSock).toBe("/srv/runtime/callbacks/s1.sock"); + expect(pane.apiKey).toBe(""); // empty, not undefined: the caller branches on the door + expect(pane.baseUrl).toBe("http://subshell-callback.invalid"); + }); + + it("a whitespace-only door is no door (the key requirement stands)", () => { + expect(() => readMcpEnv({ SUBSHELL_ID: "s1", SUBSHELL_RUNTIME_CALLBACK_SOCK: " " } as NodeJS.ProcessEnv)).toThrow( + "SUBSHELL_API_KEY", + ); + }); + + it("a RELATIVE door is refused-as-unset, loudly, not resolved against the cwd (N-3)", () => { + const notes: string[] = []; + const orig = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array): boolean => { + notes.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + // Keyless: the door cannot stand in for a key it never was. + expect(() => + readMcpEnv({ SUBSHELL_ID: "s1", SUBSHELL_RUNTIME_CALLBACK_SOCK: "callbacks/s1.sock" } as NodeJS.ProcessEnv), + ).toThrow("SUBSHELL_API_KEY"); + // With a key: the ordinary key-mode read, the door absent, and the + // stderr note names why the door was dropped. + const pane = readMcpEnv({ + SUBSHELL_ID: "s1", + SUBSHELL_API_KEY: "k", + SUBSHELL_RUNTIME_CALLBACK_SOCK: "callbacks/s1.sock", + } as NodeJS.ProcessEnv); + expect(pane.callbackSock).toBeNull(); + expect(pane.apiKey).toBe("k"); + expect(notes.join("")).toContain("not an absolute path"); + } finally { + process.stderr.write = orig; + } + // The absolute spelling still doors (the pair the review asked to pin). + const ok = readMcpEnv({ + SUBSHELL_ID: "s1", + SUBSHELL_RUNTIME_CALLBACK_SOCK: "/srv/cb/s1.sock", + } as NodeJS.ProcessEnv); + expect(ok.callbackSock).toBe("/srv/cb/s1.sock"); + }); + + it("still requires the id in either mode", () => { + expect(() => + readMcpEnv({ SUBSHELL_RUNTIME_CALLBACK_SOCK: "/s", SUBSHELL_API_KEY: "k" } as NodeJS.ProcessEnv), + ).toThrow("SUBSHELL_ID"); + }); +}); + +describe("resolveMcpDataDir", () => { + it("prefers SUBSHELL_DATA_DIR, falls back to the server dir, then tmp", () => { + expect(resolveMcpDataDir({ SUBSHELL_DATA_DIR: "/a" } as NodeJS.ProcessEnv)).toBe("/a"); + expect(resolveMcpDataDir({ SUBSHELL_SERVER_DATA_DIR: "/b" } as NodeJS.ProcessEnv)).toBe("/b"); + expect(resolveMcpDataDir({} as NodeJS.ProcessEnv)).toContain("subshell-mcp"); + }); +}); diff --git a/packages/mcp-core/src/__tests__/report.test.ts b/packages/mcp-core/src/__tests__/report.test.ts index 344715983..9d8c2c481 100644 --- a/packages/mcp-core/src/__tests__/report.test.ts +++ b/packages/mcp-core/src/__tests__/report.test.ts @@ -41,6 +41,33 @@ describe("runReport", () => { expect(await req.json()).toEqual({ kind: "turn_complete" }); }); + it("routes the report through the pane's callback DOOR when the env names one (task 25)", async () => { + // The runtime-pane contract (design 2026-10-05 §5): the env carries a door + // path and the never-resolves sentinel base URL and NO key. The report + // must ride the door (init.unix), must not attempt the sentinel host as a + // network fact, and must not invent an Authorization header. The URL + // composition stays baseUrl+path (seam-stable); only the transport moves. + const doorSeen: { url: string; unix?: string; init?: RequestInit }[] = []; + const doorFetch = (async (input: string | URL, init?: RequestInit) => { + doorSeen.push({ url: String(input), unix: (init as { unix?: string } | undefined)?.unix, init }); + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + await runReport(["attention", "turn_complete"], { + env: { + SUBSHELL_ID: "sub_42", + SUBSHELL_RUNTIME_CALLBACK_SOCK: "/srv/subshell/callbacks/sub_42.sock", + SUBSHELL_BASE_URL: "http://subshell-callback.invalid", + } as NodeJS.ProcessEnv, + fetch: doorFetch, + readStdin: async () => "", + }); + expect(doorSeen).toHaveLength(1); + expect(doorSeen[0]?.unix).toBe("/srv/subshell/callbacks/sub_42.sock"); + expect(doorSeen[0]?.url).toBe("http://subshell-callback.invalid/api/subshells/sub_42/attention"); + const headers = ((doorSeen[0]?.init?.headers ?? {}) as Record)["authorization"]; + expect(headers).toBeUndefined(); + }); + it("gives `resumed` the short POST budget and every other report the full one", () => { // PreToolUse runs the resumed report before EVERY tool call: a 2 s stall // per call during a plane brownout is the failure this budget exists to diff --git a/packages/mcp-core/src/__tests__/server.test.ts b/packages/mcp-core/src/__tests__/server.test.ts index 2a5885434..90e9c5670 100644 --- a/packages/mcp-core/src/__tests__/server.test.ts +++ b/packages/mcp-core/src/__tests__/server.test.ts @@ -62,12 +62,21 @@ describe("subshell mcp instructions", () => { it("the briefing owns the spawned-pane cleanup rule (2026-09-25: comms panes the human never hears about)", () => { expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("yours to close"); - expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("Cross-agent comms"); + // SSH-SUPPORT.md §1: the visible grouping is "Agent-created" now, and + // the briefing says so (the old "Cross-agent comms" spelling is gone). + expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("Agent-created"); + expect(SUBSHELL_MCP_INSTRUCTIONS).not.toContain("Cross-agent comms"); // The create tool REPEATS the rule for harnesses that read descriptions // without the briefing; that half is asserted on the wire in the // tools/list case below. expect(SUBSHELL_MCP_INSTRUCTIONS.length).toBeLessThan(1200); }); + + it("the briefing no longer claims EVERY pane is an agent (plain terminal panes are not; the managed SSH pane retired with its product)", () => { + expect(SUBSHELL_MCP_INSTRUCTIONS).not.toMatch(/panes on this control plane are agent sessions/); + expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("are not agents"); + expect(SUBSHELL_MCP_INSTRUCTIONS).not.toContain("grant-gated"); + }); }); /** diff --git a/packages/mcp-core/src/api-client.ts b/packages/mcp-core/src/api-client.ts index bb403db6b..d79b4985c 100644 --- a/packages/mcp-core/src/api-client.ts +++ b/packages/mcp-core/src/api-client.ts @@ -19,6 +19,13 @@ export class ApiError extends Error { * codes the server rides by name and treats everything else by status. */ readonly code?: string, + /** + * The structured body's client-safe metadata (`ApiErrorResponse.metadata`, + * set server-side via `metadataSafe`), carried through verbatim so any + * refusal that names itself in metadata (e.g. `metadata.sshCode` on the + * SSH-runtime surface) stays inspectable at the tool layer. + */ + readonly metadata?: Record, ) { super(message); this.name = "ApiError"; @@ -31,14 +38,17 @@ export class ApiError extends Error { * (`{errId, code, message, statusCode}`); anything that is not that shape * (legacy text, a proxy error page) is passed through verbatim with no code. */ -function extractErrorBody(raw: string): { message: string; code?: string } { +function extractErrorBody(raw: string): { message: string; code?: string; metadata?: Record } { try { const parsed: unknown = JSON.parse(raw); if (parsed && typeof parsed === "object") { - const body = parsed as { message?: unknown; code?: unknown }; + const body = parsed as { message?: unknown; code?: unknown; metadata?: unknown }; return { message: typeof body.message === "string" ? body.message : raw, ...(typeof body.code === "string" ? { code: body.code } : {}), + ...(body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata) + ? { metadata: body.metadata as Record } + : {}), }; } } catch { @@ -51,6 +61,17 @@ function extractErrorBody(raw: string): { message: string; code?: string } { export interface SubshellApiConfig { baseUrl: string; apiKey: string; + /** + * The runtime pane's callback door (design 2026-10-05 §5, task 25): a unix + * socket path. Set for panes an SSH runtime session launched, and when set + * EVERY request connects there (`fetch`'s `unix` transport - Bun's absolute + * socket path; the URL's host is never resolved, so the sentinel base URL + * in a pane env stays the dead-by-DNS guard it is). The plane authenticates + * each request as this pane by the door it arrives on, so `apiKey` is unused + * in this mode - the runtime contract is that no bearer material crosses + * the wire. + */ + callbackSock?: string | null; } export class SubshellApi { @@ -63,20 +84,36 @@ export class SubshellApi { ): Promise { const url = new URL(path, this.config.baseUrl); for (const [k, v] of Object.entries(init.query ?? {})) url.searchParams.set(k, String(v)); - const headers: Record = { authorization: `Bearer ${this.config.apiKey}` }; + // In door mode there is no key to send; an `Authorization` header naming + // an empty bearer would be a misleading artifact, and the callback + // executor's own auth (the plane's minted token) is the real one. + const headers: Record = + this.config.callbackSock != null ? {} : { authorization: `Bearer ${this.config.apiKey}` }; let body: string | undefined; if (init.body !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(init.body); } - const res = await fetch(url, { method: init.method ?? "GET", headers, body, signal: init.signal }); + const fetchInit: RequestInit & { unix?: string } = { + method: init.method ?? "GET", + headers, + body, + signal: init.signal, + }; + // The door branch: Bun resolves the socket path and NEVER touches the URL + // host, so the sentinel base URL stays what it is - a dead-by-DNS guard + // for any code path that bypasses this one. The `unix` field rides in the + // init object so a test seam sees the transport decision, not just the + // composed URL. + if (this.config.callbackSock != null) fetchInit.unix = this.config.callbackSock; + const res = await fetch(url, fetchInit); if (!res.ok) { // Parse the FULL body before truncating: structured error bodies can // exceed 300 chars (validation payloads), and slicing first would tear // the JSON and surface a blob where a clean `message` exists. const raw = await res.text().catch(() => ""); - const { message, code } = extractErrorBody(raw); - throw new ApiError(res.status, (message || res.statusText).slice(0, 300), code); + const { message, code, metadata } = extractErrorBody(raw); + throw new ApiError(res.status, (message || res.statusText).slice(0, 300), code, metadata); } // 204 / empty bodies resolve to undefined rather than a JSON parse error. const text = await res.text(); diff --git a/packages/mcp-core/src/env.ts b/packages/mcp-core/src/env.ts index f07c32c5b..42dbcdbfc 100644 --- a/packages/mcp-core/src/env.ts +++ b/packages/mcp-core/src/env.ts @@ -9,7 +9,12 @@ import { join } from "node:path"; /** Everything the MCP process needs to reach the backend as its subshell. */ export interface McpEnv { - /** The per-subshell bearer token (SUBSHELL_API_KEY). */ + /** + * The per-subshell bearer token (SUBSHELL_API_KEY). Empty string in runtime + * pane mode (`callbackSock` set): the plane authenticates every callback as + * this pane by the DOOR it arrived on, and the real token never crosses to + * the destination (design 2026-10-05 §5), so there is nothing to hold. + */ apiKey: string; /** Backend base URL (SUBSHELL_BASE_URL), defaulting to the local server. */ baseUrl: string; @@ -19,6 +24,14 @@ export interface McpEnv { dataDir: string; /** Optional display name for the identity (SUBSHELL_NAME). */ subshellName: string | null; + /** + * The pane's callback door (SUBSHELL_RUNTIME_CALLBACK_SOCK): a unix socket + * path, set only for panes an SSH runtime session launched. When present, + * every backend call rides that door (see `SubshellApi`/`runReport`) and the + * base URL is never contacted - the pane env carries a never-resolves + * sentinel there precisely so an accidental direct call dies at DNS. + */ + callbackSock: string | null; } /** @@ -35,13 +48,33 @@ export function resolveMcpDataDir(env: NodeJS.ProcessEnv): string { export function readMcpEnv(env: NodeJS.ProcessEnv = process.env): McpEnv { const apiKey = env.SUBSHELL_API_KEY; const subshellId = env.SUBSHELL_ID; - if (!apiKey) throw new Error("subshell mcp: SUBSHELL_API_KEY is not set"); + // The runtime pane door (design 2026-10-05 §5, task 25): with a door, the + // pane has no key BY DESIGN - the plane executes every callback as the pane + // from the pane's own minted token, which never travels to the destination. + // Without a door the key is as required as it always was; the error text + // stays the pre-door sentence so every non-runtime pane's failure reads + // exactly as it did before this field existed. + const rawSock = env.SUBSHELL_RUNTIME_CALLBACK_SOCK; + let callbackSock = rawSock !== undefined && rawSock.trim() !== "" ? rawSock.trim() : null; + // A door is an absolute filesystem path (the plane composes it from the + // runtime's `hello.dataDir`). A relative value cannot be what was meant: + // it would resolve against the harness's cwd, which the pane env never + // composes. Treat it as NO door (the key-required path, loud on stderr) + // rather than dialing some other path beside the user's project. + if (callbackSock !== null && !callbackSock.startsWith("/")) { + process.stderr.write( + `subshell mcp: ignoring SUBSHELL_RUNTIME_CALLBACK_SOCK "${callbackSock}" (not an absolute path); requiring SUBSHELL_API_KEY\n`, + ); + callbackSock = null; + } + if (!apiKey && callbackSock === null) throw new Error("subshell mcp: SUBSHELL_API_KEY is not set"); if (!subshellId) throw new Error("subshell mcp: SUBSHELL_ID is not set"); return { - apiKey, + apiKey: apiKey ?? "", baseUrl: env.SUBSHELL_BASE_URL ?? "http://127.0.0.1:3080", subshellId, dataDir: resolveMcpDataDir(env), subshellName: env.SUBSHELL_NAME ?? null, + callbackSock, }; } diff --git a/packages/mcp-core/src/report.ts b/packages/mcp-core/src/report.ts index 8db29f646..bce26d1b9 100644 --- a/packages/mcp-core/src/report.ts +++ b/packages/mcp-core/src/report.ts @@ -105,19 +105,30 @@ export async function runReport(argv: string[], io: ReportIo = {}): Promise { const env = readMcpEnv(); - const api = new SubshellApi({ baseUrl: env.baseUrl, apiKey: env.apiKey }); + // `callbackSock` is the whole runtime-pane difference (task 25): every tool + // call, the identity registration, and the extend timer ride the single + // `api` object, so the door is wired once here and nowhere else. + const api = new SubshellApi({ baseUrl: env.baseUrl, apiKey: env.apiKey, callbackSock: env.callbackSock }); const own = await loadOrCreateIdentity(env.dataDir, `sess:${env.subshellId}`); // Register/rotate our key so members can seal replies to us. Best-effort: a // 409 (identity_required handled server-side only gates create/join) must diff --git a/packages/pane-runtime/src/__tests__/seed-log-from-history.test.ts b/packages/pane-runtime/src/__tests__/seed-log-from-history.test.ts new file mode 100644 index 000000000..e5cd57db3 --- /dev/null +++ b/packages/pane-runtime/src/__tests__/seed-log-from-history.test.ts @@ -0,0 +1,72 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { seedLogFromHistory } from "../seed-log.js"; +import type { TmuxRunner } from "../tmux-runner.js"; + +/** + * The pipe-pane attach race backfill (the CI harness cell proof): a pane + * that printed BEFORE the capture child attached has its first bytes only in + * tmux history. seedLogFromHistory copies history into the log exactly while + * the log has received nothing; once the stream is flowing, reconstruction + * must never be prepended. Units run without a tmux server (the packages + * shard has none; the live proof is the e2e cell in spec 22). + */ + +function tmuxWithHistory(history: string): TmuxRunner { + return { capturePane: async () => history } as unknown as TmuxRunner; +} + +const base = mkdtempSync(join(tmpdir(), "subshell-seed-")); + +describe("seedLogFromHistory", () => { + it("writes history into a log the pipe never reached, newline-terminated at 0600", async () => { + const logFile = join(base, "empty.log"); + await seedLogFromHistory(tmuxWithHistory("SEED-MARKER-1"), "sock", "p1", logFile); + const text = readFileSync(logFile, "utf8"); + expect(text).toBe("SEED-MARKER-1\n"); // the missing newline was appended + expect(statSync(logFile).mode & 0o777).toBe(0o600); + }); + + it("leaves a flowing stream untouched: a non-empty file IS the tail already arriving", async () => { + const logFile = join(base, "flowing.log"); + writeFileSync(logFile, "live-bytes\n"); + await seedLogFromHistory(tmuxWithHistory("EARLIER-STUFF"), "sock", "p2", logFile); + expect(readFileSync(logFile, "utf8")).toBe("live-bytes\n"); + }); + + it("creates nothing for a silent pane: mode preamble and blank rows are decoration, not content", async () => { + // The stub mirrors the REAL capturePane contract (measured): the result + // leads with all five DECSET forms - the `l` forms included - and pads + // with blank rows, so a raw trim is never empty for a live-but-silent + // pane. The guard must strip first. + const preamble = "\x1b[?1049l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l"; + for (const [i, history] of ["", "\n \r\n", `${preamble}\n\n \n \n`].entries()) { + const logFile = join(base, `silent-${i}.log`); + await seedLogFromHistory(tmuxWithHistory(history), "sock", "p3", logFile); + expect(() => statSync(logFile)).toThrow(); + } + }); + + it("a failed append (log dir gone mid-flight) returns the note and throws nothing", async () => { + const logFile = join(base, "no-such-dir", "deep.log"); + const note = await seedLogFromHistory(tmuxWithHistory("MARKER\n"), "sock", "p5", logFile); + expect(note).toStartWith("log seed failed"); + }); + + it("a throwing capture is the best-effort it claims: no throw, no file", async () => { + const boom = { + capturePane: async () => { + throw new Error("no server"); + }, + } as unknown as TmuxRunner; + const logFile = join(base, "boom.log"); + await seedLogFromHistory(boom, "sock", "p4", logFile); + expect(() => statSync(logFile)).toThrow(); + }); +}); + +afterAll(() => { + rmSync(base, { recursive: true, force: true }); // bun evaluates the whole file before running tests; cleanup belongs here +}); diff --git a/packages/pane-runtime/src/index.ts b/packages/pane-runtime/src/index.ts index 2a585070d..b338a78d8 100644 --- a/packages/pane-runtime/src/index.ts +++ b/packages/pane-runtime/src/index.ts @@ -138,7 +138,47 @@ export { OUTPUT_CAP, runBounded, } from "./run-bounded.js"; +export { seedLogFromHistory } from "./seed-log.js"; export { shellQuote } from "./shell.js"; +export { killGroup } from "./ssh/kill-group.js"; +export { classifySshFailure, sshTransportFailure } from "./ssh/ssh-diagnose.js"; +export { + defaultSshConfigPath, + discoverSshAliases, + isDiscoverableAlias, + type SshAliasDiscovery, + type SshConfigWalk, + type SshHostBlock, + type SshWalkBudget, + walkSshConfig, +} from "./ssh/ssh-discover.js"; +export { + buildSshInvocation, + remoteCommandLine, + renderSshConfigContents, + type SshInvocationInput, + sshChildEnv, +} from "./ssh/ssh-render.js"; +export { + parseProxyHop, + resolveSshAliasConfig, + type SshResolutionDeps, +} from "./ssh/ssh-resolve.js"; +export { sessionTargetSnapshot, sshSessionTmuxSocket } from "./ssh/ssh-session-invocation.js"; +export type { + SshSessionHooks, + SshSessionOpenOutcome, + SshSessionOpenRequest, +} from "./ssh/ssh-session-open.js"; +export { type SshSessionLifecycle, type SshSessionRecord, SshSessionRecordStore } from "./ssh/ssh-session-store.js"; +export { + getSshSessionSupervisor, + peekSshSessionSupervisor, + resetSshSessionSupervisorsForTests, + SshSessionSupervisor, + type SshSessionSupervisorDeps, +} from "./ssh/ssh-session-supervisor.js"; +export { runSshProcess, type SshProcessResult, sshChildPath } from "./ssh/ssh-spawn.js"; export { safeTransferPath } from "./tar-blocks.js"; export { type ExtractResult, extractTarGz } from "./tar-extractor.js"; export { extractTgz, type TarEntry, type TgzLimits } from "./tar-vendor.js"; diff --git a/packages/pane-runtime/src/seed-log.ts b/packages/pane-runtime/src/seed-log.ts new file mode 100644 index 000000000..ab05b7788 --- /dev/null +++ b/packages/pane-runtime/src/seed-log.ts @@ -0,0 +1,50 @@ +import { appendFile, stat } from "node:fs/promises"; +import { stripAnsi } from "@internal/backend-errors"; + +import type { TmuxRunner } from "./tmux-runner.js"; + +/** How far back the seed reaches: tmux clamps `-S` to the pane's history. */ +const SEED_SCROLLBACK_LINES = 100_000; + +/** + * Backfill a pane's replay log with what it printed BEFORE the pipe-pane + * child could attach. pipe-pane streams only the post-attach tail, so a pane + * whose program emits in its first microseconds (any script that echoes a + * banner, then settles into silence) leaves those bytes in tmux's history and + * never in the log: measured in the CI container, where the harness e2e cell + * lost the fake binary's startup marker while local timing always won the + * race. The rule is deliberately conservative: seed ONLY while the file has + * received nothing - the capture child flushes every read, so a non-empty + * file IS the stream already flowing, and prepending a screen reconstruction + * would duplicate what arrived. It never throws: a pane with an imperfect + * replay beats a refused launch, and both attach twins call it AFTER their + * attach branch (the agent's daemon executor and the plane's local twin - + * the race has no machine class, and a best-effort attach whose pipe failed + * still gets whatever history holds). + * + * The emptiness test STRIPS first: capture-pane output leads with the mode + * preamble (all five DECSET forms, `l`s included) and pads with blank rows, + * so a raw trim is never empty for a live pane - measured, a fully silent + * fresh pane answers 40 preamble bytes. Preamble and blanks are decoration, + * not pane content. + * + * @returns `null` when nothing was written, else a one-line description for + * the caller's log (seeded, or the best-effort failure). + */ +export async function seedLogFromHistory( + tmux: TmuxRunner, + socket: string, + subshellName: string, + logFile: string, +): Promise { + try { + const history = await tmux.capturePane(socket, subshellName, SEED_SCROLLBACK_LINES); + if (stripAnsi(history).trim() === "") return null; + const existing = await stat(logFile).catch(() => null); + if (existing !== null && existing.size > 0) return null; + await appendFile(logFile, history.endsWith("\n") ? history : `${history}\n`, { mode: 0o600 }); + return `seeded the replay log from pane history (${history.length} chars)`; + } catch (err) { + return `log seed failed (best-effort, the pipe carries the tail): ${err instanceof Error ? err.message : String(err)}`; + } +} diff --git a/packages/pane-runtime/src/ssh/__tests__/helpers.ts b/packages/pane-runtime/src/ssh/__tests__/helpers.ts new file mode 100644 index 000000000..99bdc637c --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/helpers.ts @@ -0,0 +1,149 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; + +/** + * SSH test fixtures. NOTHING here may touch a developer's `~/.ssh` or the + * live instance (SSH-SUPPORT.md §6): every config the tests read is a file + * under a fresh temp dir, every "ssh" the tests run is a shell script this + * file bakes with absolute paths (the runtime's child env is an allowlist, so + * a knob delivered by env would be a knob the PRODUCTION child could never + * see — baked constants are the honest test seam), and the isolated-sshd + * suite owns its temp HOME end to end. + */ + +/** Fresh temp root (realpath'd: macOS `/var` symlink noise breaks prefix asserts). */ +export function tempRoot(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +export function cleanup(root: string): void { + rmSync(root, { recursive: true, force: true }); +} + +/** A grammatically valid approved snapshot; override any field. */ +export function makeSnapshot(overrides: Partial = {}): SshConnectionSnapshotWire { + return { + alias: "deploy-app02", + host: "app-02.example.com", + user: "deploy", + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: ["/home/deploy/.ssh/known_hosts"], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...overrides, + }; +} + +/** A sha256-looking request digest. */ +export function makeDigest(seed: string): string { + let h = 0; + for (const ch of seed) h = (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0; + return h.toString(16).padStart(8, "0").repeat(8); +} + +/** Valid plane-shaped run id (hex + hyphen, ≤ 64). */ +export function makeRunId(n: number): string { + return `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`; +} + +/** Read every `ARGS:` line a recording shim appended, in order (one joined argv string per spawn). */ +export function shimArgs(logPath: string): string[] { + try { + return readFileSync(logPath, "utf8") + .split("\n") + .filter((l) => l.startsWith("ARGS:")) + .map((l) => l.slice("ARGS:".length)); + } catch { + return []; + } +} + +/** All recorded lines from a shim log. */ +export function shimLog(logPath: string): string { + try { + return readFileSync(logPath, "utf8"); + } catch { + return ""; + } +} + +/** + * Write the recording ssh shim into `dir/ssh`. Knobs are BAKED CONSTANTS: + * + * - `opts.dashG`: when set, an invocation with `-G` anywhere cats this text + * and exits 0 (the resolution evaluator's canned answer). + * - `opts.stdout` / `opts.stdoutFile` / `opts.stderr` / `opts.stderrFile`: + * bytes to emit (inline or from a file this function seeds). + * - `opts.sleep`: seconds to `exec sleep` after emitting (the long-running + * run the cancel/deadline paths stop). + * - `opts.exitCode`: the exit status. + * + * The script appends `ARGS:` plus `ENV:` probe lines naming the + * auth-agent, askpass, DISPLAY, TERM, and the full key list of its + * environment, and `CFG:` when an `-F` is present. + * The `-G` branch runs BEFORE the ARGS recording only for -G invocations so + * resolution tests can assert on the emitted config; every other invocation + * records first. + */ +export function writeSshShim( + dir: string, + opts: { + dashG?: string; + stdout?: string; + stdoutFile?: string; + stderr?: string; + stderrFile?: string; + sleep?: number; + exitCode?: number; + } = {}, +): { bin: string; log: string } { + mkdirSync(dir, { recursive: true }); + const log = join(dir, "shim.log"); + const parts: string[] = ["#!/bin/sh"]; + parts.push(`LOG='${log}'`); + if (opts.stdoutFile) parts.push(`OUTF='${opts.stdoutFile}'`); + if (opts.stderrFile) parts.push(`ERRF='${opts.stderrFile}'`); + parts.push(`printf 'ARGS:%s\\n' "$*" >> "$LOG"`); + parts.push( + `printf 'ENV:auth=%s ask=%s disp=%s term=%s\\n' "\${SSH_AUTH_SOCK-}" "\${SSH_ASKPASS-}" "\${DISPLAY-}" "\${TERM-}" >> "$LOG"`, + ); + parts.push(`printf 'ENVKEYS:%s\\n' "$(env | cut -d= -f1 | sort | tr '\\n' ' ')" >> "$LOG"`); + parts.push( + `prev=""; for a in "$@"; do if [ "$prev" = "-F" ]; then printf 'CFG:<<\\n'; cat "$a"; printf '>>\\n' >> "$LOG"; fi; prev="$a"; done >> "$LOG" 2>/dev/null`, + ); + if (opts.dashG !== undefined) { + parts.push(`for a in "$@"; do if [ "$a" = "-G" ]; then printf 'GARGS:%s\\n' "$*" >> "$LOG"; cat <<'GEOF'`); + parts.push(opts.dashG); + parts.push("GEOF\nexit 0\nfi; done"); + } + if (opts.stdout !== undefined) parts.push(`printf '%s' ${shellSingleQuote(opts.stdout)}`); + if (opts.stdoutFile) parts.push(`[ -n "\${OUTF-}" ] && cat "$OUTF"`); + if (opts.stderr !== undefined) parts.push(`printf '%s' ${shellSingleQuote(opts.stderr)} 1>&2`); + if (opts.stderrFile) parts.push(`[ -n "\${ERRF-}" ] && cat "$ERRF" 1>&2`); + if (opts.sleep !== undefined) parts.push(`exec sleep ${opts.sleep}`); + parts.push(`exit ${opts.exitCode ?? 0}`); + const bin = join(dir, "ssh"); + writeFileSync(bin, `${parts.join("\n")}\n`, { mode: 0o755 }); + chmodSync(bin, 0o755); + return { bin, log }; +} + +function shellSingleQuote(v: string): string { + return `'${v.replaceAll("'", `'\\''`)}'`; +} + +/** `~`-less POSIX single-quote for fixture config text (paths here are literals). */ +export { shellSingleQuote as sq }; diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts new file mode 100644 index 000000000..db45fab8c --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts @@ -0,0 +1,158 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { defaultSshConfigPath, discoverSshAliases, walkSshConfig } from "../ssh-discover.js"; +import { cleanup, tempRoot } from "./helpers.js"; + +/** + * Discovery is a NAME-HUNT with budgets (ssh-discover.ts module doc): what it + * must get right is which names appear, which are excluded, and what the + * two "the list may be incomplete" facts report. Config CONTENT never + * escapes the walk — the answers carry no directive values at all. + */ + +let root: string; + +beforeAll(() => { + root = tempRoot("subshell-ssh-discover-"); +}); + +afterAll(() => { + cleanup(root); +}); + +function fixtureConfig(tag: string, body: string): string { + const dir = join(root, tag); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "config"); + writeFileSync(path, body); + return path; +} + +describe("discoverSshAliases", () => { + it("reports plain Host names, sorted and deduplicated", () => { + const config = fixtureConfig( + "plain", + [ + "# a comment", + "Host beta", + " HostName beta.example.com", + "Host alpha gamma", + " User deploy", + "Host beta", // duplicate name across blocks + "", + ].join("\n"), + ); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + expect(found.aliases).toEqual(["alpha", "beta", "gamma"]); + expect(found.includeCycle).toBe(false); + expect(found.truncated).toBe(false); + }); + + it("omits wildcard-only patterns and negations, keeps plain siblings", () => { + const config = fixtureConfig( + "wildcards", + [ + "Host *.example.com !secret.example.com", // nothing plain on this line + " User x", + "Host prod-*", // pattern prefix, not an alias + "Host qa-server", + " User y", + "Host q?", // single-char pattern + ].join("\n"), + ); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + expect(found.aliases).toEqual(["qa-server"]); + }); + + it("follows includes and detects an include cycle", () => { + const dir = join(root, "cycle"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "a.conf"), ["Include b.conf", "Host alias-a", " User x"].join("\n")); + writeFileSync( + join(dir, "b.conf"), + ["Include a.conf", "Host alias-b"].join("\n"), // back into a's ancestry + ); + const walk = walkSshConfig(join(dir, "a.conf"), root); + expect(walk.includeCycle).toBe(true); + expect(walk.files.length).toBe(2); // a then b; the re-include stopped at the cycle, did not spin + }); + + it("dedupes a diamond include WITHOUT reporting a cycle", () => { + const dir = join(root, "diamond"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "common.conf"), "Host shared"); + writeFileSync(join(dir, "top.conf"), ["Include common.conf", "Include common.conf", "Host other"].join("\n")); + const found = discoverSshAliases({ homeDir: root, configPath: join(dir, "top.conf") }); + expect(found.aliases).toEqual(["other", "shared"]); + expect(found.includeCycle).toBe(false); + }); + + it("expands glob includes with ~", () => { + const dir = join(root, "glob"); + const inc = join(dir, "conf.d"); + mkdirSync(inc, { recursive: true }); + writeFileSync(join(inc, "one.conf"), "Host g-one"); + writeFileSync(join(inc, "two.conf"), "Host g-two"); + writeFileSync(join(inc, "skip.txt"), "Host g-not-included"); + // ~ expansion: homeDir is the fixture root. + const found = discoverSshAliases({ + homeDir: root, + configPath: fixtureConfig("glob-root", "Include ~/glob/conf.d/*.conf"), + }); + expect(found.aliases).toEqual(["g-one", "g-two"]); + }); + + it("flags truncated when the file budget is exhausted", () => { + const dir = join(root, "budget"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "b.conf"), "Host from-b"); + writeFileSync(join(dir, "c.conf"), "Host from-c"); + const configPath = join(dir, "config"); + writeFileSync(configPath, ["Include b.conf", "Include c.conf", "Host from-root"].join("\n")); + const found = discoverSshAliases({ homeDir: dir, configPath, budget: { maxFiles: 2 } }); // root + b; c is never read + expect(found.truncated).toBe(true); + expect(found.aliases).toContain("from-b"); + expect(found.aliases).not.toContain("from-c"); + }); + + it("flags truncated when the alias cap is reached", () => { + const lines: string[] = []; + for (let i = 0; i < 501; i++) lines.push(`Host alias-${String(i).padStart(4, "0")}`); + const config = fixtureConfig("cap", lines.join("\n")); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + expect(found.aliases.length).toBe(500); + expect(found.truncated).toBe(true); + }); + + it("sees Match exec in the walk (the local-execution signal §2 refuses on)", () => { + const config = fixtureConfig("matchexec", ["Match exec echo hi", " User x", "Host m-alias"].join("\n")); + const walk = walkSshConfig(config, root); + expect(walk.matchExecSeen).toBe(true); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + expect(found.aliases).toEqual(["m-alias"]); // Match blocks carry no names + }); + + it("missing config file yields an empty honest list, never a throw", () => { + const found = discoverSshAliases({ homeDir: root, configPath: join(root, "nope", "config") }); + expect(found).toEqual({ aliases: [], includeCycle: false, truncated: false }); + }); + + it("the default path is the home config", () => { + expect(defaultSshConfigPath("/home/zed")).toBe("/home/zed/.ssh/config"); + }); + + it("answers names only: no directive value text appears anywhere in the result", () => { + const config = fixtureConfig( + "namesonly", + ["Host top-secret-host", " IdentityFile /home/deploy/.ssh/private_key_material", " User supersecret"].join( + "\n", + ), + ); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + const blob = JSON.stringify(found); + expect(blob).toContain("top-secret-host"); // the NAME is the answer + expect(blob).not.toContain("private_key_material"); + expect(blob).not.toContain("supersecret"); + }); +}); diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-loopback-sshd.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-loopback-sshd.test.ts new file mode 100644 index 000000000..4a432d89b --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-loopback-sshd.test.ts @@ -0,0 +1,283 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir, userInfo } from "node:os"; +import { join } from "node:path"; +import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; +import { classifySshFailure } from "../ssh-diagnose.js"; +import { buildSshInvocation, remoteCommandLine, renderSshConfigContents, sshChildEnv } from "../ssh-render.js"; +import { runSshProcess } from "../ssh-spawn.js"; + +/** + * WORKSTREAM G (Wave 2), deliverable 1: the G-owned ISOLATED SSH FIXTURE - a + * real sshd bound to loopback on an OS-allocated port (the probe binds + * 127.0.0.1:0, reads the number, and hands it to sshd), a temp HOME, generated + * keys and trust material under a temp dir this fixture OWNS, tracked pids, + * and cleanup only of its own resources (SSH-SUPPORT.md §6: "isolated SSH + * fixture with temporary accounts, keys, trust files, and config... Never use + * the live instance or a developer's SSH configuration... Bind test services to + * loopback, track their processes, and clean up only resources the fixture + * owns"). The fixture HOME carries ONLY the files these lines write - no + * `.ssh` config a child could read ambiently - and every child runs under + * `sshChildEnv` with HOME pointed at it. When sshd is absent the suite answers + * LOUD (console.warn + every case skipped), and the shim-driven suites + * elsewhere run everywhere. + * + * The rows this fixture adds over the existing trust suite (unknown/changed + * host key, benign run, ProxyJump - covered there) are the certificate half of + * the §6 "SSH policy" row, which only a real OpenSSH server answers honestly: + * + * - **certificate auth works**: a client certificate signed by a CA the + * destination trusts authenticates through the FULL rendered path + * (IdentityFile + CertificateFile out of the approved snapshot); + * - **revoked credentials fail closed**: a KRL named under RevokedKeys on a + * second daemon refuses the SAME cert+key that just passed the first one - + * revocation is honored, not assumed; + * - **key-only auth is the shipped posture**: against a password-only + * destination the BatchMode child fails fast with an actionable named + * classification and no interactive prompt, no askpass, no hang (§2's + * "passwords... produce actionable instructions" claim, proven on a daemon + * that genuinely offers passwords; if this host's sshd cannot come up with + * password auth at all, THAT case says so loudly). + */ + +const HAVE = Bun.which("ssh") !== null && Bun.which("ssh-keygen") !== null && Bun.which("sshd") !== null; +if (!HAVE) { + console.warn( + "[ssh-loopback-fixture] ssh/ssh-keygen/sshd not present: REAL-DAEMON SSH-POLICY TESTS SKIPPED (loud skip, spec §6)", + ); +} + +let root: string; +let home: string; // the fixture HOME: NO .ssh config, NO developer material +let sshBin: string; +let account: string; +let clientKey = ""; +let clientCert = ""; +let knownHosts = ""; + +/** Free an OS-assigned loopback port the way sshd should get one: bind :0, read it, stop. */ +async function ephemeralLoopbackPort(): Promise { + const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {}, error() {} } }); + const port = probe.port; + probe.stop(); + await Bun.sleep(10); + return port; +} + +/** Tracked pids: the fixture kills ONLY what it started (spec §6). */ +const pids: number[] = []; + +async function startSshd(dir: string, extra: string[]): Promise<{ pid: number; port: number } | null> { + mkdirSync(dir, { recursive: true }); + const port = await ephemeralLoopbackPort(); + const cfg = [ + `Port ${port}`, + "ListenAddress 127.0.0.1", + `HostKey ${join(root, "hostkey")}`, + `PidFile ${join(dir, "sshd.pid")}`, + "StrictModes no", + "UsePAM no", + "PermitUserEnvironment no", + "PrintMotd no", + "LogLevel VERBOSE", + ...extra, + ].join("\n"); + writeFileSync(join(dir, "sshd_config"), cfg); + const logFd = openSync(join(dir, "sshd.log"), "w"); + const proc = Bun.spawn(["/usr/sbin/sshd", "-D", "-f", join(dir, "sshd_config"), "-e"], { + stdout: "ignore", + stderr: logFd, + stdin: "ignore", + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + }); + pids.push(proc.pid); + // Readiness: KEEP asking the port through ssh itself; "auth refused" already + // means a daemon is listening (exactly the fact we wait for). + for (let i = 0; i < 100; i++) { + const probe = Bun.spawnSync( + [ + sshBin, + "-p", + String(port), + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=1", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "127.0.0.1", + "true", + ], + { stdout: "ignore", stderr: "pipe" }, + ); + if (!/Connection refused|connect to host 127\.0\.0\.1 port|Connection timed out/i.test(probe.stderr.toString())) { + return { pid: proc.pid, port }; + } + await Bun.sleep(100); + } + return null; +} + +function snapshotFor(port: number, over: Partial = {}): SshConnectionSnapshotWire { + return { + alias: "fixture", + host: "127.0.0.1", + user: account, + port, + identityFiles: [clientKey], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [knownHosts], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...over, + }; +} + +/** One run through the FULL local path: render the config, build argv, spawn. */ +async function runAgainst(_port: number, snapshot: SshConnectionSnapshotWire, command: string) { + const dir = mkdtempSync(join(root, "exec-")); + const configPath = join(dir, "config"); + writeFileSync(configPath, renderSshConfigContents(snapshot), { mode: 0o600 }); + const argv = buildSshInvocation({ sshBin, snapshot, configPath, remoteCommand: remoteCommandLine(command, null) }); + const env = await sshChildEnv(snapshot, home); + return runSshProcess(argv, env, 15_000); +} + +let caDaemonPort = 0; // TrustedUserCAKeys, nothing revoked +let revokedDaemonPort = 0; // TrustedUserCAKeys + RevokedKeys KRL +let pwDaemonPort = 0; // password-only; 0 = this host would not serve it +let pwAvailable = false; + +beforeAll(async () => { + if (!HAVE) return; + root = mkdtempSync(join(tmpdir(), "subshell-sshd-g-")); + home = join(root, "home"); + mkdirSync(home); + account = userInfo().username; + sshBin = Bun.which("ssh")!; + chmodSync(root, 0o700); + + for (const name of ["hostkey", "clientkey", "cakey"]) { + const kg = Bun.spawnSync(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", join(root, name)], { + stderr: "pipe", + }); + if (kg.exitCode !== 0) throw new Error(`ssh-keygen ${name} failed: ${kg.stderr.toString()}`); + } + clientKey = join(root, "clientkey"); + clientCert = join(root, "clientkey-cert.pub"); + const sign = Bun.spawnSync( + ["ssh-keygen", "-s", join(root, "cakey"), "-I", "subshell-g", "-n", account, "-V", "+15m", `${clientKey}.pub`], + { + stdout: "pipe", + stderr: "pipe", + }, + ); + if (sign.exitCode !== 0) throw new Error(`cert signing failed: ${sign.stderr.toString()}`); + + const krlPath = join(root, "revoked.krl"); + const krl = Bun.spawnSync(["ssh-keygen", "-k", "-f", krlPath, "-s", join(root, "cakey.pub"), clientCert], { + stdout: "pipe", + stderr: "pipe", + }); + if (krl.exitCode !== 0) throw new Error(`KRL build failed: ${krl.stderr.toString()}`); + + const ca = await startSshd(join(root, "sshd-ca"), [ + "PubkeyAuthentication yes", + "PasswordAuthentication no", + "KbdInteractiveAuthentication no", + `TrustedUserCAKeys ${join(root, "cakey.pub")}`, + ]); + if (!ca) throw new Error("CA-trust sshd did not come up on loopback"); + caDaemonPort = ca.port; + + const rev = await startSshd(join(root, "sshd-revoked"), [ + "PubkeyAuthentication yes", + "PasswordAuthentication no", + "KbdInteractiveAuthentication no", + `TrustedUserCAKeys ${join(root, "cakey.pub")}`, + `RevokedKeys ${krlPath}`, + ]); + if (!rev) throw new Error("revoked-list sshd did not come up on loopback"); + revokedDaemonPort = rev.port; + + const pw = await startSshd(join(root, "sshd-pw"), [ + "PubkeyAuthentication no", + "PasswordAuthentication yes", + "KbdInteractiveAuthentication no", + ]); + pwAvailable = pw !== null; + pwDaemonPort = pw?.port ?? 0; + if (!pwAvailable) + console.warn( + "[ssh-loopback-fixture] password-auth sshd did not come up on this host: password-posture case SKIPPED", + ); + + // Trust file: one line per daemon, [host]:port authority exactly as ssh + // will check it (the fixture's ONLY trust material). + const hostLine = readFileSync(join(root, "hostkey.pub"), "utf8").trim().split(" ").slice(0, 2).join(" "); + knownHosts = join(root, "known_hosts"); + const entries = [caDaemonPort, revokedDaemonPort, ...(pwAvailable ? [pwDaemonPort] : [])]; + writeFileSync(knownHosts, entries.map((p) => `[127.0.0.1]:${p} ${hostLine}\n`).join("")); +}, 120_000); + +afterAll(async () => { + for (const pid of pids) { + try { + process.kill(pid, "SIGTERM"); + } catch { + // already gone + } + } + if (root) rmSync(root, { recursive: true, force: true }); +}, 20_000); + +describe("loopback sshd: certificates, live revocation, key-only posture (spec §6 'SSH policy')", () => { + const test = HAVE ? it : it.skip; + + test("a CA-signed client certificate authenticates through the rendered path", async () => { + const res = await runAgainst( + caDaemonPort, + snapshotFor(caDaemonPort, { certificateFiles: [clientCert] }), + "echo cert-auth-ok", + ); + expect(res.timedOut).toBe(false); + expect(res.stdout).toContain("cert-auth-ok"); + expect(res.code).toBe(0); + }, 40_000); + + test("the SAME certificate fails closed on the daemon whose RevokedKeys KRL names it", async () => { + const res = await runAgainst( + revokedDaemonPort, + snapshotFor(revokedDaemonPort, { certificateFiles: [clientCert] }), + "echo must-not-run", + ); + expect(res.timedOut).toBe(false); + expect(res.stdout).not.toContain("must-not-run"); + expect(res.code).toBe(255); // transport/auth refusal, never a remote success + expect(res.code).not.toBe(0); + }, 40_000); + + test("a password-only destination fails the BatchMode child fast and NAMED, with no prompt and no hang", async () => { + if (!pwAvailable) return; // the loud warn fired in setup; the skip is on the record + const started = Date.now(); + const res = await runAgainst(pwDaemonPort, snapshotFor(pwDaemonPort), "echo never"); + expect(res.stdout).not.toContain("never"); + expect(res.code).toBe(255); + // The actionable class: §2's "passwords produce actionable instructions" + // requires the refusal to be NAMEABLE from stderr, and sshd says it. + expect(classifySshFailure(res.stderr)).toBe("auth_mode_unsupported"); + expect(res.timedOut).toBe(false); + expect(Date.now() - started).toBeLessThan(15_000); // BatchMode: no interactive wait + }, 40_000); +}); diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts new file mode 100644 index 000000000..13efe575d --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "bun:test"; +import { buildSshInvocation, remoteCommandLine, renderSshConfigContents, sshChildEnv } from "../ssh-render.js"; +import { makeSnapshot } from "./helpers.js"; + +/** + * The renderer is where §2's mandatory every-hop policy becomes bytes. The + * tests below are the policy's regression net: every clause of "BatchMode, + * strict host checking, pinned trust files, no agent/X11 forwarding, no + * forwards, no local commands, no escapes, no multiplexing, ProxyJump-chain + * only, no RemoteCommand/SendEnv/SetEnv import" must appear in the rendered + * artifact, and the argv must carry ONLY destination-scoped facts. + */ + +describe("renderSshConfigContents", () => { + const config = renderSshConfigContents(makeSnapshot()); + + it("carries the mandatory policy under Host * (which the jump children read too)", () => { + expect(config).toContain("Host *"); + const required: [string, string][] = [ + ["BatchMode", "yes"], + ["StrictHostKeyChecking", "yes"], + ["ForwardAgent", "no"], + ["ForwardX11", "no"], + ["ClearAllForwardings", "yes"], + ["Tunnel", "no"], + ["PermitRemoteOpen", "none"], + ["PermitLocalCommand", "no"], + ["RemoteCommand", "none"], + ["EscapeChar", "none"], + ["ControlMaster", "no"], + ["ControlPath", "none"], + ["PasswordAuthentication", "no"], + ["KbdInteractiveAuthentication", "no"], + ["HostbasedAuthentication", "no"], + ["GSSAPIAuthentication", "no"], + ["VerifyHostKeyDNS", "no"], + ["CanonicalizeHostname", "no"], + ]; + for (const [key, value] of required) { + expect(config).toContain(` ${key} ${value}`); + } + // The removed Kerberos* alias must never come back: recent OpenSSH no + // longer resolves it (10.2p1 warns on every launch and continues), and + // GSSAPIAuthentication above is the spelling that actually disables that + // auth path. + expect(config).not.toContain("KerberosAuthentication"); + }); + + it("pins the trust files from the snapshot, or fails closed to /dev/null", () => { + expect(config).toContain(" UserKnownHostsFile /home/deploy/.ssh/known_hosts"); + const empty = renderSshConfigContents(makeSnapshot({ knownHostsFiles: [] })); + expect(empty).toContain(" UserKnownHostsFile /dev/null"); + }); + + it("renders identity/certificate refs under Host * so EVERY hop authenticates from approved material", () => { + const rendered = renderSshConfigContents( + makeSnapshot({ identityFiles: ["/home/deploy/.ssh/id_ed25519"], certificateFiles: ["/home/deploy/.ssh/id.pub"] }), + ); + expect(rendered).toContain(" IdentityFile /home/deploy/.ssh/id_ed25519"); + expect(rendered).toContain(" CertificateFile /home/deploy/.ssh/id.pub"); + }); + + it("quotes path directives so a whitespace-bearing absolute path cannot misparse (and plain paths stay byte-stable)", () => { + const rendered = renderSshConfigContents( + makeSnapshot({ + identityFiles: ["/home/deploy/my keys/id_ed25519"], + certificateFiles: ['/opt/we"ird/cert.pub'], + knownHostsFiles: ["/home/deploy/my hosts/known_hosts", "/plain/path"], + }), + ); + // OpenSSH's config tokenizer splits values on whitespace and honors + // double quotes with backslash escapes — that IS the quoting accepted. + expect(rendered).toContain(' IdentityFile "/home/deploy/my keys/id_ed25519"'); + expect(rendered).toContain(' CertificateFile "/opt/we\\"ird/cert.pub"'); + expect(rendered).toContain(' UserKnownHostsFile "/home/deploy/my hosts/known_hosts"'); + expect(rendered).toContain(" UserKnownHostsFile /plain/path"); // no churn for bytes that need none + }); + + it("ships its header with no em dash (the voice rule covers generated strings)", () => { + expect(config).toContain("# Subshell managed SSH config - GENERATED, do not edit."); + expect(config).not.toContain("—"); + }); + + it("never renders ProxyCommand or any forbidden concept", () => { + for (const word of ["ProxyCommand", "LocalForward", "RemoteForward", "DynamicForward", "SendEnv", "SetEnv"]) { + // The ONLY occurrence of the policy line words is the refusal line we DO render + // (RemoteCommand none etc.); a command VALUE must never appear. + const occurrences = config.split(word).length - 1; + if (word === "RemoteCommand") { + expect(occurrences).toBe(1); // the `RemoteCommand none` policy line itself + } else { + expect(occurrences).toBe(0); + } + } + expect(config).toContain("RemoteCommand none"); + }); + + it("refuses to render a hand-built snapshot carrying a forbidden member", () => { + const smuggled = { ...makeSnapshot(), proxyCommand: "nc attacker 4444" } as unknown as ReturnType< + typeof makeSnapshot + >; + expect(() => renderSshConfigContents(smuggled)).toThrow("not renderable"); + }); +}); + +describe("buildSshInvocation", () => { + it("points -F at the rendered file and passes ONLY destination-scoped options", () => { + const argv = buildSshInvocation({ + sshBin: "/usr/bin/ssh", + snapshot: makeSnapshot({ hostKeyAlias: "app02-web", port: 2222 }), + configPath: "/var/lib/subshell/ssh/runs/r1/config", + remoteCommand: "echo hi", + }); + expect(argv[0]).toBe("/usr/bin/ssh"); + expect(argv.slice(1, 3)).toEqual(["-F", "/var/lib/subshell/ssh/runs/r1/config"]); + expect(argv).toContain("-p"); + expect(argv[argv.indexOf("-p") + 1]).toBe("2222"); + expect(argv).toContain("-l"); + expect(argv[argv.indexOf("-l") + 1]).toBe("deploy"); + expect(argv).toContain("HostKeyAlias=app02-web"); + // policy options are NOT command-line options: they live in the -F file + // (the jump children read the file, never the parent's argv). + expect(argv.every((a) => !a.startsWith("BatchMode"))).toBe(true); + // the `--` terminator, then host, then the command as ONE element. + const dash = argv.indexOf("--"); + expect(argv.slice(dash)).toEqual(["--", "app-02.example.com", "echo hi"]); + }); + + it("adds -tt only when the caller forces it (terminal launch always does; runs never do)", () => { + const plain = buildSshInvocation({ + sshBin: "/usr/bin/ssh", + snapshot: makeSnapshot(), + configPath: "/x/config", + remoteCommand: "echo hi", + }); + expect(plain).not.toContain("-tt"); + const tty = buildSshInvocation({ + sshBin: "/usr/bin/ssh", + snapshot: makeSnapshot(), + configPath: "/x/config", + forceTty: true, + }); + expect(tty).toContain("-tt"); + // -tt rides right after -F , ahead of the destination-scoped facts + expect(tty.slice(3, 5)).toEqual(["-tt", "-p"]); + }); + + it("normalizes the whole ProxyJump chain into the approved hop list", () => { + const argv = buildSshInvocation({ + sshBin: "/usr/bin/ssh", + snapshot: makeSnapshot({ + proxyJumps: [ + { host: "jump.example.com", user: "ops", port: 22 }, + { host: "[2001:db8::1]", user: null, port: 2222 }, + ], + }), + configPath: "/x/config", + }); + const jump = argv[argv.indexOf("ProxyJump=ops@jump.example.com,[2001:db8::1]:2222") - 1]; + expect(jump).toBe("-o"); + expect(argv).not.toContain("-W"); // the chain is ssh's mechanism, ours is the approved list + }); + + it("refuses an option-like or empty config path", () => { + expect(() => + buildSshInvocation({ sshBin: "/usr/bin/ssh", snapshot: makeSnapshot(), configPath: "-o evil" }), + ).toThrow("absolute"); + }); +}); + +describe("remote command composition", () => { + it("quotes the directory as POSIX data and keeps ONLY the command as shell code", () => { + const line = remoteCommandLine("rm -rf build && echo done", "/home/deploy/it's a dir"); + // single-quoted data: the apostrophe escapes by the shellQuote contract; + // the `&&` inside the QUOTE is data, the `&&` outside is the cd gate. + expect(line).toBe(`cd '/home/deploy/it'\\''s a dir' && rm -rf build && echo done`); + }); + + it("a failed cd short-circuits before the command runs", () => { + const line = remoteCommandLine("whoami", "/gone"); + expect(line.startsWith("cd '/gone' && ")).toBe(true); + }); +}); + +describe("sshChildEnv", () => { + it("carries the allowlist + PATH, and NEVER an askpass hook, DISPLAY, TERM, or Subshell credential", async () => { + const env = await sshChildEnv(makeSnapshot(), "/home/deploy", { + PATH: "/usr/bin", + HOME: "/home/deploy", + SSH_ASKPASS: "/tmp/evil-askpass", + SSH_ASKPASS_REQUIRE: "force", + DISPLAY: ":0", + TERM: "xterm", + BETTER_AUTH_SECRET: "super-secret", + SUBSHELL_API_KEY: "sess-secret", + SUBSHELL_BASE_URL: "http://plane:3080", + DYLD_LIBRARY_PATH: "/tmp/evil", + LD_PRELOAD: "/tmp/evil", + SHLVL: "3", + LANG: "C.UTF-8", + LC_CTYPE: "en_US.UTF-8", + }); + expect(env.PATH.length).toBeGreaterThan(0); + expect(env.HOME).toBe("/home/deploy"); + expect(env.LANG).toBe("C.UTF-8"); + expect(env.LC_CTYPE).toBe("en_US.UTF-8"); + for (const banned of [ + "SSH_ASKPASS", + "SSH_ASKPASS_REQUIRE", + "DISPLAY", + "TERM", + "BETTER_AUTH_SECRET", + "SUBSHELL_API_KEY", + "SUBSHELL_BASE_URL", + "DYLD_LIBRARY_PATH", + "LD_PRELOAD", + "SHLVL", + "SSH_AUTH_SOCK", // not in this snapshot + ]) { + expect(banned in env).toBe(false); + } + }); + + it("names the agent socket ONLY when the approved snapshot does", async () => { + const withAgent = await sshChildEnv(makeSnapshot({ authAgentSocket: "/run/user/1000/ssh-agent" }), "/h", { + PATH: "/bin", + }); + expect(withAgent.SSH_AUTH_SOCK).toBe("/run/user/1000/ssh-agent"); + }); + + it("PATH is built from the parent PATH plus login-shell entries, never replaced by the child's HOME guess", async () => { + const env = await sshChildEnv(makeSnapshot(), "/h", { PATH: "/usr/bin:/bin" }); + expect(env.PATH.split(":")).toContain("/usr/bin"); + }); +}); diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts new file mode 100644 index 000000000..0a708603d --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts @@ -0,0 +1,435 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseProxyHop, resolveSshAliasConfig } from "../ssh-resolve.js"; +import { cleanup, tempRoot, writeSshShim } from "./helpers.js"; + +/** + * Resolution tests run pane-runtime's resolver against a SHIM ssh that emits + * canned `ssh -G` output — the shape of the answer is what the filter must + * decide on, and the shim keeps the decision hermetic. (The real sshd suite + * in `ssh-loopback-sshd.test.ts` covers the same paths against the actual + * OpenSSH evaluator; here we test the filter, not OpenSSH.) + * + * The canned fixtures mirror the REAL grammar measured on OpenSSH 10.2p1 + * (review finding C1: the first fixtures omitted `-G`'s default + * `tunnel false` / `permitremoteopen any` lines and the tilde-form default + * identityfiles, so a filter bug hiding under those spellings was invisible + * to every fixture). The suite ends with the pin the fixture mirror cannot + * be trusted to be honest about: `resolveSshAliasConfig` driven by the REAL + * host `ssh -G`, loud-skipped only where ssh is absent. + * + * The shim's `-F` handling is deliberately bypassed for the `-G` branch + * except to confirm the resolver names the fixture config file: the canned + * output IS the fake OpenSSH answer. + */ + +let root: string; + +beforeAll(() => { + root = tempRoot("subshell-ssh-resolve-"); +}); + +afterAll(() => cleanup(root)); + +function gConfig(body: string, _aliasLines = true): { homeDir: string; configPath: string } { + const dir = tempRoot("subshell-ssh-resolve-cfg-"); + const configPath = join(dir, "config"); + writeFileSync(configPath, body); + return { homeDir: dir, configPath }; +} + +/** Run the resolver with a shim whose -G output is `gOutput`; also hands back the fixture home (tilde-expansion asserts). */ +async function resolveWith( + alias: string, + gOutput: string, + opts: { + configBody?: string; + env?: Record; + account?: string; + } = {}, +): Promise<{ out: Awaited>; homeDir: string }> { + const dir = tempRoot("subshell-ssh-resolve-run-"); + const shim = writeSshShim(join(dir, "bin"), { dashG: gOutput }); + const cfg = gConfig(opts.configBody ?? `Host ${alias}\n HostName resolved.example\n`); + const out = await resolveSshAliasConfig(alias, { + sshBin: shim.bin, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin", HOME: cfg.homeDir, ...opts.env }, + connectingAccount: opts.account ?? "opsuser", + timeoutMs: 5_000, + }); + return { out, homeDir: cfg.homeDir }; +} + +/** + * The filter's own clean answer, spelled like a real 10.x `-G`: the default + * `tunnel false` / `permitremoteopen any` / `permitlocalcommand no` lines are + * PRESENT (an unconfigured option is echoed with its default spelling, and + * the filter must know that spelling — a fixture that omitted them let the + * every-alias-refused bug through). + */ +const CLEAN_G = [ + "host resolved.example", + "hostname resolved.example", + "user deploy", + "port 2222", + "identityfile /home/ops/.ssh/id_ed25519", + "certificatefile /home/ops/.ssh/id_ed25519-cert.pub", + "userknownhostsfile /home/ops/.ssh/known_hosts", + "hostkeyalias app02-web", + "forwardagent no", + "gssapiauthentication no", + "tunnel false", + "tunneldevice any:any", + "permitremoteopen any", + "permitlocalcommand no", + "addkeystoagent false", + "controlmaster false", +].join("\n"); + +/** + * A plain-alias answer straight out of OpenSSH 10.x defaults: the identity + * files arrive in `~/.ssh/...` TILDE FORM (the `-G` default spelling), the + * known-hosts file is already absolute, and there is no explicit + * IdentityAgent line. This is the dump that turned every alias without an + * explicit IdentityFile into `config_ambiguous` before the tilde expanded. + */ +const REAL10X_G = [ + "host plainhost", + "hostname plainhost", + "user opsuser", + "port 22", + "addressfamily any", + "batchmode no", + "checkhostip no", + "compression no", + "controlmaster false", + "forwardx11 no", + "gssapiauthentication no", + "hashknownhosts no", + "identitiesonly no", + "kbdinteractiveauthentication yes", + "passwordauthentication yes", + "permitlocalcommand no", + "pubkeyauthentication true", + "requesttty auto", + "stricthostkeychecking ask", + "tcpkeepalive yes", + "tunnel false", + "verifyhostkeydns false", + "visualhostkey no", + "canonicalizemaxdots 1", + "connectionattempts 1", + "serveralivecountmax 3", + "serveraliveinterval 0", + "requiredrsasize 1024", + "ciphers chacha20-poly1305@openssh.com,aes128-gcm@openssh.com", + "loglevel INFO", + "identityfile ~/.ssh/id_rsa", + "identityfile ~/.ssh/id_ecdsa", + "identityfile ~/.ssh/id_ecdsa_sk", + "identityfile ~/.ssh/id_ed25519", + "identityfile ~/.ssh/id_ed25519_sk", + "globalknownhostsfile /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2", + "userknownhostsfile /home/opsuser/.ssh/known_hosts /home/opsuser/.ssh/known_hosts2", + "logverbose none", + "channeltimeout none", + "permitremoteopen any", + "addkeystoagent false", + "forwardagent no", + "connecttimeout none", + "tunneldevice any:any", +].join("\n"); + +describe("resolveSshAliasConfig", () => { + it("normalizes a clean answer into the approved snapshot", async () => { + const { out } = await resolveWith("app02", CLEAN_G); + expect(out.accepted).toBe(true); + if (out.accepted) { + expect(out.snapshot.host).toBe("resolved.example"); + expect(out.snapshot.user).toBe("deploy"); + expect(out.snapshot.port).toBe(2222); + expect(out.snapshot.identityFiles).toEqual(["/home/ops/.ssh/id_ed25519"]); + expect(out.snapshot.certificateFiles).toEqual(["/home/ops/.ssh/id_ed25519-cert.pub"]); + expect(out.snapshot.knownHostsFiles).toEqual(["/home/ops/.ssh/known_hosts"]); + expect(out.snapshot.hostKeyAlias).toBe("app02-web"); + expect(out.snapshot.alias).toBe("app02"); + expect(out.connectingAccount).toBe("opsuser"); + // forbidden members exist as null, by grammar + expect(out.snapshot.proxyCommand).toBeNull(); + } + }); + + it("accepts a whole-dump of real OpenSSH 10.x defaults, expanding tilde identity refs", async () => { + const { out, homeDir } = await resolveWith("plainhost", REAL10X_G, { + configBody: "Host something-else\n HostName elsewhere\n", + }); + expect(out.accepted).toBe(true); + if (out.accepted) { + // The tilde defaults expanded against the fixture home; nothing on the + // snapshot may stay tilde-form (the parser refuses it, and refusing a + // DEFAULT config is the C1 defect). + expect(out.snapshot.identityFiles).toEqual([ + join(homeDir, ".ssh/id_rsa"), + join(homeDir, ".ssh/id_ecdsa"), + join(homeDir, ".ssh/id_ecdsa_sk"), + join(homeDir, ".ssh/id_ed25519"), + join(homeDir, ".ssh/id_ed25519_sk"), + ]); + expect(out.snapshot.identityFiles.every((p) => p.startsWith("/"))).toBe(true); + expect(out.snapshot.knownHostsFiles).toEqual([ + "/home/opsuser/.ssh/known_hosts", + "/home/opsuser/.ssh/known_hosts2", + ]); + expect(out.snapshot.certificateFiles).toEqual([]); + expect(out.snapshot.hostKeyAlias).toBeNull(); + expect(out.snapshot.user).toBeNull(); // -G echoed the connecting account's own default + } + }); + + it("blocks a CONFIGURED tunnel or permitremoteopen while the default spellings pass", async () => { + const defaults = await resolveWith("app02", CLEAN_G); + expect(defaults.out.accepted).toBe(true); + + const tunnel = await resolveWith("app02", `${CLEAN_G.replace("tunnel false", "tunnel yes")}`); + expect(tunnel.out).toEqual({ accepted: false, code: "unsupported_setting", settings: ["Tunnel"] }); + + const remote = await resolveWith("app02", CLEAN_G.replace("permitremoteopen any", "permitremoteopen host:1080")); + expect(remote.out).toEqual({ accepted: false, code: "unsupported_setting", settings: ["PermitRemoteOpen"] }); + }); + + it("reads one HostName plus one Port as ONE route, not a conflict (real configs set both)", async () => { + const { out } = await resolveWith("app02", CLEAN_G, { + configBody: ["Host app02", " HostName resolved.example", " Port 2222"].join("\n"), + }); + expect(out.accepted).toBe(true); + }); + + it("maps a config ProxyCommand to a named refusal naming the keyword", async () => { + const { out } = await resolveWith("app02", `${CLEAN_G}\nproxycommand nc -X proxy 443 %h %p`); + expect(out).toEqual({ accepted: false, code: "unsupported_setting", settings: ["ProxyCommand"] }); + }); + + it("refuses each unrepresentable family by name, together when several are set", async () => { + const { out } = await resolveWith( + "app02", + [ + CLEAN_G, + "locallyforward 8080 localhost:80", + "permitremoteopen yes", + "remotecommand /bin/sh", + "sendenv FOO", + "localcommand /bin/true", + ].join("\n"), + ); + expect(out.accepted).toBe(false); + if (!out.accepted) { + expect(out.code).toBe("unsupported_setting"); + expect(out.settings).toEqual(["LocalForward", "PermitRemoteOpen", "LocalCommand", "RemoteCommand", "SendEnv"]); + } + }); + + it("refuses a Match exec config before the evaluator even decides", async () => { + const { out } = await resolveWith("app02", CLEAN_G, { + configBody: "Match exec curl -s trust.example\n User x\nHost app02\n HostName resolved.example\n", + }); + expect(out).toEqual({ accepted: false, code: "unsupported_setting", settings: ["Match exec"] }); + }); + + it("refuses a hop chain longer than the frozen cap", async () => { + const hops = ["a.example", "b.example", "c.example", "d.example", "e.example"].join(","); + const { out } = await resolveWith("app02", `${CLEAN_G}\nproxyjump ${hops}`); + expect(out).toEqual({ accepted: false, code: "proxy_chain_too_long", settings: [] }); + }); + + it("normalizes a bounded ProxyJump chain into approved hops", async () => { + const { out } = await resolveWith("app02", `${CLEAN_G}\nproxyjump ops@jump.example.com:2222,bastion`); + expect(out.accepted).toBe(true); + if (out.accepted) { + expect(out.snapshot.proxyJumps).toEqual([ + { host: "jump.example.com", user: "ops", port: 2222 }, + { host: "bastion", user: "deploy", port: 22 }, + ]); + } + }); + + it("treats a token that is not in the config as a manual destination only when -G echoes it as the host", async () => { + const manual = await resolveWith( + "fresh.host.example", + "host fresh.host.example\nhostname fresh.host.example\nuser opsuser\nport 22", + { + configBody: "Host something-else\n HostName elsewhere\n", + }, + ); + expect(manual.out.accepted).toBe(true); + if (manual.out.accepted) expect(manual.out.snapshot.host).toBe("fresh.host.example"); + + const rewritten = await resolveWith( + "ghost", + "host not-the-alias.example\nhostname not-the-alias.example\nuser opsuser\nport 22", + { + configBody: "Host unrelated\n User x\n", + }, + ); + expect(rewritten.out).toEqual({ accepted: false, code: "config_missing", settings: [] }); + }); + + it("resolves a conflicting-Hostname alias to config_ambiguous", async () => { + const { out } = await resolveWith("app02", CLEAN_G, { + configBody: ["Host app02", " Hostname one.example", "Host app02", " Hostname two.example"].join("\n"), + }); + expect(out).toEqual({ accepted: false, code: "config_ambiguous", settings: [] }); + }); + + it("resolves a conflicting-Port alias (same host, two ports) to config_ambiguous", async () => { + // The ports arm ALONE must refuse: no HostName difference is present to + // trip the hostNames set, so this pins the `ports.size > 1` branch. + const { out } = await resolveWith("app02", CLEAN_G, { + configBody: ["Host app02", " Port 22", "Host app02", " Port 2222"].join("\n"), + }); + expect(out).toEqual({ accepted: false, code: "config_ambiguous", settings: [] }); + }); + + it("names the agent socket from the account env, and an explicit IdentityAgent path", async () => { + const viaEnv = await resolveWith("app02", `${CLEAN_G}\nidentityagent ssh-agent`, { + env: { SSH_AUTH_SOCK: "/run/user/1000/ssh.sock" }, + }); + expect(viaEnv.out.accepted && viaEnv.out.snapshot.authAgentSocket).toBe("/run/user/1000/ssh.sock"); + + const explicit = await resolveWith("app02", `${CLEAN_G}\nidentityagent /home/ops/.ssh/custom-agent.sock`, { + env: { SSH_AUTH_SOCK: "/run/user/1000/ssh.sock" }, + }); + expect(explicit.out.accepted && explicit.out.snapshot.authAgentSocket).toBe("/home/ops/.ssh/custom-agent.sock"); + + const none = await resolveWith("app02", `${CLEAN_G}\nidentityagent none`, { + env: { SSH_AUTH_SOCK: "/run/user/1000/ssh.sock" }, + }); + expect(none.out.accepted && none.out.snapshot.authAgentSocket).toBeNull(); + }); + + it("stores user null when -G reports the connecting account's own default", async () => { + const out = await resolveWith("app02", CLEAN_G.replace("user deploy", "user opsuser"), { account: "opsuser" }); + expect(out.out.accepted && out.out.snapshot.user).toBeNull(); + }); + + it("refuses option-like and empty aliases before spawning anything", async () => { + const dir = tempRoot("subshell-ssh-resolve-bad-"); + const shim = writeSshShim(join(dir, "bin"), { dashG: CLEAN_G }); + const cfg = gConfig("Host x"); + for (const bad of ["-oProxyCommand=evil", "with space", ""]) { + const out = await resolveSshAliasConfig(bad, { + sshBin: shim.bin, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin" }, + connectingAccount: "opsuser", + timeoutMs: 5_000, + }); + expect(out.accepted).toBe(false); + } + }); + + it("a failing evaluator answers config_ambiguous, never a crash", async () => { + const dir = tempRoot("subshell-ssh-resolve-fail-"); + const shim = writeSshShim(join(dir, "bin"), { exitCode: 255, stderr: "bad configuration line" }); + // no dashG: the shim exits 255 for the -G call too. + const cfg = gConfig("Host app02\n HostName resolved.example\n"); + const out = await resolveSshAliasConfig("app02", { + sshBin: shim.bin, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin" }, + connectingAccount: "opsuser", + timeoutMs: 5_000, + }); + expect(out.accepted).toBe(false); + if (!out.accepted) expect(out.code).toBe("config_ambiguous"); + }); + + // THE C1 pin: the fixture mirror can only be as honest as the hand that + // wrote it, so the filter is finally run against the REAL host OpenSSH. + // The loud-skip pattern is `ssh-loopback-sshd.test.ts`'s: no ssh binary, + // loud warn, every case skipped, the shim suites above still run. + const HAVE_SSH = Bun.which("ssh") !== null; + if (!HAVE_SSH) { + console.warn( + "[ssh-resolve] ssh not present: REAL `ssh -G` GRAMMAR PIN SKIPPED (loud skip; shim fixtures above mirror 10.x output)", + ); + } + + describe("against the real host ssh -G", () => { + it.skipIf(!HAVE_SSH)( + "accepts an alias with NO explicit IdentityFile (tilde defaults + default tunnel spellings)", + async () => { + const cfg = gConfig("Host realg\n HostName 127.0.0.1\n Port 2222\n User targetuser\n"); + const out = await resolveSshAliasConfig("realg", { + sshBin: Bun.which("ssh") as string, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin" }, + connectingAccount: "opsuser", + timeoutMs: 10_000, + }); + expect(out.accepted).toBe(true); + if (out.accepted) { + expect(out.snapshot.host).toBe("127.0.0.1"); + expect(out.snapshot.port).toBe(2222); + expect(out.snapshot.user).toBe("targetuser"); + // The whole C1 chain in one assert: real defaults printed, no + // unsupported_setting (the tunnel/permitremoteopen arms), and no + // config_ambiguous (the tilde identityfiles expanded). + expect(out.snapshot.identityFiles.length).toBeGreaterThan(0); + expect(out.snapshot.identityFiles.every((p) => p.startsWith(`${cfg.homeDir}/.ssh/`))).toBe(true); + } + }, + 20_000, + ); + + it.skipIf(!HAVE_SSH)( + "still refuses, by name, what a real config sets", + async () => { + const cfg = gConfig( + "Host realbad\n HostName 127.0.0.1\n PermitRemoteOpen host:1080\n LocalCommand touch /tmp/nope\n", + ); + const out = await resolveSshAliasConfig("realbad", { + sshBin: Bun.which("ssh") as string, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin" }, + connectingAccount: "opsuser", + timeoutMs: 10_000, + }); + expect(out.accepted).toBe(false); + if (!out.accepted) { + expect(out.code).toBe("unsupported_setting"); + expect(out.settings).toContain("PermitRemoteOpen"); + expect(out.settings).toContain("LocalCommand"); // the M1 typo, on the real evaluator's own echo + } + }, + 20_000, + ); + }); +}); + +describe("parseProxyHop", () => { + it("parses the whole token grammar", () => { + expect(parseProxyHop("jump.example.com", null)).toEqual({ host: "jump.example.com", user: null, port: 22 }); + expect(parseProxyHop("ops@jump.example.com:2222", null)).toEqual({ + host: "jump.example.com", + user: "ops", + port: 2222, + }); + expect(parseProxyHop("[2001:db8::1]", null)).toEqual({ host: "[2001:db8::1]", user: null, port: 22 }); + expect(parseProxyHop("ops@[2001:db8::1]:2222", null)).toEqual({ + host: "[2001:db8::1]", + user: "ops", + port: 2222, + }); + expect(parseProxyHop("@jump.example.com", null)).toBeNull(); + expect(parseProxyHop("host:notaport", null)).toBeNull(); + expect(parseProxyHop("host:99999", null)).toBeNull(); + expect(parseProxyHop("host:0", null)).toBeNull(); + }); +}); diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-session-open-classify.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-session-open-classify.test.ts new file mode 100644 index 000000000..ccb433296 --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-session-open-classify.test.ts @@ -0,0 +1,88 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SSH_RUNTIME_SERVE_IN_USE_EXIT, type SshSessionTargetWire } from "@internal/subshell-protocol"; +import type { SshSessionHooks } from "../ssh-session-open.js"; +import { getSshSessionSupervisor, resetSshSessionSupervisorsForTests } from "../ssh-session-supervisor.js"; + +/** + * The pre-hello death classification (review m2), with a fake ssh (the + * recording shim pattern this dir's helpers established): the open reads the + * child's exit POSTURE when the hello never arrived, and the posture must map + * to the remedy the human can act on. + * + * - **A door bind refused exits {@link SSH_RUNTIME_SERVE_IN_USE_EXIT}**: the + * destination already carries a live session, and the serve now says so with + * its own exit code instead of dying as a generic throw. Before the fix it + * landed in the default arm and read "the binary is missing" on a machine + * whose binary is running the first session RIGHT NOW. + * - **The frozen arms stay frozen**: 127 (login-shell PATH drift past the + * probe) and the default arm stay `runtime_missing`; 255/null stays the + * transport verdict. The new branch is inserted, not substituted. + */ + +const roots: string[] = []; +function tempRoot(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + roots.push(dir); + return dir; +} + +/** + * A fake ssh whose serve invocation prints NOTHING on stdout and exits with + * `serveExit` (the pre-hello death the classifier reads). The probe still + * succeeds: the child only dies after it exists. + */ +function writeFakeSsh(dir: string, serveExit: number): string { + const script = [ + "#!/bin/sh", + `case "$*" in`, + ` *"command -v"*) printf '/usr/bin/subshell\\n'; exit 0 ;;`, + `esac`, + `case "$*" in`, + ` *"runtime-serve"*) exit ${serveExit} ;;`, + `esac`, + `exit 1`, + ].join("\n"); + const bin = join(dir, "ssh"); + writeFileSync(bin, `${script}\n`, { mode: 0o755 }); + chmodSync(bin, 0o755); + return bin; +} + +const target: SshSessionTargetWire = { alias: "cls", host: "127.0.0.1", port: 22, user: null, identityFile: null }; + +const hooks: SshSessionHooks = { emitBytes: async () => {}, emitDiag: () => {}, onLost: () => {} }; + +async function refused(serveExit: number): Promise { + const root = tempRoot(`cls-${serveExit}-`); + const sup = getSshSessionSupervisor({ + dataDir: join(root, "nodedata"), + homeDir: join(root, "home"), + sshBin: writeFakeSsh(root, serveExit), + nowMs: Date.now, + }); + const out = await sup.open({ ref: crypto.randomUUID(), target, runtimeCommand: "subshell" }, hooks); + if (out.kind !== "refused") throw new Error(`expected a refusal, got ${out.kind}`); + return out.code; +} + +afterAll(() => { + resetSshSessionSupervisorsForTests(); + for (const dir of roots) rmSync(dir, { recursive: true, force: true }); +}); + +describe("pre-hello death classification", () => { + test("the serve's own bind-refusal exit names session_in_use, never runtime_missing (m2)", async () => { + const code = await refused(SSH_RUNTIME_SERVE_IN_USE_EXIT); + expect(code).toBe("session_in_use"); // the remedy is the OTHER session + expect(code).not.toBe("runtime_missing"); // the binary demonstrably exists + }); + + test("the frozen arms stay: 127 and the default read runtime_missing, 255 reads the transport", async () => { + expect(await refused(127)).toBe("runtime_missing"); // PATH drift past the probe, honest either way + expect(await refused(1)).toBe("runtime_missing"); // the generic-throw default arm, unchanged + expect(await refused(255)).toBe("connection_failed"); // ssh's own failure, unclassified + }); +}); diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-session-supervisor.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-session-supervisor.test.ts new file mode 100644 index 000000000..c9d111d87 --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-session-supervisor.test.ts @@ -0,0 +1,366 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + encodeSshSessionFrame, + SSH_RUNTIME_PROTOCOL, + SSH_SESSIONS_PER_NODE, + type SshSessionTargetWire, +} from "@internal/subshell-protocol"; +import type { SshSessionHooks } from "../ssh-session-open.js"; +import { type SshSessionRecord, SshSessionRecordStore } from "../ssh-session-store.js"; +import { getSshSessionSupervisor, resetSshSessionSupervisorsForTests } from "../ssh-session-supervisor.js"; + +/** + * The brokered-session supervisor's two DURABLE promises, unit-tested with a + * fake ssh (the recording shim pattern this dir's helpers established - no + * real ssh, no network): + * + * - **The per-node quota refuses BEFORE a spawn** (`ssh-session-supervisor.ts` + * cap): the 9th open on a full node answers the bare `session_quota`, and + * the fake ssh proves it was never even invoked - the refusal is decided + * with the acceptance, not after a child exists (the walkthrough measured + * the plane's mirror; this pins the node's own copy). + * - **Boot reconcile + exit posture**: an `accepted`/`open` record with no + * live child under this process reads back `lost` (never a restart, never a + * claim about the destination's tmux), a live fake child's record survives + * the sweep, and a USER close records `closed` FIRST so the death on the + * way down is neither rewritten to `lost` nor reported through `onLost`. + */ + +const roots: string[] = []; +function tempRoot(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + roots.push(dir); + return dir; +} + +/** The hello frame the fake child prints as its first stdout bytes (broker's hello gate). */ +const HELLO_BYTES = encodeSshSessionFrame({ + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "9.9.9-fake", + os: "linux", + arch: "x64", + capabilities: ["ssh-runtime", "callback-sock"], + homeDir: "/home/dst", + dataDir: "/home/dst/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-fake000000", + paneCount: 0, +}); + +/** + * The fake ssh: a probe (`command -v …`) answers an absolute runtime path and + * logs `PROBE`; a serve invocation (`… runtime-serve …`) logs `SERVE`, prints + * the hello bytes, and holds as `sleep` until the supervisor's group-kill. + * Every spawn counts in the log, so "refused before spawn" is observable. + */ +function writeFakeSsh(dir: string): { bin: string; log: string; pids: string } { + const log = join(dir, "invocations.log"); + const pids = join(dir, "serve-pids.log"); + const hello = join(dir, "hello.bin"); + writeFileSync(hello, HELLO_BYTES); + const script = [ + "#!/bin/sh", + `case "$*" in`, + ` *"command -v"*) printf 'PROBE\\n' >> '${log}'; printf '/usr/bin/subshell\\n'; exit 0 ;;`, + `esac`, + `case "$*" in`, + ` *"runtime-serve"*)`, + ` printf 'SERVE\\n' >> '${log}'`, + ` printf '%s\\n' "$$" >> '${pids}'`, // its own pid, preserved through the exec + ` cat '${hello}'`, + ` exec sleep 300`, + ` ;;`, + `esac`, + `exit 1`, + ].join("\n"); + const bin = join(dir, "ssh"); + writeFileSync(bin, `${script}\n`, { mode: 0o755 }); + chmodSync(bin, 0o755); + return { bin, log, pids }; +} + +/** Poll until the pid is gone (ESRCH: dead AND reaped). True once it is. */ +async function waitForPidDeath(pid: number, budgetMs: number): Promise { + const t0 = Date.now(); + for (;;) { + try { + process.kill(pid, 0); + } catch { + return true; + } + if (Date.now() - t0 > budgetMs) return false; + await new Promise((r) => setTimeout(r, 20)); + } +} + +function invocations(log: string): { probe: number; serve: number } { + let text = ""; + try { + text = readFileSync(log, "utf8"); + } catch { + return { probe: 0, serve: 0 }; + } + return { + probe: text.split("\n").filter((l) => l === "PROBE").length, + serve: text.split("\n").filter((l) => l === "SERVE").length, + }; +} + +const target: SshSessionTargetWire = { alias: "sup", host: "127.0.0.1", port: 22, user: null, identityFile: null }; + +/** One supervisor fixture: fresh node data dir + home + fake ssh + recorded hooks. */ +function mkFixture(tag: string) { + const root = tempRoot(`sup-${tag}-`); + const fake = writeFakeSsh(root); + const dataDir = join(root, "nodedata"); + const homeDir = join(root, "home"); + let clock = 1_700_000_000_000; + const lost: { exitCode: number | null; signal: string | null }[] = []; + const hooks: SshSessionHooks = { + emitBytes: async () => {}, + emitDiag: () => {}, + onLost: (info) => lost.push(info), + }; + const store = new SshSessionRecordStore({ dataDir, nowMs: () => clock }); + const sup = getSshSessionSupervisor({ dataDir, homeDir, sshBin: fake.bin, nowMs: () => clock }); + return { sup, store, hooks, lost, log: fake.log, pids: fake.pids, setClock: (t: number) => (clock = t) }; +} + +const openReq = (ref: string) => ({ ref, target, runtimeCommand: "subshell" }); +const stray = (store: SshSessionRecordStore, ref: string, lifecycle: SshSessionRecord["lifecycle"]): void => { + store.write({ ref, lifecycle, host: "127.0.0.1", port: 22, user: null, openedAtMs: 1 }); +}; + +async function waitLive(sup: ReturnType, empty: boolean): Promise { + const t0 = Date.now(); + while ((sup.liveRefs().length === 0) !== empty) { + if (Date.now() - t0 > 5000) throw new Error(`live map never became ${empty ? "empty" : "non-empty"}`); + await new Promise((r) => setTimeout(r, 20)); + } +} + +afterAll(async () => { + resetSshSessionSupervisorsForTests(); + for (const dir of roots) rmSync(dir, { recursive: true, force: true }); +}); + +describe("per-node quota (node side)", () => { + test("the open past SSH_SESSIONS_PER_NODE refuses session_quota without ever invoking ssh", async () => { + const f = mkFixture("quota"); + const refs: string[] = []; + try { + for (let i = 0; i < SSH_SESSIONS_PER_NODE; i++) { + const ref = crypto.randomUUID(); + const out = await f.sup.open(openReq(ref), f.hooks); + expect(out.kind, `open ${i + 1} fits under the cap`).toBe("open"); + if (out.kind === "open") { + expect(out.result.hello.runtimeProtocol).toBe(SSH_RUNTIME_PROTOCOL); + expect(out.result.host).toBe("127.0.0.1"); + } + refs.push(ref); + } + expect(f.sup.liveRefs().length).toBe(SSH_SESSIONS_PER_NODE); + const denied = await f.sup.open(openReq(crypto.randomUUID()), f.hooks); + expect(denied).toEqual({ kind: "refused", code: "session_quota" }); // the bare equality-mapped code the plane maps + // Refused BEFORE a spawn: the fake ssh was invoked for exactly the 8 + // accepted opens (one probe + one serve each), nothing for the 9th. + expect(invocations(f.log)).toEqual({ probe: SSH_SESSIONS_PER_NODE, serve: SSH_SESSIONS_PER_NODE }); + // And nothing durable was accepted for the refused ref (no stray + // `accepted` record that the next boot would read back as `lost`). + expect(f.sup.reconcileAtBoot()).toEqual([]); + } finally { + for (const ref of refs) f.sup.close(ref); + await waitLive(f.sup, true); + } + }); +}); + +describe("awaited input backpressure", () => { + test("a blocked pipe waits for flush, caps concurrent bytes, and cancels pending writes on close", async () => { + const f = mkFixture("input-cap"); + const ref = crypto.randomUUID(); + try { + expect((await f.sup.open(openReq(ref), f.hooks)).kind).toBe("open"); + expect(await f.sup.sendAsync(ref, new Uint8Array(32))).toBe("ok"); + let settled = false; + const blocked = f.sup.sendAsync(ref, new Uint8Array(768 * 1024)).then((value) => { + settled = true; + return value; + }); + await Bun.sleep(50); + expect(settled, "the child never reads stdin; a full pipe must not acknowledge").toBe(false); + expect(await f.sup.sendAsync(ref, new Uint8Array(512 * 1024))).toBe("session_unknown"); + expect(await blocked).toBe("session_unknown"); + expect(await f.sup.sendAsync(ref, new Uint8Array(1))).toBe("session_unknown"); + await waitLive(f.sup, true); + } finally { + f.sup.close(ref); + await waitLive(f.sup, true); + } + }); + + test("a stopped child releases a pending flush and rejects later writes", async () => { + const f = mkFixture("input-death"); + const ref = crypto.randomUUID(); + try { + expect((await f.sup.open(openReq(ref), f.hooks)).kind).toBe("open"); + const blocked = f.sup.sendAsync(ref, new Uint8Array(768 * 1024)); + await Bun.sleep(50); + process.kill(Number(readFileSync(f.pids, "utf8").trim()), "SIGKILL"); + expect(await blocked).toBe("session_unknown"); + await waitLive(f.sup, true); + expect(await f.sup.sendAsync(ref, new Uint8Array(1))).toBe("session_unknown"); + } finally { + f.sup.close(ref); + await waitLive(f.sup, true); + } + }); + + test("a stalled reader loses its child after the flush deadline", async () => { + const f = mkFixture("input-timeout"); + const ref = crypto.randomUUID(); + try { + expect((await f.sup.open(openReq(ref), f.hooks)).kind).toBe("open"); + const began = Date.now(); + expect(await f.sup.sendAsync(ref, new Uint8Array(768 * 1024))).toBe("session_unknown"); + expect(Date.now() - began).toBeGreaterThanOrEqual(9_000); + await waitLive(f.sup, true); + } finally { + f.sup.close(ref); + await waitLive(f.sup, true); + } + }, 15_000); +}); + +describe("boot reconcile and exit posture", () => { + test("stray accepted/open records read back lost; closed and lost are history, left alone", () => { + const f = mkFixture("reconcile"); + stray(f.store, "a1", "accepted"); + stray(f.store, "o1", "open"); + stray(f.store, "c1", "closed"); + stray(f.store, "l1", "lost"); + f.setClock(2_000); + + const swept = f.sup.reconcileAtBoot(); + expect(swept.map((r) => r.ref).sort()).toEqual(["a1", "o1"]); + expect(f.store.read(f.store.path("a1"))?.lifecycle).toBe("lost"); + expect(f.store.read(f.store.path("a1"))?.lostAtMs).toBe(2_000); // the injectable clock rode the sweep + expect(f.store.read(f.store.path("o1"))?.lifecycle).toBe("lost"); + expect(f.store.read(f.store.path("c1"))?.lifecycle).toBe("closed"); + expect(f.store.read(f.store.path("c1"))?.lostAtMs).toBeUndefined(); + expect(f.store.read(f.store.path("l1"))?.lifecycle).toBe("lost"); + // Idempotent: a second sweep finds nothing (the lost terminal state is final). + expect(f.sup.reconcileAtBoot()).toEqual([]); + }); + + test("the constructor reconciles before any open can run", () => { + const root = tempRoot("sup-boot-"); + const fake = writeFakeSsh(root); + const dataDir = join(root, "nodedata"); + const store = new SshSessionRecordStore({ dataDir, nowMs: Date.now }); + const ref = crypto.randomUUID(); + store.write({ ref, lifecycle: "accepted", host: "h", port: 22, user: null, openedAtMs: 1 }); // last daemon's crash-between + const sup = getSshSessionSupervisor({ dataDir, homeDir: join(root, "home"), sshBin: fake.bin, nowMs: Date.now }); + expect(sup.liveRefs()).toEqual([]); + expect(store.read(store.path(ref))?.lifecycle).toBe("lost"); + expect(sup.reconcileAtBoot()).toEqual([]); // the build-time sweep already took it + }); + + test("a live child's record survives the sweep, and a close records closed BEFORE the death", async () => { + const f = mkFixture("exit"); + const ref = crypto.randomUUID(); + try { + const out = await f.sup.open(openReq(ref), f.hooks); + expect(out.kind).toBe("open"); + expect(f.store.read(f.store.path(ref))?.lifecycle).toBe("open"); + stray(f.store, "stray1", "accepted"); + + // The sweep with a REAL live child: only the stranger is swept; the + // child's `open` record is untouched (live map is authority, not disk). + const swept = f.sup.reconcileAtBoot(); + expect(swept.map((r) => r.ref)).toEqual(["stray1"]); + expect(f.store.read(f.store.path(ref))?.lifecycle).toBe("open"); + + // The user close: `closed` lands FIRST, so the SIGTERM death on the way + // down is neither rewritten to `lost` nor reported as a loss. + expect(f.sup.close(ref)).toBe("ok"); + expect(f.store.read(f.store.path(ref))?.lifecycle).toBe("closed"); + await waitLive(f.sup, true); + await new Promise((r) => setTimeout(r, 50)); // let the exit pump finish its last write + expect(f.store.read(f.store.path(ref))?.lifecycle).toBe("closed"); + expect(f.lost, "a close is not a loss (the stopping latch)").toEqual([]); + } finally { + f.sup.close(ref); // idempotent if the child already died + await waitLive(f.sup, true); + } + }); + + test("a store.write that throws cannot spare the group-kill: the child dies and close() does not throw (review round-4 MINOR2)", async () => { + const f = mkFixture("close-diskerr"); + const ref = crypto.randomUUID(); + const proto = SshSessionRecordStore.prototype; + const realWrite = proto.write; + let faults = 0; + try { + const out = await f.sup.open(openReq(ref), f.hooks); + expect(out.kind).toBe("open"); + expect(f.store.read(f.store.path(ref))?.lifecycle).toBe("open"); + const servePids = readFileSync(f.pids, "utf8") + .split("\n") + .filter((l) => l !== "") + .map(Number); + expect(servePids).toHaveLength(1); + + // The fault: the disk refuses the `closed` record write. The real + // store is bare sync fs (mkdir/writeFileSync/chmodSync), so ENOSPC/ + // EROFS/EACCES throws are ordinary events at this seam; every other + // write stays true so the pumps and the exit settle keep landing. + proto.write = function (this: SshSessionRecordStore, rec: SshSessionRecord): void { + if (rec.lifecycle === "closed") { + faults += 1; + throw Object.assign(new Error("ENOSPC: no space left on device"), { code: "ENOSPC" }); + } + realWrite.call(this, rec); + }; + + let threw: unknown = null; + let verdict: unknown = null; + try { + verdict = f.sup.close(ref); + } catch (e) { + threw = e; + } + + // The headline, and the RED this test was written against: with the + // record unwritable the GROUP must still die. The old order (write, + // then kill) let the throw spare EVERY close() call site - the m-B + // reclaim, the emitBytes self-heal, the link-close drain (m-A's + // per-ref catch keeps the loop alive but cannot kill a child whose + // close threw before killGroup) - orphaning a child that holds a + // quota slot and the destination's door: every later open there + // answers session_in_use for a session the plane does not have. + expect(faults, "the fault actually fired on the close record").toBe(1); + const dead = await waitForPidDeath(servePids[0] as number, 3000); + expect(dead, "the group-kill must land even when the record write throws").toBe(true); + + // The throw never escapes close(): the drain and the reclaim answer + // through this seam, and the return contract stays `"ok"`. + expect(threw, "a disk error in the record is a reconcile detail, not a caller-visible failure").toBeNull(); + expect(verdict).toBe("ok"); + + // The death stays QUIET on the `stopping` latch, not on the record: + // the exit pump finds no `closed` on disk, settles the history to + // `lost` (history, never authority), and reports no loss. + await waitLive(f.sup, true); + expect(f.lost, "the stopping latch is what keeps the death quiet").toEqual([]); + expect(f.store.read(f.store.path(ref))?.lifecycle).toBe("lost"); + } finally { + proto.write = realWrite; + f.sup.close(ref); // idempotent once dead; reaps cleanly if RED left the child alive + await waitLive(f.sup, true); + } + }); +}); diff --git a/packages/pane-runtime/src/ssh/__tests__/sshd-fixture.test.ts b/packages/pane-runtime/src/ssh/__tests__/sshd-fixture.test.ts new file mode 100644 index 000000000..ac3089995 --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/sshd-fixture.test.ts @@ -0,0 +1,259 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs"; +import { userInfo } from "node:os"; +import { join } from "node:path"; +import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; +import { classifySshFailure } from "../ssh-diagnose.js"; +import { buildSshInvocation, remoteCommandLine, renderSshConfigContents, sshChildEnv } from "../ssh-render.js"; +import { runSshProcess } from "../ssh-spawn.js"; +import { cleanup, tempRoot } from "./helpers.js"; + +/** + * The real-daemon suite (brief: "real isolated sshd fixture … gated on sshd + * presence with a loud skip line"). Temp HOME, generated host key + trusted + * client key, loopback ports, own pid tracked, cleanup only of resources the + * fixture started (SSH-SUPPORT.md §6: "Never use the live instance or a + * developer's SSH configuration" — this fixture's sshd_config sets no system + * paths, its keys are generated, its ports are loopback and ephemeral). + * + * Covers the trust behaviors that only a real OpenSSH answers honestly: + * unknown key refusal, changed key refusal (fail closed, never re-trust), a + * benign probe run through the FULL rendered path, and the ProxyJump claim + * the renderer depends on: that the `-F` file — and ONLY that file — is what + * the jump children read (the fixture's HOME carries no `.ssh` at all, so any + * ambient read would fail closed and the jump test would go red). + */ + +const HAVE_SSH = Bun.which("ssh") !== null && Bun.which("ssh-keygen") !== null && Bun.which("sshd") !== null; +if (!HAVE_SSH) { + // A loud skip line, not a silent green: on a host without sshd this whole + // trust surface is UNVERIFIED, and the suite result should say so. + console.warn("[ssh-sshd-fixture] sshd/ssh-keygen/ssh not present on this host: REAL-DAEMON SSH TESTS SKIPPED"); +} + +let root: string; +let home: string; // the fixture's fake HOME — deliberately has NO .ssh dir +let sshBin: string; +let sshdPid: number | null = null; +let sshd2Pid: number | null = null; +let port = 0; +let port2 = 0; +let clientKey = ""; +let knownHosts = ""; +let knownHosts2 = ""; +let account = ""; + +async function freePort(): Promise { + for (let i = 0; i < 20; i++) { + const p = 20_000 + Math.floor(Math.random() * 40_000); + try { + const srv = Bun.listen({ hostname: "127.0.0.1", port: p, socket: { data() {} } }); + srv.unref?.(); + srv.stop(); + await Bun.sleep(20); + return p; + } catch { + // taken: try another + } + } + throw new Error("no free loopback port"); +} + +async function startSshd(dir: string, listenPort: number, pidFile: string, hostKeyFile: string): Promise { + mkdirSync(dir, { recursive: true }); + const cfg = [ + `Port ${listenPort}`, + "ListenAddress 127.0.0.1", + `HostKey ${hostKeyFile}`, + `PidFile ${pidFile}`, + `AuthorizedKeysFile ${join(home, ".ssh", "authorized_keys")}`, + "StrictModes no", + "UsePAM no", + "PasswordAuthentication no", + "KbdInteractiveAuthentication no", + "PubkeyAuthentication yes", + "PermitUserEnvironment no", + "PrintMotd no", + "LogLevel VERBOSE", + ].join("\n"); + writeFileSync(join(dir, "sshd_config"), cfg); + // sshd -e means "log to stderr" and takes NO file argument: the fixture + // redirects the child's stderr into its own file. + const logFd = openSync(join(dir, "sshd.log"), "w"); + const proc = Bun.spawn(["/usr/sbin/sshd", "-D", "-f", join(dir, "sshd_config"), "-e"], { + stdout: "ignore", + stderr: logFd, + stdin: "ignore", + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + }); + closeSync(logFd); + // Readiness probe: KEEP trying the port through ssh itself until the answer + // is no longer "nobody is listening" (an auth refusal means the daemon is + // up — exactly the fact we are waiting for). + for (let i = 0; i < 100; i++) { + const probe = Bun.spawnSync( + [ + Bun.which("ssh")!, + "-p", + String(listenPort), + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=1", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "127.0.0.1", + "true", + ], + { + stdout: "ignore", + stderr: "pipe", + }, + ); + const err = probe.stderr.toString(); + if (!/Connection refused|connect to host 127\.0\.0\.1 port|Connection timed out/i.test(err)) return proc.pid; + await Bun.sleep(100); + } + const log = readFileSync(join(dir, "sshd.log"), "utf8").slice(-2000); + throw new Error(`sshd did not come up on 127.0.0.1:${listenPort}\n${log}`); +} + +function snapshotFor(p: number, overrides: Partial = {}): SshConnectionSnapshotWire { + return { + alias: "fixture", + host: "127.0.0.1", + user: account, + port: p, + identityFiles: [clientKey], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [knownHosts], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...overrides, + }; +} + +beforeAll(async () => { + if (!HAVE_SSH) return; + root = tempRoot("subshell-sshd-"); + home = join(root, "home"); + mkdirSync(join(home, ".ssh"), { recursive: true }); // authorized_keys only — NO config, NO trust files of the developer + account = userInfo().username; + sshBin = Bun.which("ssh")!; + // host key + client key + Bun.spawnSync(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", join(root, "hostkey")]); + Bun.spawnSync(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", join(root, "clientkey")]); + clientKey = join(root, "clientkey"); + writeFileSync(join(home, ".ssh", "authorized_keys"), `${readFileSync(`${clientKey}.pub`, "utf8").trim()}\n`); + knownHosts = join(root, "known_hosts"); + port = await freePort(); + port2 = await freePort(); + sshdPid = await startSshd(join(root, "sshd1"), port, join(root, "sshd1.pid"), join(root, "hostkey")); + sshd2Pid = await startSshd(join(root, "sshd2"), port2, join(root, "sshd2.pid"), join(root, "hostkey")); + // rewrite the known_hosts lines with the [host]:port authority ssh will check + const keyTypeAndBlob = readFileSync(join(root, "hostkey.pub"), "utf8").trim().split(" ").slice(0, 2).join(" "); + writeFileSync(knownHosts, `[127.0.0.1]:${port} ${keyTypeAndBlob}\n`); + knownHosts2 = join(root, "known_hosts2"); + writeFileSync(knownHosts2, `[127.0.0.1]:${port} ${keyTypeAndBlob}\n[127.0.0.1]:${port2} ${keyTypeAndBlob}\n`); + chmodSync(root, 0o700); +}, 90_000); + +afterAll(async () => { + // keygen + two daemon starts + readiness probes are process-spawn work the + // default 5 s hook budget cannot cover. + for (const pid of [sshdPid, sshd2Pid]) { + if (pid !== null && pid > 1) { + try { + process.kill(pid, "SIGTERM"); + } catch { + // already gone + } + } + } + if (root) cleanup(root); +}, 20_000); + +async function runSnapshot(p: number, command: string, overrides: Partial = {}) { + const dir = join(root, `run-${p}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + const configPath = join(dir, "config"); + const snap = snapshotFor(p, overrides); + writeFileSync(configPath, renderSshConfigContents(snap), { mode: 0o600 }); + const argv = buildSshInvocation({ + sshBin, + snapshot: snap, + configPath, + remoteCommand: remoteCommandLine(command, null), + }); + const env = await sshChildEnv(snap, home); // HOME has NO .ssh: only -F and the argv exist + return await runSshProcess(argv, env, 15_000); +} + +describe("real isolated sshd", () => { + const test = HAVE_SSH ? it : it.skip; + + test("a benign run through the rendered config executes on the destination", async () => { + const res = await runSnapshot(port, "echo subshell-fixture-ok"); + expect(res.timedOut).toBe(false); + expect(res.stdout).toContain("subshell-fixture-ok"); + expect(res.code).toBe(0); + }, 30_000); + + test("unknown host key fails closed and classifies host_key_unknown", async () => { + const res = await runSnapshot(port, "echo nope", { knownHostsFiles: [] }); + expect(res.code).toBe(255); + expect(res.stdout).not.toContain("nope"); + expect(classifySshFailure(res.stderr)).toBe("host_key_unknown"); + }, 30_000); + + test("changed host key fails closed, never re-trusts, and classifies host_key_changed", async () => { + const wrong = join(root, "known_hosts_wrong"); + Bun.spawnSync(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", join(root, "wrongkey")]); + const wrongBlob = readFileSync(join(root, "wrongkey.pub"), "utf8").trim().split(" ").slice(0, 2).join(" "); + writeFileSync(wrong, `[127.0.0.1]:${port} ${wrongBlob}\n`); + const res = await runSnapshot(port, "echo pwned", { knownHostsFiles: [wrong] }); + expect(res.code).toBe(255); + expect(res.stdout).not.toContain("pwned"); + expect(res.stderr).toContain("REMOTE HOST IDENTIFICATION HAS CHANGED"); + expect(classifySshFailure(res.stderr)).toBe("host_key_changed"); + }, 30_000); + + test("ProxyJump: the -F policy file is what the JUMP child authenticates against (no ambient HOME config exists)", async () => { + // Destination is sshd #2; hop is sshd #1. The child ssh the jump spawns + // gets only what the PARENT's -F file and the propagated options give it: + // the fixture HOME has no `.ssh`, and hop trust lives ONLY in the -F + // file's UserKnownHostsFile entry. Success therefore proves §2's + // "normalize every hop under the same restrictions" actually reaches the + // hop; failure would prove the renderer cannot rely on -F and must + // compose the jump chain explicitly instead. + const dir = join(root, "jump"); + mkdirSync(dir, { recursive: true }); + const snap = snapshotFor(port2, { + proxyJumps: [{ host: "127.0.0.1", user: account, port }], + knownHostsFiles: [knownHosts2], + }); + const configPath = join(dir, "config"); + writeFileSync(configPath, renderSshConfigContents(snap), { mode: 0o600 }); + const argv = buildSshInvocation({ + sshBin, + snapshot: snap, + configPath, + remoteCommand: remoteCommandLine("echo via-hop", null), + }); + const env = await sshChildEnv(snap, home); + const res = await runSshProcess(argv, env, 20_000); + expect(res.stdout).toContain("via-hop"); + expect(res.code).toBe(0); + }, 60_000); +}); diff --git a/packages/pane-runtime/src/ssh/kill-group.ts b/packages/pane-runtime/src/ssh/kill-group.ts new file mode 100644 index 000000000..107b0d8e0 --- /dev/null +++ b/packages/pane-runtime/src/ssh/kill-group.ts @@ -0,0 +1,23 @@ +/** + * Stop a child and everything it spawned, as ONE unit. Every SSH child the + * runtime starts is spawned detached into its own process group, so the + * kill addresses `-pid` (the group) and only falls back to the bare pid + * when the group is already gone or was never ours. Extracted from the + * retired structured-run child module so the brokered-session supervisor + * and the child keep sharing ONE implementation (the run family died with + * the destination product; this helper outlived it by the retirement + * brief's "extract shared helpers" rule). + */ +export function killGroup(pid: number, signal: "SIGTERM" | "SIGKILL"): void { + try { + process.kill(-pid, signal); + return; + } catch { + // ESRCH / EPERM: no group (or not ours) — fall to the single pid. + } + try { + process.kill(pid, signal); + } catch { + // already reaped + } +} diff --git a/packages/pane-runtime/src/ssh/ssh-diagnose.ts b/packages/pane-runtime/src/ssh/ssh-diagnose.ts new file mode 100644 index 000000000..47f2d26bc --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-diagnose.ts @@ -0,0 +1,72 @@ +import type { SshErrorCode } from "@internal/subshell-protocol"; + +/** + * Honest classification of ssh's failure output. + * + * The connection probe and an ambiguous exit 255 are the two places a + * failure must be NAMED (SSH-SUPPORT.md §3: "unknown must not masquerade as + * failed or successful", and a failed test that names no code is refused by + * the wire validator). Both read the same stderr signals, so both consult + * ONE classifier here rather than each keeping a regex pile. + * + * These matches read OpenSSH's own diagnostics (ssh/sshconnect2.c, + * readconf.c wording) across the versions the feature supports; a + * classification miss degrades to `null` / `connection_failed`, never to a + * WRONG name: `connection_failed` is the frozen code that "must never be + * dressed up as a diagnosis". + */ + +/** Case-insensitive patterns naming OpenSSH's own transport/setup failures on stderr. */ +const TRANSPORT_FAILURE_RES: readonly RegExp[] = [ + /connection (closed|refused|reset|timed out)/i, + /connection established but /i, + /broken pipe/i, + /network is unreachable/i, + /no route to host/i, + /operation timed out/i, + /could not resolve hostname/i, + /name or service not known/i, + /unable to negotiate/i, + /no matching (host|key|cipher|mac) type/i, + /host key verification failed/i, + /man-in-the-middle attack/i, + /REMOTE HOST IDENTIFICATION HAS CHANGED/i, + /too many authentication failures/i, + /permission denied \(/i, + /no supported authentication methods/i, + /client_loop: send disconnect/i, + /mux_client_request/i, + /proxy command exited/i, + /connection to [^ ]+ port \d+ (closed|timed out)/i, +]; + +/** True when stderr carries a signal that ssh's TRANSPORT (or its setup) failed — the corroborating fact that reads a 255 as ssh's own, not the remote program's. */ +export function sshTransportFailure(stderr: string): boolean { + return TRANSPORT_FAILURE_RES.some((re) => re.test(stderr)); +} + +/** + * Best-effort named code for a failed connect/setup, from the child's stderr. + * Returns null when nothing more specific than "it failed" is visible; the + * caller then answers the frozen `connection_failed`. + * + * Order matters: a changed-key refusal also contains "verification failed", + * so the more specific test runs first. + * + * @param stderr - the ssh child's captured stderr (may be empty) + */ +export function classifySshFailure(stderr: string): SshErrorCode | null { + const s = stderr; + if (/REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(s)) return "host_key_changed"; + if (/revoked/i.test(s)) return "host_key_revoked"; + if (/host key verification failed/i.test(s)) return "host_key_unknown"; + if (/password|keyboard-interactive|native keyboard-interactive/i.test(s)) return "auth_mode_unsupported"; + if ( + /permission denied|no supported authentication methods|too many authentication failures|unprotected private key|bad passphrase|identity file .* not accessible/i.test( + s, + ) + ) { + return "key_unavailable"; + } + return null; +} diff --git a/packages/pane-runtime/src/ssh/ssh-discover.ts b/packages/pane-runtime/src/ssh/ssh-discover.ts new file mode 100644 index 000000000..4ef4ac911 --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-discover.ts @@ -0,0 +1,364 @@ +import { closeSync, constants, lstatSync, openSync, readdirSync, readSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { SSH_MAX_DISCOVERED_ALIASES, SSH_NAME_MAX_CHARS } from "@internal/subshell-protocol"; + +/** + * Bounded discovery of SSH alias NAMES (SSH-SUPPORT.md §2: "Discover aliases + * by bounded parsing of the account's config and includes; omit wildcard-only + * entries, detect include cycles"). + * + * This parser answers NAMES, never config contents, and it is deliberately a + * NAME-HUNT and not an implementation of ssh_config semantics: the authoritative + * resolution of what an alias means is `ssh -G` (`ssh-resolve.ts`), which is + * OpenSSH's own evaluator. What must not be outsourced is the BOUNDS: reading + * the account's config is the one step an alias-rich `Include` chain can turn + * into arbitrary filesystem traversal and unbounded work, so the walk carries + * hard budgets (file count, total bytes, include-expansion width) and a cycle + * detector over its own ancestor chain. A cycle is a FACT the human reviewing + * the list needs (`includeCycle`), never a silent stop. + * + * A few ssh_config facts this parser must still get right, because they decide + * which names are reported: `Host` takes a whitespace-separated pattern list + * (wildcard patterns and `!`-negations are not aliases), `Include` takes + * whitespace-separated globs with `~` expansion, `Match` blocks carry no names, + * and `#` comments run from unquoted whitespace-preceded hashes. Anything + * subtler than that is `ssh -G`'s problem by design. + */ + +/** The alias list the command answers; mirrors the frozen `NodeSshAliasListResult` shape. */ +export interface SshAliasDiscovery { + /** Alias names, deduplicated, sorted; wildcard-only and negated patterns excluded. */ + aliases: string[]; + /** One config file appeared in another's include ANCESTRY chain; the list is what parsed before the repeat. */ + includeCycle: boolean; + /** A budget was exhausted or the name cap was reached; more may exist than were returned. */ + truncated: boolean; +} + +/** One parsed `Host` block: its pattern tokens and the directives set under it. */ +export interface SshHostBlock { + /** The pattern tokens after `Host` (verbatim, including wildcards/negations). */ + tokens: string[]; + /** Directives under this block, in file order; keyword lowercased, value raw. */ + directives: { keyword: string; value: string }[]; +} + +/** What one bounded walk produced, for discovery AND resolution to share. */ +export interface SshConfigWalk { + /** Every `Host` block in include order, plus the pre-first-Host global directives. */ + hostBlocks: SshHostBlock[]; + /** Directives seen before any `Host` line (the implicit-global context). */ + globalDirectives: { keyword: string; value: string }[]; + /** A `Match exec` line was seen anywhere in the walk (a local-execution signal §2 refuses on). */ + matchExecSeen: boolean; + /** The config files that were actually read, walk order (cycle detection is done by the walk itself). */ + files: string[]; + includeCycle: boolean; + truncated: boolean; +} + +/** Budgets for one walk; defaults are the shipped numbers, tests pass smaller. */ +export interface SshWalkBudget { + /** Max config files read in one walk (includes count). */ + maxFiles?: number; + /** Max bytes read across all files. */ + maxTotalBytes?: number; + /** Max globs expanded per `Include` directive. */ + maxIncludeMatches?: number; + /** Longest single line kept (openbsd caps config lines too; longer lines are dropped). */ + maxLineBytes?: number; +} + +const DEFAULT_BUDGET = { + maxFiles: 64, + maxTotalBytes: 2 * 1024 * 1024, + maxIncludeMatches: 256, + maxLineBytes: 4096, +}; + +/** Default location of the connecting account's SSH client config. */ +export function defaultSshConfigPath(homeDir: string): string { + return join(homeDir, ".ssh", "config"); +} + +/** + * An alias name worth reporting: pattern syntax (`*`, `?`), negation (`!`), + * option-like leading `-`, whitespace, and control characters are all excluded, + * matching the hygiene the wire grammar applies to aliases + * (`parseSshNodeCommandBody`'s `isAliasName`) so a name discovery reports is a + * name resolution can be asked for. + */ +export function isDiscoverableAlias(token: string): boolean { + return ( + token.length > 0 && + token.length <= SSH_NAME_MAX_CHARS && + !token.includes("*") && + !token.includes("?") && + !token.startsWith("!") && + !token.startsWith("-") && + !/\s|[\p{Cc}]/u.test(token) + ); +} + +/** Keyword/value split: `keyword value`, `keyword=value`, case-insensitive keyword. */ +function splitKeywordArg(line: string): { keyword: string; arg: string } | null { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) return null; + // Strip comments: an unquoted `#` preceded by whitespace ends the meaningful + // text. Quoted values (rare, and only meaningful for Match/exec arguments + // this parser never evaluates) are left alone rather than half-parsed. + const comment = findCommentStart(trimmed); + const body = comment === -1 ? trimmed : trimmed.slice(0, comment).trimEnd(); + if (body === "") return null; + const eq = body.indexOf("="); + const ws = body.search(/\s/); + const cut = eq === -1 ? ws : ws === -1 ? eq : Math.min(eq, ws); + if (cut === -1) return { keyword: body.toLowerCase(), arg: "" }; + const keyword = body.slice(0, cut).toLowerCase(); + let arg = body.slice(cut); + if (arg.startsWith("=")) arg = arg.slice(1); + return { keyword, arg: arg.trim() }; +} + +/** Index of the comment start: a `#` at position 0 or preceded by whitespace (and not inside quotes). */ +function findCommentStart(line: string): number { + let inSingle = false; + let inDouble = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (ch === "#" && !inSingle && !inDouble && (i === 0 || /\s/.test(line[i - 1]))) return i; + } + return -1; +} + +/** Expand a leading `~` / `~/` against the home dir; anything else passes through. Exported for {@link resolveSshAliasConfig}: `ssh -G` prints default identity paths in tilde form, and the snapshot grammar takes only absolutes. */ +export function expandTilde(raw: string, homeDir: string): string { + if (raw === "~") return homeDir; + if (raw.startsWith("~/")) return join(homeDir, raw.slice(2)); + return raw; +} + +/** + * Expand one Include glob to concrete file paths. The pattern is anchored at + * the longest non-globbing directory prefix; the remainder is matched + * segment-by-segment against `readdirSync`, capped by `maxMatches`. This is a + * deliberately small glob engine (`*` and `?` within a segment, no `**`, no + * bracket classes): ssh_config Include globs that this cannot expand yield + * fewer aliases, never an unbounded walk. + */ +function expandIncludeGlob(pattern: string, includingFile: string, homeDir: string, maxMatches: number): string[] { + let expanded = expandTilde(pattern, homeDir); + if (!isAbsolute(expanded)) expanded = resolve(dirname(includingFile), expanded); + const segments = expanded.split("/").filter((s) => s !== ""); + const out: string[] = ["/"]; + for (const segment of segments) { + const next: string[] = []; + const globbing = segment.includes("*") || segment.includes("?"); + if (!globbing) { + for (const base of out) next.push(join(base, segment)); + } else { + const re = globToRe(segment); + for (const base of out) { + let names: string[]; + try { + names = readdirSync(base); + } catch { + continue; // unreadable directory: OpenSSH would skip it too + } + for (const name of names) { + if (re.test(name)) next.push(join(base, name)); + // Cap hit mid-walk: `next` already holds `maxMatches` real matches, + // so return it (the old `atRoot ? [] : next` was dead - `atRoot` is + // false at every point this line runs, review M7). + if (next.length >= maxMatches) return next; + } + } + } + out.length = 0; + out.push(...next); + } + const files: string[] = []; + for (const p of out) { + try { + if (lstatSync(p).isFile()) files.push(p); + } catch { + // missing/unreachable: not an error, an empty match + } + if (files.length >= maxMatches) break; + } + return files; +} + +function globToRe(segment: string): RegExp { + const escaped = segment + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replaceAll("*", ".*") + .replaceAll("?", "."); + return new RegExp(`^${escaped}$`); +} + +/** Read one config file within the budgets; returns its lines and mutates the byte total. */ +function readCapped(file: string, bytesLeft: number, maxLineBytes: number): { lines: string[]; used: number } { + let fd: number; + try { + // O_NOFOLLOW: a planted symlink at an Include target is refused, not + // chased — the walk reads the account's config, not whatever a symlink + // on its path was pointed at. + fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch { + return { lines: [], used: 0 }; + } + try { + const cap = Math.max(0, Math.min(bytesLeft, DEFAULT_BUDGET.maxTotalBytes)); + if (cap === 0) return { lines: [], used: 0 }; + const buf = Buffer.allocUnsafe(cap + 1); + const n = readSync(fd, buf, 0, cap + 1, null); + const used = Math.min(n, cap); + const text = buf.subarray(0, used).toString("utf8"); + const lines = text.split("\n"); + if (n > cap) lines.pop(); // a cut tail line is a partial line, not data + return { + lines: lines.map((l) => (l.length > maxLineBytes ? l.slice(0, maxLineBytes) : l)), + used: n, // over-budget reads still count what was attempted + }; + } finally { + closeSync(fd); + } +} + +/** + * A quoted Include token: OpenSSH accepts `"…"` around a pattern containing + * spaces; peel them so the space-split token list stays correct. + */ +function peelQuotes(token: string): string { + if (token.length >= 2 && token.startsWith('"') && token.endsWith('"')) return token.slice(1, -1); + return token; +} + +/** + * Walk one account's ssh_config: read the root file, follow `Include` globs + * with budgets, and collect `Host` block structure + `Match exec` presence. + * Cycle detection is over the walk's own ACTIVE ancestor chain (a file under + * itself), so a diamond (two includes of one shared file) dedupes silently + * while a true cycle raises `includeCycle`. NEVER throws: every fs surprise + * ends the walk of THAT file, not the discovery. + * + * @param rootFile - absolute path of the account's config (may not exist) + * @param homeDir - the connecting account's home, for `~` expansion + * @param budget - walk budgets (see {@link SshWalkBudget}) + */ +export function walkSshConfig(rootFile: string, homeDir: string, budget: SshWalkBudget = {}): SshConfigWalk { + const caps = { ...DEFAULT_BUDGET, ...budget }; + const walk: SshConfigWalk = { + hostBlocks: [], + globalDirectives: [], + matchExecSeen: false, + files: [], + includeCycle: false, + truncated: false, + }; + let filesRead = 0; + let bytesUsed = 0; + let currentBlock: SshHostBlock | null = null; + const seen = new Set(); // every file already parsed (dedupe) + const active = new Set(); // the ancestor chain (cycle detector) + + const openBlock = (): SshHostBlock => { + const block: SshHostBlock = { tokens: [], directives: [] }; + walk.hostBlocks.push(block); + currentBlock = block; + return block; + }; + + const visit = (file: string): void => { + let real: string; + try { + real = realpathSync(file); + } catch { + return; + } + if (active.has(real)) { + walk.includeCycle = true; // a file under its own includer: the §2 fact + return; + } + if (seen.has(real)) return; // a second ordinary include: dedupe, not a cycle + if (filesRead >= caps.maxFiles || bytesUsed >= caps.maxTotalBytes) { + walk.truncated = true; + return; + } + active.add(real); + seen.add(real); + filesRead += 1; + walk.files.push(real); + const budgetLeft = caps.maxTotalBytes - bytesUsed; + const { lines, used } = readCapped(real, budgetLeft, caps.maxLineBytes); + bytesUsed += used; + if (used > budgetLeft) walk.truncated = true; // the file was cut by the byte budget + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ""); + const parsed = splitKeywordArg(line); + if (!parsed) continue; + const { keyword, arg } = parsed; + if (keyword === "host") { + const block = openBlock(); + block.tokens = arg + .split(/\s+/) + .filter((t) => t !== "") + .map(peelQuotes); + } else if (keyword === "include") { + const tokens = arg + .split(/\s+/) + .filter((t) => t !== "") + .map(peelQuotes); + for (const token of tokens) { + for (const included of expandIncludeGlob(token, real, homeDir, caps.maxIncludeMatches)) { + visit(included); + } + } + } else if (keyword === "match") { + // `Match exec …` runs a command locally (the §2 execution fact); the + // criteria keyword pair is what matters, not the command. + const criteria = arg.toLowerCase(); + if (/(^|\s)exec(\s|$)/.test(criteria)) walk.matchExecSeen = true; + } else if (keyword !== "") { + (currentBlock ? currentBlock.directives : walk.globalDirectives).push({ keyword, value: arg }); + } + } + active.delete(real); + }; + + visit(rootFile); + return walk; +} + +/** + * The `ssh_discover_aliases` engine: walk the account's config and answer the + * deduplicated, sorted alias names under the wire cap. Returns NAMES only — + * the frozen result envelope carries no config content, and neither does this. + * + * @param opts.homeDir - the connecting account's home (default: this process's) + * @param opts.configPath - the account's config file (default: `/.ssh/config`) + * @param opts.budget - walk budgets + */ +export function discoverSshAliases( + opts: { homeDir?: string; configPath?: string; budget?: SshWalkBudget } = {}, +): SshAliasDiscovery { + const homeDir = opts.homeDir ?? homedir(); + const walk = walkSshConfig(opts.configPath ?? defaultSshConfigPath(homeDir), homeDir, opts.budget); + const names = new Set(); + for (const block of walk.hostBlocks) { + for (const token of block.tokens) { + if (isDiscoverableAlias(token)) names.add(token); + } + } + let truncated = walk.truncated; + let aliases = [...names].sort(); + if (aliases.length > SSH_MAX_DISCOVERED_ALIASES) { + aliases = aliases.slice(0, SSH_MAX_DISCOVERED_ALIASES); + truncated = true; + } + return { aliases, includeCycle: walk.includeCycle, truncated }; +} diff --git a/packages/pane-runtime/src/ssh/ssh-render.ts b/packages/pane-runtime/src/ssh/ssh-render.ts new file mode 100644 index 000000000..6a8a584fd --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -0,0 +1,266 @@ +import { + parseSshConnectionSnapshot, + SSH_PATH_MAX_CHARS, + type SshConnectionSnapshotWire, + type SshHopWire, +} from "@internal/subshell-protocol"; +import { shellQuote } from "../shell.js"; +import { sshChildPath } from "./ssh-spawn.js"; + +/** + * The runtime renderer: an approved snapshot in, argv + own config file out + * (SSH-SUPPORT.md §2: "Render runtime config from an explicit allowlist; do + * not let runtime SSH reread ambient user/system configuration"). + * + * Two artifacts, one policy: + * + * - **The config file** (`renderSshConfigContents`) carries the mandatory + * policy under `Host *`. This is NOT belt-and-suspenders over the command + * line: ssh spawns ProxyJump children (`ssh -W …`) that do NOT inherit the + * parent's `-o` options, and the jump child re-reads the `-F` file. A + * policy written only on the command line would restrict the last hop and + * nothing above it. The `Host *` shape is what makes the policy + * EVERY-HOP, which is exactly what §2 demands. + * - **The argv** (`buildSshInvocation`) carries only the DESTINATION-scoped + * facts (port, user, HostKeyAlias, ProxyJump chain) — options the jump + * children must NOT inherit — plus `-F` so the user config the whole chain + * reads is ours. + * + * Config keyword precedence is the second reason this split works: within one + * ssh invocation the command line wins over the user file, which wins over + * the system file, PER KEYWORD (first-obtained wins). Every key the policy + * names appears in our file, so `/etc/ssh/ssh_config` cannot reintroduce a + * forwarding directive, a control socket, or canonicalization for this + * connection's hops. Keywords the policy does not name and a system file + * could set remain root's to decide — inside the OS trust boundary, per + * docs/security.md §0-1, and resolution already ran `-G` WITH that system + * file, so what it refused, the snapshot never carries. + * + * The snapshot is re-validated on entry: the renderer's refusal set is + * "anything the grammar cannot express", and the grammar is the protocol's, + * so renderer and wire can never disagree about what is renderable. + */ + +/** + * Refuse to render anything the frozen grammar rejects (a hand-built object + * never reaches the child). The check IS `parseSshConnectionSnapshot`: the + * same rebuild the wire performs, so renderer and contract can never + * disagree about what is renderable, and grammar growth on the protocol side + * lands here automatically. The option-like leading dashes ride on top: the + * grammar's own doc names them ssh-argv material, and the renderer is the + * last station before argv. + */ +function assertRenderable(snapshot: SshConnectionSnapshotWire): void { + if (parseSshConnectionSnapshot(snapshot) === null) throw new Error("snapshot not renderable"); + if (snapshot.host.startsWith("-") || snapshot.proxyJumps.some((h) => h.host.startsWith("-"))) { + throw new Error("snapshot not renderable"); + } +} + +/** + * The mandatory every-hop policy, as `Host *` lines. Each entry is the §2 + * runtime-policy sentence made concrete; the comments name which. + */ +const MANDATORY_POLICY: readonly [string, string][] = [ + // noninteractive auth only: no password/MFA prompt can stall a supervised run + ["BatchMode", "yes"], + // require existing verified host trust; unknown/changed/revoked fail closed + ["StrictHostKeyChecking", "yes"], + // no agent or X11 forwarding (the §2 "no agent/X11 forwarding" sentence) + ["ForwardAgent", "no"], + ["ForwardX11", "no"], + ["ForwardX11Trusted", "no"], + // no local/remote/dynamic forwards or tunnels, whatever any config said + ["ClearAllForwardings", "yes"], + ["Tunnel", "no"], + ["PermitRemoteOpen", "none"], + // no local commands on any branch (§2 "no local commands") + ["PermitLocalCommand", "no"], + // the command contract owns the remote side; no imported remote command / env (§2 "Do not import RemoteCommand, SendEnv, SetEnv") + ["RemoteCommand", "none"], + // no escape menu — `~` is a local shell (§2 "no SSH escape commands") + ["EscapeChar", "none"], + // no ambient control sockets or multiplexing (§2's explicit clause) + ["ControlMaster", "no"], + ["ControlPath", "none"], + // auth is key/certificate only (§2 "Require key-based authentication in v1") + ["PasswordAuthentication", "no"], + ["KbdInteractiveAuthentication", "no"], + ["HostbasedAuthentication", "no"], + // GSSAPIAuthentication is the canonical spelling; the legacy + // `KerberosAuthentication` alias it subsumed is REMOVED from recent OpenSSH + // (measured: 10.2p1 warns `Unsupported option "kerberosauthentication"` on + // EVERY launch, e2e fixture log). The warning was noise, never a refusal, + // but a policy line the client can no longer parse disables nothing: the + // GSSAPI line above is what actually kills that auth path. + ["GSSAPIAuthentication", "no"], + // trust comes ONLY from the pinned files below: DNS- and fetch-based checks + // are not "existing verified host trust", and canonicalization could send + // the connection to a host the reviewer never approved + ["VerifyHostKeyDNS", "no"], + ["CanonicalizeHostname", "no"], +]; + +/** A destination or hop as ssh argv syntax: `user@host`, `host:port`, brackets for IPv6 kept. */ +function hopToken(hop: SshHopWire): string { + const who = hop.user !== null ? `${hop.user}@` : ""; + const port = hop.port !== 22 ? `:${hop.port}` : ""; + return `${who}${hop.host}${port}`; +} + +/** + * A file-valued ssh_config token as OpenSSH's own tokenizer accepts it: the + * config parser splits values on WHITESPACE (control chars are already + * refused by the snapshot grammar), so an absolute path carrying a space — + * legal POSIX, legal `isAbsPosixPath` — would otherwise misparse into two + * tokens (a truncated identity file plus garbage). Double quotes with + * backslash escapes are the quoting OpenSSH's strdelim honors for + * file-valued keywords; the escape is applied before the wrap so the value + * is byte-exact after the parser's unquote. Plain paths stay unquoted: the + * rendered file is byte-stable in the snapshot (a dedup record's config is + * re-read by digest), and quoting every path would churn bytes for no gain. + */ +function configPathValue(path: string): string { + if (!/[\s"\\]/.test(path)) return path; + return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +/** One config line for each known-hosts ref, or the fail-closed /dev/null stand-in when the snapshot names none. */ +function knownHostsLines(files: string[]): string[] { + if (files.length === 0) return [" UserKnownHostsFile /dev/null"]; + return files.map((f) => ` UserKnownHostsFile ${configPathValue(f)}`); +} + +/** + * Render the runtime ssh_config FILE for one snapshot: the policy above, + * every hop included, plus the snapshot's trust refs and identity refs under + * the same `Host *` so jump children authenticate from the same approved + * material (`SshHopWire`'s doc: "authentication for the whole chain comes + * from the snapshot's own refs"). Contents are deterministic in the snapshot + * — the same approval always renders the same file, which is what makes the + * run's recorded config byte-checkable. + */ +export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire): string { + assertRenderable(snapshot); + const lines = [ + "# Subshell managed SSH config - GENERATED, do not edit.", + "# Mandatory runtime policy for the connection and every ProxyJump hop (§2).", + "Host *", + ...MANDATORY_POLICY.map(([key, value]) => ` ${key} ${value}`), + ...knownHostsLines(snapshot.knownHostsFiles), + ...snapshot.identityFiles.map((f) => ` IdentityFile ${configPathValue(f)}`), + ...snapshot.certificateFiles.map((f) => ` CertificateFile ${configPathValue(f)}`), + "", + ]; + return lines.join("\n"); +} + +/** Inputs to {@link buildSshInvocation}. */ +export interface SshInvocationInput { + /** Absolute path of the ssh binary (the ladder resolved it; this never searches). */ + sshBin: string; + snapshot: SshConnectionSnapshotWire; + /** Absolute path the rendered config file was written to (the caller owns the write; the renderer owns the `-F` clause). */ + configPath: string; + /** + * The remote command line, as the single argv string ssh will hand the + * remote shell (`remoteCommandLine` builds the cd-prefixed form). Absent = + * no command: an interactive session under the pane's PTY. + */ + remoteCommand?: string; + /** + * Force a remote PTY (`-tt`). Structured runs pass nothing (their contract + * is a pipe, no TTY). Managed TERMINAL panes pass `true` ALWAYS, with or + * without a remote command: OpenSSH only auto-requests a tty when ssh + * carries no command at all, and the terminal launch's cd + login-shell + * line IS a command — without `-tt` that session would run with no remote + * PTY at all (no line editing, no programs that require a tty). The + * destination pane's own PTY is necessary but not sufficient; the remote + * one is what `ssh` must be told to allocate (coordinator ruling, fix + * round 1: uniform `-tt` for every terminal launch). + */ + forceTty?: boolean; +} + +/** + * Build ssh's argv for one snapshot. EVERY element is a string this function + * composed from snapshot-scoped values; the destination never comes from a + * config string, never from `~`, never from PATH (the binary itself is an + * absolute argv[0] the caller resolved). Option-like destinations are + * impossible by grammar (the snapshot validator refuses a leading `-`) plus + * re-checked here ({@link assertRenderable}); the `--` terminator is the last + * word to getopt anyway. + * + * @returns the complete argv, `--` separated from options, remote command as + * ONE trailing element when there is one (a multi-word command must not + * become N argv strings ssh would re-join with its own spacing). + */ +export function buildSshInvocation(input: SshInvocationInput): string[] { + assertRenderable(input.snapshot); + const { snapshot } = input; + if ( + input.configPath.length === 0 || + !input.configPath.startsWith("/") || + input.configPath.length > SSH_PATH_MAX_CHARS + ) { + throw new Error("config path must be absolute POSIX"); + } + const argv = [input.sshBin, "-F", input.configPath]; + if (input.forceTty === true) argv.push("-tt"); + argv.push("-p", String(snapshot.port)); + if (snapshot.user !== null) argv.push("-l", snapshot.user); + if (snapshot.hostKeyAlias !== null) argv.push("-o", `HostKeyAlias=${snapshot.hostKeyAlias}`); + if (snapshot.proxyJumps.length > 0) { + argv.push("-o", `ProxyJump=${snapshot.proxyJumps.map(hopToken).join(",")}`); + } + argv.push("--", snapshot.host); + if (input.remoteCommand !== undefined) argv.push(input.remoteCommand); + return argv; +} + +/** + * The remote command line for a structured run (SSH-SUPPORT.md §3: "A failed + * directory change must prevent command execution"). The directory is POSIX + * DATA ({@link shellQuote}-quoted — it may be a path the human chose through + * a picker, never shell material); the command is the ONE intentional shell + * code in this contract and rides after `&&` so a failed `cd` short-circuits + * the remote shell before it runs anything. + */ +export function remoteCommandLine(command: string, remoteDir: string | null): string { + if (remoteDir === null) return command; + return `cd ${shellQuote(remoteDir)} && ${command}`; +} + +/** + * The COMPLETE environment of a managed ssh child (§2, "Spawn using explicit + * argv and a minimal environment"). + * + * An allowlist, never an inheritance: no app secrets, no Subshell + * credentials, no preset env, no loader overrides (`DYLD_*`/`LD_*` are not + * even consultable here), no shell startup overrides, and **never an askpass + * hook** — `SSH_ASKPASS`/`SSH_ASKPASS_REQUIRE`/`DISPLAY` are absent BY + * EXCLUSION, which is what keeps BatchMode honest (a refused auth is a fast + * named failure, never a GUI prompt an attacker-controlled binary answers). + * The authentication-agent socket is the one credential-adjacent variable + * that may ride, and only when the APPROVED SNAPSHOT names it — the + * connecting account's trusted setup decided that path at resolution time. + */ +export async function sshChildEnv( + snapshot: SshConnectionSnapshotWire, + homeDir: string, + baseEnv: Record = process.env, +): Promise> { + const env: Record = { + PATH: await sshChildPath(), + HOME: homeDir, + }; + for (const key of ["USER", "LOGNAME", "LANG", "TMPDIR"]) { + const value = baseEnv[key]; + if (value !== undefined && value !== "") env[key] = value; + } + for (const [key, value] of Object.entries(baseEnv)) { + if (key.startsWith("LC_") && value !== undefined && value !== "") env[key] = value; + } + if (snapshot.authAgentSocket !== null) env.SSH_AUTH_SOCK = snapshot.authAgentSocket; + return env; +} diff --git a/packages/pane-runtime/src/ssh/ssh-resolve.ts b/packages/pane-runtime/src/ssh/ssh-resolve.ts new file mode 100644 index 000000000..6b65e0419 --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-resolve.ts @@ -0,0 +1,361 @@ +import { existsSync } from "node:fs"; +import { userInfo } from "node:os"; +import { + type NodeSshResolveOutcomeWire, + parseSshConnectionSnapshot, + SSH_MAX_PROXY_HOPS, + SSH_PROBE_DEADLINE_MS, + type SshConnectionSnapshotWire, + type SshErrorCode, + type SshHopWire, +} from "@internal/subshell-protocol"; +import { + defaultSshConfigPath, + expandTilde, + type SshHostBlock, + type SshWalkBudget, + walkSshConfig, +} from "./ssh-discover.js"; +import { runSshProcess, sshChildPath } from "./ssh-spawn.js"; + +/** + * Resolution: alias in, approved snapshot out (SSH-SUPPORT.md §2: "Human-only + * resolution followed by an approved normalized configuration"). + * + * The evaluator is OpenSSH itself — `ssh -G ` prints the effective + * config for a destination without connecting, which is the only answer that + * honors include precedence, defaults, and `Match` the way the real client + * would apply them. What this module adds is the FILTER: every resolved + * setting is either represented in the frozen snapshot grammar or refused by + * name, and the finished snapshot is re-validated through + * {@link parseSshConnectionSnapshot} before it is ever returned, so the + * resolver cannot emit a shape the wire would later reject. + * + * **Disclosure this step owes the human:** `ssh -G` EVALUATES the account's + * config, which means a trusted `Match exec` can run a LOCAL command during + * resolution. The walk separately detects `Match exec` and refuses to make + * such a config a saved connection, but detection happens after the eval, so + * the refusal is about what gets stored, not about what already ran. The + * resolve command's JSDoc on the wire names this; UI copy must render it + * before the button (SSH-SUPPORT.md §2, Configuration is executable). + */ + +/** Inputs {@link resolveSshAliasConfig} needs; every one is injectable so tests never touch a developer's `~/.ssh`. */ +export interface SshResolutionDeps { + /** Absolute path of the ssh(1) binary (the agent resolves the ladder; nothing here re-searches PATH to pick the executable). */ + sshBin: string; + /** The connecting account's home directory (HOME for the child, `~` expansion). */ + homeDir: string; + /** The account's config file (default `/.ssh/config`). */ + configPath?: string; + /** Process env whose `SSH_AUTH_SOCK` participates in agent resolution (default: this process's). */ + env?: Record; + /** The connecting OS account name (display fact); default `os.userInfo().username` when obtainable. */ + connectingAccount?: string; + /** Overall deadline for the `ssh -G` evaluation. */ + timeoutMs?: number; + /** Config-walk budgets (tests pass small ones). */ + walkBudget?: SshWalkBudget; +} + +/** One line of `ssh -G` output, lowercased keyword + raw argument string. */ +type GLine = { keyword: string; value: string }; + +/** Refusal with no config keywords to name (the code itself is the whole diagnosis). */ +function refuse(code: SshErrorCode, settings: string[] = []): NodeSshResolveOutcomeWire { + return { accepted: false, code, settings }; +} + +/** Parse `-G` stdout: one `keyword value...` line each; blank/continuation-free by OpenSSH's own format. */ +function parseDashG(stdout: string): GLine[] { + const lines: GLine[] = []; + for (const raw of stdout.split("\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + const cut = line.search(/\s/); + if (cut === -1) lines.push({ keyword: line.toLowerCase(), value: "" }); + else lines.push({ keyword: line.slice(0, cut).toLowerCase(), value: line.slice(cut + 1).trim() }); + } + return lines; +} + +/** All values of any of the accepted spellings, whitespace-split (the list options' own delimiter). */ +function valuesOf(g: GLine[], ...keywords: string[]): string[] { + const out: string[] = []; + for (const line of g) { + if (keywords.includes(line.keyword)) out.push(...line.value.split(/\s+/).filter((v) => v !== "")); + } + return out; +} + +/** True when any value of the keyword is "set" (not none/off/no/0 — the option's unconfigured spelling). */ +function setWhere(g: GLine[], keyword: string, unconfigured: readonly string[] = ["none"]): boolean { + return setWhereAny(g, [keyword], unconfigured); +} + +function setWhereAny(g: GLine[], keywords: readonly string[], unconfigured: readonly string[] = ["none"]): boolean { + return valuesOf(g, ...keywords).some((v) => !unconfigured.includes(v.toLowerCase())); +} + +/** + * The forward keyword, BOTH ways. OpenSSH's `ssh -G` echoes the option + * keyword, and the historical/config spellings of these three differ across + * versions (`LocalForward` vs the `locallyforward` echo, likewise remote) — + * accepting both spellings means a version drift can only ever make us + * REFUSE on a present directive, never miss one. Missing a forward would be + * the silent-omission defect §2 names. + */ +const FORWARD_LOCAL_KEYWORDS = ["localforward", "locallyforward"]; +const FORWARD_REMOTE_KEYWORDS = ["remoteforward", "remotelyforward"]; +const FORWARD_DYNAMIC_KEYWORDS = ["dynamicforward"]; + +/** Parse one ProxyJump token `[user@]host[:port]` into the frozen hop shape; bracketed IPv6 keeps its brackets (the snapshot grammar wants them). */ +export function parseProxyHop(token: string, defaultUser: string | null): SshHopWire | null { + if (token === "") return null; + let rest = token; + let user: string | null = null; + const at = rest.lastIndexOf("@"); + if (at > 0) { + user = rest.slice(0, at); + rest = rest.slice(at + 1); + if (user === "") user = null; + } else if (at === 0) { + return null; // "@host" — an empty user is not a route fact to store + } + let port: number | null = null; + let host = rest; + if (rest.startsWith("[")) { + const close = rest.indexOf("]"); + if (close === -1) return null; + host = rest.slice(0, close + 1); + const tail = rest.slice(close + 1); + if (tail.startsWith(":")) { + port = Number(tail.slice(1)); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + } else if (tail !== "") { + return null; + } + } else { + const colon = rest.lastIndexOf(":"); + if (colon !== -1) { + host = rest.slice(0, colon); + port = Number(rest.slice(colon + 1)); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + } + } + if (host === "") return null; + return { host, user: user ?? defaultUser, port: port ?? 22 }; +} + +/** Case-insensitive alias membership among a Host block's pattern tokens (negations excluded — `!x` never makes a block match x). */ +function blockMatchesAlias(block: SshHostBlock, alias: string): boolean { + return block.tokens.some((t) => !t.startsWith("!") && !/[?*]/.test(t) && t.toLowerCase() === alias.toLowerCase()); +} + +/** + * Resolve one alias on this machine into the approved snapshot, or a refusal + * naming WHY (§2: "refuse with a named limitation rather than silently + * changing connection semantics"). A refusal is an ACCEPTED command answer + * (it rides `accepted:false`, in-the-data) so the human can read which + * setting blocked and go edit the config. + * + * @param alias - the config token the human chose (also a manual destination + * when the config has no block for it and the token is itself a hostname) + * @param deps - binary path, home, config path, env, budgets (all injectable) + */ +export async function resolveSshAliasConfig( + alias: string, + deps: SshResolutionDeps, +): Promise { + // Shape gate FIRST (defense-in-depth beside the wire parser): this string + // reaches `ssh -G` as an argv tail — an option-like or control-bearing + // token must die here, not at getopt. + if (alias.length === 0 || alias.length > 253 || alias.startsWith("-") || /\s|\p{Cc}/u.test(alias)) { + return refuse("config_missing"); + } + const configPath = deps.configPath ?? defaultSshConfigPath(deps.homeDir); + const env = deps.env ?? (process.env as Record); + const connectingAccount = deps.connectingAccount ?? safeUsername(); + + // The structural walk happens before the evaluation: `Match exec` is a + // config-shape fact the walk sees without running anything, and alias + // membership (manual alias vs config alias) comes from the same walk the + // discovery command answers with — one parser, one truth about "in config". + const walk = walkSshConfig(configPath, deps.homeDir, deps.walkBudget); + if (walk.matchExecSeen) return refuse("unsupported_setting", ["Match exec"]); + + const argv = [deps.sshBin, "-G"]; + if (existsSync(configPath)) argv.push("-F", configPath); + argv.push(alias); + const childEnv: Record = { + PATH: await sshChildPath(), + HOME: deps.homeDir, + ...(env.LANG ? { LANG: env.LANG } : {}), + ...(env.TMPDIR ? { TMPDIR: env.TMPDIR } : {}), + ...(env.SSH_AUTH_SOCK ? { SSH_AUTH_SOCK: env.SSH_AUTH_SOCK } : {}), + }; + for (const [k, v] of Object.entries(env)) if (k.startsWith("LC_") && v) childEnv[k] = v; + const run = await runSshProcess(argv, childEnv, deps.timeoutMs ?? SSH_PROBE_DEADLINE_MS); + if (run.timedOut || run.spawnError || run.code !== 0) { + // The evaluator could not be brought to answer: the destination cannot be + // reduced to one reviewable route. `config_ambiguous` is the frozen code + // for "cannot be uniquely resolved"; settings carries ssh's own last line + // so the human sees a hint that is not a diagnosis (never a config blob). + const last = run.stderr.trim().split("\n").pop() ?? ""; + return refuse("config_ambiguous", last === "" ? [] : [`ssh -G failed: ${last.slice(0, 200)}`]); + } + const g = parseDashG(run.stdout); + + // --- forbidden settings: any of these present at RESOLUTION fails setup --- + const blocked: string[] = []; + if (setWhere(g, "proxycommand")) blocked.push("ProxyCommand"); + if (setWhereAny(g, FORWARD_LOCAL_KEYWORDS)) blocked.push("LocalForward"); + if (setWhereAny(g, FORWARD_REMOTE_KEYWORDS)) blocked.push("RemoteForward"); + if (setWhereAny(g, FORWARD_DYNAMIC_KEYWORDS)) blocked.push("DynamicForward"); + // The unconfigured spellings are the ones OpenSSH's `-G` PRINTS for an + // unconfigured option, measured on 10.2p1: `tunnel false` and + // `permitremoteopen any`. A config-set value is whatever the config chose + // (`tunnel yes`, `permitremoteopen hosts:socks:1080`), which lands outside + // these lists and blocks. The historical `no` spelling stays accepted: + // older `-G` output and hand-fed fixtures use it. + if (setWhere(g, "tunnel", ["no", "false"])) blocked.push("Tunnel"); + if (setWhere(g, "permitremoteopen", ["none", "any"])) blocked.push("PermitRemoteOpen"); + if (setWhere(g, "permitlocalcommand", ["no"])) blocked.push("PermitLocalCommand"); + if (setWhere(g, "localcommand")) blocked.push("LocalCommand"); + if (setWhere(g, "remotecommand")) blocked.push("RemoteCommand"); + if (g.some((l) => l.keyword === "sendenv" && l.value !== "")) blocked.push("SendEnv"); + if (g.some((l) => l.keyword === "setenv" && l.value !== "")) blocked.push("SetEnv"); + if (setWhere(g, "knownhostscommand")) blocked.push("KnownHostsCommand"); + if (blocked.length > 0) return refuse("unsupported_setting", blocked); + // NOTE on what is NOT refused here: ForwardAgent/ForwardX11/ControlPath/ + // EscapeChar/GSSAPI found in config are killed by the MANDATORY runtime + // policy (ssh-render.ts applies it to every hop regardless), so they change + // nothing the human approved and stay representable. A ProxyCommand has no + // kill switch — it IS the route — so it fails setup instead. That asymmetry + // is the grammar's, not an accident. + + // --- destination facts --- + const hostValues = valuesOf(g, "hostname"); + const hostLine = valuesOf(g, "host")[0] ?? alias; + const host = hostValues[0] ?? hostLine; + const matchedBlocks = walk.hostBlocks.filter((b) => blockMatchesAlias(b, alias)); + const inConfig = matchedBlocks.length > 0; + if (!inConfig) { + // Manual-alias path: only legitimate when OpenSSH itself treated the + // token as the hostname (no config rewrite happened). Anything else means + // the token is neither an alias nor a destination the account can reach. + if (host.toLowerCase() !== alias.toLowerCase()) return refuse("config_missing"); + } else { + // Two blocks setting CONFLICTING destination facts cannot be reduced to + // one route a human reviewed (first-obtained-wins makes -G deterministic, + // but the ORDER that decided it is config-invisible noise the reviewer + // would never catch) — §2's config_ambiguous. One HostName plus one Port + // is NOT a conflict (measured on 10.2p1: the shared-set spelling refused + // every `HostName x` + `Port n` block, the ordinary config shape) — each + // family is counted on its own, and only a family with two different + // values is the ambiguity this check names. + const hostNames = new Set(); + const ports = new Set(); + for (const b of [...walk.globalDirectives, ...matchedBlocks.flatMap((m) => m.directives)]) { + if (b.keyword === "hostname" && b.value !== "") hostNames.add(b.value.toLowerCase()); + if (b.keyword === "port" && b.value !== "") ports.add(b.value); + } + if (hostNames.size > 1 || ports.size > 1) return refuse("config_ambiguous"); + } + + const portRaw = Number(valuesOf(g, "port")[0] ?? "22"); + const port = Number.isInteger(portRaw) ? portRaw : NaN; + const userRaw = valuesOf(g, "user")[0] ?? null; + // -G always prints SOME user (the connecting account's own default); null + // means exactly that default, so the snapshot stores null for it and a real + // value only when the config chose a different account. + const user = userRaw !== null && userRaw.toLowerCase() !== (connectingAccount ?? "").toLowerCase() ? userRaw : null; + + // --- trust + identity refs (paths on THIS machine; the snapshot carries refs, never contents) --- + // `-G` prints the DEFAULT identity files in tilde form (`identityfile + // ~/.ssh/id_rsa`); the snapshot grammar takes only absolute paths, so the + // tilde expands against the same home ssh itself would expand it against. + // `~user/...` does not expand: the parser refuses the ref whole, which is + // fail-closed, not a silent route change. + const refPaths = (keyword: string): string[] => + valuesOf(g, keyword) + .filter((v) => v !== "" && v !== "none") + .map((v) => expandTilde(v, deps.homeDir)); + const identityFiles = refPaths("identityfile"); + const certificateFiles = refPaths("certificatefile"); + const knownHostsFiles = refPaths("userknownhostsfile"); + const hostKeyAliasRaw = valuesOf(g, "hostkeyalias")[0] ?? "none"; + const hostKeyAlias = hostKeyAliasRaw !== "none" && hostKeyAliasRaw !== "" ? hostKeyAliasRaw : null; + + // --- agent: ONLY an absolute socket from the account's trusted setup (an + // explicit IdentityAgent path, else the account's own SSH_AUTH_SOCK) --- + const identityAgent = valuesOf(g, "identityagent")[0] ?? null; + let authAgentSocket: string | null = null; + if (identityAgent?.startsWith("/")) { + authAgentSocket = identityAgent; + } else if (identityAgent !== "none" && env.SSH_AUTH_SOCK?.startsWith("/")) { + // Everything that is not a path and not `none` — the explicit `ssh-agent` + // spelling, any future one, and the keyword's absence — means "use the + // account's agent", whose socket is exactly the account's env + // `SSH_AUTH_SOCK`. `none` above is the only way to say "no agent"; the + // env sock is trusted because it is the connecting account's own setup, + // and the child env carries it only when the snapshot names a socket. + authAgentSocket = env.SSH_AUTH_SOCK; + } + + // --- jump chain: comma-separated per directive, all directives concatenated in config order --- + const hops: SshHopWire[] = []; + for (const line of g.filter((l) => l.keyword === "proxyjump")) { + for (const token of line.value + .split(",") + .map((t) => t.trim()) + .filter((t) => t !== "")) { + const hop = parseProxyHop(token, user); + if (!hop) return refuse("config_ambiguous", ["ProxyJump"]); + hops.push(hop); + } + } + if (hops.length > SSH_MAX_PROXY_HOPS) return refuse("proxy_chain_too_long"); + + const candidate = { + alias, + host, + user, + port, + identityFiles, + certificateFiles, + authAgentSocket, + knownHostsFiles, + hostKeyAlias, + proxyJumps: hops, + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + } satisfies SshConnectionSnapshotWire; + + const approved = parseSshConnectionSnapshot(candidate); + if (approved === null) { + // The normalized facts do not fit the grammar (a hostname with spaces in + // config, an out-of-range port, a relative identity path from a broken + // build): unrepresentable, and refused whole. + return refuse("config_ambiguous"); + } + return { + accepted: true, + snapshot: approved, + ...(connectingAccount !== undefined ? { connectingAccount } : {}), + }; +} + +function safeUsername(): string | undefined { + try { + return userInfo().username; + } catch { + return undefined; + } +} diff --git a/packages/pane-runtime/src/ssh/ssh-session-invocation.ts b/packages/pane-runtime/src/ssh/ssh-session-invocation.ts new file mode 100644 index 000000000..a7c431aa2 --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-session-invocation.ts @@ -0,0 +1,133 @@ +import { createHash } from "node:crypto"; +import { userInfo } from "node:os"; +import { join } from "node:path"; +import { + parseSshRuntimeHello, + type SshConnectionSnapshotWire, + type SshSessionTargetWire, +} from "@internal/subshell-protocol"; + +/** + * The broker's per-destination facts (design 2026-10-05 §3/§6): the pieces + * the supervisor composes BEFORE and AROUND a spawn that are not about the + * child's lifecycle - the deterministic tmux socket name that makes a second + * session to the same machine land on the same server, the approved snapshot + * a session target renders to, and the hello-boundary scan that is the + * broker's ONLY frame awareness. Split from `ssh-session-supervisor.ts` the + * way `ssh-render.ts` is split from the run family. + */ + +/* ------------------------------------------------------------------ */ +/* deterministic per-destination tmux socket (design §6 reconciliation) */ +/* ------------------------------------------------------------------ */ + +/** + * `subshell-ssh-` for one destination: a second session opened against + * the same `host:port:user` lands on the same tmux server, which is what makes + * "the next session finds the first session's panes" work. Hashed (sha1, 12 + * hex, exactly the `tmuxSocketFor` naming rules) because the name rides a + * `-L` argument and must never embed punctuation; same-input-same-name is the + * whole reconciliation protocol and is pinned by test. + */ +export function sshSessionTmuxSocket(host: string, port: number, user: string | null): string { + const hash = createHash("sha1") + .update(`${host}:${port}:${user ?? ""}`) + .digest("hex") + .slice(0, 12); + return `subshell-ssh-${hash}`; +} + +/* ------------------------------------------------------------------ */ +/* the snapshot the broker renders (target facts under an unchanged policy) */ +/* ------------------------------------------------------------------ */ + +/** + * Build the approved snapshot for a session target. The trust refs are the + * connecting account's OWN default files (design §8: keys and config stay on + * the connecting node); the auth agent is deliberately excluded - a brokered + * session is keys-only (§3). ProxyJump is out of the slice's target grammar; + * the renderer supports it the moment a later workstream widens the target. + * Returns null when the snapshot grammar refuses the facts (the caller names + * `config_ambiguous` then; it cannot happen for parsed targets, and the belt + * is here because this is the last station before a render). + */ +export function sessionTargetSnapshot(target: SshSessionTargetWire, homeDir: string): SshConnectionSnapshotWire { + return { + alias: target.alias, + host: target.host, + user: target.user, + port: target.port, + identityFiles: target.identityFile === null ? [] : [target.identityFile], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [join(homeDir, ".ssh", "known_hosts"), "/etc/ssh/ssh_known_hosts"], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + }; +} + +/** The connecting account's name for the open result (a display fact; null when the OS says nothing). */ +export function safeUsername(): string | undefined { + try { + return userInfo().username; + } catch { + return undefined; + } +} + +/* ------------------------------------------------------------------ */ +/* hello boundary scan (the broker's ONLY frame awareness) */ +/* ------------------------------------------------------------------ */ + +/** The hello-boundary outcome: the hello plus the bytes after it, or a named protocol verdict. */ +export type HelloScan = + | { kind: "hello"; hello: NonNullable>; rest: Uint8Array } + | { kind: "incomplete" } + /** Well-framed bytes that are NOT a hello, or a prefix too nonsense to be a frame: the stream is speaking something else. */ + | { kind: "protocol" }; + +/** + * Scan buffered bytes for the length-prefixed hello frame. This is NOT the + * codec imported at the ends: it cannot fail-closed a session on a verdict + * the codec alone owns - but it must recognize the hello boundary, and the + * ONE thing it treats as terminal (a complete frame that is not a hello, or a + * lying prefix) is exactly what the codec would also kill. The duplication of + * the 4-byte read is the price of the broker never importing the runtime + * grammar beyond the hello, and the codec's own tests pin this same shape. + */ +export function scanForHello(buf: Uint8Array): HelloScan { + for (;;) { + if (buf.byteLength < 4) return { kind: "incomplete" }; + const declared = new DataView(buf.buffer as ArrayBuffer, buf.byteOffset).getUint32(0, false); + if (declared === 0 || declared > 262_144) return { kind: "protocol" }; + if (buf.byteLength < 4 + declared) return { kind: "incomplete" }; + const body = buf.subarray(4, 4 + declared); + const rest = buf.slice(4 + declared); + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(body)); + } catch { + return { kind: "protocol" }; + } + const hello = parseSshRuntimeHello(parsed); + if (hello !== null) return { kind: "hello", hello, rest }; + return { kind: "protocol" }; + } +} + +/** Concatenate two byte views (the pump buffer's only growth operation). */ +export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + if (a.byteLength === 0) return b; + const out = new Uint8Array(a.byteLength + b.byteLength); + out.set(a, 0); + out.set(b, a.byteLength); + return out; +} diff --git a/packages/pane-runtime/src/ssh/ssh-session-open.ts b/packages/pane-runtime/src/ssh/ssh-session-open.ts new file mode 100644 index 000000000..68e452621 --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-session-open.ts @@ -0,0 +1,367 @@ +import { chmodSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + parseSshSessionOpenResult, + SSH_PROBE_DEADLINE_MS, + SSH_RUNTIME_SERVE_IN_USE_EXIT, + SSH_SESSION_IN_USE, + SSH_SESSION_OPEN_DEADLINE_MS, + SSH_SESSION_RUNTIME_MISSING, + type SshErrorCode, + type SshSessionOpenResultWire, + type SshSessionTargetWire, +} from "@internal/subshell-protocol"; +import { shellQuote } from "../shell.js"; +import { killGroup } from "./kill-group.js"; +import { classifySshFailure } from "./ssh-diagnose.js"; +import { buildSshInvocation, remoteCommandLine, renderSshConfigContents, sshChildEnv } from "./ssh-render.js"; +import { + concatBytes, + type HelloScan, + safeUsername, + scanForHello, + sessionTargetSnapshot, + sshSessionTmuxSocket, +} from "./ssh-session-invocation.js"; +import type { SshSessionRecordStore } from "./ssh-session-store.js"; +import { runSshProcess } from "./ssh-spawn.js"; + +/** + * The open-spawn/hello-pump sequence of one brokered session (design + * 2026-10-05 §3): durable acceptance, the runtime probe, the child spawn, the + * hello gate, and the pumps that outlive the open. The supervisor + * (`ssh-session-supervisor.ts`) owns the surface - ref grammar, dedup, quota, + * the send/close door, boot reconcile, and the live map this file registers + * into; this file owns the ONE path that takes a request to a live child, + * in the order that order is load-bearing: + * + * - **Probe before spawn**: `command -v ` over the same rendered + * policy; an absent runtime answers `runtime_missing` with guidance for the + * BINARY only (design §7 - never enrollment, never `subshell setup`). + * - **Durable acceptance before spawn**: the state file lands first, so a + * crash between acceptance and spawn reads back `lost`, never a ghost open. + * - **Hello gates the open**: the child must produce a parsed hello within + * {@link SSH_SESSION_OPEN_DEADLINE_MS} or the open fails and the group is + * killed. A half-open session is never reported as open. + * - **Opaque pump**: stdout bytes AFTER the hello are forwarded through the + * transport hooks without interpretation; stderr streams diagnostic lines; + * the child's exit closes the record and reports the loss once. + * - **Kill the GROUP** at agent exit: the spawn arms the process-exit hook + * that SIGKILLs every tracked pid (design §3: "at agent exit the child goes + * with it"). + * + * Refusals the plane maps by EQUALITY ride the bare codes: `runtime_missing`, + * `session_in_use`, `session_protocol`, `connection_failed` and the classified + * transport codes. + * `run_unknown` (bad ref), `run_conflict` (digest mismatch) and + * `session_quota` are decided by the supervisor BEFORE this function runs. + */ + +/** The transport's side of the pump, wired by the agent's executor (the WS is not this file's business). */ +export interface SshSessionHooks { + /** Forward pumped stdout bytes (post-hello) to the plane. Awaiting is the transport's backpressure beat. */ + emitBytes(chunk: Uint8Array): Promise; + /** Forward one stderr line (diagnostics text only). */ + emitDiag(line: string): void; + /** + * Report the child's unexpected death once (`ssh_session_lost` upstream). + * Not called for an explicit supervisor `close`, and not called before the + * open answered (the refusal already carried the fact). + */ + onLost(info: { exitCode: number | null; signal: string | null }): void; +} + +/** One open request; every fact arrives already parsed from the wire. */ +export interface SshSessionOpenRequest { + ref: string; + target: SshSessionTargetWire; + /** Default applied at the command layer; never empty (the parser refuses one). */ + runtimeCommand: string; +} + +/** Open outcomes: the parsed hello + destination facts, or a named refusal. */ +export type SshSessionOpenOutcome = + | { kind: "open"; result: SshSessionOpenResultWire } + | { kind: "refused"; code: SshErrorCode }; + +/** The live half of one session: child, dedup facts, and the stop latch. */ +export interface LiveSession { + pid: number; + stdin: { write(data: Uint8Array): unknown; flush?(): number | Promise; flushAsync?(): Promise }; + result: SshSessionOpenResultWire; + requestDigest: string; + stopping: boolean; + killEscalate?: ReturnType; +} + +/** The supervisor facts the open flow drives; the live map receives the child on success. */ +export interface SshSessionOpenDriver { + homeDir: string; + sshBin: string; + store: SshSessionRecordStore; + nowMs: () => number; + live: Map; + /** The dedup digest for this request (the supervisor owns the rule; the live entry carries it). */ + requestDigest: string; +} + +/** Process-level live pids, group-killed on `exit` (design §3's agent-exit promise; `exit` is sync-safe). */ +const exitTracked = new Set(); +let exitHookArmed = false; +function armExitHook(): void { + if (exitHookArmed) return; + exitHookArmed = true; + process.on("exit", () => { + for (const pid of exitTracked) { + try { + killGroup(pid, "SIGKILL"); + } catch { + // nothing more a dying process can do + } + } + }); +} + +/** Undo the acceptance for a refusal that never spawned (the crash-between window is the reconcile's, not this path's). */ +function dropRecord(store: SshSessionRecordStore, ref: string, configPath: string): void { + store.drop(ref); + try { + unlinkSync(configPath); + } catch { + // the rendered config is transient; the ssh retention sweep owns strays + } +} + +/** + * Take one accepted open request to a live child: acceptance record, probe, + * spawn, hello gate, pumps (see the module doc for the order and what each + * promises). The caller has already decided grammar, dedup, and quota; a + * success registers the child in `drv.live` and the pumps keep it updated + * there for the child's whole life. + */ +export async function runSessionOpen( + drv: SshSessionOpenDriver, + req: SshSessionOpenRequest, + hooks: SshSessionHooks, +): Promise { + const snapshot = sessionTargetSnapshot(req.target, drv.homeDir); + const sessionDir = drv.store.ensureDir(); + const configPath = join(sessionDir, `${req.ref}.config`); + writeFileSync(configPath, renderSshConfigContents(snapshot), { mode: 0o600 }); + chmodSync(configPath, 0o600); + drv.store.write({ + ref: req.ref, + lifecycle: "accepted", + host: req.target.host, + port: req.target.port, + user: req.target.user, + openedAtMs: drv.nowMs(), + }); + const env = await sshChildEnv(snapshot, drv.homeDir); + + // (1) Runtime probe over the same rendered policy, binary facts only. + const probe = await runSshProcess( + buildSshInvocation({ + sshBin: drv.sshBin, + snapshot, + configPath, + remoteCommand: `command -v ${shellQuote(req.runtimeCommand)}`, + }), + env, + SSH_PROBE_DEADLINE_MS, + ); + const probeAnswered = probe.code === 0 && probe.stdout.trimStart().startsWith("/") && probe.stdout.trim() !== ""; + if (!probeAnswered) { + dropRecord(drv.store, req.ref, configPath); + if (probe.timedOut || probe.spawnError) return { kind: "refused", code: "connection_failed" }; + // Exit 1 from `command -v` is the named absence; a 255 (or a null + // from a signal) may be the transport refusing, which the classifier + // names; anything else the probe answered without printing an + // absolute path is as absent as absence. + if (probe.code === 1) return { kind: "refused", code: SSH_SESSION_RUNTIME_MISSING }; + if (probe.code === 255 || probe.code === null) { + return { kind: "refused", code: classifySshFailure(probe.stderr) ?? "connection_failed" }; + } + return { kind: "refused", code: "connection_failed" }; + } + + // (2) Spawn the long-lived child. NO -tt: a destination PTY would echo + // and rewrite the protocol bytes; stdout must be protocol-only (design + // §2), which a pipe guarantees and a tty cannot. The remote command is + // one program invocation with per-token quoting (the slice's runtime + // entry point; the justification for the token riding it is in the + // supervisor's header). + const socket = sshSessionTmuxSocket(req.target.host, req.target.port, req.target.user); + const serveLine = remoteCommandLine( + `${shellQuote(req.runtimeCommand)} runtime-serve --session ${shellQuote(req.ref)} --tmux-socket ${shellQuote(socket)}`, + null, + ); + const argv = buildSshInvocation({ sshBin: drv.sshBin, snapshot, configPath, remoteCommand: serveLine }); + let proc: Bun.Subprocess; + try { + proc = Bun.spawn(argv, { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env, + cwd: drv.store.dir(), + detached: true, // own group: the stop reaches ssh's helper children (killGroup) + }); + } catch { + dropRecord(drv.store, req.ref, configPath); + return { kind: "refused", code: "connection_failed" }; + } + armExitHook(); + exitTracked.add(proc.pid); + const stdin = proc.stdin as unknown as LiveSession["stdin"]; + const openStartedAt = drv.nowMs(); + + // (3) The hello gate. Read until the hello boundary, the child dying, or + // the deadline. Death before hello maps by ssh's own exit posture: + // the serve's own busy exit = the destination already carries a live + // session (m2 - the binary demonstrably EXISTS, it is running that + // session), 127 ("command not found" past the probe - a login-shell PATH + // drift, honest either way), 255/signal = transport, anything else (or a + // protocol verdict) = the named refusal it is. + const stdoutReader = (proc.stdout as ReadableStream).getReader(); + const exitedEarly = proc.exited.then((code) => ({ code: code as number | null })).catch(() => ({ code: null })); + let scan: HelloScan = { kind: "incomplete" }; + let buf: Uint8Array = new Uint8Array(0); + let deadlineHit = false; + while (scan.kind === "incomplete") { + const remaining = SSH_SESSION_OPEN_DEADLINE_MS - (drv.nowMs() - openStartedAt); + const raced = await Promise.race([ + stdoutReader.read().then((r) => ({ kind: "read" as const, r })), + exitedEarly.then((e) => ({ kind: "child" as const, e })), + new Promise<{ kind: "deadline" }>((resolve) => { + const t = setTimeout(() => resolve({ kind: "deadline" }), Math.max(1, remaining)); + t.unref?.(); + }), + ]); + if (raced.kind === "deadline") { + deadlineHit = true; + break; + } + if (raced.kind === "child") break; // hello never came; classify below + const r = raced.r; + if (r.done === true) break; // stdout closed without a hello + if (r.value !== undefined) { + buf = concatBytes(buf, r.value); + scan = scanForHello(buf); + if (scan.kind === "hello") buf = scan.rest; + } + } + if (scan.kind !== "hello") { + killGroup(proc.pid, "SIGKILL"); + void stdoutReader.cancel().catch(() => {}); + exitTracked.delete(proc.pid); + dropRecord(drv.store, req.ref, configPath); + if (scan.kind === "protocol") return { kind: "refused", code: "session_protocol" }; + if (deadlineHit) return { kind: "refused", code: "connection_failed" }; + // The child is gone before hello: ask it how. The serve's busy exit goes + // FIRST (review m2): a destination with a live session answers it, and + // the default arm below would dress that up as "binary missing" - the + // wrong remedy on a machine whose binary is running the other session. + const early = await exitedEarly; + if (early.code === SSH_RUNTIME_SERVE_IN_USE_EXIT) return { kind: "refused", code: SSH_SESSION_IN_USE }; + if (early.code === 127) return { kind: "refused", code: SSH_SESSION_RUNTIME_MISSING }; + if (early.code === 255 || early.code === null) { + return { kind: "refused", code: classifySshFailure("") ?? "connection_failed" }; + } + return { kind: "refused", code: SSH_SESSION_RUNTIME_MISSING }; + } + + const built: SshSessionOpenResultWire = { + hello: scan.hello, + host: req.target.host, + port: req.target.port, + user: req.target.user, + ...(safeUsername() !== undefined ? { connectingAccount: safeUsername() } : {}), + }; + const validated = parseSshSessionOpenResult(built); + if (validated === null) { + killGroup(proc.pid, "SIGKILL"); + exitTracked.delete(proc.pid); + dropRecord(drv.store, req.ref, configPath); + return { kind: "refused", code: "connection_failed" }; + } + drv.store.write({ + ref: req.ref, + lifecycle: "open", + host: req.target.host, + port: req.target.port, + user: req.target.user, + openedAtMs: drv.nowMs(), + }); + const live: LiveSession = { + pid: proc.pid, + stdin, + result: validated, + requestDigest: drv.requestDigest, + stopping: false, + }; + drv.live.set(req.ref, live); + + // (4) The pumps, from the hello boundary onward. stdout forwards raw + // bytes through the transport hook (whose await IS the backpressure); + // stderr streams text lines; the exit closes the record. + const heldAfterHello = scan.rest.byteLength > 0 ? scan.rest : null; + void (async (): Promise => { + try { + if (heldAfterHello !== null) await hooks.emitBytes(heldAfterHello); + for (;;) { + const { done, value } = await stdoutReader.read(); + if (done) break; + if (value !== undefined) await hooks.emitBytes(value); + } + } catch { + // stream broke; the exit branch carries the loss fact + } + })(); + void (async (): Promise => { + const reader = (proc.stderr as ReadableStream).getReader(); + const decoder = new TextDecoder(); + let tail = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value === undefined) continue; + tail += decoder.decode(value, { stream: true }); + let nl = tail.indexOf("\n"); + while (nl >= 0) { + const line = tail.slice(0, nl).trimEnd(); + tail = tail.slice(nl + 1); + if (line !== "") hooks.emitDiag(line); + nl = tail.indexOf("\n"); + } + } + } catch { + // diagnostics are best-effort by definition + } + })(); + void (async (): Promise => { + let exitCode: number | null = null; + let signal: string | null = null; + try { + exitCode = await proc.exited; + if (exitCode === null) signal = (proc as unknown as { signal?: string | null }).signal ?? null; + } catch { + // unobservable: the honest nulls + } + exitTracked.delete(proc.pid); + if (live.killEscalate !== undefined) clearTimeout(live.killEscalate); + const wasStopping = live.stopping; + drv.live.delete(req.ref); + const rec = drv.store.read(drv.store.path(req.ref)); + if (rec && rec.lifecycle !== "closed") drv.store.write({ ...rec, lifecycle: "lost", lostAtMs: drv.nowMs() }); + try { + unlinkSync(configPath); + } catch { + // as above: transient bytes + } + if (!wasStopping) hooks.onLost({ exitCode, signal }); + })(); + + return { kind: "open", result: validated }; +} diff --git a/packages/pane-runtime/src/ssh/ssh-session-store.ts b/packages/pane-runtime/src/ssh/ssh-session-store.ts new file mode 100644 index 000000000..37d139ddf --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-session-store.ts @@ -0,0 +1,145 @@ +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * The durable record half of the brokered-session supervisor (design + * 2026-10-05 §3): what `/ssh/sessions/.json` says about a + * session, how it is read/written atomically-enough (0600, the same write + * discipline every SSH state file keeps), and the boot reconcile that reads a + * dead daemon's `accepted`/`open` records back as `lost`. + * + * The file is the DISK half of a two-file supervisor split (the retired run + * family used the same shape): this half knows the DISK, the supervisor half + * knows the CHILD. The lifecycle is history, never authority - the live map + * in the supervisor decides what is open; a record only tells the truth about + * a child that is not there. + */ + +/** The lifecycle the node-side record keeps (history, never authority: the live map decides). */ +export type SshSessionLifecycle = "accepted" | "open" | "lost" | "closed"; +const LIFECYCLES: readonly string[] = ["accepted", "open", "lost", "closed"]; + +export interface SshSessionRecord { + /** The plane-minted session ref. */ + ref: string; + /** {@link SshSessionLifecycle}; `accepted` lands before the spawn, `open` after the hello. */ + lifecycle: SshSessionLifecycle; + /** Reviewed destination host (the target is parsed by the time an acceptance lands). */ + host: string; + /** Reviewed destination port. */ + port: number; + /** Destination account or null (the connecting account's default). */ + user: string | null; + /** Epoch ms of the acceptance write (durable-before-spawn, the crash-between witness). */ + openedAtMs: number; + /** Epoch ms of the terminal transition (lost or closed). */ + lostAtMs?: number; +} + +/** Where one session's records live and how time is told (injectable clock). */ +export interface SshSessionStoreDeps { + /** Node data dir; records land under `/ssh/sessions/`. */ + dataDir: string; + /** Epoch-ms clock. */ + nowMs(): number; +} + +export class SshSessionRecordStore { + readonly #dataDir: string; + readonly #nowMs: () => number; + + constructor(deps: SshSessionStoreDeps) { + this.#dataDir = deps.dataDir; + this.#nowMs = deps.nowMs; + } + + /** + * Every `accepted`/`open` record with no live child under `liveRefs` becomes + * `lost` on disk (never a restart, never a claim about the destination's + * tmux - that server SURVIVED; only the child did not). Returns the swept + * records (the supervisor logs them; nobody else reads them). + */ + reconcileAll(liveRefs: ReadonlySet): SshSessionRecord[] { + const out: SshSessionRecord[] = []; + const dir = this.dir(); + if (!existsSync(dir)) return out; + for (const name of readdirSync(dir)) { + if (!name.endsWith(".json")) continue; + const rec = this.read(join(dir, name)); + if (rec && (rec.lifecycle === "accepted" || rec.lifecycle === "open") && !liveRefs.has(rec.ref)) { + const lost: SshSessionRecord = { ...rec, lifecycle: "lost", lostAtMs: this.#nowMs() }; + this.write(lost); + out.push(lost); + } + } + return out; + } + + /** The session dir (NOT created; a read scan may run against a node that never brokers). */ + dir(): string { + return join(this.#dataDir, "ssh", "sessions"); + } + + /** The session dir, created 0700 (the accept path calls this before its first write). */ + ensureDir(): string { + const dir = this.dir(); + mkdirSync(dir, { recursive: true }); + try { + chmodSync(dir, 0o700); + } catch { + // the dir is inside the agent's own 0700 data dir; a failed tighten is not a leak + } + return dir; + } + + path(ref: string): string { + return join(this.dir(), `${ref}.json`); + } + + /** Read one record BY PATH (`path(ref)` composes it); null for absent, unreadable, or schema-refusing (a corrupt record is not a lifecycle). */ + read(path: string): SshSessionRecord | null { + try { + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + if (typeof raw.ref !== "string" || typeof raw.lifecycle !== "string") return null; + if (!LIFECYCLES.includes(raw.lifecycle)) return null; + if (typeof raw.host !== "string" || typeof raw.port !== "number" || typeof raw.openedAtMs !== "number") + return null; + if (!("user" in raw) || !(raw.user === null || typeof raw.user === "string")) return null; + return { + ref: raw.ref, + lifecycle: raw.lifecycle as SshSessionLifecycle, + host: raw.host, + port: raw.port, + user: raw.user as string | null, + openedAtMs: raw.openedAtMs, + ...(typeof raw.lostAtMs === "number" ? { lostAtMs: raw.lostAtMs } : {}), + }; + } catch { + return null; + } + } + + write(rec: SshSessionRecord): void { + const dir = this.ensureDir(); + const path = join(dir, `${rec.ref}.json`); + writeFileSync(path, `${JSON.stringify(rec)}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); + } + + /** Forget one record entirely (the never-spawned unroll; nothing to reconcile afterwards). */ + drop(ref: string): void { + try { + unlinkSync(this.path(ref)); + } catch { + // nothing recorded means nothing to undo + } + } +} + +/** The digest that makes a duplicate open answer the live result instead of spawning twice. */ +export function digestSessionRequest(target: unknown, runtimeCommand: string): string { + return createHash("sha256") + .update(JSON.stringify([target, runtimeCommand])) + .digest("hex"); +} diff --git a/packages/pane-runtime/src/ssh/ssh-session-supervisor.ts b/packages/pane-runtime/src/ssh/ssh-session-supervisor.ts new file mode 100644 index 000000000..715b3c35e --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-session-supervisor.ts @@ -0,0 +1,288 @@ +import { + isSshSessionRef, + SSH_CANCEL_GRACE_MS, + SSH_SESSION_UNKNOWN, + SSH_SESSIONS_PER_NODE, +} from "@internal/subshell-protocol"; +import { killGroup } from "./kill-group.js"; +import { + type LiveSession, + runSessionOpen, + type SshSessionHooks, + type SshSessionOpenOutcome, + type SshSessionOpenRequest, +} from "./ssh-session-open.js"; +import { digestSessionRequest, type SshSessionRecord, SshSessionRecordStore } from "./ssh-session-store.js"; + +const MAX_PENDING_INPUT_BYTES = 1024 * 1024; +const INPUT_FLUSH_TIMEOUT_MS = 10_000; +interface PendingInput { + bytes: number; + cancel: Set<() => void>; +} + +/** + * The brokered-session engine's SURFACE (design 2026-10-05 §3): one SSH child + * per session under the SAME mandatory policy the structured runs use, and + * the door the agent's `ssh_session_*` executors call. The child-spawning + * sequence itself - durable acceptance, runtime probe, spawn, hello gate, + * pumps - lives in `ssh-session-open.ts` and registers its children into the + * live map this class owns; the DISK half is `ssh-session-store.ts` (records, + * boot reconcile scan) and the per-destination facts are + * `ssh-session-invocation.ts` (socket naming, snapshot, hello scan) - the run + * family's store/supervisor split, mirrored. + * + * What stays HERE is the decision surface and the durable promises around it: + * + * - **Grammar, dedup, quota before anything is accepted**: a bad ref answers + * `run_unknown`, a replayed identical request re-answers the live result + * (no second child), a different payload under one ref answers + * `run_conflict`, and the per-node quota is decided with the acceptance, + * not after it (the run supervisor's reasoning, kept verbatim by the chain + * that serializes opens). + * - **One serialized chain**: concurrent opens cannot both pass the quota + * check and both spawn. + * - **Kill the GROUP** on close (SIGTERM, {@link SSH_CANCEL_GRACE_MS}, + * SIGKILL) FIRST, and record `closed` best-effort after: the `stopping` + * latch (not the record) keeps the death on the way down from reporting + * itself as `lost`, and no disk fault in the record may spare the kill + * (round-4 review MINOR2 - an orphan holds a quota slot and the + * destination's door). The destination's tmux server and its panes stay + * up; killing them is terminate, never a close side effect. + * - **Boot reconcile**: a state file claiming `accepted`/`open` with no live + * child under this process reads back `lost`, never a restart. The tmux + * server the last session spoke to SURVIVED on the destination (design §6); + * this record only tells the truth about the child that did not. + * - **Send is write-only truth**: a ref this process has no live child for + * answers the bare {@link SSH_SESSION_UNKNOWN}, never a retry cue. + * + * Why the node owner running this is NOT a widening: the node's OS user can + * already run any command on the destination through their own SSH - that is + * what owning an account on the connecting node means. The open flow composes + * exactly one such command (the runtime's own entry point, per-token quoted), + * under the un-weakened runtime policy, and pipes its stdio. + * + * Refusals the plane maps by EQUALITY ride the bare codes: `run_unknown`, + * `run_conflict`, `session_quota`, and whatever the open flow names + * (`runtime_missing`, `session_in_use`, `session_protocol`, + * `connection_failed`, the classified transport codes); + * {@link SSH_SESSION_UNKNOWN} answers send/close on a ref this process has no + * live child for. + */ + +/** Everything the supervisor needs from its host. */ +export interface SshSessionSupervisorDeps { + /** Node data dir; session records live under `/ssh/sessions/`. */ + dataDir: string; + /** The connecting account's home (the ssh child's HOME, the default trust refs). */ + homeDir: string; + /** Absolute ssh binary (the ladder resolved it; nothing here re-searches). */ + sshBin: string; + /** Epoch-ms clock (injectable). */ + nowMs(): number; +} + +export class SshSessionSupervisor { + readonly #homeDir: string; + readonly #sshBin: string; + readonly #store: SshSessionRecordStore; + readonly #nowMs: () => number; + readonly #live = new Map(); + readonly #pendingInput = new WeakMap(); + /** One pending-open chain: quota + acceptance decided together (the run supervisor's reasoning). */ + #openChain: Promise = Promise.resolve(); + + constructor(deps: SshSessionSupervisorDeps) { + this.#homeDir = deps.homeDir; + this.#sshBin = deps.sshBin; + this.#store = new SshSessionRecordStore({ dataDir: deps.dataDir, nowMs: deps.nowMs }); + this.#nowMs = deps.nowMs; + // Boot reconcile at BUILD time: the first open in this process is what + // reads last daemon's stale `accepted`/`open` records back to `lost` + // (never a restart, never a claim about the destination's tmux). Records + // from a node that never brokers stay untouched, which is fine: they are + // read only through this map. + this.reconcileAtBoot(); + } + + /** Live session refs this process supervises right now (the boot reconcile's "mine" set). */ + liveRefs(): string[] { + return [...this.#live.keys()]; + } + + /** Every `accepted`/`open` state with no live child under this process becomes `lost` (never a restart, never a claim about the destination's tmux). */ + reconcileAtBoot(): SshSessionRecord[] { + return this.#store.reconcileAll(new Set(this.#live.keys())); + } + + /** + * Open one session. Order is the contract: ref grammar -> dedup/quota -> + * the open flow (`ssh-session-open.ts`: durable acceptance, probe, spawn, + * hello, pumps). Every refusal happens before a long-lived child exists; + * every acceptance before any spawn, so a crash between the two reads back + * `lost` at the next boot. + */ + async open(req: SshSessionOpenRequest, hooks: SshSessionHooks): Promise { + const run = async (): Promise => { + if (!isSshSessionRef(req.ref)) return { kind: "refused", code: "run_unknown" }; + const digest = this.#digest(req); + const existing = this.#live.get(req.ref); + if (existing) { + if (existing.requestDigest === digest) return { kind: "open", result: existing.result }; + return { kind: "refused", code: "run_conflict" }; + } + if (this.#live.size >= SSH_SESSIONS_PER_NODE) return { kind: "refused", code: "session_quota" }; + return runSessionOpen( + { + homeDir: this.#homeDir, + sshBin: this.#sshBin, + store: this.#store, + nowMs: this.#nowMs, + live: this.#live, + requestDigest: digest, + }, + req, + hooks, + ); + }; + const chained = this.#openChain.then(run, run); + this.#openChain = chained.then( + () => undefined, + () => undefined, + ); + return await chained; + } + + /** + * Feed plane->runtime bytes into the child's stdin. Unknown refs answer the + * bare {@link SSH_SESSION_UNKNOWN}: the plane already learned the loss from + * the close report; a write to a dead child must never read as a retry cue. + */ + send(ref: string, bytes: Uint8Array): "ok" | typeof SSH_SESSION_UNKNOWN { + if (!isSshSessionRef(ref)) return SSH_SESSION_UNKNOWN; + const live = this.#live.get(ref); + if (!live) return SSH_SESSION_UNKNOWN; + try { + live.stdin.write(bytes); + void Promise.resolve(live.stdin.flushAsync?.()).catch(() => {}); + return "ok"; + } catch { + return SSH_SESSION_UNKNOWN; + } + } + + /** Acknowledge only flushed bytes, with a per-child memory and time bound. + * A stalled sink loses its session rather than accepting more input forever. + * Keep the legacy synchronous send surface for its existing callers. + */ + async sendAsync(ref: string, bytes: Uint8Array): Promise<"ok" | typeof SSH_SESSION_UNKNOWN> { + if (!isSshSessionRef(ref)) return SSH_SESSION_UNKNOWN; + const live = this.#live.get(ref); + if (!live || live.stopping) return SSH_SESSION_UNKNOWN; + if (bytes.byteLength === 0) return "ok"; + let pending = this.#pendingInput.get(live); + if (!pending) { + pending = { bytes: 0, cancel: new Set() }; + this.#pendingInput.set(live, pending); + } + if (pending.bytes + bytes.byteLength > MAX_PENDING_INPUT_BYTES || pending.cancel.size >= 32) { + this.close(ref); + return SSH_SESSION_UNKNOWN; + } + pending.bytes += bytes.byteLength; + let cancel!: () => void; + const cancelled = new Promise((resolve) => { + cancel = () => resolve(false); + pending.cancel.add(cancel); + }); + const timer = setTimeout(() => { + if (this.#live.get(ref) === live) this.close(ref); + else cancel(); + }, INPUT_FLUSH_TIMEOUT_MS); + try { + // Bun's FileSink exposes flush(), which becomes a promise when its + // pipe fills. flushAsync is retained only for existing injected sinks. + const flush = live.stdin.flush?.bind(live.stdin) ?? live.stdin.flushAsync?.bind(live.stdin); + if (!flush) throw new Error("SSH input cannot flush"); + live.stdin.write(bytes); + const flushed = await Promise.race([Promise.resolve(flush()).then(() => true), cancelled]); + return flushed && this.#live.get(ref) === live && !live.stopping ? "ok" : SSH_SESSION_UNKNOWN; + } catch { + if (this.#live.get(ref) === live && !live.stopping) this.close(ref); + return SSH_SESSION_UNKNOWN; + } finally { + clearTimeout(timer); + pending.bytes -= bytes.byteLength; + pending.cancel.delete(cancel); + } + } + + /** + * Close one session as a USER act (design §6's Close): SIGTERM the group, + * SIGKILL after the grace, THEN record `closed` best-effort. The kill + * leads because the record write is bare sync fs (ENOSPC/EROFS/EACCES are + * ordinary) and a disk fault must never spare it: an orphan holds a quota + * slot and the destination's door (round-4 review MINOR2). The `stopping` + * latch, not the record, keeps the death on the way down from reporting + * itself as `lost`; a missing `closed` costs only history, which the exit + * pump or the boot reconcile settles. The group-kill is NOT delegated to + * the runtime's own good behavior: the plane sends this command on every + * user close (and the protocol-mismatch unroll), and a runtime that + * ignored the `close` frame is exactly why it exists. + */ + close(ref: string): "ok" | typeof SSH_SESSION_UNKNOWN { + const live = this.#live.get(ref); + if (!live) return SSH_SESSION_UNKNOWN; + if (live.stopping) return "ok"; + // The latch FIRST: it is what quiets the death handler (ssh-session-open + // reads it when the exit lands), so every act below is free to fail. + live.stopping = true; + for (const cancel of this.#pendingInput.get(live)?.cancel ?? []) cancel(); + killGroup(live.pid, "SIGTERM"); + live.killEscalate = setTimeout(() => { + killGroup(live.pid, "SIGKILL"); + }, SSH_CANCEL_GRACE_MS); + live.killEscalate.unref?.(); + try { + const rec = this.#store.read(this.#store.path(ref)); + if (rec) this.#store.write({ ...rec, lifecycle: "closed", lostAtMs: this.#nowMs() }); + } catch { + // A failed record is a reconcile detail: the child is dead (the kill + // went first), the live map is authority, and the exit pump (or the + // next boot reconcile) settles the disk to `lost` for the dead pid. + } + return "ok"; + } + + #digest(req: SshSessionOpenRequest): string { + return digestSessionRequest(req.target, req.runtimeCommand); + } +} + +/** Process-level registry: one supervisor per data dir (the run supervisor's singleton posture). */ +const supervisors = new Map(); + +/** The supervisor for one data dir, built lazily from caller-supplied facts (never at import). */ +export function getSshSessionSupervisor(deps: { + dataDir: string; + homeDir: string; + sshBin: string; + nowMs?: () => number; +}): SshSessionSupervisor { + let instance = supervisors.get(deps.dataDir); + if (!instance) { + instance = new SshSessionSupervisor({ ...deps, nowMs: deps.nowMs ?? Date.now }); + supervisors.set(deps.dataDir, instance); + } + return instance; +} + +/** The live supervisor for a data dir, if one was ever built (reconcile/sweeps reach through this; NEVER builds one). */ +export function peekSshSessionSupervisor(dataDir: string): SshSessionSupervisor | undefined { + return supervisors.get(dataDir); +} + +/** Drop the registry (test isolation). @internal */ +export function resetSshSessionSupervisorsForTests(): void { + supervisors.clear(); +} diff --git a/packages/pane-runtime/src/ssh/ssh-spawn.ts b/packages/pane-runtime/src/ssh/ssh-spawn.ts new file mode 100644 index 000000000..44baaeebf --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-spawn.ts @@ -0,0 +1,133 @@ +/** + * One ssh child, run to completion, captured, with a deadline that holds. + * + * This is the short-RPC sibling of the brokered-session supervisor: + * `ssh -G` resolution and the session probe both finish or die inside a + * bounded window and their stdout/stderr + * ARE the answer, so they need capture, not a streaming output store. The + * deadline posture is `run-bounded.ts`'s (kill AND cancel the readers, then + * escalate to SIGKILL — killing the child does not close pipes its children + * hold), simplified to a captured two-stream read because nothing here + * streams lines; a chatty ssh cannot matter because `-G` and a bounded probe + * produce a fixed small shape, and the capture caps keep even a pathological + * child inside the RPC envelope. + */ + +import { loginPathEntries } from "../login-path.js"; + +/** Captured-output cap per stream for the short ssh RPCs (config dumps are small; a runaway is capped, not followed). */ +const CAPTURE_CAP_BYTES = 256 * 1024; + +/** Escalation gap between SIGTERM and SIGKILL on the deadline path. */ +const KILL_GRACE_MS = 2_000; + +/** The outcome of one short ssh process. */ +export interface SshProcessResult { + /** Exit code, or null when the process was killed by a signal or never spawned. */ + code: number | null; + stdout: string; + stderr: string; + /** The deadline ended the run. */ + timedOut: boolean; + /** The child could not be spawned at all (no such binary); the message is on stderr. */ + spawnError: boolean; +} + +/** + * Run `argv` to completion or deadline, capturing both streams separately. + * + * @param argv - the command; argv[0] must be an absolute path — the callers + * resolve `ssh` through the binary ladder BEFORE calling, and nothing here + * consults PATH to pick an executable (the §2 "explicit argv" rule). + * @param env - the COMPLETE child environment; no inheritance, by design + * @param timeoutMs - hard deadline; the child is terminated at it + */ +export async function runSshProcess( + argv: readonly string[], + env: Record, + timeoutMs: number, +): Promise { + let proc: Bun.Subprocess<"ignore", "pipe", "pipe">; + try { + proc = Bun.spawn([...argv], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env, + }) as Bun.Subprocess<"ignore", "pipe", "pipe">; + } catch (err) { + return { + code: null, + stdout: "", + stderr: err instanceof Error ? err.message : String(err), + timedOut: false, + spawnError: true, + }; + } + const outReader: CappedReader = proc.stdout.getReader(); + const errReader: CappedReader = proc.stderr.getReader(); + let timedOut = false; + let escalate: ReturnType | undefined; + const stop = (): void => { + timedOut = true; + proc.kill(); + void outReader.cancel().catch(() => {}); + void errReader.cancel().catch(() => {}); + escalate ??= setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch { + // already reaped + } + }, KILL_GRACE_MS); + escalate.unref?.(); + }; + const timer = setTimeout(stop, timeoutMs); + timer.unref?.(); + try { + const [stdout, stderr] = await Promise.all([drainCapped(outReader), drainCapped(errReader)]); + const code = await proc.exited; + return { code: timedOut ? null : code, stdout, stderr, timedOut, spawnError: false }; + } catch { + return { code: null, stdout: "", stderr: "", timedOut, spawnError: false }; + } finally { + clearTimeout(timer); + if (escalate !== undefined) clearTimeout(escalate); + } +} + +/** The minimal reader shape the drain needs (structural, like `run-bounded.ts`'s — the exact reader class varies by stream generic). */ +interface CappedReader { + read(): Promise<{ done?: boolean; value?: Uint8Array }>; + cancel(): Promise; +} + +/** Read a stream up to the cap; past the cap KEEP DRAINING (a child blocked on a full, unread pipe never exits). */ +async function drainCapped(reader: CappedReader): Promise { + const chunks: Uint8Array[] = []; + let kept = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value !== undefined && kept < CAPTURE_CAP_BYTES) { + chunks.push(value); + kept += value.byteLength; + } + } + } catch { + // cancelled by the deadline: return what arrived + } + return new TextDecoder().decode(Buffer.concat(chunks)); +} + +/** + * The PATH value an ssh child gets: this process's PATH plus the login + * shell's entries (a systemd-launched daemon's PATH is baked at install time + * and cannot see a Homebrew /usr/local/bin ssh installed later). Exposed so + * the supervised run's spawn and the short RPCs state the PATH rule once. + */ +export async function sshChildPath(): Promise { + const entries = [...(process.env.PATH ?? "").split(":"), ...(await loginPathEntries())].filter(Boolean); + return [...new Set(entries)].join(":"); +} diff --git a/packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts b/packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts new file mode 100644 index 000000000..90e798278 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts @@ -0,0 +1,120 @@ +/** + * Shared SSH fixtures (Gate A, pruned with the destination product by + * Workstream C: the run/terminal/test/control factories deleted with their + * wire arms). Workstreams B, C, D and G build their suites + * from THESE factories rather than hand-rolling frames: a fixture that stops + * parsing is a contract break caught in the protocol package, not four silent + * drifts downstream. Every factory returns the WIRE type (plain data), every + * "boundary" export is the exact legal extreme, and `invalidSnapshotVariants` + * is the adversarial set the node-side validator must refuse - one member per + * absent-forbidden field plus the hygiene refusals. + * + * Pure data + spread overrides; imports nothing from `bun:test` so production + * code could (but should not) share it. + */ + +import type { SshConnectionSnapshotWire, SshHopWire } from "../../ssh-config.js"; +import type { SshErrorCode } from "../../ssh-errors.js"; +import type { SshDiscoverAliasesCommand, SshResolveConfigCommand } from "../../ssh-frames.js"; +import { SSH_MAX_IDENTITY_REFS, SSH_MAX_PROXY_HOPS } from "../../ssh-limits.js"; +import type { NodeSshAliasListResult, NodeSshResolveOutcomeWire } from "../../ssh-results.js"; + +/** A well-formed hop (fixture arg for chains). */ +export function makeHop(n = 0): SshHopWire { + return { host: `jump-${n}.example.net`, user: n % 2 === 0 ? `jumpuser${n}` : null, port: 22 }; +} + +/** The canonical approved snapshot: two identities, an agent, two known-hosts files, one hop. */ +export function makeSnapshot(overrides: Partial = {}): SshConnectionSnapshotWire { + return { + alias: "app02", + host: "app-02.example.net", + user: "deploy", + port: 22, + identityFiles: ["/home/deploy/.ssh/id_ed25519", "/home/deploy/.ssh/deploy_key"], + certificateFiles: ["/home/deploy/.ssh/id_ed25519-cert.pub"], + authAgentSocket: "/run/user/1000/ssh-agent.sock", + knownHostsFiles: ["/home/deploy/.ssh/known_hosts", "/etc/ssh/ssh_known_hosts"], + hostKeyAlias: null, + proxyJumps: [makeHop(0)], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...overrides, + }; +} + +/** + * An invalid snapshot per forbidden field, with a realistic offending value. + * The node validator must refuse EVERY entry here, and the resolve/UI story + * must be able to name which field blocked. + */ +export function invalidSnapshotVariants(): { field: string; snapshot: Record }[] { + const base = makeSnapshot(); + return [ + { field: "proxyCommand", snapshot: { ...base, proxyCommand: "nc proxy 8443" } }, + { field: "forwards", snapshot: { ...base, forwards: "LocalForward 9000 socks" } }, + { field: "tunnels", snapshot: { ...base, tunnels: "yes" } }, + { field: "localCommands", snapshot: { ...base, localCommands: "notify-send up" } }, + { field: "remoteCommand", snapshot: { ...base, remoteCommand: "sh -i" } }, + { field: "sendEnv", snapshot: { ...base, sendEnv: "LANG LC_*" } }, + { field: "setEnv", snapshot: { ...base, setEnv: "FOO=bar" } }, + { field: "escapes", snapshot: { ...base, escapes: "~C" } }, + ]; +} + +/** Hygiene violations the snapshot parser must refuse (beyond the forbidden members). */ +export function invalidSnapshotHygieneVariants(): { field: string; snapshot: Record }[] { + const base = makeSnapshot(); + return [ + { field: "optionLikeHost", snapshot: { ...base, host: "-oProxyCommand=touch_pwned" } }, + { field: "controlCharUser", snapshot: { ...base, user: "deploy\x00" } }, + { field: "relativeIdentity", snapshot: { ...base, identityFiles: [".ssh/id_ed25519"] } }, + { field: "zeroPort", snapshot: { ...base, port: 0 } }, + { + field: "overMaxHops", + snapshot: { ...base, proxyJumps: Array.from({ length: SSH_MAX_PROXY_HOPS + 1 }, (_, i) => makeHop(i)) }, + }, + { + field: "identityListTooLong", + snapshot: { + ...base, + identityFiles: Array.from({ length: SSH_MAX_IDENTITY_REFS + 1 }, (_, i) => `/home/deploy/.ssh/id_${i}`), + }, + }, + { field: "missingUserKey", snapshot: Object.fromEntries(Object.entries(base).filter(([k]) => k !== "user")) }, + ]; +} + +/* ------------------------------------------------------------------ */ +/* commands */ +/* ------------------------------------------------------------------ */ + +/** The one input-free command, spelled for census loops. */ +export function makeDiscoverAliases(): SshDiscoverAliasesCommand { + return { type: "ssh_discover_aliases" }; +} + +export function makeResolveConfig(overrides: Partial = {}): SshResolveConfigCommand { + return { type: "ssh_resolve_config", alias: "app02", ...overrides }; +} + +export function makeAliasList(overrides: Partial = {}): NodeSshAliasListResult { + return { aliases: ["app01", "app02", "staging"], includeCycle: false, truncated: false, ...overrides }; +} + +export function makeResolveOk(snapshot = makeSnapshot()): NodeSshResolveOutcomeWire { + return { accepted: true, snapshot }; +} + +export function makeResolveRefused( + code: SshErrorCode = "unsupported_setting", + settings = ["ProxyCommand"], +): NodeSshResolveOutcomeWire { + return { accepted: false, code, settings }; +} diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 6894a0f12..4bf645a10 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -310,7 +310,11 @@ describe("parseNodeCommandBody", () => { // 15 is the archive-transfer surface (spec 2026-10-01 §2): five commands // (archive_create, file_read, transfer_write, archive_extract, // tree_manifest) plus the window/entry constants both ends must agree on. - expect(NODE_PROTOCOL_VERSION).toBe(15); + // 16 is the SSH surface (spec SSH-SUPPORT.md §4): the nine-command family + // plus the snapshot/run-facts envelopes and the input-generation fence. + // 17 is the SSH session-runtime surface (design 2026-10-05 §2/§3): the + // three ssh_session_* commands and the session_frame event arm. + expect(NODE_PROTOCOL_VERSION).toBe(17); }); it("accepts set_allowed_dirs and rejects a missing or non-array dirs", () => { diff --git a/packages/subshell-protocol/src/__tests__/node-results.test.ts b/packages/subshell-protocol/src/__tests__/node-results.test.ts index 331f7a94a..9c3a2e041 100644 --- a/packages/subshell-protocol/src/__tests__/node-results.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-results.test.ts @@ -49,6 +49,11 @@ describe("node result contracts (spec §3.3, phase-2 wire note)", () => { expect(parseNodeLogReadResult({ bytes_b64: "", next: 5, size: 3 })).toBeNull(); // empty read must not jump past EOF expect(parseNodeLogReadResult({ bytes_b64: "", next: 3, size: 3 })).not.toBeNull(); // empty tail read is legal expect(parseNodeLogReadResult({ bytes_b64: "aGk=", next: -1, size: 2 })).toBeNull(); + // The SSH rotation echo died with the managed SSH pane (design + // 2026-10-05 §7): the type has no such field, the parser validates no + // such field, and a stale agent answer still carrying one is simply + // unknown-key-tolerant data, never a refusal. + expect(parseNodeLogReadResult({ bytes_b64: "aGk=", next: 2, size: 2, logGeneration: 3 })).not.toBeNull(); }); it("the scalar results validate narrowly", () => { diff --git a/packages/subshell-protocol/src/__tests__/ssh-config.test.ts b/packages/subshell-protocol/src/__tests__/ssh-config.test.ts new file mode 100644 index 000000000..67aab9922 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-config.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { parseSshConnectionSnapshot, SSH_FORBIDDEN_SNAPSHOT_FIELDS } from "../ssh-config.js"; +import { SSH_MAX_PROXY_HOPS } from "../ssh-limits.js"; +import { + invalidSnapshotHygieneVariants, + invalidSnapshotVariants, + makeHop, + makeSnapshot, +} from "./fixtures/ssh-fixtures.js"; + +/** + * The Gate A snapshot grammar: what the node re-checks on every command, so + * these refusals ARE the security story of §2's "configuration is executable" + * rule at the boundary. A green suite here means a `ProxyCommand` cannot + * reach a resolver's argv through a snapshot, whatever the plane stored. + */ +describe("parseSshConnectionSnapshot", () => { + it("round-trips the canonical snapshot with every field intact", () => { + const snapshot = makeSnapshot(); + expect(parseSshConnectionSnapshot(structuredClone(snapshot))).toEqual(snapshot); + }); + + it("accepts an all-null minimal snapshot (no user, no agent, no hops)", () => { + const minimal = makeSnapshot({ + user: null, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + proxyJumps: [], + }); + expect(parseSshConnectionSnapshot(minimal)).toEqual(minimal); + }); + + it("rebuilt output is not the input object (stray keys die with the copy)", () => { + const input = { ...makeSnapshot(), proxyUseExec: true } as unknown as Record; + const out = parseSshConnectionSnapshot(input); + expect(out).not.toBeNull(); + expect("proxyUseExec" in (out as unknown as Record)).toBe(false); + expect(out).not.toBe(input); + }); + + it("refuses every absent-forbidden member carrying a value", () => { + for (const { field, snapshot } of invalidSnapshotVariants()) { + expect(parseSshConnectionSnapshot(structuredClone(snapshot))).toBeNull(); + // The list is the documented eight; a fixture drift (a variant whose + // field is not in the constant) fails right here, not in a downstream + // matcher that forgot to check one. + expect((SSH_FORBIDDEN_SNAPSHOT_FIELDS as readonly string[]).includes(field)).toBe(true); + } + }); + + it("refuses explicit non-null spellings too (true, 0, empty string: only null is absent)", () => { + for (const value of [true, false, 0, "", [], {}] as unknown[]) { + expect(parseSshConnectionSnapshot({ ...makeSnapshot(), forwards: value })).toBeNull(); + } + }); + + it("hygiene refusals: option-like hosts, control characters, relative paths, bad ports, over-cap lists, missing keys", () => { + for (const { field, snapshot } of invalidSnapshotHygieneVariants()) { + expect(parseSshConnectionSnapshot(structuredClone(snapshot))).toBeNull(); + expect(field).not.toEqual(""); + } + }); + + it("a hop chain exactly at the cap parses; one past it does not", () => { + const hops = Array.from({ length: SSH_MAX_PROXY_HOPS }, (_, i) => makeHop(i)); + expect(parseSshConnectionSnapshot(makeSnapshot({ proxyJumps: hops }))).not.toBeNull(); + expect(parseSshConnectionSnapshot(makeSnapshot({ proxyJumps: [...hops, makeHop(99)] }))).toBeNull(); + }); + + it("an IPv6 literal host and a bracketed alias survive (colons are legal there)", () => { + expect(parseSshConnectionSnapshot(makeSnapshot({ host: "[2001:db8::2]:22" }))).toBeNull(); // a port baked into host is NOT the shape; port is its own field + expect(parseSshConnectionSnapshot(makeSnapshot({ host: "[2001:db8::2]" }))).not.toBeNull(); + }); + + it("rejects null and non-objects whole", () => { + expect(parseSshConnectionSnapshot(null)).toBeNull(); + expect(parseSshConnectionSnapshot("app02")).toBeNull(); + expect(parseSshConnectionSnapshot([])).toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/__tests__/ssh-frames.test.ts b/packages/subshell-protocol/src/__tests__/ssh-frames.test.ts new file mode 100644 index 000000000..1a6814e7e --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-frames.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "bun:test"; +import { NODE_PROTOCOL_VERSION, parseNodeCommandBody } from "../node-frames.js"; +import { parseNodeSshAliasList, parseNodeSshResolveOutcome } from "../node-results.js"; +import { + isSshErrorCode, + NODE_RESULT_SSH_GENERATION_STALE, + SSH_ERROR_CODES, + SSH_ERROR_DESCRIPTIONS, +} from "../ssh-errors.js"; +import { parseSshNodeCommandBody, SSH_COMMAND_TYPES } from "../ssh-frames.js"; +import { SSH_MAX_PROXY_HOPS, SSH_PROBE_DEADLINE_MS } from "../ssh-limits.js"; +import { + makeAliasList, + makeDiscoverAliases, + makeResolveConfig, + makeResolveOk, + makeResolveRefused, + makeSnapshot, +} from "./fixtures/ssh-fixtures.js"; + +/** + * The wire census for the surviving SSH reads (design 2026-10-05 §7). + * `parseNodeCommandBody` is the only door: every accepted arm must survive + * delegation byte-for-fact, every malformed arm must be refused BEFORE + * dispatch, and the retired destination-execution `type` strings must no + * longer parse at all - a peer still sending them is answered like any + * unknown command, never dispatched. + */ +describe("ssh commands via parseNodeCommandBody (delegation)", () => { + it("the family has exactly the two surviving types", () => { + expect([...SSH_COMMAND_TYPES]).toEqual(["ssh_discover_aliases", "ssh_resolve_config"]); + }); + + it("each fixture command round-trips through the delegating parser unchanged", () => { + for (const cmd of [makeDiscoverAliases(), makeResolveConfig()]) { + expect(parseNodeCommandBody(structuredClone(cmd))).toEqual(cmd); + } + }); + + it("the SSH reads shipped under protocol 16 and the 17 bump belongs to the session family", () => { + // The session-runtime slice (design 2026-10-05) took 17 for + // ssh_session_open/send/close + session_frame. The two SSH reads' + // grammar is unchanged by that bump, and the retirement pruned the + // unreleased 16 arms without touching the number. + expect(NODE_PROTOCOL_VERSION).toBe(17); + }); + + it("retired destination-execution types no longer parse (the wire has no such door)", () => { + const withSnapshot = { ...makeSnapshot() }; + expect(parseNodeCommandBody({ type: "ssh_test_connection", snapshot: withSnapshot })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_run_start", runId: "r" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_run_status", runId: "r" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_run_read", runId: "r" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_run_cancel", runId: "r" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_terminal_launch", subshellId: "s", socket: "x" })).toBeNull(); + expect( + parseNodeCommandBody({ type: "ssh_input_control", subshellId: "s", mode: "human", generation: 2 }), + ).toBeNull(); + }); + + it("resolve_config refuses option-like and empty aliases at the grammar", () => { + expect(parseNodeCommandBody(makeResolveConfig({ alias: "-oProxyCommand=pwn" }))).toBeNull(); + expect(parseNodeCommandBody(makeResolveConfig({ alias: "" }))).toBeNull(); + expect(parseNodeCommandBody(makeResolveConfig({ alias: "app 02" }))).toBeNull(); + }); + + it("an unknown ssh_* type falls through to null (dispatch never sees it)", () => { + expect(parseNodeCommandBody({ type: "ssh_run_resurrect", runId: "r" })).toBeNull(); + expect(parseSshNodeCommandBody({ type: "ssh_run_resurrect" })).toBeNull(); + }); +}); + +describe("input generation fence on the existing writers", () => { + it("input accepts an optional positive-int inputGeneration and refuses nonsense", () => { + const base = { type: "input", subshellId: "s", data: "ls\n" } as const; + expect(parseNodeCommandBody(base)).toEqual(base); + expect(parseNodeCommandBody({ ...base, inputGeneration: 7 })).toEqual({ ...base, inputGeneration: 7 }); + expect(parseNodeCommandBody({ ...base, inputGeneration: 0 })).toBeNull(); + expect(parseNodeCommandBody({ ...base, inputGeneration: "7" })).toBeNull(); + expect(parseNodeCommandBody({ ...base, inputGeneration: undefined })).toBeNull(); + }); + + it("prompt_deliver carries the same optional fence", () => { + const base = { type: "prompt_deliver", subshellId: "s", text: "t", settleTimeoutMs: 1000, pollMs: 50 } as const; + expect(parseNodeCommandBody({ ...base, inputGeneration: 3 })).toEqual({ ...base, inputGeneration: 3 }); + expect(parseNodeCommandBody({ ...base, inputGeneration: -2 })).toBeNull(); + }); + + it("the stale-generation refusal is the bare equality constant", () => { + expect(NODE_RESULT_SSH_GENERATION_STALE).toBe("stale input generation"); + }); +}); + +describe("ssh result validators", () => { + it("alias list: cap enforced, flags required, garbage refused", () => { + expect(parseNodeSshAliasList(makeAliasList())).toEqual(makeAliasList()); + expect(parseNodeSshAliasList({ aliases: ["a", 7], includeCycle: false, truncated: false })).toBeNull(); + expect(parseNodeSshAliasList({ aliases: ["a"], truncated: false })).toBeNull(); + }); + + it("resolve: the accepted arm revalidates the snapshot; the refusal arm requires a code and settings list", () => { + expect(parseNodeSshResolveOutcome(makeResolveOk())).toEqual(makeResolveOk()); + expect(parseNodeSshResolveOutcome(makeResolveRefused())).toEqual(makeResolveRefused()); + expect( + parseNodeSshResolveOutcome({ accepted: true, snapshot: { ...makeSnapshot(), forwards: "LocalForward 1 2" } }), + ).toBeNull(); + expect(parseNodeSshResolveOutcome({ accepted: false, code: "nope", settings: [] })).toBeNull(); + expect(parseNodeSshResolveOutcome({ accepted: false, code: "quota_runs", settings: [] })).toBeNull(); + expect(parseNodeSshResolveOutcome({ accepted: false, code: "unsupported_setting", settings: "why" })).toBeNull(); + }); +}); + +describe("the surviving limits", () => { + it("the probe deadline and chain cap keep their numbers", () => { + expect(SSH_PROBE_DEADLINE_MS).toBe(30 * 1000); + expect(SSH_MAX_PROXY_HOPS).toBe(4); // the chain cap the snapshot enforces + }); +}); + +describe("the frozen error set", () => { + it("covers the surviving surfaces and every description exists for every code", () => { + for (const code of [ + "unsupported_setting", + "host_key_unknown", + "host_key_changed", + "key_unavailable", + "auth_mode_unsupported", + "config_ambiguous", + "stale_command", + "run_conflict", + "run_unknown", + "connection_failed", + "runtime_missing", + "session_quota", + "session_protocol", + ] as const) { + expect((SSH_ERROR_CODES as readonly string[]).includes(code)).toBe(true); + } + // The destination product's quota/storage refusals deleted with it; the + // set is frozen only for what the surviving surfaces can still answer. + expect(isSshErrorCode("quota_runs")).toBe(false); + expect(isSshErrorCode("quota_terminals")).toBe(false); + expect(isSshErrorCode("storage_full")).toBe(false); + for (const code of SSH_ERROR_CODES) { + expect(SSH_ERROR_DESCRIPTIONS[code].length).toBeGreaterThan(10); + expect(isSshErrorCode(code)).toBe(true); + } + expect(isSshErrorCode("quota_prompts")).toBe(false); + expect(isSshErrorCode(undefined)).toBe(false); + }); +}); diff --git a/packages/subshell-protocol/src/__tests__/ssh-session-frames.test.ts b/packages/subshell-protocol/src/__tests__/ssh-session-frames.test.ts new file mode 100644 index 000000000..154288fe5 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-session-frames.test.ts @@ -0,0 +1,489 @@ +/** + * Grammar tests for the session-runtime family (design 2026-10-05 §2/§3): + * parse round-trips for every new arm, refusals at the boundaries the plane + * and the runtime both rely on, and the fold-ins into the shared command and + * event parsers. + */ +import { describe, expect, test } from "bun:test"; +import { + encodeSshSessionFrame, + isSshSessionRef, + type NodeEvent, + parseNodeCommandBody, + parseNodeEvent, + parseNodeSshSessionOpenResult, + parseSshRuntimeCommandFrame, + parseSshRuntimeEventFrame, + parseSshRuntimeHello, + parseSshRuntimeReportRows, + parseSshSessionNodeCommandBody, + parseSshSessionOpenResult, + parseSshSessionTarget, + SSH_REQ_ID_MAX_CHARS, + SSH_RUNTIME_PROTOCOL, + SSH_SESSION_FRAME_MAX_BYTES, + SSH_SESSION_LOG_WINDOW_BYTES, + SSH_SESSION_PUMP_CHUNK_BYTES, + type SshRuntimeEventFrame, + type SshRuntimeHelloWire, + SshSessionFrameDecoder, + type SshSessionNodeCommandBody, + type SshSessionTargetWire, + sshRuntimeProtocolSupported, +} from "../index.js"; + +const ref = "0f4c1a7e-9b62-4a11-8d3e-5c2b1a0f9e8d"; + +function validTarget(): SshSessionTargetWire { + return { + alias: "e2edest", + host: "127.0.0.1", + port: 2222, + user: "theo", + identityFile: "/home/theo/.ssh/id_ed25519", + }; +} + +function validHello(): SshRuntimeHelloWire { + return { + type: "hello", + runtimeProtocol: SSH_RUNTIME_PROTOCOL, + agentVersion: "1.5.0", + os: "linux", + arch: "x64", + capabilities: ["panes", "callback-sock"], + homeDir: "/home/theo", + dataDir: "/home/theo/.local/share/subshell/runtime", + tmuxSocket: "subshell-ssh-ab12cd34ef56", + paneCount: 2, + }; +} + +describe("parseSshSessionTarget", () => { + test("accepts the full shape and rebuilds it", () => { + const t = parseSshSessionTarget(validTarget()); + expect(t).toEqual(validTarget()); + }); + test("null user and null identityFile are explicit and legal", () => { + const t = parseSshSessionTarget({ ...validTarget(), user: null, identityFile: null }); + expect(t?.user).toBeNull(); + expect(t?.identityFile).toBeNull(); + }); + test("refuses option-like hosts, zero/overflow ports, control chars, relative identity", () => { + expect(parseSshSessionTarget({ ...validTarget(), host: "-oProxyCommand=x" })).toBeNull(); + expect(parseSshSessionTarget({ ...validTarget(), port: 0 })).toBeNull(); + expect(parseSshSessionTarget({ ...validTarget(), port: 70000 })).toBeNull(); + expect(parseSshSessionTarget({ ...validTarget(), user: "a\nb" })).toBeNull(); + expect(parseSshSessionTarget({ ...validTarget(), identityFile: ".ssh/id" })).toBeNull(); + }); +}); + +describe("parseSshSessionNodeCommandBody", () => { + test("open round-trips through the shared command parser", () => { + const cmd = { + type: "ssh_session_open", + ref, + target: validTarget(), + runtimeCommand: "/opt/wrap/subshell", + } satisfies SshSessionNodeCommandBody; + const parsed = parseSshSessionNodeCommandBody(cmd); + expect(parsed).toEqual(cmd); + expect(parseNodeCommandBody(JSON.stringify(cmd))).toEqual(cmd); + }); + test("open without runtimeCommand omits the key (the default lives in the broker)", () => { + const parsed = parseSshSessionNodeCommandBody({ type: "ssh_session_open", ref, target: validTarget() }); + expect(parsed && "runtimeCommand" in parsed).toBe(false); + }); + test("open refuses junk refs, bad base64 send payloads, and option-like runtimeCommand", () => { + expect( + parseSshSessionNodeCommandBody({ type: "ssh_session_open", ref: "not base64url!", target: validTarget() }), + ).toBeNull(); + expect(parseSshSessionNodeCommandBody({ type: "ssh_session_send", ref, data_b64: "%%%!" })).toBeNull(); + const bad = parseSshSessionNodeCommandBody({ + type: "ssh_session_open", + ref, + target: validTarget(), + runtimeCommand: "--upload-proxy", + }); + expect(bad).toBeNull(); + const spaced = parseSshSessionNodeCommandBody({ + type: "ssh_session_open", + ref, + target: validTarget(), + runtimeCommand: "subshell; rm -rf /", + }); + expect(spaced).toBeNull(); + }); + test("send round-trips with empty payload; close needs only the ref", () => { + expect(parseSshSessionNodeCommandBody({ type: "ssh_session_send", ref, data_b64: "" })).toEqual({ + type: "ssh_session_send", + ref, + data_b64: "", + }); + expect(parseSshSessionNodeCommandBody({ type: "ssh_session_close", ref })).toEqual({ + type: "ssh_session_close", + ref, + }); + }); +}); + +describe("parseNodeEvent session_frame arm", () => { + test("round-trips the pump chunk", () => { + const ev = { type: "session_frame", ref, data_b64: Buffer.from("hi").toString("base64") } satisfies NodeEvent; + expect(parseNodeEvent(JSON.stringify(ev))).toEqual(ev); + }); + test("refuses non-base64 payloads, empty refs, and path-shaped refs", () => { + expect(parseNodeEvent({ type: "session_frame", ref, data_b64: "!!!" })).toBeNull(); + expect(parseNodeEvent({ type: "session_frame", ref: "", data_b64: "" })).toBeNull(); + expect(parseNodeEvent({ type: "session_frame", ref: "../escape", data_b64: "" })).toBeNull(); + }); +}); + +describe("hello + open result", () => { + test("hello round-trips; major mismatch names itself via sshRuntimeProtocolSupported", () => { + const h = parseSshRuntimeHello(validHello()); + expect(h).toEqual(validHello()); + expect(h && sshRuntimeProtocolSupported(h)).toBe(true); + const other = parseSshRuntimeHello({ ...validHello(), runtimeProtocol: 99 }); + expect(other && sshRuntimeProtocolSupported(other)).toBe(false); + expect(parseSshRuntimeHello({ ...validHello(), paneCount: -1 })).toBeNull(); + expect(parseSshRuntimeHello({ ...validHello(), tmuxSocket: "with space" })).toBeNull(); + }); + test("open result requires the whole hello and echoes the destination", () => { + const openResult = { + hello: validHello(), + host: "127.0.0.1", + port: 2222, + user: "theo", + connectingAccount: "nodehost", + }; + expect(parseSshSessionOpenResult(openResult)).toEqual(openResult); + expect(parseNodeSshSessionOpenResult({ ...openResult, hello: { ...validHello(), agentVersion: "" } })).toBeNull(); + expect(parseSshSessionOpenResult({ ...openResult, user: undefined })).toBeNull(); + }); +}); + +describe("runtime command frames", () => { + test("launch carries the node link body verbatim (shallow envelope check here)", () => { + const body = { type: "launch", subshellId: "s-1", socket: "subshell-x", cwd: "/tmp", harnessId: "terminal" }; + const frame = { type: "launch", ref, cmd: body }; + expect(parseSshRuntimeCommandFrame(frame)?.type).toBe("launch"); + expect(parseSshRuntimeCommandFrame({ type: "launch", ref, cmd: { type: "input" } })).toBeNull(); + }); + test("input/terminate/probe/tail arms round-trip", () => { + expect(parseSshRuntimeCommandFrame({ type: "input", ref, subshellId: "s", data: "ls\r" })).toEqual({ + type: "input", + ref, + subshellId: "s", + data: "ls\r", + }); + expect(parseSshRuntimeCommandFrame({ type: "probe", ref, subshellIds: ["a", "b"] })).not.toBeNull(); + expect( + parseSshRuntimeCommandFrame({ type: "tail_start", ref, subshellId: "s", subId: "t", fromByte: 0 }), + ).not.toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "tail_stop", ref, subId: "t" })).not.toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "subshells_report", ref })).not.toBeNull(); + }); + test("rest_response validates status and body shape", () => { + expect(parseSshRuntimeCommandFrame({ type: "rest_response", reqId: "r1", status: 200, body: "{}" })).toEqual({ + type: "rest_response", + reqId: "r1", + status: 200, + body: "{}", + }); + expect(parseSshRuntimeCommandFrame({ type: "rest_response", reqId: "r1", status: 99 })).toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "rest_response", reqId: "r1", status: 200, body: 5 })).toBeNull(); + }); + test("list_dirs/stat_dir/remove_paths/close round-trip; unknown types refuse", () => { + expect(parseSshRuntimeCommandFrame({ type: "list_dirs", ref, path: "" })).not.toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "stat_dir", ref, path: "/tmp" })).not.toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "stat_dir", ref, path: "tmp" })).toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "remove_paths", ref, paths: ["/a/b"] })).not.toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "remove_paths", ref, paths: ["b"] })).toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "close", ref })).toEqual({ type: "close", ref }); + expect(parseSshRuntimeCommandFrame({ type: "teleport", ref })).toBeNull(); + }); +}); + +describe("runtime event frames", () => { + test("result ok/error arms, output, exit, subshells_report, rest_request round-trip", () => { + expect(parseSshRuntimeEventFrame({ type: "result", ref, ok: true, data: { n: 1 } })).toEqual({ + type: "result", + ref, + ok: true, + data: { n: 1 }, + }); + expect(parseSshRuntimeEventFrame({ type: "result", ref, ok: false, error: "nope" })).not.toBeNull(); + expect( + parseSshRuntimeEventFrame({ + type: "output", + subshellId: "s", + subId: "t", + fromByte: 0, + toByte: 3, + data_b64: Buffer.from("abc").toString("base64"), + }), + ).not.toBeNull(); + expect( + parseSshRuntimeEventFrame({ type: "exit", subshellId: "s", exitCode: null, at: "2026-10-05T00:00:00Z" }), + ).not.toBeNull(); + expect( + parseSshRuntimeEventFrame({ + type: "subshells_report", + subshells: [{ subshellId: "s", alive: true, exitCode: null }], + }), + ).not.toBeNull(); + const rr = { + type: "rest_request", + reqId: "q", + method: "post", + path: "/api/subshells/s/input", + body: "{}", + } as const satisfies SshRuntimeEventFrame; + const parsed = parseSshRuntimeEventFrame(rr); + // The method is uppercased at the parse: the allowlist matcher compares + // one spelling and a frame must not smuggle lowercase past it. + expect(parsed).toEqual({ ...rr, method: "POST" }); + expect(parseSshRuntimeEventFrame({ type: "rest_request", reqId: "q", method: "GET", path: "relative" })).toBeNull(); + }); + test("malformed variants refuse across every arm", () => { + expect(parseSshRuntimeEventFrame({ type: "result", ref: "!", ok: true })).toBeNull(); + expect(parseSshRuntimeEventFrame({ type: "unknown_thing" })).toBeNull(); + expect(parseSshRuntimeEventFrame({ type: "exit", subshellId: "s", exitCode: 1.5, at: "x" })).toBeNull(); + }); +}); + +describe("the reqId grammar (review I-A: the echoed id is inside the frame-cap arithmetic)", () => { + const req = "1b4e28ba-2fa1-11d2-883f-0016d3cca427"; + test("an honest uuid round-trips both directions", () => { + const asked = parseSshRuntimeEventFrame({ + type: "rest_request", + reqId: req, + method: "GET", + path: "/api/subshells/s", + }); + expect(asked && asked.type === "rest_request" ? asked.reqId : null).toBe(req); + const answered = parseSshRuntimeCommandFrame({ type: "rest_response", reqId: req, status: 200, body: "{}" }); + expect(answered && answered.type === "rest_response" ? answered.reqId : null).toBe(req); + }); + test("oversize, non-printable, and empty ids refuse at the parse, both directions", () => { + // Null is the refusal: the caller drops the frame fail-closed (the plane + // marks the session lost; the runtime the same) and never sizes an answer + // around an id it cannot bound. + expect( + parseSshRuntimeEventFrame({ + type: "rest_request", + reqId: "x".repeat(SSH_REQ_ID_MAX_CHARS + 1), + method: "GET", + path: "/api/subshells/s", + }), + ).toBeNull(); + expect( + parseSshRuntimeCommandFrame({ type: "rest_response", reqId: "x".repeat(SSH_REQ_ID_MAX_CHARS + 1), status: 200 }), + ).toBeNull(); + expect( + parseSshRuntimeEventFrame({ type: "rest_request", reqId: "a\tb", method: "GET", path: "/api/subshells/s" }), + ).toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "rest_response", reqId: "a\u001b[0m", status: 200 })).toBeNull(); + expect( + parseSshRuntimeEventFrame({ type: "rest_request", reqId: "", method: "GET", path: "/api/subshells/s" }), + ).toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "rest_response", reqId: "", status: 200 })).toBeNull(); + }); + test("the cap itself passes (64 printable chars, escape-heavy included)", () => { + const atCap = '"'.repeat(SSH_REQ_ID_MAX_CHARS); // printable; JSON doubles each quote's byte cost + const asked = parseSshRuntimeEventFrame({ + type: "rest_request", + reqId: atCap, + method: "GET", + path: "/api/subshells/s", + }); + expect(asked && asked.type === "rest_request" ? asked.reqId : null).toBe(atCap); + }); +}); + +describe("framing codec", () => { + test("single frame round-trips exactly", () => { + const frame = { hello: "world", n: 1 }; + const bytes = encodeSshSessionFrame(frame); + const d = new SshSessionFrameDecoder(); + expect(d.push(bytes)).toEqual([frame]); + expect(d.failed).toBeNull(); + }); + test("frames split across pushes reassemble in order", () => { + const a = encodeSshSessionFrame({ i: 1 }); + const b = encodeSshSessionFrame({ i: 2 }); + const all = new Uint8Array(a.byteLength + b.byteLength); + all.set(a, 0); + all.set(b, a.byteLength); + const d = new SshSessionFrameDecoder(); + const out: unknown[] = []; + // byte-at-a-time push: the pathological chunking a real ssh stdout can produce + for (const byte of all) out.push(...d.push(new Uint8Array([byte]))); + expect(out).toEqual([{ i: 1 }, { i: 2 }]); + expect(d.failed).toBeNull(); + }); + test("oversize declared length fails the decoder before any body byte", () => { + const lying = new Uint8Array(4); + new DataView(lying.buffer).setUint32(0, SSH_SESSION_FRAME_MAX_BYTES + 1, false); + const d = new SshSessionFrameDecoder(); + expect(d.push(lying)).toEqual([]); + expect(d.failed).toBe("oversize"); + // terminal: later pushes return nothing forever + expect(d.push(encodeSshSessionFrame({ ok: true }))).toEqual([]); + }); + test("garbage leading bytes (login banner) fail the open, never a silent skip", () => { + const banner = new TextEncoder().encode("Last login: Mon Oct 5 10:14:33 2026\r\n"); + // interpret the banner bytes AS a length prefix: whatever it decodes to, + // the decoder must refuse or wait forever - a resync would be the bug. + const d = new SshSessionFrameDecoder(); + d.push(banner.subarray(0, 4)); + if (d.failed === null) { + // not yet oversized: feed the rest; either an oversize or a + // malformed-json verdict must land, and the frame after must not parse + d.push(banner.subarray(4)); + d.push(encodeSshSessionFrame({ hello: true })); + } + expect(d.failed).not.toBeNull(); + }); + test("oversized real frame refuses at the encode, and a valid max frame passes", () => { + expect(() => encodeSshSessionFrame({ big: "x".repeat(SSH_SESSION_FRAME_MAX_BYTES) })).toThrow(); + const fit = { big: "y".repeat(120_000) }; + const bytes = encodeSshSessionFrame(fit); + const d = new SshSessionFrameDecoder(); + expect(d.push(bytes)).toEqual([fit]); + }); + /** + * The C1 size pin (review 2026-10-06): the runtime link answers `log_read` + * and live `output` frames with base64 INSIDE the JSON envelope, so a + * window's raw bytes inflate 4/3 before the envelope is glued on. The one + * runtime-safe budget must encode full; the node-link sizes that rode the + * session codec unclamped (256 KiB log tail, 192 KiB tail chunk) must + * refuse at the encode - that refusal IS the bug this pin guards against. + */ + describe("runtime transfer budget (C1)", () => { + const uuid = "0f4c1a7e-9b62-4a11-8d3e-5c2b1a0f9e8d"; + const b64 = (rawBytes: number): string => Buffer.alloc(rawBytes, 0xff).toString("base64"); + + test("a max-size log_read result frame (window = the budget) encodes under the cap", () => { + const bytes = encodeSshSessionFrame({ + type: "result", + ref: uuid, + ok: true, + data: { bytes_b64: b64(SSH_SESSION_LOG_WINDOW_BYTES), next: 131_072, size: 9_999_999_999 }, + }); + expect(bytes.byteLength).toBeLessThanOrEqual(4 + SSH_SESSION_FRAME_MAX_BYTES); + }); + + test("a max-size output chunk (raw = the budget) encodes under the cap", () => { + const bytes = encodeSshSessionFrame({ + type: "output", + subshellId: uuid, + subId: uuid, + fromByte: 131_071, + toByte: 262_143, + data_b64: b64(SSH_SESSION_LOG_WINDOW_BYTES), + }); + expect(bytes.byteLength).toBeLessThanOrEqual(4 + SSH_SESSION_FRAME_MAX_BYTES); + }); + + test("the node-link sizes refuse: a 256 KiB tail window and a 192 KiB chunk overshoot the cap", () => { + // LOG_TAIL_BYTES (256 KiB) and TAIL_CHUNK_BYTES (192 KiB) are the node + // link's numbers; pinned here so they can never quietly ride the + // session codec again. + expect(() => + encodeSshSessionFrame({ + type: "result", + ref: uuid, + ok: true, + data: { bytes_b64: b64(256 * 1024), next: 0, size: 0 }, + }), + ).toThrow(); + expect(() => + encodeSshSessionFrame({ + type: "output", + subshellId: uuid, + subId: uuid, + fromByte: 0, + toByte: 192 * 1024, + data_b64: b64(192 * 1024), + }), + ).toThrow(); + }); + + test("the pump chunk bound is a raw 192 KiB slice: base64 lands at 262,144 chars, inside the node link's own frame", () => { + expect(b64(SSH_SESSION_PUMP_CHUNK_BYTES).length).toBe(262_144); + }); + }); + + test("isSshSessionRef matches the node-path id grammar", () => { + expect(isSshSessionRef(ref)).toBe(true); + expect(isSshSessionRef("x".repeat(65))).toBe(false); + expect(isSshSessionRef("nope!")).toBe(false); + }); +}); + +describe("task 25 arms: detect command, rest_request paneId, hello selfInvoke", () => { + const spec = { + id: "claude-code", + binaryName: "claude", + envOverride: "CLAUDE_PATH", + knownPaths: ["/usr/local/bin/claude"], + }; + test("detect round-trips the node link's specs array and env NAMES (shallow envelope, launch-arm precedent)", () => { + const frame = parseSshRuntimeCommandFrame({ type: "detect", ref, specs: [spec], envNames: ["CLAUDE_CONFIG_DIR"] }); + expect(frame).toEqual({ type: "detect", ref, specs: [spec], envNames: ["CLAUDE_CONFIG_DIR"] }); + // The EMPTY ask is a legal no-op on both halves, exactly like the node link's arm. + expect(parseSshRuntimeCommandFrame({ type: "detect", ref, specs: [], envNames: [] })).not.toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "detect", ref: "!", specs: [], envNames: [] })).toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "detect", ref, specs: {}, envNames: [] })).toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "detect", ref, specs: [], envNames: [1] })).toBeNull(); + expect(parseSshRuntimeCommandFrame({ type: "detect", ref, specs: [] })).toBeNull(); + }); + test("rest_request paneId round-trips, malformed refuses, absent passes through", () => { + const base = { type: "rest_request", reqId: "q", method: "GET", path: `/api/subshells/${ref}` }; + const tagged = parseSshRuntimeEventFrame({ ...base, paneId: ref }); + expect(tagged && tagged.type === "rest_request" ? tagged.paneId : null).toBe(ref); + const bare = parseSshRuntimeEventFrame(base); + expect(bare && bare.type === "rest_request" ? "paneId" in bare : true).toBe(false); + // A half-parseable attribution is the ambiguity the field exists to remove: + // malformed refuses the FRAME, never silently degrades to the shared door. + expect(parseSshRuntimeEventFrame({ ...base, paneId: "has space" })).toBeNull(); + expect(parseSshRuntimeEventFrame({ ...base, paneId: 42 })).toBeNull(); + }); + test("hello selfInvoke round-trips when reported and refuses malformed spellings", () => { + const withInvoke = { ...validHello(), selfInvoke: { command: "/usr/local/bin/subshell", args: ["runtime-serve"] } }; + expect(parseSshRuntimeHello(withInvoke)).toEqual(withInvoke); + expect(parseSshRuntimeHello(validHello())).toEqual(validHello()); // absent stays absent + expect( + parseSshRuntimeHello({ ...validHello(), selfInvoke: { command: "relative/subshell", args: [] } }), + ).toBeNull(); + expect(parseSshRuntimeHello({ ...validHello(), selfInvoke: { command: "/x", args: "y" } })).toBeNull(); + expect(parseSshRuntimeHello({ ...validHello(), selfInvoke: { args: [] } })).toBeNull(); + // The open result carries the hello whole, additive field included. + const openResult = { hello: withInvoke, host: "127.0.0.1", port: 2222, user: "theo" }; + expect(parseSshSessionOpenResult(openResult)).toEqual(openResult); + }); +}); + +describe("parseSshRuntimeReportRows (the census grammar)", () => { + test("the close RESULT payload and the subshells_report EVENT arm share one shape", () => { + const rows = [{ subshellId: ref, alive: true, exitCode: null }]; + expect(parseSshRuntimeReportRows(rows)).toEqual(rows); + expect(parseSshRuntimeReportRows([])).toEqual([]); // an empty census is an honest "no panes" + // The same rows arriving inside the event frame parse identically (one + // grammar, checked twice by the two callers that receive it). + const ev = parseSshRuntimeEventFrame({ type: "subshells_report", subshells: rows }); + expect(ev && ev.type === "subshells_report" ? ev.subshells : null).toEqual(rows); + }); + test("non-array payloads and bad rows refuse", () => { + expect(parseSshRuntimeReportRows(null)).toBeNull(); + expect(parseSshRuntimeReportRows({ subshells: [] })).toBeNull(); + expect(parseSshRuntimeReportRows([{ subshellId: ref }])).toBeNull(); // no alive + expect(parseSshRuntimeReportRows([{ subshellId: ref, alive: 1, exitCode: null }])).toBeNull(); // alive is a bool + expect(parseSshRuntimeReportRows([{ subshellId: ref, alive: false, exitCode: "0" }])).toBeNull(); // code is an int + expect(parseSshRuntimeReportRows([{ subshellId: ref, alive: false, exitCode: 3 }])).toEqual([ + { subshellId: ref, alive: false, exitCode: 3 }, + ]); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 005d4852e..d7121537a 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -142,6 +142,9 @@ export { parseNodePathExistsResult, parseNodeProbeEntries, parseNodePromptDeliver, + parseNodeSshAliasList, + parseNodeSshResolveOutcome, + parseNodeSshSessionOpenResult, parseNodeStatDirResult, parseNodeTreeManifestPage, parseNodeWriteFileResult, @@ -226,5 +229,80 @@ export { type SizingPolicy, type ViewerCapacity, } from "./shared-geometry.js"; +export { + parseSshConnectionSnapshot, + SSH_FORBIDDEN_SNAPSHOT_FIELDS, + type SshConnectionSnapshotWire, + type SshForbiddenSnapshotField, + type SshHopWire, +} from "./ssh-config.js"; +export { + isSshErrorCode, + NODE_RESULT_SSH_GENERATION_STALE, + SSH_ERROR_CODES, + SSH_ERROR_DESCRIPTIONS, + type SshErrorCode, +} from "./ssh-errors.js"; +export { + parseSshNodeCommandBody, + SSH_COMMAND_TYPES, + type SshDiscoverAliasesCommand, + type SshNodeCommandBody, + type SshResolveConfigCommand, +} from "./ssh-frames.js"; +export { + SSH_CANCEL_GRACE_MS, + SSH_MAX_CERT_REFS, + SSH_MAX_DISCOVERED_ALIASES, + SSH_MAX_IDENTITY_REFS, + SSH_MAX_KNOWN_HOSTS_FILES, + SSH_MAX_PROXY_HOPS, + SSH_NAME_MAX_CHARS, + SSH_PATH_MAX_CHARS, + SSH_PROBE_DEADLINE_MS, +} from "./ssh-limits.js"; +export type { NodeSshAliasListResult, NodeSshResolveOutcomeWire } from "./ssh-results.js"; +export { + encodeSshSessionFrame, + SSH_SESSION_FRAME_MAX_BYTES, + type SshSessionCodecFailure, + SshSessionFrameDecoder, +} from "./ssh-session-codec.js"; +export { + isSshSessionRef, + parseSshRuntimeCommandFrame, + parseSshRuntimeEventFrame, + parseSshRuntimeHello, + parseSshRuntimeReportRows, + parseSshSessionNodeCommandBody, + parseSshSessionOpenResult, + parseSshSessionTarget, + sshRuntimeProtocolSupported, +} from "./ssh-session-frame-parsers.js"; +export { + SSH_REQ_ID_MAX_CHARS, + SSH_RUNTIME_PROTOCOL, + SSH_RUNTIME_SERVE_IN_USE_EXIT, + SSH_SESSION_COMMAND_TYPES, + SSH_SESSION_IN_USE, + SSH_SESSION_INBOUND_QUEUE_FRAMES, + SSH_SESSION_LOG_WINDOW_BYTES, + SSH_SESSION_OPEN_DEADLINE_MS, + SSH_SESSION_PUMP_CHUNK_BYTES, + SSH_SESSION_RUNTIME_MISSING, + SSH_SESSION_UNKNOWN, + SSH_SESSIONS_PER_NODE, + type SshRuntimeCommandFrame, + type SshRuntimeEventFrame, + type SshRuntimeHelloWire, + type SshRuntimeLaunchBody, + type SshRuntimeReportRow, + type SshSessionCloseCommand, + type SshSessionNodeCommandBody, + type SshSessionOpenCommand, + type SshSessionOpenResultWire, + type SshSessionSendCommand, + type SshSessionTargetWire, +} from "./ssh-session-frames.js"; export { MAX_UPLOAD_BYTES } from "./uploads.js"; export { MIN_NODE_VERSION, nodeVersionSupported, semverLt } from "./versions.js"; diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 270ea7108..74f20a1eb 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1,5 +1,8 @@ import { BASE64_RE, isBool, isInt, isNum, isRecord, isStr, isStrArray, isStringMap } from "./guards.js"; import type { JsonValue } from "./json.js"; +import { parseSshNodeCommandBody, type SshNodeCommandBody } from "./ssh-frames.js"; +import { parseSshSessionNodeCommandBody } from "./ssh-session-frame-parsers.js"; +import type { SshSessionNodeCommandBody } from "./ssh-session-frames.js"; /** * Node ↔ control-plane wire contract (spec 2026-08-31 §3). @@ -133,8 +136,35 @@ import type { JsonValue } from "./json.js"; * log-confined beside the generalized `file_read`, and `write_file`'s upload * roots stay exactly as narrow as they are while `transfer_write` arrives as * its own policy-gated sibling. + * + * **15 → 16 is the SSH surface (spec 2026-10-04 §4, reshaped by the + * 2026-10-05 §7 retirement before either bump shipped).** What the wire + * carries: the two pre-session reads `ssh_discover_aliases` and + * `ssh_resolve_config` over the normalized-config snapshot, plus the additive + * `inputGeneration` fence field on `input`/`prompt_deliver`. The destination + * execution arms the first draft named (`ssh_test_connection`, the run + * quartet, `ssh_terminal_launch`, `ssh_input_control`) were deleted with the + * product, legal without a version bump precisely because 16 and 17 are + * unreleased. Additive, and breaking anyway, because the gate is exact-match. + * The bump is coordinated with `MIN_NODE_VERSION` and the agent's own version + * so `releases.ts` never offers a protocol-15 build to a protocol-16 plane. + * + * **16 → 17 is the SSH session-runtime surface (design 2026-10-05 §2/§3).** + * Three commands broker a destination runtime over a live SSH child — + * `ssh_session_open`, `ssh_session_send`, `ssh_session_close` (types in + * `ssh-session-frames.ts`, grammar in `ssh-session-frame-parsers.ts`) — and one new event arm carries the child's + * protocol bytes up: `session_frame { ref, data_b64 }`. The frames pumped + * INSIDE a session belong to the runtime's own `runtimeProtocol` namespace + * and never ride this version; the node link changes only by adding these + * four shapes. Additive, breaking as always (exact-match gate), and + * coordinated with `MIN_NODE_VERSION` and the agent's own version the same + * way every bump here has been. Of the 15→16 SSH family, what rides this + * wire is the two pre-session reads and the `inputGeneration` fence; the + * destination-execution arms already left with the product, and that removal + * took no bump of its own because 16 and 17 never shipped (the paragraph + * above). */ -export const NODE_PROTOCOL_VERSION = 15; +export const NODE_PROTOCOL_VERSION = 17; /** * The FIRST protocol whose agents verify the publisher signature on an @@ -692,7 +722,20 @@ export type NodeCommandBody = } | { type: "terminate"; subshellId: string } | { type: "kill"; subshellId: string } - | { type: "input"; subshellId: string; data: string } + | { + type: "input"; + subshellId: string; + data: string; + /** + * SSH feature (Gate A contract): the pane's input generation this write + * belongs to. REQUIRED by contract for managed SSH terminals - every + * writer goes through it, so a takeover or revocation fences queued + * input at the machine, not just at the plane. Absent means "no + * generation policy" (every ordinary pane); present-but-stale answers + * the bare `NODE_RESULT_SSH_GENERATION_STALE` refusal. + */ + inputGeneration?: number; + } | { type: "resize"; subshellId: string; cols: number; rows: number } | { /** Agent-side prompt settle loop: capture-poll until the pane is quiet, type + Enter */ @@ -701,6 +744,8 @@ export type NodeCommandBody = text: string; settleTimeoutMs: number; pollMs: number; + /** Same input-generation fence as `input` (nudges and prompts are writers too). */ + inputGeneration?: number; } | { /** Pane snapshot; optional `lines` prepends that many reflowed history rows (attach replay). */ @@ -1067,7 +1112,16 @@ export type NodeCommandBody = * sizing round trips is the caller that knows the frame cap. */ maxBytes: number; - }; + } + // The SSH reads (survivors of the Gate A contract): two commands folded in + // as one union member so the SSH grammar lives in `ssh-frames.ts`; their + // answers are validated by `parseNodeSshAliasList` / `parseNodeSshResolveOutcome`. + | SshNodeCommandBody + // The session-runtime family (design 2026-10-05 §3): three commands broker a + // destination runtime over one SSH child, folded in the same one-member way + // so the session grammar lives in the ssh-session-frames pair; the open + // command's answer is validated by `parseNodeSshSessionOpenResult`. + | SshSessionNodeCommandBody; /** * One node's maintenance state, as it travels in either direction. @@ -1294,7 +1348,16 @@ export type NodeEvent = | { type: "output"; subshellId: string; subId: string; fromByte: number; toByte: number; data_b64: string } | { type: "exit"; subshellId: string; exitCode: number | null; at: string } | { type: "subshells_report"; subshells: { subshellId: string; alive: boolean; exitCode: number | null }[] } - | { type: "error"; code: string; message: string }; + | { type: "error"; code: string; message: string } + /** + * Pumped bytes from one brokered session's SSH child (design 2026-10-05 + * §3): `ref` is the plane-minted session id, `data_b64` raw stdout content + * in ≤ 192 KiB pieces (a chunk boundary is a chunk boundary - the session's + * own codec reassembles frames across pushes). The broker seals whatever the + * child wrote; the runtime frames inside are the plane's and the runtime's + * business, on their own `runtimeProtocol` version namespace. + */ + | { type: "session_frame"; ref: string; data_b64: string }; /* ------------------------------------------------------------------ */ /* validators (hand-rolled, parseClientFrame style — spec §3) */ @@ -1398,8 +1461,16 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { } return { type: "capture", subshellId: value.subshellId }; } - case "input": - return isStr(value.subshellId) && isStr(value.data) ? (value as unknown as NodeCommandBody) : null; + case "input": { + if (!isStr(value.subshellId) || !isStr(value.data)) return null; + // Optional input-generation fence (SSH Gate A): a positive integer or + // absent. WHAT pane requires it is policy (managed SSH terminals, by + // contract); the grammar only refuses a nonsense value, so a stale + // generation reaches the node's equality check rather than parsing away. + if ("inputGeneration" in value && !(isInt(value.inputGeneration) && (value.inputGeneration as number) >= 1)) + return null; + return value as unknown as NodeCommandBody; + } case "resize": return isStr(value.subshellId) && isInt(value.cols) && @@ -1414,7 +1485,9 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { isNum(value.settleTimeoutMs) && (value.settleTimeoutMs as number) > 0 && isNum(value.pollMs) && - (value.pollMs as number) > 0 + (value.pollMs as number) > 0 && + // Same optional fence as `input` above: nudges and prompts are writers. + (!("inputGeneration" in value) || (isInt(value.inputGeneration) && (value.inputGeneration as number) >= 1)) ? (value as unknown as NodeCommandBody) : null; case "probe": @@ -1605,6 +1678,17 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { return "cursor" in value ? { type: "tree_manifest", root: value.root, cursor: value.cursor as string, maxBytes: value.maxBytes } : { type: "tree_manifest", root: value.root, maxBytes: value.maxBytes as number }; + case "ssh_discover_aliases": + case "ssh_resolve_config": + // Delegation, not a second parser: the SSH grammar (both arms, one + // file) lives in ssh-frames.ts beside the commands it narrows. + return parseSshNodeCommandBody(value); + case "ssh_session_open": + case "ssh_session_send": + case "ssh_session_close": + // Same delegation posture: the session grammar lives in + // ssh-session-frame-parsers.ts beside the types it narrows. + return parseSshSessionNodeCommandBody(value); default: return null; } @@ -1712,6 +1796,16 @@ export function parseNodeEvent(raw: string | object): NodeEvent | null { return isStr(value.code) && isStr(value.message) ? { type: "error", code: value.code, message: value.message } : null; + case "session_frame": + // The broker's pump is the only producer; a chunk is opaque bytes here + // BY DESIGN - the session grammar is parsed one layer up, and an + // event-layer that reached inside would be a second decoder to drift. + return isStr(value.ref) && + /^[0-9a-fA-F-]{1,64}$/.test(value.ref) && + isStr(value.data_b64) && + BASE64_RE.test(value.data_b64) + ? { type: "session_frame", ref: value.ref, data_b64: value.data_b64 } + : null; default: return null; } diff --git a/packages/subshell-protocol/src/node-results.ts b/packages/subshell-protocol/src/node-results.ts index 2d68968c7..ba1b0a7fa 100644 --- a/packages/subshell-protocol/src/node-results.ts +++ b/packages/subshell-protocol/src/node-results.ts @@ -19,8 +19,14 @@ * validators stand on live in `guards.ts` (shared with `node-frames.ts`). */ -import { BASE64_RE, isBool, isInt, isRecord, isStr, isStringMap } from "./guards.js"; +import { BASE64_RE, isBool, isInt, isRecord, isStr, isStrArray, isStringMap } from "./guards.js"; import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_PAGE_ENTRIES } from "./node-frames.js"; +import { parseSshConnectionSnapshot } from "./ssh-config.js"; +import { isSshErrorCode } from "./ssh-errors.js"; +import { SSH_MAX_DISCOVERED_ALIASES } from "./ssh-limits.js"; +import type { NodeSshAliasListResult, NodeSshResolveOutcomeWire } from "./ssh-results.js"; +import { parseSshSessionOpenResult } from "./ssh-session-frame-parsers.js"; +import type { SshSessionOpenResultWire } from "./ssh-session-frames.js"; function isNonEmptyStr(value: unknown): value is string { return isStr(value) && value.length > 0; @@ -152,7 +158,7 @@ export interface NodeLogReadResult { /** * Validates and narrows a `log_read` command's `result{data}`. - * Invariant: base64 payload, non-negative integer offsets, and an empty read + * Invariant: base64 payload, non-negative integer offsets, an empty read * must not report an offset past EOF (`bytes_b64 === "" ? next <= size : true`). * @param data - the `data` member of a successful result frame * @returns the narrowed result, or null when malformed @@ -537,3 +543,58 @@ export function parseNodeAgentLogSlice(data: unknown): NodeAgentLogSlice | null if (nextByte < 0 || size < 0) return null; return { text, nextByte, size, truncated }; } + +/* ------------------------------------------------------------------ */ +/* ssh discovery/resolution (survivors of the Gate A contract) */ +/* ------------------------------------------------------------------ */ + +/** + * Validates and narrows an `ssh_discover_aliases` command's `result{data}`. + * The list cap is part of the shape (same reasoning as the transfer page + * cap): an answer past {@link SSH_MAX_DISCOVERED_ALIASES} is malformed here, + * because the `truncated` flag is what the cap's existence depends on. + * @param data - the `data` member of a successful result frame + * @returns the narrowed list, or null when malformed + */ +export function parseNodeSshAliasList(data: unknown): NodeSshAliasListResult | null { + if (!isRecord(data) || !isBool(data.includeCycle) || !isBool(data.truncated)) return null; + if (!isStrArray(data.aliases) || (data.aliases as string[]).length > SSH_MAX_DISCOVERED_ALIASES) return null; + return { aliases: [...(data.aliases as string[])], includeCycle: data.includeCycle, truncated: data.truncated }; +} + +/** + * Validates and narrows an `ssh_resolve_config` command's `result{data}`. + * The accepted arm runs the FULL snapshot validator: a resolve answer is the + * one moment an unvalidated snapshot could enter the system, so this is where + * the plane's copy gets its grammar checked before it is stored. + * @param data - the `data` member of a successful result frame + * @returns the narrowed outcome, or null when malformed + */ +export function parseNodeSshResolveOutcome(data: unknown): NodeSshResolveOutcomeWire | null { + if (!isRecord(data) || !isBool(data.accepted)) return null; + if (data.accepted) { + const snapshot = parseSshConnectionSnapshot(data.snapshot); + if (!snapshot) return null; + if ("connectingAccount" in data && !isStr(data.connectingAccount)) return null; + return { + accepted: true, + snapshot, + ...("connectingAccount" in data ? { connectingAccount: data.connectingAccount as string } : {}), + }; + } + if (!isSshErrorCode(data.code) || !isStrArray(data.settings)) return null; + return { accepted: false, code: data.code, settings: [...(data.settings as string[])] }; +} + +/** + * Validates and narrows an `ssh_session_open` command's `result{data}` (design + * 2026-10-05 §3: the open answer carries the parsed hello plus the real + * destination). Delegates the whole shape to `parseSshSessionOpenResult` - + * one grammar, the session file beside the frames it narrows; this export is + * the four-site result-validator rule made visible. + * @param data - the `data` member of a successful result frame + * @returns the narrowed open result, or null when malformed + */ +export function parseNodeSshSessionOpenResult(data: unknown): SshSessionOpenResultWire | null { + return parseSshSessionOpenResult(data); +} diff --git a/packages/subshell-protocol/src/ssh-config.ts b/packages/subshell-protocol/src/ssh-config.ts new file mode 100644 index 000000000..388b7788d --- /dev/null +++ b/packages/subshell-protocol/src/ssh-config.ts @@ -0,0 +1,246 @@ +/** + * The approved normalized connection snapshot (SSH-SUPPORT.md §2: + * "Configuration is executable, not passive data"). + * + * This is the ONLY connection shape the wire ever carries. OpenSSH config can + * start local helpers, forward ports and reuse ambient sockets, so nothing + * here is a passthrough: the human-facing resolution step produces this shape + * once, and the NODE re-validates it with {@link + * parseSshConnectionSnapshot} on EVERY command that embeds a snapshot + * (resolve, test, run start, terminal launch). The plane approving a snapshot + * is not the load-bearing defense - this validator refusing a forbidden + * member is, on the machine that will actually run ssh. + * + * The eight absent-forbidden members (`proxyCommand`, `forwards`, `tunnels`, + * `localCommands`, `remoteCommand`, `sendEnv`, `setEnv`, `escapes`) are typed + * as literal `null` ON PURPOSE: they exist in the shape to be refused. A + * resolver that faithfully recorded a `ProxyCommand` produced a value this + * parser rejects, which is the "refuse with a named limitation rather than + * silently changing connection semantics" rule expressed in grammar. The + * runtime check is stricter than the type: any present non-null value fails, + * so hand-built JSON cannot smuggle one past TypeScript. + * + * Hand-rolled in the `node-frames.ts` validator style; imports no `node:` + * builtin; lives in the Metro-safe barrel. + */ + +import { isInt, isRecord, isStr } from "./guards.js"; +import { + SSH_MAX_CERT_REFS, + SSH_MAX_IDENTITY_REFS, + SSH_MAX_KNOWN_HOSTS_FILES, + SSH_MAX_PROXY_HOPS, + SSH_NAME_MAX_CHARS, + SSH_PATH_MAX_CHARS, +} from "./ssh-limits.js"; + +/** + * One ProxyJump hop as the approved snapshot carries it: a destination and + * nothing else. Hops are normalized "under the same restrictions" as the + * final destination (SSH-SUPPORT.md §2) - they never carry identity files or + * options; authentication for the whole chain comes from the snapshot's own + * refs, which is what makes the chain reviewable as a route. + * + * A `type` alias, not an interface: snapshots ride inside JSON command fields, + * and an interface has no implicit index signature. + */ +export type SshHopWire = { + /** Hop hostname (same hygiene as the destination host) */ + host: string; + /** Hop user; null = the connecting account's default */ + user: string | null; + /** Hop port, already resolved (an absent `Port` means 22) */ + port: number; +}; + +/** + * A resolved, approved SSH destination, normalized to the settings §2 supports + * and nothing more. Persisted server-side per connection revision and + * embedded verbatim in every node command that connects. + */ +export interface SshConnectionSnapshotWire { + /** + * The config token the human chose (display and review context only - + * routing uses `host`, and a manual alias with no config behind it still + * carries the destination it resolved to). + */ + alias: string; + /** Resolved destination hostname. Never an option-like string: the validator refuses a leading `-`. */ + host: string; + /** Destination user; null means the connecting account's own default */ + user: string | null; + /** Destination port, resolved (1..65535; a default-until-resolved `Port` never reaches the wire as 0) */ + port: number; + /** + * Identity file references: ABSOLUTE POSIX paths on the CONNECTING NODE, + * never key contents. Credential material stays where the OS put it. + */ + identityFiles: string[]; + /** Certificate file references (OpenSSH allows several `CertificateFile` lines), same path-only rule. */ + certificateFiles: string[]; + /** + * The authentication-agent socket to use: an absolute path from the + * connecting account's trusted setup, or null to run with no agent. An + * already-unlocked agent MAY be used (SSH-SUPPORT.md §2, Authentication and + * trust); this field is the only channel through which one is named. + */ + authAgentSocket: string | null; + /** Known-hosts files to consult. Revocation/CA semantics inside them are the runtime's to preserve; the refs are the snapshot's to carry. */ + knownHostsFiles: string[]; + /** OpenSSH `HostKeyAlias` when the config sets one; the name strict checking looks up. */ + hostKeyAlias: string | null; + /** Bounded ProxyJump chain, outermost first, at most {@link SSH_MAX_PROXY_HOPS} hops. */ + proxyJumps: SshHopWire[]; + /** Literal null: a resolved `ProxyCommand` makes this snapshot REFUSABLE, never renderable. */ + proxyCommand: null; + /** Literal null: `LocalForward`/`RemoteForward`/`DynamicForward` found at resolution fail setup. */ + forwards: null; + /** Literal null: `Tunnel`/`PermitRemoteOpen` found at resolution fail setup. */ + tunnels: null; + /** Literal null: `LocalCommand`, `PermitLocalCommand`, or a `Match exec` found at resolution fail setup. */ + localCommands: null; + /** Literal null: `RemoteCommand` found at resolution fails setup; it would replace the command contract. */ + remoteCommand: null; + /** Literal null: `SendEnv` found at resolution fails setup; no environment rides the link. */ + sendEnv: null; + /** Literal null: `SetEnv` found at resolution fails setup; same reason, remote side. */ + setEnv: null; + /** Literal null: live escape characters fail setup; the ~ menu is an interactive local shell. */ + escapes: null; +} + +/** The absent-forbidden members, in the order the parser refuses them; the resolve `settings` list names these strings back. */ +export const SSH_FORBIDDEN_SNAPSHOT_FIELDS = [ + "proxyCommand", + "forwards", + "tunnels", + "localCommands", + "remoteCommand", + "sendEnv", + "setEnv", + "escapes", +] as const; + +/** One member of {@link SSH_FORBIDDEN_SNAPSHOT_FIELDS}. */ +export type SshForbiddenSnapshotField = (typeof SSH_FORBIDDEN_SNAPSHOT_FIELDS)[number]; + +/** + * Control characters and space: nothing a host, user, or alias may contain on + * any sane SSH stack. Spelled as the Unicode category (C0+C1 controls + * including DEL) because biome refuses the raw `\x00` spellings, and the + * category is the rule stated as its own name anyway. + */ +const NAME_UNSAFE_RE = /\s|\p{Cc}/u; + +/** + * A hostname: a DNS-ish label run (this grammar admits IPv4 literals and + * underscores, which occur in real deployments) OR a bracketed IPv6 literal. + * A port baked onto the host (`[::1]:2222`) is refused on purpose: `port` is + * its own field, and accepting the composed spelling would let a host string + * carry a second authority the reviewer never saw. + */ +const HOST_RE = /^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])$/; + +/** A POSIX-ish username: the characters ssh's `user@host` split and PAM actually meet. */ +const USER_RE = /^[A-Za-z0-9._-]+$/; + +/** + * A wire name (config alias, `HostKeyAlias`): length-capped, no whitespace or + * control characters, and NOT option-like (a leading `-` is how a value + * becomes a flag when argv is assembled - the §2 injection rule, checked where + * the value is born rather than trusting every later composer). + */ +function isWireName(value: unknown): value is string { + return ( + isStr(value) && + value.length > 0 && + value.length <= SSH_NAME_MAX_CHARS && + !value.startsWith("-") && + !NAME_UNSAFE_RE.test(value) + ); +} + +/** A destination hostname (or bracketed IPv6), by grammar not by hygiene: see {@link HOST_RE}. */ +function isHostName(value: unknown): value is string { + return isStr(value) && value.length <= SSH_NAME_MAX_CHARS && HOST_RE.test(value); +} + +/** A username, by grammar: see {@link USER_RE}. Length is bounded by the same cap. */ +function isUserName(value: unknown): value is string { + return isStr(value) && value.length <= SSH_NAME_MAX_CHARS && USER_RE.test(value); +} + +/** An absolute POSIX path: leading `/`, length-capped, no control characters. Existence and ownership are the node's to check; shape is ours. */ +function isAbsPosixPath(value: unknown): value is string { + return isStr(value) && value.startsWith("/") && value.length <= SSH_PATH_MAX_CHARS && !/\p{Cc}/u.test(value); +} + +/** A resolved port: a positive integer inside the legal range. */ +function isPort(value: unknown): value is number { + return isInt(value) && (value as number) >= 1 && (value as number) <= 65535; +} + +/** A path list within `cap`, or empty. Every ref list shares this grammar. */ +function isPathListWithin(value: unknown, cap: number): value is string[] { + return Array.isArray(value) && value.length <= cap && value.every(isAbsPosixPath); +} + +function isHop(value: unknown): value is SshHopWire { + if (!isRecord(value) || !isHostName(value.host) || !isPort(value.port)) return false; + if (!("user" in value)) return false; + return value.user === null || isUserName(value.user); +} + +/** + * Validates and narrows an arbitrary value into an approved connection + * snapshot. Both sides call it: the plane before it stores or dispatches one, + * the node before it renders a command line from one. A snapshot that fails + * ANY check is refused whole - there is no partial approval, and the + * forbidden-member refusal is deliberately a PARSE failure rather than a + * field drop (dropping a `ProxyCommand` silently is the exact defect §2 names). + * + * @param value - candidate snapshot (parsed JSON; unknown provenance) + * @returns the narrowed snapshot, or null when malformed or non-approvable + */ +export function parseSshConnectionSnapshot(value: unknown): SshConnectionSnapshotWire | null { + if (!isRecord(value)) return null; + // Forbidden members first: any present non-null value refuses the whole + // snapshot, checked before the shape of everything else so a resolver bug + // surfaces as "this is not approvable", never as "incomplete input". + for (const field of SSH_FORBIDDEN_SNAPSHOT_FIELDS) { + if (value[field] != null) return null; + } + if (!isWireName(value.alias) || !isHostName(value.host) || !isPort(value.port)) return null; + if (!("user" in value) || !(value.user === null || isUserName(value.user))) return null; + if (!isPathListWithin(value.identityFiles, SSH_MAX_IDENTITY_REFS)) return null; + if (!isPathListWithin(value.certificateFiles, SSH_MAX_CERT_REFS)) return null; + if (!isPathListWithin(value.knownHostsFiles, SSH_MAX_KNOWN_HOSTS_FILES)) return null; + if (!("authAgentSocket" in value) || !(value.authAgentSocket === null || isAbsPosixPath(value.authAgentSocket))) + return null; + if (!("hostKeyAlias" in value) || !(value.hostKeyAlias === null || isWireName(value.hostKeyAlias))) return null; + if (!Array.isArray(value.proxyJumps) || value.proxyJumps.length > SSH_MAX_PROXY_HOPS) return null; + for (const hop of value.proxyJumps) if (!isHop(hop)) return null; + // Return a REBUILT object, never the input cast: the eight forbidden fields + // are written as literal null here, so what the caller holds is provably a + // snapshot the grammar can express - stray keys on the input die with it. + return { + alias: value.alias, + host: value.host, + user: value.user === undefined ? null : (value.user as string | null), + port: value.port, + identityFiles: [...(value.identityFiles as string[])], + certificateFiles: [...(value.certificateFiles as string[])], + authAgentSocket: value.authAgentSocket === undefined ? null : (value.authAgentSocket as string | null), + knownHostsFiles: [...(value.knownHostsFiles as string[])], + hostKeyAlias: value.hostKeyAlias === undefined ? null : (value.hostKeyAlias as string | null), + proxyJumps: (value.proxyJumps as SshHopWire[]).map((h) => ({ host: h.host, user: h.user, port: h.port })), + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + }; +} diff --git a/packages/subshell-protocol/src/ssh-errors.ts b/packages/subshell-protocol/src/ssh-errors.ts new file mode 100644 index 000000000..53aaae663 --- /dev/null +++ b/packages/subshell-protocol/src/ssh-errors.ts @@ -0,0 +1,148 @@ +/** + * The named SSH refusal/limit codes, frozen (SSH-SUPPORT.md §2/§3: "refuse + * with a named limitation rather than silently changing connection + * semantics"). One set for the whole feature: the node answers a refused + * command with the BARE code as its `result{error}` string when the plane must + * map it (the exact posture of the `NODE_RESULT_*` constants - the plane + * compares `NodeRpcError.detail` by EQUALITY, never a substring, so a refusal + * that wants a typed remedy is this string and nothing else); the resolution + * and test result envelopes carry the same codes in their `code` fields; and + * the server's policy refusals and API error metadata surface them verbatim. + * + * The set was FROZEN after Gate A; the retirement of the destination + * execution product (design 2026-10-05 §7) pruned the three quota/storage + * codes only ITS runtimes emitted (`quota_runs`, `quota_terminals`, + * `storage_full`), a deletion that is legal precisely because protocol 17 + * was never released. What remains is what the SURVIVING surfaces still + * answer: resolution refusals for the wizard, and the bare codes the + * session-runtime supervisor maps by equality. Adding a code is a + * coordinated change; renaming or deleting one is a wire change like any + * other. + * + * Imports no `node:` builtin; this module is in the Metro-safe barrel. + */ + +/** The refusal set as a runtime list. Exported beside the type because the parsers, the routes and the SPA all iterate it. */ +export const SSH_ERROR_CODES = [ + /** Resolution found a setting the approved normalization cannot represent (`ProxyCommand`, forwards, tunnels, local commands, `RemoteCommand`, `SendEnv`, `SetEnv`, escapes, …). The resolve answer's `settings` list names which. */ + "unsupported_setting", + /** The alias does not appear in the account's config (or an include) the discovery parsed. */ + "config_missing", + /** The alias matches conflicting `Host` blocks the normalizer cannot uniquely order; a human edits the config or enters the destination differently. */ + "config_ambiguous", + /** Strict host checking found no trust entry for the destination (or a hop). There is no accept endpoint in v1: the human trusts the key on the connecting account outside Subshell. */ + "host_key_unknown", + /** The destination's key does not match the trusted entry. Fail closed; never an automatic re-trust. */ + "host_key_changed", + /** The trusted entry is a revocation marker (`@revoked`) or the certificate chains to a revoked CA. */ + "host_key_revoked", + /** A named identity file is unreadable or needs a passphrase no in-band path can answer (key unlocking lives outside Subshell). */ + "key_unavailable", + /** The destination demands password, MFA or keyboard-interactive auth. v1 is key/certificate auth only. */ + "auth_mode_unsupported", + /** The resolved `ProxyJump` chain exceeds {@link SSH_MAX_PROXY_HOPS}. */ + "proxy_chain_too_long", + /** + * A signed command arrived past its freshness window after a reconnect. + * Rejecting it is the no-automatic-replay rule; the caller re-decides, the + * node never does. RESERVED, emitted nowhere (review M8): the freshness + * window the JWS signature carries is what actually refuses a stale start + * (the signing layer rejects the expired command before any handler runs), + * so no dispatch site names this code. It stays in the frozen set on purpose + * - the wire grammar and every reader's equality table are protocol-frozen + * and a bump cannot reclaim a removed name; the note is what keeps a future + * reader from "fixing" the apparent dead code by emitting it. + */ + "stale_command", + /** A start carried an ID the node already accepted for a DIFFERENT request digest. Durable dedup refused it; nothing was spawned twice. The session-open dedup kept the spelling (design §3). */ + "run_conflict", + /** The node never accepted this ID (or its record expired). A surviving equality code: the session arms answer it for an unknown session ref. */ + "run_unknown", + /** The pre-spawn connect probe failed for no more specific reason - the session open's reachability probe kept this spelling too. The one code that must never be dressed up as a diagnosis. */ + "connection_failed", + /** + * The session-runtime probe found no runtime binary on the destination + * (design 2026-10-05 §3: the `command -v` probe before the child spawns; + * the design names this refusal `SSH_RUNTIME_MISSING`). The remedy is the + * binary install ONLY - never enrollment, never `subshell setup` - and + * `SSH_ERROR_DESCRIPTIONS` keeps it to exactly that sentence. + */ + "runtime_missing", + /** Opening a session was refused at the per-node active-session quota (design §3, sibling of `quota_terminals`). */ + "session_quota", + /** The brokered child spoke something whose first frame is not a runtime hello (design §2: malformed leading bytes fail the open; the broker names it, the plane relays it). */ + "session_protocol", + /** + * The destination already carries a LIVE session: the second serve died at + * its callback-door bind (review m2 - the door refuses a live listener, and + * the bind site is the ONE enforcement point, since a plane-side + * second-session gate would break §6's reopen-and-adopt journey). The + * remedy is the other session - close it, do not touch the binary. + */ + "session_in_use", +] as const; + +/** One named refusal from {@link SSH_ERROR_CODES}. */ +export type SshErrorCode = (typeof SSH_ERROR_CODES)[number]; + +/** Whether `value` is one of the frozen codes. */ +export function isSshErrorCode(value: unknown): value is SshErrorCode { + return typeof value === "string" && (SSH_ERROR_CODES as readonly string[]).includes(value); +} + +/** + * One honest sentence per code, for any surface that must explain a refusal + * it cannot elaborate (MCP prose, SPA detail rows). Shipped strings: plain + * words, no markup, no secrets, and each one names the remedy's LOCATION + * (this account, this config, retry-later) because a code the human cannot + * act on is a bug report waiting to be filed. + */ +export const SSH_ERROR_DESCRIPTIONS: Record = { + unsupported_setting: + "The resolved SSH config needs a setting Subshell will not run (proxy commands, forwarding, tunnels, local commands, remote commands, or environment injection). Edit the config or choose another alias.", + config_missing: "That alias is not in the connecting account's SSH config. Check the spelling or add it there.", + config_ambiguous: + "The alias matches conflicting config blocks that cannot be reduced to one destination. Simplify the config or enter the alias differently.", + host_key_unknown: + "The destination has no trusted host key on the connecting account. Trust it there first; Subshell never accepts keys on your behalf.", + host_key_changed: + "The destination's host key no longer matches the trusted entry. Verify out of band and update the entry on the connecting account.", + host_key_revoked: + "The trusted host key or certificate chain is revoked. Restore a valid trust entry on the connecting account.", + key_unavailable: + "A referenced identity key cannot be used as-is (unreadable, or passphrase-protected). Unlock or reconfigure it on the connecting account.", + auth_mode_unsupported: + "The destination requires password or interactive authentication. Subshell supports key and certificate auth only.", + proxy_chain_too_long: "The jump-host chain is longer than Subshell will review and run. Shorten the ProxyJump path.", + stale_command: "The request expired before it reached the node. Nothing ran; send it again if it should.", + run_conflict: + "A run with this ID already exists with different contents. The earlier request stands; this one was refused.", + run_unknown: + "This node has no record of that run. Its result may never have existed or its history expired; never assume it completed.", + connection_failed: + "The connection test failed. Check the destination, the network, and the connecting account's SSH setup.", + runtime_missing: + "The destination has no Subshell binary. Install the `subshell` binary there (the same install as any node's agent, without enrollment); no account setup or daemon is needed.", + session_quota: "This machine already carries its share of open SSH sessions. Close one first.", + session_protocol: + "The destination answered the session open with something that is not the Subshell runtime's handshake. Check that the named program is the `subshell` binary.", + session_in_use: "This destination already has a live session. Close that session there first, then connect again.", +}; + +/** + * `result.error` from an input or prompt-delivery the node refused because its + * per-pane generation had moved on: a takeover or a revocation fenced it. + * + * THE single frozen wire spelling for a generation refusal. There is + * deliberately no matching member of `SSH_ERROR_CODES` (the Gate A review + * ruled one event, one name): the plane matches `NodeRpcError.detail` against + * THIS constant by equality, exactly like every other bare `NODE_RESULT_*`. + * Relocated here from the retired run-facts module (design 2026-10-05 §7). + * What keeps the spelling alive is the fence's SEAM, not its use: with the + * destination product retired, nothing records a generation, so every pane + * is an ordinary pane under the agent's deny-by-construction rule and this + * refusal cannot fire today. The wire field, the monotonic mirror, and this + * frozen answer are what the next control-transition feature adopts instead + * of reinventing (the honest header: apps/node/agent/src/input-generation.ts). + */ +export const NODE_RESULT_SSH_GENERATION_STALE = "stale input generation"; diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts new file mode 100644 index 000000000..8b5ba7d89 --- /dev/null +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -0,0 +1,87 @@ +/** + * The two surviving SSH node commands (design 2026-10-05 §7): the bounded + * config DISCOVERY and the single-alias RESOLUTION that feed the wizard's + * review step. They ride the existing signed/encrypted command transport + * unchanged - signing proves WHO ordered it, this file freezes WHAT was + * ordered. + * + * The rest of the family the SSH-SUPPORT.md §4 table named (`ssh_test_ + * connection`, the run start/status/read/cancel quartet, `ssh_terminal_ + * launch`, `ssh_input_control`) retired with the destination execution + * product; protocol 17 never shipped, so their `type` arms are deleted, not + * refused. The brokered-session commands live in `ssh-session-frames.ts`. + * + * The commands are TRANSPORT-AGNOSTIC plain objects: the server-hosted + * `local` node executes the same runtime in-process against these exact + * bodies, so nothing may assume a socket, a jti, or a signature - those are + * the transport's, and they live outside the body. + * + * Hand-rolled in the `node-frames.ts` style; imports no `node:` builtin; + * lives in the Metro-safe barrel. + */ + +import { isRecord, isStr } from "./guards.js"; +import { SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; + +/* ------------------------------------------------------------------ */ +/* command bodies */ +/* ------------------------------------------------------------------ */ + +/** + * Bounded parse of the connecting account's SSH config for alias NAMES. Takes + * no input beyond the command itself (which node answers it is the + * transport's, not the body's). Human-only at the API level; the node holds + * no concept of who asked - the signature already did that part. + */ +export interface SshDiscoverAliasesCommand { + type: "ssh_discover_aliases"; +} + +/** Resolve one alias into the approved normalized snapshot (or a named refusal). Human config step. */ +export interface SshResolveConfigCommand { + type: "ssh_resolve_config"; + /** The config token to resolve. Shape-checked here; whether it EXISTS in the account's config is what the command is for. */ + alias: string; +} + +/** Every SSH-family command body, as one union the {@link NodeCommandBody} union folds in. */ +export type SshNodeCommandBody = SshDiscoverAliasesCommand | SshResolveConfigCommand; + +/** Every SSH command `type`, for census tests and dispatch tables. */ +export const SSH_COMMAND_TYPES = ["ssh_discover_aliases", "ssh_resolve_config"] as const; + +/* ------------------------------------------------------------------ */ +/* arm validators (delegated from parseNodeCommandBody) */ +/* ------------------------------------------------------------------ */ + +/** Same hygiene the snapshot applies to names; a config alias is the same kind of string. */ +function isAliasName(value: unknown): value is string { + return ( + isStr(value) && + value.length > 0 && + value.length <= SSH_NAME_MAX_CHARS && + !value.startsWith("-") && + !/\s|\p{Cc}/u.test(value) + ); +} + +/** + * Validates and narrows any `ssh_*` command body. `parseNodeCommandBody` + * routes its two `type` arms here so the SSH grammar lives in ONE file + * beside the commands it narrows; the contract it upholds is the same: a + * NON-null return is safe to switch on by `type`. + * + * @param value - candidate payload whose `type` starts with `ssh_` + * @returns the narrowed command, or null when malformed + */ +export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | null { + if (!isRecord(value) || !isStr(value.type)) return null; + switch (value.type) { + case "ssh_discover_aliases": + return { type: "ssh_discover_aliases" }; + case "ssh_resolve_config": + return isAliasName(value.alias) ? { type: "ssh_resolve_config", alias: value.alias } : null; + default: + return null; + } +} diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts new file mode 100644 index 000000000..0e4050bbb --- /dev/null +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -0,0 +1,133 @@ +/** + * The SSH resource limits, frozen (SSH-SUPPORT.md §3's limits table). + * + * Everything here is PURE data: the rows of the spec table, each named, plus + * the derived bounds the wire parsers enforce so neither end of the link can + * drift. The rows are law after Gate A: the node runtime enforces them at the + * connecting machine, the plane refuses dispatch past them before a command + * is ever signed, and the surfaces render the same numbers the enforcement + * uses. Changing a row is a coordinated edit, not a local convenience - + * which is why they live here rather than in the packages that act on them. + * + * The destination execution product's rows (run deadlines, active-run and + * terminal quotas, output retention and aggregate storage, the read window + * and long-poll caps) deleted with it (design 2026-10-05 §7). What remains: + * the probe deadline, the snapshot/grammar bounds, the cancel grace the + * session supervisor borrows, and the session-runtime table. + * + * Imports no `node:` builtin; this module is in the Metro-safe barrel. + */ + +/* ------------------------------------------------------------------ */ +/* the spec table (SSH-SUPPORT.md §3), one export per row */ +/* ------------------------------------------------------------------ */ + +/** + * Connection/setup probe: a 30-second OVERALL deadline, and no automatic + * execution retry ever (SSH-SUPPORT.md §3's table row; the no-retry rule is + * the durable-dispatch contract, not a tuning knob). + */ +export const SSH_PROBE_DEADLINE_MS = 30 * 1000; + +/* ------------------------------------------------------------------ */ +/* derived contract bounds (chosen at the Gate A freeze, parser-enforced) */ +/* ------------------------------------------------------------------ */ + +/** + * Bounded ProxyJump chain (SSH-SUPPORT.md §2: "a bounded `ProxyJump` chain"). + * FOUR is a freeze decision, not a spec row: beyond four hops a normalized + * hop table stops being humanly reviewable, which is the whole point of + * reviewing the route before saving it. Resolution refuses longer chains by + * name (`proxy_chain_too_long`); the hop count a snapshot may carry is + * validated at every command that embeds one. + */ +export const SSH_MAX_PROXY_HOPS = 4; + +/** Identity-file references one approved snapshot may name. Absolute paths, never contents. */ +export const SSH_MAX_IDENTITY_REFS = 8; + +/** Certificate-file references one approved snapshot may name (OpenSSH allows several `CertificateFile` lines). */ +export const SSH_MAX_CERT_REFS = 8; + +/** Known-hosts files one approved snapshot may name (the trust refs §2's "preserve referenced trust files" sentence). */ +export const SSH_MAX_KNOWN_HOSTS_FILES = 8; + +/** Aliases one `ssh_discover_aliases` answer may carry; past it the answer sets `truncated`. */ +export const SSH_MAX_DISCOVERED_ALIASES = 500; + +/** Longest config alias / hostname / username the wire carries (253 is DNS's practical ceiling; the SSH token cannot legitimately exceed it). */ +export const SSH_NAME_MAX_CHARS = 253; + +/** Longest absolute POSIX path the wire carries (Linux's PATH_MAX is 4096; a path needing more is not ours to route). */ +export const SSH_PATH_MAX_CHARS = 4094; + +/** + * Grace period a SIGTERM gets before the group is SIGKILLed. Born with the + * structured runs, kept because the session supervisor kills the brokered + * child's GROUP with exactly this grace (design §3). Remote descendants are + * NEVER confirmed dead by any grace period - that honesty survived the run + * family. + */ +export const SSH_CANCEL_GRACE_MS = 5_000; + +/* ------------------------------------------------------------------ */ +/* session-runtime limits (design 2026-10-05 §2/§3) */ +/* ------------------------------------------------------------------ */ + +/** + * Inbound runtime-frame queue bound per session (design §2: 128 frames). + * Overflow CLOSES the session fail-closed - a plane that cannot drain its own + * session must stop the flow at the source rather than buffer without bound. + */ +export const SSH_SESSION_INBOUND_QUEUE_FRAMES = 128; + +/** + * Open deadline: spawn the child AND receive its parsed hello within this + * window, or the open fails - never a half-open session, never a retry (the + * no-automatic-execution-replay rule reads the probe's failure as final here + * too). + */ +export const SSH_SESSION_OPEN_DEADLINE_MS = 30_000; + +/** Active brokered sessions one connecting node may hold, across all owners. */ +export const SSH_SESSIONS_PER_NODE = 8; + +/** + * Longest callback correlation id (`rest_request`/`rest_response` `reqId`) + * the wire carries, printable characters only. The bound is load-bearing + * beyond tidiness: the plane echoes the id back inside a size-capped + * `rest_response` frame, and its answer-truncation budget is measured with + * the ACTUAL id, so the frame-cap arithmetic needs it finite and its escape + * cost knowable BEFORE the answer is sized. The runtime mints uuids (36 + * chars); 64 matches the session-ref ceiling, so no honest id is refused. + */ +export const SSH_REQ_ID_MAX_CHARS = 64; + +/** + * The ONE runtime-safe transfer window, in RAW bytes, for anything whose answer + * rides the session link as base64: a `log_read` window and a live-tail + * `output` chunk alike cap at this. The node link's own windows + * (`LOG_TAIL_BYTES` 256 KiB, `TAIL_CHUNK_BYTES` 192 KiB) are sized for the + * 1 MiB node frame and BREAK the session codec: base64 inflates 4/3, so 256 + * KiB raw is ~349,528 chars and even 192 KiB raw lands at 262,144 chars - + * exactly {@link SSH_SESSION_FRAME_MAX_BYTES} before the JSON envelope is + * glued on - and `encodeSshSessionFrame` throws past the cap. 128 KiB raw + * base64s to ~174,764 chars; the worst `result`/`output` envelope around it + * (uuid refs, numeric offsets, key quotes) costs well under 200 bytes, so a + * full window encodes at about 175 KiB against the 262,144-byte cap. The + * plane clamps its `log_read` asks to this and the runtime chunks its tail + * pump to this (`ctx.outputChunkCeilingBytes`); neither end splits a frame + * after composition, and the sizes that overflow are pinned refused by test. + */ +export const SSH_SESSION_LOG_WINDOW_BYTES = 128 * 1024; + +/** + * Largest raw stdout slice one brokered `session_frame` event may carry + * (design §3's pump, restated as a number: the node-frames `session_frame` + * doc always claimed "in ≤ 192 KiB pieces", and the broker's read can return + * more in one chunk). 192 KiB raw base64s to 262,144 chars, comfortably under + * the node link's own 1 MiB frame cap; the session's codec reassembles frames + * across pushes, so a chunk boundary is a chunk boundary and splitting costs + * the stream nothing but an event. + */ +export const SSH_SESSION_PUMP_CHUNK_BYTES = 192 * 1024; diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts new file mode 100644 index 000000000..ae0cb6ea6 --- /dev/null +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -0,0 +1,60 @@ +import type { SshConnectionSnapshotWire } from "./ssh-config.js"; +import type { SshErrorCode } from "./ssh-errors.js"; + +/** + * The SSH result ENVELOPE types - what the two surviving `ssh_*` discovery + * commands' `result{data}` carries (the Gate A split from `ssh-frames.ts`; + * exported NAMES unchanged). The test/run/control envelopes retired with the + * destination product (design 2026-10-05 §7). The `parse*` validators for + * these live in `node-results.ts` beside every other result validator (the + * four-site rule in the integration maps). + * + * Imports no `node:` builtin; lives in the Metro-safe barrel. + */ + +/** + * `ssh_discover_aliases` answer: NAMES of usable aliases, never config file + * contents (SSH-SUPPORT.md §2). Wildcard-only entries are already excluded + * and include cycles detected (the cycle is a FACT the human reviewing the + * list needs, never a silent stop). + */ +export interface NodeSshAliasListResult { + /** Alias names, sorted, deduplicated, at most {@link SSH_MAX_DISCOVERED_ALIASES}. */ + aliases: string[]; + /** An include cycle was detected during the bounded parse; the list is what parsed before it. */ + includeCycle: boolean; + /** The alias cap was hit; more exist. */ + truncated: boolean; +} + +/** + * `ssh_resolve_config` answer. The refusal is IN THE DATA, not an `ok:false` + * result: "this config needs a ProxyCommand" is a SUCCESSFUL resolution + * outcome the human must read (which setting blocked), not a transport error. + * `settings` names the blocked settings by the config keyword (a `ProxyCommand` + * row, a `LocalForward` row, …) so the human can edit the config and re-resolve. + */ +export type NodeSshResolveOutcomeWire = + | { + /** The alias normalized cleanly into an approved snapshot. */ + accepted: true; + /** The snapshot the human reviews, then saves. */ + snapshot: SshConnectionSnapshotWire; + /** + * The connecting account's OS user name, when the node could report it + * - the §3 UI sentence "review resolved destination AND connecting OS + * account" needs a name the snapshot itself never carries. Optional by + * design: an agent that cannot resolve `os.userInfo` answers without + * it and the review proceeds (the destination facts are the load- + * bearing half; this is display). + */ + connectingAccount?: string; + } + | { + /** Resolution refused: the config needs more than the approved normalization can run, or the destination is unusable. */ + accepted: false; + /** The named limitation ({@link SshErrorCode} subset: unsupported_setting, config_missing, config_ambiguous, proxy_chain_too_long, …). */ + code: SshErrorCode; + /** Config keywords that blocked acceptance; empty when the code names the whole cause. */ + settings: string[]; + }; diff --git a/packages/subshell-protocol/src/ssh-session-codec.ts b/packages/subshell-protocol/src/ssh-session-codec.ts new file mode 100644 index 000000000..5df7cf350 --- /dev/null +++ b/packages/subshell-protocol/src/ssh-session-codec.ts @@ -0,0 +1,136 @@ +/** + * The SSH-session transport codec: 4-byte big-endian length prefix + one JSON + * frame (design 2026-10-05 §2: "the SSH child's stdin/stdout, 4-byte + * big-endian length prefix + one JSON frame"). + * + * This is the codec for the RUNTIME link that rides the SSH child's stdio - + * NOT the node-link websocket, which frames its own messages through Bun. The + * three consumers share one file so the two ends cannot drift: the runtime + * (stdout is protocol-only), the plane's session reader (child stdout bytes + * arrive in arbitrary chunks re-sealed through `session_frame` events, so the + * decoder MUST buffer across pushes and MUST reassemble frames split by a + * chunk boundary), and unit tests. + * + * Fail-closed is the whole posture: an oversized declared length, a frame + * that does not parse, or a leading byte run that cannot begin a frame + * (login banner text, a PTY echo) permanently breaks the decoder - never a + * skip, never a resync. A broken decoder is what makes the design's "login + * banners and malformed leading bytes fail the open" true; there is no code + * path that looks for the next plausible JSON start. + * + * Imports no `node:` builtin (TextEncoder/TextDecoder are web globals + * present in Bun, Node and Hermes); this module is in the Metro-safe barrel. + */ + +/** + * Largest single frame on the runtime link (`MAX_SESSION_FRAME_BYTES` in the + * design freeze). 256 KiB base64s to about 350 KiB, which the broker then + * seals under the node link's own {@link NODE_MAX_FRAME_BYTES} cap - the + * smaller number is the runtime's because a runtime frame must fit inside a + * session_frame event with headroom. + */ +export const SSH_SESSION_FRAME_MAX_BYTES = 262_144; + +/** Length-prefix width both ends agree on. */ +const LENGTH_PREFIX_BYTES = 4; + +/** A decoder's terminal verdict once it stops accepting bytes; the reason rides into the close and the log. */ +export type SshSessionCodecFailure = "oversize" | "malformed-json" | "not-json-object"; + +/** + * Encode one frame: the JSON bytes with their length as a 4-byte big-endian + * prefix. Throws on a frame whose JSON exceeds {@link + * SSH_SESSION_FRAME_MAX_BYTES} - the sender refusing is the codec's half of + * the bounded-frame promise; a sender that cannot build a frame must decide + * its own remedy (chunk, or fail the call), never emit a lying prefix. + * + * @param frame - any JSON-serializable value (the session's frame types) + * @returns the exact bytes to write to the stream + */ +export function encodeSshSessionFrame(frame: unknown): Uint8Array { + const json = JSON.stringify(frame); + const body = new TextEncoder().encode(json); + if (body.byteLength > SSH_SESSION_FRAME_MAX_BYTES) { + throw new Error(`session frame exceeds ${SSH_SESSION_FRAME_MAX_BYTES} bytes`); + } + const out = new Uint8Array(LENGTH_PREFIX_BYTES + body.byteLength); + const view = new DataView(out.buffer); + view.setUint32(0, body.byteLength, false); + out.set(body, LENGTH_PREFIX_BYTES); + return out; +} + +/** + * Streaming decoder for the length-prefixed frame grammar. + * + * `push` accepts arbitrarily split bytes (an SSH child's stdout arrives in + * whatever pieces the transport chose) and returns the frames that COMPLETED + * during that push, in order. Once {@link failed} is set the decoder is + * terminal: every further push returns nothing (fail-closed - a stream that + * produced garbage once is not re-joined at the next plausible boundary). + */ +export class SshSessionFrameDecoder { + #buffer: Uint8Array = new Uint8Array(0); + #failure: SshSessionCodecFailure | null = null; + + /** The terminal failure, or null while the decoder still accepts bytes. */ + get failed(): SshSessionCodecFailure | null { + return this.#failure; + } + + /** + * Feed bytes; get back the JSON values of every frame completed by them. + * A declared length above {@link SSH_SESSION_FRAME_MAX_BYTES} fails the + * whole stream (`oversize`) the moment the four prefix bytes are seen, + * before any body byte is demanded - a lying prefix must not be able to + * make the reader buffer unboundedly. + * + * @param chunk - the next bytes of the stream (any size, may split frames) + * @returns the parsed frames in stream order; empty when nothing completed + */ + push(chunk: Uint8Array): unknown[] { + const frames: unknown[] = []; + if (this.#failure !== null || chunk.byteLength === 0) return frames; + this.#buffer = concat(this.#buffer, chunk); + for (;;) { + if (this.#buffer.byteLength < LENGTH_PREFIX_BYTES) return frames; + const declared = new DataView(this.#buffer.buffer as ArrayBuffer, this.#buffer.byteOffset).getUint32(0, false); + if (declared > SSH_SESSION_FRAME_MAX_BYTES) { + this.#fail("oversize"); + return frames; + } + const total = LENGTH_PREFIX_BYTES + declared; + if (this.#buffer.byteLength < total) return frames; // wait for the rest of the body + const body = this.#buffer.subarray(LENGTH_PREFIX_BYTES, total); + this.#buffer = this.#buffer.slice(total); + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(body)); + } catch { + this.#fail("malformed-json"); + return frames; + } + // The session grammar only ever carries objects; a bare string or + // array frame is the same class of stream corruption as bad JSON. + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + this.#fail("not-json-object"); + return frames; + } + frames.push(parsed); + } + } + + #fail(reason: SshSessionCodecFailure): void { + this.#failure = reason; + this.#buffer = new Uint8Array(0); + } +} + +/** Copy-free-enough concat for the sizes here (a frame is <= 256 KiB and pushes are chunk-sized). */ +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + if (a.byteLength === 0) return b; + const out = new Uint8Array(a.byteLength + b.byteLength); + out.set(a, 0); + out.set(b, a.byteLength); + return out; +} diff --git a/packages/subshell-protocol/src/ssh-session-frame-parsers.ts b/packages/subshell-protocol/src/ssh-session-frame-parsers.ts new file mode 100644 index 000000000..6da0dbf28 --- /dev/null +++ b/packages/subshell-protocol/src/ssh-session-frame-parsers.ts @@ -0,0 +1,430 @@ +/** + * Parsers for the session-runtime wire family (design 2026-10-05 §2/§3): the + * validators that narrow unknown JSON down to {@link SshSessionTargetWire}, + * {@link SshSessionNodeCommandBody}, {@link SshRuntimeHelloWire}, + * {@link SshSessionOpenResultWire}, and the two runtime-link frame unions. + * + * The WIRE TYPES and constants live in `ssh-session-frames.ts`; the GRAMMAR + * lives here (the `ssh-frames.ts` / `ssh-results.ts` split, one concern per + * file). Both ends import through the package barrel; `node-frames.ts` routes + * its three `ssh_session_*` arms here, the runtime and the plane parse every + * stdio frame through the same functions, so the two ends cannot drift. + * + * Every parse is total: unknown in, narrowed value or null out, never a + * throw. Null IS the refusal (fail-closed, the codec's own posture). + */ + +import { BASE64_RE, isBool, isInt, isRecord, isStr, isStrArray } from "./guards.js"; +import { SSH_NAME_MAX_CHARS, SSH_PATH_MAX_CHARS, SSH_REQ_ID_MAX_CHARS } from "./ssh-limits.js"; +import { + SSH_RUNTIME_PROTOCOL, + type SshRuntimeCommandFrame, + type SshRuntimeDetectSpecs, + type SshRuntimeEventFrame, + type SshRuntimeHelloWire, + type SshRuntimeLaunchBody, + type SshRuntimeReportRow, + type SshSessionNodeCommandBody, + type SshSessionOpenResultWire, + type SshSessionTargetWire, +} from "./ssh-session-frames.js"; + +/** + * The session ref grammar: hex+hyphen, <= 64, the same shape + * `isNodeSubshellId` pins for anything interpolated into a node-side path + * (the run family's `isSshRunId` is this exact local twin, for the same + * cycle-avoidance reason: `node-frames.ts` imports this file, so this file + * may not import the guard from it). + */ +export function isSshSessionRef(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-fA-F-]{1,64}$/.test(value); +} + +/** Same hygiene the SSH family applies to names. */ +function isAliasName(value: unknown): value is string { + return ( + isStr(value) && + value.length > 0 && + value.length <= SSH_NAME_MAX_CHARS && + !value.startsWith("-") && + !/\s|\p{Cc}/u.test(value) + ); +} + +/** Destination host: the snapshot grammar's shape (DNS-ish labels or bracketed IPv6), refused option-like at the lead. */ +function isSessionHost(value: unknown): value is string { + if (!isStr(value) || value.length === 0 || value.length > SSH_NAME_MAX_CHARS) return false; + return /^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])$/.test(value); +} + +/** Destination account: the characters ssh's `user@host` split and PAM actually meet. */ +function isSessionUser(value: unknown): value is string { + return isStr(value) && /^[A-Za-z0-9._-]+$/.test(value) && value.length <= SSH_NAME_MAX_CHARS; +} + +/** An absolute POSIX path ref (shape only; existence and ownership are the node's). */ +function isAbsRef(value: unknown): value is string { + return isStr(value) && value.startsWith("/") && value.length <= SSH_PATH_MAX_CHARS && !/\p{Cc}/u.test(value); +} + +/** + * The callback correlation id grammar: non-empty, printable, at most + * {@link SSH_REQ_ID_MAX_CHARS}. The runtime mints uuids; no more structure is + * claimed because the plane's only use is echoing the id back inside a + * size-capped `rest_response` - which is exactly why the bound belongs in the + * GRAMMAR: the plane sizes the answer with the real id, so an unbounded + * inbound id would put the frame-cap guarantee outside anyone's arithmetic. + */ +function isReqId(value: unknown): value is string { + return isStr(value) && value.length > 0 && value.length <= SSH_REQ_ID_MAX_CHARS && !/\p{Cc}/u.test(value); +} + +/** + * Validates and narrows one session target. A non-null return is safe to + * render: the broker still re-runs it (defense in depth, the snapshot rule), + * and the mandatory policy is what makes the render itself deny-by-default. + */ +export function parseSshSessionTarget(value: unknown): SshSessionTargetWire | null { + if (!isRecord(value)) return null; + if (!isAliasName(value.alias) || !isSessionHost(value.host)) return null; + if (!isInt(value.port) || (value.port as number) < 1 || (value.port as number) > 65535) return null; + if (!("user" in value) || !(value.user === null || isSessionUser(value.user))) return null; + if ("identityFile" in value && !(value.identityFile === null || isAbsRef(value.identityFile))) return null; + return { + alias: value.alias, + host: value.host, + port: value.port as number, + user: (value.user ?? null) as string | null, + identityFile: (value.identityFile ?? null) as string | null, + }; +} + +/** + * Validates and narrows any `ssh_session_*` command body. `parseNodeCommandBody` + * routes its three arms here, so the session grammar lives in one place beside + * the types it narrows (the `ssh-frames.ts` precedent, split into + * types/parsers like its run-family sibling). + * + * @param value - candidate payload whose `type` starts with `ssh_session_` + * @returns the narrowed command, or null when malformed + */ +export function parseSshSessionNodeCommandBody(value: unknown): SshSessionNodeCommandBody | null { + if (!isRecord(value) || !isStr(value.type)) return null; + switch (value.type) { + case "ssh_session_open": { + if (!isSshSessionRef(value.ref)) return null; + const target = parseSshSessionTarget(value.target); + if (target === null) return null; + if ("runtimeCommand" in value) { + // A program name or absolute path - never shell syntax, never option-like + // (a leading `-` would ride into the composed remote command as a flag). + if (!isStr(value.runtimeCommand) || value.runtimeCommand.length === 0) return null; + if (value.runtimeCommand.length > SSH_PATH_MAX_CHARS || /\s|\p{Cc}/u.test(value.runtimeCommand)) return null; + if (value.runtimeCommand.startsWith("-")) return null; + if (!value.runtimeCommand.startsWith("/") && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value.runtimeCommand)) + return null; + return { + type: "ssh_session_open", + ref: value.ref, + target, + runtimeCommand: value.runtimeCommand, + }; + } + return { type: "ssh_session_open", ref: value.ref, target }; + } + case "ssh_session_send": + return isSshSessionRef(value.ref) && isStr(value.data_b64) && BASE64_RE.test(value.data_b64) + ? { type: "ssh_session_send", ref: value.ref, data_b64: value.data_b64 } + : null; + case "ssh_session_close": + return isSshSessionRef(value.ref) ? { type: "ssh_session_close", ref: value.ref } : null; + default: + return null; + } +} + +/** Validates and narrows the runtime's hello frame; null is a refused open (fail-closed, never a partial trust). */ +export function parseSshRuntimeHello(value: unknown): SshRuntimeHelloWire | null { + if (!isRecord(value) || value.type !== "hello") return null; + if (!isInt(value.runtimeProtocol) || (value.runtimeProtocol as number) < 1) return null; + if (!isStr(value.agentVersion) || value.agentVersion.length === 0) return null; + if (!isStr(value.os) || value.os.length === 0 || !isStr(value.arch) || value.arch.length === 0) return null; + if (!isStrArray(value.capabilities)) return null; + if (!isAbsRef(value.homeDir) || !isAbsRef(value.dataDir)) return null; + if (!isStr(value.tmuxSocket) || value.tmuxSocket.length === 0 || !/^[A-Za-z0-9._-]+$/.test(value.tmuxSocket)) + return null; + if (!isInt(value.paneCount) || (value.paneCount as number) < 0) return null; + // Additive (task 25): the self-invoke PREFIX the plane appends verbs to. + // A malformed one refuses the WHOLE hello (fail-closed, like every other + // field) rather than dropping the fact and letting the plane silently fall + // back - a runtime that reports a broken self-invoke is a runtime build + // problem, and the named open refusal is where the operator learns it. + if ("selfInvoke" in value && value.selfInvoke !== undefined) { + const si = value.selfInvoke; + if (!isRecord(si) || !isStr(si.command) || si.command.length === 0 || !isStrArray(si.args)) return null; + if (!isAbsRef(si.command)) return null; + } + const selfInvoke = + "selfInvoke" in value && value.selfInvoke !== undefined + ? { + command: (value.selfInvoke as { command: string }).command, + args: [...((value.selfInvoke as { args: string[] }).args ?? [])], + } + : undefined; + return { + type: "hello", + runtimeProtocol: value.runtimeProtocol as number, + agentVersion: value.agentVersion, + os: value.os, + arch: value.arch, + capabilities: [...value.capabilities], + homeDir: value.homeDir, + dataDir: value.dataDir, + tmuxSocket: value.tmuxSocket, + paneCount: value.paneCount as number, + ...(selfInvoke !== undefined ? { selfInvoke } : {}), + }; +} + +/** Validates and narrows the `ssh_session_open` result data (the broker's answer after eating the hello). */ +export function parseSshSessionOpenResult(value: unknown): SshSessionOpenResultWire | null { + if (!isRecord(value)) return null; + const hello = parseSshRuntimeHello(value.hello); + if (hello === null) return null; + if (!isSessionHost(value.host)) return null; + if (!isInt(value.port) || (value.port as number) < 1 || (value.port as number) > 65535) return null; + if (!("user" in value) || !(value.user === null || isSessionUser(value.user))) return null; + if ("connectingAccount" in value && value.connectingAccount !== undefined && !isStr(value.connectingAccount)) + return null; + return { + hello, + host: value.host, + port: value.port as number, + user: value.user as string | null, + ...(typeof value.connectingAccount === "string" ? { connectingAccount: value.connectingAccount } : {}), + }; +} + +/** + * Validates the pane census rows the runtime reports twice over: as the + * `subshells_report` EVENT frame, and as the `close` command's RESULT data + * (design §6's final report). One grammar for both, because it is one fact + * (alive/dead per pane) leaving one process. + * + * @returns the narrowed rows (an empty array is a valid empty census), or + * null when the payload fits no census shape at all + */ +export function parseSshRuntimeReportRows(value: unknown): SshRuntimeReportRow[] | null { + if (!Array.isArray(value)) return null; + const rows: SshRuntimeReportRow[] = []; + for (const s of value) { + if (!isRecord(s) || !isStr(s.subshellId) || !isBool(s.alive)) return null; + if (!(s.exitCode === null || isInt(s.exitCode))) return null; + rows.push({ subshellId: s.subshellId, alive: s.alive, exitCode: s.exitCode as number | null }); + } + return rows; +} + +/** The shared shape of the pane-keyed command arms; null when `value` fits no pane arm. */ +function parsePaneCommand(value: Record): SshRuntimeCommandFrame | null { + if (!isSshSessionRef(value.ref)) return null; + // `subshellId` is per-arm: `probe` names a BATCH of ids and has no single + // pane, so the guard belongs to the arms that address one. + if (value.type === "probe") { + return isStrArray(value.subshellIds) ? { type: "probe", ref: value.ref, subshellIds: value.subshellIds } : null; + } + if (!isStr(value.subshellId)) return null; + const subshellId = value.subshellId; + switch (value.type) { + case "input": + return isStr(value.data) ? { type: "input", ref: value.ref, subshellId, data: value.data } : null; + case "terminate": + return { type: "terminate", ref: value.ref, subshellId }; + case "kill": + return { type: "kill", ref: value.ref, subshellId }; + case "capture": + if ("lines" in value && !(isInt(value.lines) && (value.lines as number) > 0)) return null; + return "lines" in value + ? { type: "capture", ref: value.ref, subshellId, lines: value.lines as number } + : { type: "capture", ref: value.ref, subshellId }; + case "resize": + return isInt(value.cols) && isInt(value.rows) && (value.cols as number) > 0 && (value.rows as number) > 0 + ? { type: "resize", ref: value.ref, subshellId, cols: value.cols, rows: value.rows } + : null; + case "pane_size": + return { type: "pane_size", ref: value.ref, subshellId }; + case "pane_cursor": + return { type: "pane_cursor", ref: value.ref, subshellId }; + case "log_read": + return isInt(value.fromByte) && + (value.fromByte as number) >= 0 && + isInt(value.maxBytes) && + (value.maxBytes as number) > 0 + ? { + type: "log_read", + ref: value.ref, + subshellId, + fromByte: value.fromByte, + maxBytes: value.maxBytes, + } + : null; + case "tail_start": + return isStr(value.subId) && isInt(value.fromByte) && (value.fromByte as number) >= 0 + ? { type: "tail_start", ref: value.ref, subshellId, subId: value.subId, fromByte: value.fromByte } + : null; + default: + return null; + } +} + +/** + * Validates and narrows one plane->runtime command frame (the runtime parses + * EVERY inbound frame through this; a null answer is a protocol violation and + * closes the session, never a partial trust). `tail_stop` and the id-less + * arms share the ref grammar, so the split by shape lives here rather than in + * two functions. + */ +export function parseSshRuntimeCommandFrame(value: unknown): SshRuntimeCommandFrame | null { + if (!isRecord(value) || !isStr(value.type)) return null; + // The non-pane-keyed arms share only the ref requirement; anything else + // dispatches on the pane id inside `parsePaneCommand`. Routing is by the + // presence of `subshellId` in each arm's shape, not by a negative list. + switch (value.type) { + case "launch": { + // Shallow on the envelope, and that is the whole design: the body's + // deep grammar is `parseNodeCommandBody`'s launch arm, which the + // runtime re-runs before dispatch (one grammar, checked where it is + // executed). A plane-composed body that fails it answers + // `unsupported`/`malformed`, never a half-parsed spawn. + if (!isSshSessionRef(value.ref) || !isRecord(value.cmd) || value.cmd.type !== "launch") return null; + if (!isStr(value.cmd.subshellId)) return null; + return { type: "launch", ref: value.ref, cmd: value.cmd as unknown as SshRuntimeLaunchBody }; + } + case "tail_stop": + return isSshSessionRef(value.ref) && isStr(value.subId) + ? { type: "tail_stop", ref: value.ref, subId: value.subId } + : null; + case "subshells_report": + return isSshSessionRef(value.ref) ? { type: "subshells_report", ref: value.ref } : null; + case "list_dirs": + return isSshSessionRef(value.ref) && isStr(value.path) + ? { type: "list_dirs", ref: value.ref, path: value.path } + : null; + case "stat_dir": + return isSshSessionRef(value.ref) && isAbsRef(value.path) + ? { type: "stat_dir", ref: value.ref, path: value.path } + : null; + case "detect": { + // Shallow on the envelope, the launch arm's exact precedent: the specs' + // deep grammar is `parseNodeCommandBody`'s detect arm and the runtime + // re-runs it before the executor sees anything (one grammar, checked + // where it is executed). `envNames` is a plain string array here because + // the SAME rule the node link's arm applies is shape, not semantics: + // the executor only reads process.env at these names. + if (!isSshSessionRef(value.ref) || !Array.isArray(value.specs) || !isStrArray(value.envNames)) return null; + return { + type: "detect", + ref: value.ref, + specs: value.specs as unknown as SshRuntimeDetectSpecs, + envNames: [...value.envNames], + }; + } + case "remove_paths": + return isSshSessionRef(value.ref) && Array.isArray(value.paths) && value.paths.every(isAbsRef) + ? { type: "remove_paths", ref: value.ref, paths: [...(value.paths as string[])] } + : null; + case "rest_response": { + if (!isReqId(value.reqId)) return null; + if (!isInt(value.status) || (value.status as number) < 100 || (value.status as number) > 599) return null; + if ("body" in value && value.body !== undefined && !isStr(value.body)) return null; + return { + type: "rest_response", + reqId: value.reqId, + status: value.status as number, + ...("body" in value && typeof value.body === "string" ? { body: value.body } : {}), + }; + } + case "close": + return isSshSessionRef(value.ref) ? { type: "close", ref: value.ref } : null; + default: + return parsePaneCommand(value); + } +} + +/** + * Validates and narrows one runtime->plane event frame (the PLANE parses + * every frame the pumped bytes produce, through this same function, so both + * ends check and the shared file cannot drift). + */ +export function parseSshRuntimeEventFrame(value: unknown): SshRuntimeEventFrame | null { + if (!isRecord(value) || !isStr(value.type)) return null; + switch (value.type) { + case "hello": + return parseSshRuntimeHello(value); + case "result": { + if (!isSshSessionRef(value.ref) || !isBool(value.ok)) return null; + if (value.ok) + return "data" in value + ? { type: "result", ref: value.ref, ok: true, data: value.data } + : { type: "result", ref: value.ref, ok: true }; + return isStr(value.error) ? { type: "result", ref: value.ref, ok: false, error: value.error } : null; + } + case "output": + return isStr(value.subshellId) && + isStr(value.subId) && + isInt(value.fromByte) && + isInt(value.toByte) && + (value.fromByte as number) >= 0 && + (value.toByte as number) >= (value.fromByte as number) && + isStr(value.data_b64) && + BASE64_RE.test(value.data_b64) + ? { + type: "output", + subshellId: value.subshellId, + subId: value.subId, + fromByte: value.fromByte, + toByte: value.toByte, + data_b64: value.data_b64, + } + : null; + case "exit": + return isStr(value.subshellId) && isStr(value.at) && (value.exitCode === null || isInt(value.exitCode)) + ? { type: "exit", subshellId: value.subshellId, exitCode: value.exitCode as number | null, at: value.at } + : null; + case "subshells_report": { + const rows = parseSshRuntimeReportRows(value.subshells); + return rows === null ? null : { type: "subshells_report", subshells: rows }; + } + case "rest_request": { + if (!isReqId(value.reqId)) return null; + if (!isStr(value.method) || !/^[A-Za-z]+$/.test(value.method)) return null; + if (!isStr(value.path) || !value.path.startsWith("/") || value.path.length > SSH_PATH_MAX_CHARS) return null; + if ("body" in value && value.body !== undefined && !isStr(value.body)) return null; + // The attribution id (pane doors) rides the SUBSHELL-id grammar - the + // same shape rule the launch body's subshellId obeys. A malformed paneId + // refuses the FRAME (fail-closed like every field): a half-parsed + // attribution is exactly the ambiguity this field exists to remove. + if ("paneId" in value && value.paneId !== undefined && !isSshSessionRef(value.paneId)) return null; + return { + type: "rest_request", + reqId: value.reqId, + method: value.method.toUpperCase(), + path: value.path, + ...("body" in value && typeof value.body === "string" ? { body: value.body } : {}), + ...("paneId" in value && typeof value.paneId === "string" ? { paneId: value.paneId } : {}), + }; + } + default: + return null; + } +} + +/** + * Whether a parsed runtime hello speaks the protocol this plane understands + * (design §2's named refusal). The grammar pins the number to an integer, so + * a match is plain equality: there is no minor a parser could miss, and a + * future protocol bump is a number change the plane refuses BY NAME. + */ +export function sshRuntimeProtocolSupported(hello: SshRuntimeHelloWire): boolean { + return hello.runtimeProtocol === SSH_RUNTIME_PROTOCOL; +} diff --git a/packages/subshell-protocol/src/ssh-session-frames.ts b/packages/subshell-protocol/src/ssh-session-frames.ts new file mode 100644 index 000000000..964fdc23f --- /dev/null +++ b/packages/subshell-protocol/src/ssh-session-frames.ts @@ -0,0 +1,350 @@ +/** + * SSH session-runtime wire types (design 2026-10-05 §2/§3): the node-link + * command family that brokers a session (`ssh_session_open/send/close`, the + * new `session_frame` event arm in `node-frames.ts`) and the RUNTIME-LINK + * frames that ride the SSH child's stdio inside it (hello, the plane's + * command frames, the runtime's event frames, the callback request/response + * pair). + * + * Two grammars, one type file, because they are one feature viewed from its + * two halves: the node link speaks signed plain objects (like the SSH family + * before them, these are TRANSPORT-AGNOSTIC - the socket/jti/signature belong + * to the transport and live outside the body), and the runtime link speaks + * length-prefixed JSON frames (`ssh-session-codec.ts`) whose own version + * namespace is `runtimeProtocol`, deliberately separate from + * {@link NODE_PROTOCOL_VERSION} so a runtime rebuild never forces a node-link + * bump. The node link IS bumped by the new commands (17), the runtime link + * is not versioned by it. + * + * This file is the WIRE TYPES and constants; the validators that narrow + * unknown JSON into these shapes live in `ssh-session-frame-parsers.ts` + * (the `ssh-frames.ts` / `ssh-results.ts` split - one concern per file). + * Hand-rolled in the `ssh-frames.ts` style; imports no `node:` builtin; + * lives in the Metro-safe barrel. Result ENVELOPE validation for the open + * command lives in `node-results.ts` beside every other result validator + * (the four-site rule the integration maps name). + */ + +// TYPE-ONLY import: no runtime cycle with `node-frames.ts`, which value-imports +// the arm parser from the parsers module. The launch body is the node link's +// OWN command shape so a runtime pane and a node-link pane are launched by one +// grammar (the design's "mirror the node command bodies, minus crypto"). +import type { DetectSpecWire, NodeCommandBody } from "./node-frames.js"; +import { + SSH_REQ_ID_MAX_CHARS, + SSH_SESSION_INBOUND_QUEUE_FRAMES, + SSH_SESSION_LOG_WINDOW_BYTES, + SSH_SESSION_OPEN_DEADLINE_MS, + SSH_SESSION_PUMP_CHUNK_BYTES, + SSH_SESSIONS_PER_NODE, +} from "./ssh-limits.js"; + +// Re-exported for consumers that read the session surface from this file; the +// NUMBERS live in ssh-limits.ts beside every other row of the limits table. +export { + SSH_REQ_ID_MAX_CHARS, + SSH_SESSION_INBOUND_QUEUE_FRAMES, + SSH_SESSION_LOG_WINDOW_BYTES, + SSH_SESSION_OPEN_DEADLINE_MS, + SSH_SESSION_PUMP_CHUNK_BYTES, + SSH_SESSIONS_PER_NODE, +}; + +/** The node link's `launch` body, reused verbatim as the runtime launch frame's payload. */ +export type SshRuntimeLaunchBody = Extract; + +/** The node link's `detect` specs, reused verbatim as the runtime detect frame's payload (same rule, same answer). */ +export type SshRuntimeDetectSpecs = DetectSpecWire[]; + +/* ------------------------------------------------------------------ */ +/* constants */ +/* ------------------------------------------------------------------ */ + +/** + * The runtime link's version (design §2): ONE integer, matched by equality on + * the plane (`sshRuntimeProtocolSupported`). Bump it whenever the two ends + * must understand something new; additive frames that BOTH an old runtime and + * an old plane can ignore are gated by the hello's `capabilities` list, not + * by a number the grammar could not even speak. A mismatch refuses the open + * by name, saying both numbers. + */ +export const SSH_RUNTIME_PROTOCOL = 1; + +/** + * The runtime-probe refusal, answered as the BARE code in `result{error}` + * like every equality-mapped refusal. The design names it + * `SSH_RUNTIME_MISSING`; it joins the lowercase `SSH_ERROR_CODES` family + * because the set is code-shaped data (`config_missing`), and the name that + * ships IS the code that answers. The remedy it names is the binary install + * ONLY - never enrollment, never `subshell setup` (design §7). + */ +export const SSH_SESSION_RUNTIME_MISSING = "runtime_missing"; + +/** A brokered session the node has no live child for (send/close on an unknown or dead ref). */ +export const SSH_SESSION_UNKNOWN = "session_unknown"; + +/** + * The exit code `subshell runtime-serve` answers when its bootstrap died + * because a callback door already has a LIVE listener (review m2): the + * destination carries another session, and the serve says so with THIS + * number instead of dying as a generic error the classifier would read as + * "binary missing". Chosen off every code the open path can otherwise + * produce: 0 clean end, 2 flag refusal, 1 generic bootstrap throw, 126/127 + * the login shell's own lookup verdicts, 255 ssh itself, null a signal kill. + * The broker's classifier maps it to {@link SSH_SESSION_IN_USE} ahead of the + * 127/255/default arms. + */ +export const SSH_RUNTIME_SERVE_IN_USE_EXIT = 20; + +/** + * The occupied-destination refusal: the second serve died at its door bind + * (the bind-site live-listener probe is the enforcement point; design + * 2026-10-05 §6's reopen-after-loss journey forbids a plane-side pre-open + * refusal, so the honest name comes from the POST-spawn death). Answers as + * the bare code in `result{error}`, equality-mapped like the rest of the + * family; the remedy names the OTHER session, never the binary. + */ +export const SSH_SESSION_IN_USE = "session_in_use"; + +/* ------------------------------------------------------------------ */ +/* node-link command family (plane -> connecting node) */ +/* ------------------------------------------------------------------ */ + +/** + * One brokered session's destination, as the OPEN command names it. Deliberately + * narrower than the full approved snapshot: the session flow reviews the + * concrete `host:port user` at the plane, and the broker renders ssh argv from + * exactly these facts under the mandatory policy (design §3). Identity file is + * a PATH REF on the connecting node, never key material; null means ssh's own + * default identity set for the connecting account. + * + * A `type` alias, not an interface: these ride inside JSON command fields. + */ +export type SshSessionTargetWire = { + /** The config token the human chose (display + review context; routing uses host). */ + alias: string; + /** Destination hostname or IP literal. Never option-like (the parser refuses a leading `-`). */ + host: string; + /** Destination port, 1..65535 (no implicit-22 magic: the reviewer saw the number). */ + port: number; + /** Destination account; null = the connecting account's own default. */ + user: string | null; + /** Absolute identity-file ref on the connecting node, or null for the account's defaults. */ + identityFile: string | null; +}; + +/** New brokered-session command: probe the runtime, then spawn the SSH child and pump its stdio (design §3). */ +export interface SshSessionOpenCommand { + type: "ssh_session_open"; + /** Plane-minted session ref (the routing key on every frame and event; see the parsers' `isSshSessionRef`). */ + ref: string; + /** The reviewed destination. */ + target: SshSessionTargetWire; + /** + * Plane-supplied override of the remote program, default `"subshell"`. NOT a + * user-facing field: the slice's tests pass an absolute wrapper path. The + * node owner can already run any command on that host through their own + * SSH, so composing the remote command from this token is not a widening - + * the per-token quoting through the login-shell boundary is what keeps it + * one program invocation rather than shell soup. + */ + runtimeCommand?: string; +} + +/** Feed plane->runtime bytes into the child's stdin (the runtime frames themselves are the body; the broker never parses them). */ +export interface SshSessionSendCommand { + type: "ssh_session_send"; + /** The session ref. */ + ref: string; + /** Base64 of the raw bytes to write (may be a partial frame; the ends' codecs reassemble). */ + data_b64: string; +} + +/** + * Close one session: kill the child's group, answer once. Unknown refs answer + * the bare {@link SSH_SESSION_UNKNOWN}. The plane sends this on every user + * close (and the protocol-mismatch unroll) so the broker's group-kill is not + * left to the runtime's own exit; the node records `closed` either way. + */ +export interface SshSessionCloseCommand { + type: "ssh_session_close"; + /** The session ref. */ + ref: string; +} + +/** Every session command body, as one union the {@link NodeCommandBody} union folds in. */ +export type SshSessionNodeCommandBody = SshSessionOpenCommand | SshSessionSendCommand | SshSessionCloseCommand; + +/** Every session command `type`, for census tests and dispatch tables. */ +export const SSH_SESSION_COMMAND_TYPES = ["ssh_session_open", "ssh_session_send", "ssh_session_close"] as const; + +/* ------------------------------------------------------------------ */ +/* hello + open result (runtime facts the broker returns after parsing the first frame) */ +/* ------------------------------------------------------------------ */ + +/** + * The runtime's first frame (design §2, verbatim shape): + * `{ type: "hello", runtimeProtocol, agentVersion, os, arch, capabilities, + * homeDir, dataDir, tmuxSocket, paneCount }`. + * + * `os` is NOT constrained to the node link's three values: a runtime answers + * with `process.platform`-class text its own plane copy may not have seen, + * and refusing a new OS over a display field is how a fleet gets stranded. + * The grammar wants a non-empty name. + */ +export type SshRuntimeHelloWire = { + type: "hello"; + /** The runtime link's protocol; the plane refuses a mismatch by name. */ + runtimeProtocol: number; + /** The runtime binary's version string (also the session's `runtimeVersion`). */ + agentVersion: string; + /** OS name as the destination reports it. */ + os: string; + /** Architecture as the destination reports it. */ + arch: string; + /** Capability labels this runtime build advertises (opaque to the grammar). */ + capabilities: string[]; + /** The destination account's home directory. */ + homeDir: string; + /** The runtime's data dir (a `runtime/` namespace, isolated from any enrolled daemon's). */ + dataDir: string; + /** The deterministic per-destination tmux socket the runtime serves. */ + tmuxSocket: string; + /** Panes already living on that socket at hello time (the reconcile count; 0 for a fresh destination). */ + paneCount: number; + /** + * How the runtime re-enters its OWN binary (the `ready` frame's `selfInvoke` + * fact, same shape, prefix WITHOUT a subcommand). The plane appends `mcp` to + * compose a pane's MCP registration and `report` to compose its hooks: the + * agent's exact pattern; the runtime simply reports it over the session + * channel instead of the node link. Absent = a runtime that predates the + * field, and the plane falls back to the binary's name on PATH (`subshell`), + * which is how the agent's own `ready`-without-selfInvoke fallback reads. + */ + selfInvoke?: { command: string; args: string[] }; +}; + +/** + * The `ssh_session_open` result data: the parsed hello plus the concrete + * destination the child really dialed, plus the connecting account's name + * (a display fact, like the old resolve outcome's `connectingAccount`). + * Identity files, config contents and key material never enter this shape - + * the broker answers facts, and every field here is facts. + */ +export type SshSessionOpenResultWire = { + hello: SshRuntimeHelloWire; + host: string; + port: number; + /** The destination account, or null when the connecting account's default was used. */ + user: string | null; + /** The connecting node's OS account name (display fact of the machine that dialed). */ + connectingAccount?: string; +}; + +/* ------------------------------------------------------------------ */ +/* runtime-link frames (plane <-> runtime, inside the pumped stdio) */ +/* ------------------------------------------------------------------ */ + +/** + * One pane's census row: alive/dead as the runtime's tmux sees it, with the + * exit code when dead. The SAME row shape answers the `subshells_report` + * EVENT frame and the `close` command's RESULT data (design §6's final + * report - one fact, one shape, leaving one process). + */ +export type SshRuntimeReportRow = { + /** The pane's id (a plane-minted subshell uuid). */ + subshellId: string; + /** Whether the destination tmux still reports the pane alive. */ + alive: boolean; + /** The pane's exit code when dead (null: alive, or tmux could not say - unknown reads alive upstream). */ + exitCode: number | null; +}; + +/** + * A plane->runtime command frame. Bodies mirror the node command shapes the + * runtime needs, MINUS crypto and minus the plane-machine indirection the + * node link carries (no preset blob, no resolve rule, no signed jti): the + * runtime IS on the destination, so `cwd` and paths are already destination + * paths. `ref` correlates the runtime's `result{ref}` answer; the plane mints + * it per command. + */ +export type SshRuntimeCommandFrame = + /** + * Start a pane. The body is the node link's `launch` command VERBATIM + * (`SshRuntimeLaunchBody`), so a runtime pane is launched by the identical + * grammar and the runtime answers it with the identical executor: plane + * builds argv from the plugin it holds, the runtime late-binds the + * destination's binary exactly as a node would. The RUNTIME re-validates + * `cmd` through `parseNodeCommandBody` before dispatch (the deep check + * lives in the node grammar, not twice here); the slice's plane side sends + * terminal launches only, and every other harness would ride the same body. + */ + | { type: "launch"; ref: string; cmd: SshRuntimeLaunchBody } + | { type: "input"; ref: string; subshellId: string; data: string } + | { type: "terminate"; ref: string; subshellId: string } + | { type: "kill"; ref: string; subshellId: string } + | { type: "capture"; ref: string; subshellId: string; lines?: number } + | { type: "resize"; ref: string; subshellId: string; cols: number; rows: number } + | { type: "pane_size"; ref: string; subshellId: string } + | { type: "pane_cursor"; ref: string; subshellId: string } + | { type: "probe"; ref: string; subshellIds: string[] } + | { type: "log_read"; ref: string; subshellId: string; fromByte: number; maxBytes: number } + | { type: "tail_start"; ref: string; subshellId: string; subId: string; fromByte: number } + | { type: "tail_stop"; ref: string; subId: string } + /** Directory listing for the remote picker (the design's `list_dirs`; the runtime answers it with the same executor the node link's `fs_ls` uses). */ + | { type: "list_dirs"; ref: string; path: string } + /** + * Probe binaries and answer named env values (task 25, the node link's + * `detect` body minus the link): the PLANE asks, the runtime answers exactly + * what was asked, never a scan. `specs`/`envNames` are the SAME shapes the + * node link's detect carries (`DetectSpecWire[]`, printable env NAMES); the + * envelope check is shallow like `launch`'s, and the runtime re-runs + * `parseNodeCommandBody`'s deep detect arm before the executor sees it. + * Gated by the hello's `"detect"` capability on the plane side. + */ + | { type: "detect"; ref: string; specs: SshRuntimeDetectSpecs; envNames: string[] } + | { type: "stat_dir"; ref: string; path: string } + | { type: "remove_paths"; ref: string; paths: string[] } + /** Ask for a fresh alive/dead census of every pane the runtime tracks (the reconcile ride; design §6). */ + | { type: "subshells_report"; ref: string } + /** Deliver the callback answer for one `rest_request` (the plane's side of the §5 round trip). */ + | { + type: "rest_response"; + reqId: string; + /** HTTP status the plane chose for the pane; the runtime relays it verbatim on the socket. */ + status: number; + /** Response body text (JSON from the route, or a refusal sentence); may be empty. */ + body?: string; + } + /** + * Graceful end: the runtime answers this command's `result{ref}` WITH its + * final census as `data`, closes the socket, exits 0. tmux and panes stay + * up (design §6). The plane reads the census from the result and settles + * regardless of whether one arrives (design §6's Close). + */ + | { type: "close"; ref: string }; + +/** A runtime->plane event frame (design §2: mirror `NodeEvent`, minus the link-management arms that belong to the broker). */ +export type SshRuntimeEventFrame = + | SshRuntimeHelloWire + | { type: "result"; ref: string; ok: true; data?: unknown } + | { type: "result"; ref: string; ok: false; error: string } + | { type: "output"; subshellId: string; subId: string; fromByte: number; toByte: number; data_b64: string } + | { type: "exit"; subshellId: string; exitCode: number | null; at: string } + | { type: "subshells_report"; subshells: SshRuntimeReportRow[] } + /** + * A pane's callback over `callback.sock`, forwarded bounded (design §5). + * The plane answers with a `rest_response` carrying the same `reqId`; the + * parsers bound it ({@link SSH_REQ_ID_MAX_CHARS}, printable) on both + * directions, so the answer frame's size is finite before the body is cut. + * + * `paneId` is the per-connection ATTRIBUTION (task 25): set when the runtime + * accepted the connection on that pane's OWN callback door + * (`/callbacks/.sock`, named in the pane's + * `SUBSHELL_RUNTIME_CALLBACK_SOCK` at launch), and the plane executes the + * request as exactly that pane's token; no credential ever crossed the + * wire to make the identification. Absent = the shared door + * (`/callback.sock`, the slice's manual-curl surface), where the + * plane's rule is the slice's: resolvable only for a one-pane session. + */ + | { type: "rest_request"; reqId: string; method: string; path: string; body?: string; paneId?: string }; diff --git a/packages/subshell-protocol/src/versions.ts b/packages/subshell-protocol/src/versions.ts index 0578225ba..0b51a1aa2 100644 --- a/packages/subshell-protocol/src/versions.ts +++ b/packages/subshell-protocol/src/versions.ts @@ -107,8 +107,17 @@ export function semverLt(a: string, b: string): boolean { * in the same commit. An agent below this speaks no handshake, and the whole * point of this bump is that no such agent may put a plaintext frame on the * link the encrypted one is meant to carry. + * + * Raised to 1.5.0 with protocol 17 (the SSH session-runtime surface, design + * 2026-10-05 §2/§3), the same way — `apps/node/agent/package.json` goes to + * 1.5.0 in the same commit (the published version line reached 1.4.x since + * the protocol-14 note above, so the floor lands one minor above it). An + * agent below this brokers no `ssh_session_open` and emits no + * `session_frame`, so the plane would open a session against a machine that + * could never carry one — the floor turns that into "install subshell >= + * 1.5.0" instead. */ -export const MIN_NODE_VERSION = "0.17.0"; +export const MIN_NODE_VERSION = "1.5.0"; /** * Whether an agent reporting `version` may connect. diff --git a/scripts/dep-audit.ignore.json b/scripts/dep-audit.ignore.json index 5deafae5b..568832bab 100644 --- a/scripts/dep-audit.ignore.json +++ b/scripts/dep-audit.ignore.json @@ -41,5 +41,10 @@ "GHSA-vfj7-8cjw-p6xm" ], "reason": "Build-time transitive only: micromatch pulls braces 3.0.3 under fast-glob/jiti/vite (the bundler) and under metro/@expo/cli (mobile build tooling); nothing in the server, node, or desktop runtime depends on it. The advisory is a stack-exhaustion DoS from compiling attacker-supplied, deeply-nested glob patterns, which no build script here feeds it. 3.0.3 is the latest npm version and the only version in the advisory's affected range (<= 3.0.3), and GHSA reports no patched version, so there is no in-range fix to take; track upstream and remove this exception when a fix is published (2026-10-03)." + }, + { + "package": "sprintf-js", + "advisories": ["GHSA-hp3w-g68c-fv3c"], + "reason": "@internal/backend-errors pins 1.1.3 and @loglayer's terminal transport pulls the same range; 1.1.3 is the latest npm version and GHSA reports no patched release, so there is no fix to take. The advisory is a DoS via unbounded precision specifiers, and the only call site formats with a code-defined template string (backend-errors lib.ts: sprintf(this.message, ...params) - user data reaches ARGS, never the format string), so no attacker controls a specifier's precision. Track upstream and remove this exception when a fix is published (2026-10-06)." } ]