diff --git a/.changeset/ssh-launcher-tier.md b/.changeset/ssh-launcher-tier.md new file mode 100644 index 000000000..a27ecc337 --- /dev/null +++ b/.changeset/ssh-launcher-tier.md @@ -0,0 +1,30 @@ +--- +"@internal/server": minor +"@internal/node": minor +"@internal/subshell-protocol": minor +"@internal/pane-runtime": minor +"@internal/server-web": minor +"@internal/mobile": minor +--- + +SSH launcher tier: an enabled node can open interactive SSH-terminal panes at any +sshd host. `GET /api/ssh/aliases` and `POST /api/ssh/resolve` answer alias discovery +and `ssh -G` normalization on the connecting machine; `POST /api/ssh/launch` composes +the pane from the approved snapshot (rendered `-F` config, every-hop confinement +policy, host keys trusted `accept-new` in the connecting machine's own +`known_hosts`); saved/recent hosts and a default connecting machine live on the +plane keyed by the resolved destination. SSH-terminal panes accept input from their +owner alone (sharees view). The per-node `ssh_enabled` gate from the previous tier +refuses every one of these doors until the owner turns it on. A launch form with no +prior choice still defaults to the plain Terminal rather than to SSH, whose bare +launch has no destination to open; SSH stays pickable, and a preset names its own +agent. + +Deployment: update the server before the nodes. The link gate is exact-match at +protocol 17, so a lagging agent is held (offline for every purpose but update) +until it crosses the bump on the update command. Nothing changes for anyone until +a node's gate is enabled. + +Known limitation: keystrokes typed during the initial ssh handshake can be +swallowed before the remote shell reaches its prompt; the pane is fully usable +once connected. diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index a037fd698..29215d351 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|logging.update|update|update.unknown|shares_set|local_share_changed|service` · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Deliberate non-validation & accepted risks diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9a841b00a..69b422634 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -927,16 +927,49 @@ jobs: - name: Trust the bind-mounted workspace 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 12 (session lifecycle) spawns real tmux sessions (stub harness); + # spec 22 (ssh terminal) drives a REAL sshd plus an ssh client. Neither + # OpenSSH binary is in the builder image; installing here rather than + # in the image keeps the shared image lean. The container job runs as + # root, so apt-get needs no sudo. Tools map to PACKAGES, not names: + # "ssh" is not an apt package (openssh-client ships ssh + ssh-keygen; + # openssh-server ships sshd), so the missing binaries are translated + # before the deduped set is installed. + - 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 - if [ ${#missing[@]} -gt 0 ]; then - apt-get update && apt-get install -y --no-install-recommends "${missing[@]}" + pkg_for() { + case "$1" in + tmux) echo tmux ;; + jq) echo jq ;; + ssh|ssh-keygen) echo openssh-client ;; + sshd) echo openssh-server ;; + esac + } + missing_pkgs=() + for tool in tmux jq ssh ssh-keygen sshd; do + command -v "$tool" >/dev/null && continue + pkg="$(pkg_for "$tool")" + for p in "${missing_pkgs[@]+"${missing_pkgs[@]}"}"; do + [ "$p" = "$pkg" ] && continue 2 + done + missing_pkgs+=("$pkg") + done + if [ ${#missing_pkgs[@]} -gt 0 ]; then + apt-get update && apt-get install -y --no-install-recommends "${missing_pkgs[@]}" + fi + tmux -V && jq --version && ssh -V + # sshd -V prints usage and exits nonzero on some builds, so the + # presence proof is the binary: on PATH, else the package's + # documented location (the fixture probes /usr/sbin too). + command -v sshd || ls -l /usr/sbin/sshd + # Loud post-install assertion: an apt package rename would + # otherwise surface only as spec 22 skipping, i.e. the proof + # silently not running. + if ! command -v sshd >/dev/null && [ ! -x /usr/sbin/sshd ]; then + echo "::error::openssh-server present in no sshd location (PATH or /usr/sbin/sshd)" + exit 1 fi - tmux -V && jq --version # 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/mobile/src/lib/__tests__/agent-default.test.ts b/apps/client/mobile/src/lib/__tests__/agent-default.test.ts index 113962a0b..b434cf050 100644 --- a/apps/client/mobile/src/lib/__tests__/agent-default.test.ts +++ b/apps/client/mobile/src/lib/__tests__/agent-default.test.ts @@ -47,6 +47,19 @@ describe("defaultAgentId", () => { expect(defaultAgentId([TERMINAL, CLAUDE, CODEX], ALL_INSTALLED, null)).toBe("claude-code"); }); + it("a second terminal-type plugin does not steal the shell default", () => { + // Web mirror (CI e2e 2026-10-08): the catalog arrives id-sorted, so + // `ssh` (also type terminal, binary nearly always present) stands before + // `terminal`; the last resort must still land on the plain shell, which + // runs presetless, not on a bare `ssh` that exits without a host. + const SSH = plugin({ id: "ssh", name: "SSH", type: "terminal" }); + expect(defaultAgentId([SSH, TERMINAL], ALL_INSTALLED, null)).toBe("terminal"); + // A declared agent still outranks the shell tier. + expect(defaultAgentId([SSH, TERMINAL, CLAUDE], ALL_INSTALLED, null)).toBe("claude-code"); + // The shell tier is a preference, not a filter: ssh alone still fills. + expect(defaultAgentId([SSH], ALL_INSTALLED, null)).toBe("ssh"); + }); + it("a missing type (older server) is still pickable, but no longer outranks a declared agent", () => { // The middle tier reads `=== "agent-harness"` rather than // `!== "terminal"` now, because a third type exists (network) and an diff --git a/apps/client/mobile/src/lib/agent-default.ts b/apps/client/mobile/src/lib/agent-default.ts index b5232b3e1..1ee2ba82c 100644 --- a/apps/client/mobile/src/lib/agent-default.ts +++ b/apps/client/mobile/src/lib/agent-default.ts @@ -1,10 +1,14 @@ import type { PluginView } from "@/types/plugin"; import type { SubshellView } from "@/types/subshell"; +/** The built-in plain shell, by plugin id; the shell tier's favorite. Name + * matches the web mirror's constant of the same spelling. */ +const TERMINAL_PLUGIN_ID = "terminal"; + /** * The New screen's default-agent rule (spec 2026-09-13 §5) — the mobile * mirror of `defaultAgentId` in - * `apps/server/web/src/lib/subshell-compat.ts` (its call site there is + * `apps/server/web/src/lib/subshell-compat.tsx` (its call site there is * `apps/server/web/src/components/subshell-picker/new-subshell-form.tsx`), * kept in step by hand (the repo convention for this screen; change one, * change both). Pure so the rule is testable without a device. @@ -16,8 +20,8 @@ import type { SubshellView } from "@/types/subshell"; * stays the backstop for the race. * * The order: the harness of the user's most recent subshell while usable → - * the first usable plugin whose type IS `agent-harness` → anything usable → - * null. + * the first usable plugin whose type IS `agent-harness` → the plain shell + * (id `terminal`) → anything usable → null. * * That middle tier is a positive test, not the old `!== "terminal"`. The * exclusion was right while two types existed and became wrong the moment a @@ -26,6 +30,13 @@ import type { SubshellView } from "@/types/subshell"; * longer wins that tier; it still reaches the "anything usable" fallback, so * nothing becomes unpickable. * + * The shell tier is the ssh plugin's doing, mirrored from web (CI e2e + * 2026-10-08): `ssh` is also type terminal and its binary is nearly always + * present, and the catalog arrives id-sorted, so anything-usable would land + * on a bare `ssh` that exits without a host. The plain shell runs presetless, + * so it is the fallback; it stays a preference, not a filter, so an + * ssh-only catalog still fills. + * * @param plugins - the instance catalog (`GET /api/plugins`) * @param nodeHarnessInstalled - whether the SELECTED node reports this * harness's binary installed (caller passes `true` when the node carries no @@ -49,7 +60,12 @@ export function defaultAgentId( if (recent && usable(recent)) return recent.id; } const usableAgents = plugins.filter(usable); - return usableAgents.find((p) => p.type === "agent-harness")?.id ?? usableAgents[0]?.id ?? null; + return ( + usableAgents.find((p) => p.type === "agent-harness")?.id ?? + usableAgents.find((p) => p.id === TERMINAL_PLUGIN_ID)?.id ?? + usableAgents[0]?.id ?? + null + ); } /** 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..ecd061c2e --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh.test.ts @@ -0,0 +1,169 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type JsonValue, NODE_PROTOCOL_VERSION, type NodeCommandBody } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; + +/** + * The two SSH read arms of the launcher tier (spec 2026-10-07 §4.3/§7), driven + * through `dispatchCommand` exactly as the daemon drives them (posture copied + * from commands-maintenance.test.ts: real temp dirs, the local mirror written + * with its own writer, every refusal asserted by `ok:false` + exact message). + * + * What THIS file owns that nothing else does: + * - the GATE ordering: a machine whose local mirror is not ON answers + * `ssh disabled on this node` BEFORE any binary lookup or spawn. The proof + * is a working recording shim that never runs (its log file is never even + * created) and a broken ladder whose message never surfaces. + * - discovery's names-only answer from a fixture HOME, through the frozen + * wire validator on the way out. + * - routing: a well-formed `ssh_resolve_config` reaches the HANDLER (it + * answers with the handler's own failure class, not dispatch's + * `unsupported`). Malformed aliases die at the frame parser before this + * point — the tier-1 parser tests own that refusal. + * + * The real `ssh -G` behavior against a fixture config is the tier-1 resolver + * suite's; the composed launch path is the e2e's. Do not re-test either here. + */ + +const STAMP = "2026-10-07T10:00:00.000Z"; +const GATE_REFUSAL = "ssh disabled on this node"; + +let base: string; +let savedSshPath: string | undefined; +let savedHome: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-ssh-"))); + savedSshPath = process.env.SUBSHELL_SSH_PATH; + savedHome = process.env.HOME; +}); + +afterAll(() => { + if (savedSshPath === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = savedSshPath; + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + 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 }; +} + +/** A context whose only meaningful fact is the data dir its gate mirror lives in. */ +function makeCtx(dataDir: string): CommandContext { + return { config: { dataDir } } as unknown as CommandContext; +} + +/** Fresh data dir; `on: true` writes the mirror through its real writer. */ +function dataDir(tag: string, gate: "absent" | "on" | "unreadable"): string { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + if (gate === "on") writeSshEnabled(dir, { on: true, changedAt: STAMP }); + if (gate === "unreadable") writeFileSync(join(dir, "ssh-enabled.json"), "not json at all"); + return dir; +} + +/** A fixture HOME whose account config names two hosts. */ +function homeWithConfig(tag: string): string { + const home = join(base, `${tag}-home`); + mkdirSync(join(home, ".ssh"), { recursive: true }); + writeFileSync(join(home, ".ssh", "config"), "Host beta\n User x\nHost alpha\n HostName a.example\n"); + return home; +} + +describe("ssh gate: discovery and resolve refuse before anything runs", () => { + it("an absent mirror refuses BOTH arms by the exact name, whatever the ladder says", async () => { + const shim = writeShim(join(base, "shim-gate"), { + dashG: "host resolved.example\nhostname resolved.example\nuser deploy\nport 22\n", + }); + process.env.SUBSHELL_SSH_PATH = shim.bin; + const dir = dataDir("gate-absent", "absent"); + + const discovery = await dispatchCommand(makeCtx(dir), { type: "ssh_discover_aliases" } satisfies NodeCommandBody); + expect(discovery).toEqual({ ok: false, error: GATE_REFUSAL }); + + const resolve = await dispatchCommand(makeCtx(dir), { + type: "ssh_resolve_config", + alias: "app02", + } satisfies NodeCommandBody); + expect(resolve).toEqual({ ok: false, error: GATE_REFUSAL }); + // The gate check PRECEDES the ladder and the spawn: a perfectly good shim + // was never executed, and an answer computed after lookup would have said + // something else entirely. + expect(existsSync(shim.log)).toBe(false); + }); + + it("a broken ladder does not speak first: gate-off answers with the gate's own words", async () => { + process.env.SUBSHELL_SSH_PATH = "/nonexistent/ssh-binary"; + const res = await dispatchCommand(makeCtx(dataDir("gate-vs-ladder", "absent")), { + type: "ssh_resolve_config", + alias: "app02", + } satisfies NodeCommandBody); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + }); + + it("an unreadable mirror fails CLOSED (refuses) with the same words", async () => { + const res = await dispatchCommand(makeCtx(dataDir("gate-unreadable", "unreadable")), { + type: "ssh_discover_aliases", + } satisfies NodeCommandBody); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + // The same classifier gates the resolve arm; pin the pairing, not just one side. + const res2 = await dispatchCommand(makeCtx(dataDir("gate-unreadable", "unreadable")), { + type: "ssh_resolve_config", + alias: "app02", + } satisfies NodeCommandBody); + expect(res2).toEqual({ ok: false, error: GATE_REFUSAL }); + }); +}); + +describe("ssh_discover_aliases / ssh_resolve_config arms (gate ON)", () => { + it("discovery answers the sorted NAMES through the validator from the account config", async () => { + const dir = dataDir("disc", "on"); + process.env.HOME = homeWithConfig("disc"); + const res = await dispatchCommand(makeCtx(dir), { type: "ssh_discover_aliases" } satisfies 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"]); + expect(data.includeCycle).toBe(false); + expect(data.truncated).toBe(false); + }); + + it("a well-formed resolve command REACHES the handler: its own failure class answers, not dispatch's `unsupported`", async () => { + // Gate ON + a missing binary: only the handler body can produce the + // binary-missing class. "unsupported" here would mean the dispatch arm is + // missing; the gate's words here would mean it ran before the ladder. + process.env.SUBSHELL_SSH_PATH = "/nonexistent/ssh-binary"; + const res = await dispatchCommand(makeCtx(dataDir("reach", "on")), { + type: "ssh_resolve_config", + alias: "app02", + } satisfies NodeCommandBody); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("binary missing"); + }); +}); + +/** The bump this tier owns: literal-16 pins everywhere must now read 17. */ +it("NODE_PROTOCOL_VERSION is 17 (the ssh launch block + the two read arms)", () => { + expect(NODE_PROTOCOL_VERSION).toBe(17); +}); diff --git a/apps/node/agent/src/__tests__/launch-ssh.test.ts b/apps/node/agent/src/__tests__/launch-ssh.test.ts new file mode 100644 index 000000000..509838483 --- /dev/null +++ b/apps/node/agent/src/__tests__/launch-ssh.test.ts @@ -0,0 +1,363 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import { HARNESS_BINARY_PLACEHOLDER, type NodeCommandBody, type NodeEvent } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { runExitWatchTick, startExitWatcher, stopWatcher } from "../commands/report.js"; +import type { NodeConfig } from "../config.js"; +import { SubshellMetaStore } from "../subshell-meta.js"; + +/** + * Task 5 (spec 2026-10-07 decision 4): the ssh pane's rendered config, landed + * by the SPAWNING machine at the byte-derived path, and swept when the pane + * dies. + * + * Two halves, one contract: + * - `execLaunch` writes `cmd.ssh` BEFORE meta/pane, at + * `buildSshConfigPath(dataDir, id)` — the plane names nothing: a frame whose + * `configPath` is not byte-equal to this machine's own derivation is + * refused. Modes mirror the MCP block exactly (dir 0700, file 0600, + * `enforceMode` on both). + * - the exit watcher (report.ts) removes the per-pane ssh DIR when — and only + * when — the dead pane's recorded meta names harness `ssh`, best-effort and + * inside the same relaunch-ownership guard that protects the tails. + * + * Same fake-tmux discipline as commands-launch.test.ts: a plain-object double + * records ordered calls, unstubbed methods throw. + */ + +const S1 = "11111111-1111-4111-8111-111111111111"; +const FIXED_NOW = 1_700_000_000_000; +const SSH_CONTENT = "Host *\n StrictHostKeyChecking yes\n"; + +let base: string; +let claudePathOriginal: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-launch-ssh-"))); +}); + +beforeEach(() => { + claudePathOriginal = process.env.CLAUDE_PATH; + process.env.CLAUDE_PATH = "/bin/sh"; +}); + +afterAll(() => { + if (claudePathOriginal === undefined) delete process.env.CLAUDE_PATH; + else process.env.CLAUDE_PATH = claudePathOriginal; + rmSync(base, { recursive: true, force: true }); +}); + +async function freshDataDir(tag: string): Promise { + const dir = join(base, tag); + mkdirSync(dir, { recursive: true }); + return dir; +} + +type ProbeAnswer = { ok: true; names: string[] } | { ok: false; detail: string }; + +interface Spec { + newSubshell?: (socket: string, id: string, cwd: string, cmd: string, exitHook?: string) => void; + pipePane?: (socket: string, id: string, out: string) => void; + resizeWindow?: (socket: string, id: string, cols: number, rows: number) => void; + listSubshellsChecked?: (socket: string) => ProbeAnswer | Promise; + paneExitCode?: (socket: string, id: string) => number | null; + killSubshell?: (socket: string, id: string) => void; +} + +/** A one-shot promise plus its resolver (the gated-forget fixture drives these). */ +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +/** + * Models a tick stuck INSIDE `await ctx.meta.forget(...)` (the relaunch + * interleaving from commands-launch.test.ts, kept here because the ssh sweep + * must respect the SAME ownership guard). + */ +class GateHeldMetaStore extends SubshellMetaStore { + readonly forgetEntered = deferred(); + readonly forgetGate = deferred(); + + override async forget(id: string): Promise { + await super.forget(id); + this.forgetEntered.resolve(); + await this.forgetGate.promise; + } +} + +/** Build a CommandContext over a scripted double on `dataDir`; unstubbed calls throw. */ +function makeCtx( + dataDir: string, + spec: Spec, + events: NodeEvent[], + meta?: SubshellMetaStore, +): { ctx: CommandContext; calls: Array<{ method: string; args: unknown[] }> } { + const calls: Array<{ method: string; args: unknown[] }> = []; + const method = (name: keyof Spec) => { + const impl = spec[name]; + return (...args: unknown[]) => { + calls.push({ method: name, args }); + if (!impl) throw new Error(`fake tmux: unstubbed call ${String(name)}(${JSON.stringify(args)})`); + return (impl as (...a: unknown[]) => unknown)(...args); + }; + }; + const raw = { + newSubshell: method("newSubshell"), + pipePane: method("pipePane"), + resizeWindow: method("resizeWindow"), + listSubshellsChecked: (socket: string): ProbeAnswer | Promise => { + calls.push({ method: "listSubshellsChecked", args: [socket] }); + const scripted = spec.listSubshellsChecked; + if (scripted) return scripted(socket); + throw new Error("fake tmux: unstubbed listSubshellsChecked"); + }, + paneExitCode: method("paneExitCode"), + killSubshell: method("killSubshell"), + }; + 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: meta ?? new SubshellMetaStore(dataDir), + nowMs: () => FIXED_NOW, + ws: { send: (ev) => events.push(ev) }, + watchers: new Map(), + tails: new Map(), + uploads: new Map(), + runtime: null, + requestRestart: () => {}, + }; + return { ctx, calls }; +} + +type LaunchCmd = Extract; + +/** An ssh-harness launch frame: placeholder argv resolved via CLAUDE_PATH like every launch fixture here. */ +function sshLaunchCmd(over: Partial = {}): LaunchCmd { + return { + type: "launch", + subshellId: S1, + socket: "subshell-ssh-test", + cwd: base, + harnessId: "ssh", + preset: { name: "p", env: {}, flags: [], settings: null, configIsolation: false }, + subshellEnv: { SUBSHELL_API_KEY: "k" }, + subshellName: "s1", + argv: [HARNESS_BINARY_PLACEHOLDER], + resolve: { binaryName: "claude", envOverride: "CLAUDE_PATH" }, + ...over, + }; +} + +function spawnAndPipeStub() { + return { newSubshell: () => {}, pipePane: () => {} }; +} + +/* ------------------------------------------------------------------ */ +/* execLaunch: the config write (derived path, byte-equality, modes) */ +/* ------------------------------------------------------------------ */ + +describe("execLaunch ssh config write (spec 2026-10-07 decision 4)", () => { + it("well-formed block: file at the derived path, 0600 in a 0700 dir, content byte-equal", async () => { + const dataDir = await freshDataDir("write"); + const { ctx, calls } = makeCtx(dataDir, spawnAndPipeStub(), []); + const configPath = buildSshConfigPath(dataDir, S1); + const result = await dispatchCommand(ctx, sshLaunchCmd({ ssh: { configPath, fileContent: SSH_CONTENT } })); + expect(result).toEqual({ ok: true }); + expect(await Bun.file(configPath).text()).toBe(SSH_CONTENT); + expect(statSync(configPath).mode & 0o777).toBe(0o600); + const parent = configPath.slice(0, configPath.lastIndexOf("/")); + expect(statSync(parent).mode & 0o777).toBe(0o700); + // The write precedes nothing observable in tmux ordering terms, but the + // pane still spawns exactly once and the file survives the launch. + expect(calls.map((c) => c.method)).toEqual(["newSubshell", "pipePane"]); + stopWatcher(ctx, S1); + }); + + it("the write is visible at newSubshell time (config BEFORE spawn)", async () => { + const dataDir = await freshDataDir("write-before-spawn"); + const configPath = buildSshConfigPath(dataDir, S1); + let seenAtSpawn = false; + const { ctx } = makeCtx( + dataDir, + { + ...spawnAndPipeStub(), + newSubshell: () => { + seenAtSpawn = existsSync(configPath); + }, + }, + [], + ); + const result = await dispatchCommand(ctx, sshLaunchCmd({ ssh: { configPath, fileContent: SSH_CONTENT } })); + expect(result).toEqual({ ok: true }); + expect(seenAtSpawn).toBe(true); + stopWatcher(ctx, S1); + }); + + it("configPath not byte-equal to the derivation: refused, no file, no dir, no pane, no meta", async () => { + const dataDir = await freshDataDir("mismatch"); + const { ctx, calls } = makeCtx(dataDir, spawnAndPipeStub(), []); + // A plane naming anywhere else — a sibling path in the SAME derived shape + // included — is refused: the path is this machine's derivation alone. + const wrong = buildSshConfigPath(dataDir, `${S1}-elsewhere`); + const result = await dispatchCommand(ctx, sshLaunchCmd({ ssh: { configPath: wrong, fileContent: SSH_CONTENT } })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("ssh config path refused"); + expect(calls).toEqual([]); // refused before the spawn + expect(existsSync(join(dataDir, "ssh"))).toBe(false); // not even a dir + expect(await ctx.meta.get(S1)).toBeUndefined(); + }); + + it("absolute-path config outside dataDir: refused by the same gate (byte-equality is the first door)", async () => { + const dataDir = await freshDataDir("outside"); + const { ctx, calls } = makeCtx(dataDir, spawnAndPipeStub(), []); + const result = await dispatchCommand( + ctx, + sshLaunchCmd({ ssh: { configPath: "/etc/ssh/subshell-config", fileContent: SSH_CONTENT } }), + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("ssh config path refused"); + expect(calls).toEqual([]); + expect(existsSync(join(dataDir, "ssh"))).toBe(false); + }); + + it("no ssh block on the frame: no dir is created for an ordinary pane", async () => { + const dataDir = await freshDataDir("absent"); + const { ctx } = makeCtx(dataDir, spawnAndPipeStub(), []); + const result = await dispatchCommand(ctx, sshLaunchCmd()); + expect(result).toEqual({ ok: true }); + expect(existsSync(join(dataDir, "ssh"))).toBe(false); + stopWatcher(ctx, S1); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Exit watcher: the death sweep, gated on meta harnessId === "ssh" */ +/* ------------------------------------------------------------------ */ + +/** Seed meta with an explicit harnessId (the commands-launch twin hardcodes claude-code). */ +async function recordMetaAs( + store: SubshellMetaStore, + subshellId: string, + socket: string, + harnessId: string, +): Promise { + await store.record({ + subshellId, + cwd: base, + socket, + harnessId, + name: "m", + startedAt: "2026-10-07T00:00:00.000Z", + }); +} + +/** Create a realistic per-pane ssh dir + config under dataDir. */ +function seedSshConfig(dataDir: string, subshellId: string): { dir: string; file: string } { + const file = buildSshConfigPath(dataDir, subshellId); + const dir = file.slice(0, file.lastIndexOf("/")); + mkdirSync(dir, { recursive: true }); + writeFileSync(file, SSH_CONTENT, { mode: 0o600 }); + return { dir, file }; +} + +function deadSocketSpec(): Spec { + return { listSubshellsChecked: () => ({ ok: true, names: [] }), paneExitCode: () => 0, killSubshell: () => {} }; +} + +describe("exit watcher ssh config sweep (report.ts, decision 4)", () => { + it("ssh pane dies: the per-pane ssh dir is gone, best-effort, and the death still reports", async () => { + const dataDir = await freshDataDir("sweep-ssh"); + const events: NodeEvent[] = []; + const { ctx } = makeCtx(dataDir, deadSocketSpec(), events); + await recordMetaAs(ctx.meta, S1, "s-sock", "ssh"); + const { dir, file } = seedSshConfig(dataDir, S1); + startExitWatcher(ctx, S1, "s-sock", 10_000); // the timer never fires; the tick runs manually + + await runExitWatchTick(ctx); + + expect(events).toEqual([{ type: "exit", subshellId: S1, exitCode: 0, at: new Date(FIXED_NOW).toISOString() }]); + expect(existsSync(file)).toBe(false); + expect(existsSync(dir)).toBe(false); // the DIR goes, not just the file + expect(await ctx.meta.get(S1)).toBeUndefined(); + expect(ctx.watchTick).toBeUndefined(); + }); + + it("non-ssh pane dies: a same-named dir on disk is left untouched", async () => { + const dataDir = await freshDataDir("sweep-claude"); + const events: NodeEvent[] = []; + const { ctx } = makeCtx(dataDir, deadSocketSpec(), events); + await recordMetaAs(ctx.meta, S1, "s-sock", "claude-code"); + const { dir, file } = seedSshConfig(dataDir, S1); + startExitWatcher(ctx, S1, "s-sock", 10_000); + + await runExitWatchTick(ctx); + + expect(events.length).toBe(1); // the death still reports + expect(existsSync(file)).toBe(true); + expect(existsSync(dir)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }); + + it("pane with NO meta record: nothing is removed (the kind fact is the gate, absence never asserts)", async () => { + const dataDir = await freshDataDir("sweep-nometa"); + const events: NodeEvent[] = []; + const { ctx } = makeCtx(dataDir, deadSocketSpec(), events); + const { dir, file } = seedSshConfig(dataDir, S1); + startExitWatcher(ctx, S1, "s-sock", 10_000); + + await runExitWatchTick(ctx); + + expect(events.length).toBe(1); + expect(existsSync(file)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }); + + it("relaunch DURING the forget: the stale tick never sweeps the live relaunch's config", async () => { + const dataDir = await freshDataDir("sweep-relaunch"); + const events: NodeEvent[] = []; + const gated = new GateHeldMetaStore(dataDir); + const { ctx } = makeCtx(dataDir, { ...deadSocketSpec(), ...spawnAndPipeStub() }, events, gated); + await recordMetaAs(gated, S1, "s-sock", "ssh"); + const configPath = buildSshConfigPath(dataDir, S1); + const dir = configPath.slice(0, configPath.lastIndexOf("/")); + mkdirSync(dir, { recursive: true }); + writeFileSync(configPath, "OLD", { mode: 0o600 }); + startExitWatcher(ctx, S1, "s-sock", 10_000); + + const tick = runExitWatchTick(ctx); + try { + await gated.forgetEntered.promise; // the tick is suspended inside `await ctx.meta.forget(S1)` + // Relaunch through the REAL launch path: it re-records meta and rewrites + // the config while the old tick is still in flight. + const relaunch = await dispatchCommand( + ctx, + sshLaunchCmd({ socket: "s-sock-2", ssh: { configPath, fileContent: SSH_CONTENT } }), + ); + expect(relaunch).toEqual({ ok: true }); + } finally { + gated.forgetGate.resolve(); + } + await tick; + + expect(events).toEqual([{ type: "exit", subshellId: S1, exitCode: 0, at: new Date(FIXED_NOW).toISOString() }]); + // The relaunched pane is live and its config is INTACT: the sweep ran + // inside the ownership guard, not after the forget blindly. + expect(await Bun.file(configPath).text()).toBe(SSH_CONTENT); + expect(ctx.watchers.has(S1)).toBe(true); + stopWatcher(ctx, S1); + }); +}); diff --git a/apps/node/agent/src/__tests__/ssh-dir-retention.test.ts b/apps/node/agent/src/__tests__/ssh-dir-retention.test.ts new file mode 100644 index 000000000..ada1cec3a --- /dev/null +++ b/apps/node/agent/src/__tests__/ssh-dir-retention.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { + existsSync, + lutimesSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ORPHAN_SSH_DIR_MIN_AGE_MS, sweepOrphanSshDirs } from "../ssh-dir-retention.js"; +import type { SubshellMeta, SubshellMetaStore } from "../subshell-meta.js"; + +const HOUR_MS = 3_600_000; + +// Dir names are uuid-shaped: `isSubshellId` gates the name shape, so the +// tests use ids that pass it and stray names that cannot (same discipline as +// the pane-log retention tests beside this file). +const ID_TRACKED = "aaaaaaaa-0000-4000-8000-000000000001"; +const ID_YOUNG = "bbbbbbbb-0000-4000-8000-000000000002"; +const ID_AGED = "cccccccc-0000-4000-8000-000000000003"; +const ID_NESTED = "dddddddd-0000-4000-8000-000000000004"; +const ID_SYMLINK = "eeeeeeee-0000-4000-8000-000000000005"; +const ID_OUTSIDE = "ffffffff-0000-4000-8000-000000000006"; + +function meta(id: string): SubshellMeta { + return { + subshellId: id, + cwd: "/tmp/whatever", + socket: "sock1", + harnessId: "ssh", + name: id, + startedAt: new Date(0).toISOString(), + }; +} + +let dataDir: string; +let sshDir: string; + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), "subshell-ssh-sweep-")); + sshDir = join(dataDir, "ssh"); + mkdirSync(sshDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(dataDir, { recursive: true, force: true }); +}); + +/** Create `/` (with a `config` file, the pane's real shape), backdated `ageMs`. */ +function makePaneDir(id: string, ageMs: number): string { + const dir = join(sshDir, id); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config"), "Host *\n"); + const t = (Date.now() - ageMs) / 1000; + utimesSync(dir, t, t); + return dir; +} + +/** One sweep against the temp dir with a fake meta store (the retention tests' style). */ +async function sweep(ids: string[], nowMs = Date.now()) { + return await sweepOrphanSshDirs( + dataDir, + { list: async () => ids.map(meta) } as unknown as Pick, + nowMs, + ); +} + +describe("sweepOrphanSshDirs", () => { + it("keeps the dir of an id PRESENT in meta, however aged (live/queued panes are never garbage)", async () => { + const dir = makePaneDir(ID_TRACKED, 30 * 24 * HOUR_MS); + expect(await sweep([ID_TRACKED])).toEqual({ removed: 0 }); + expect(existsSync(dir)).toBe(true); + expect(existsSync(join(dir, "config"))).toBe(true); + }); + + it("keeps an orphan younger than the hour floor (the execLaunch write-before-meta-record window)", async () => { + const dir = makePaneDir(ID_YOUNG, ORPHAN_SSH_DIR_MIN_AGE_MS / 60); // one minute old, untracked + expect(await sweep([])).toEqual({ removed: 0 }); + expect(existsSync(dir)).toBe(true); + }); + + it("removes an aged orphan (the delete-after-death leak this sweep exists for)", async () => { + const dir = makePaneDir(ID_AGED, HOUR_MS + 60_000); + expect(await sweep([])).toEqual({ removed: 1 }); + expect(existsSync(dir)).toBe(false); // DIR gone, not just the config file + }); + + it("removes a validly-named orphan dir NESTED JUNK AND ALL (the rm is recursive)", async () => { + const dir = join(sshDir, ID_NESTED); + mkdirSync(join(dir, "nested", "deeper"), { recursive: true }); + writeFileSync(join(dir, "nested", "deeper", "junk"), "x"); + const t = (Date.now() - HOUR_MS - 60_000) / 1000; + utimesSync(dir, t, t); + expect(await sweep([])).toEqual({ removed: 1 }); + expect(existsSync(dir)).toBe(false); + }); + + it("leaves non-id names untouched (stray shapes are nobody's to remove)", async () => { + // Names the name-gate cannot match: dots, and letters outside hex. + // (An "a/b" child name cannot exist; the nested case is a parent dir that + // is itself not id-shaped, kept with everything under it.) + const strays = ["..config", "cfg", `${ID_AGED}.log`, "ssh-host-config"]; + for (const name of strays) mkdirSync(join(sshDir, name), { recursive: true }); + mkdirSync(join(sshDir, "cfg", "b"), { recursive: true }); + const aged = makePaneDir(ID_AGED, HOUR_MS + 60_000); // the one entry that IS eligible + const t = (Date.now() - HOUR_MS - 60_000) / 1000; + for (const name of strays) utimesSync(join(sshDir, name), t, t); + expect(await sweep([])).toEqual({ removed: 1 }); + expect(existsSync(aged)).toBe(false); + for (const name of strays) expect(existsSync(join(sshDir, name))).toBe(true); + expect(existsSync(join(sshDir, "cfg", "b"))).toBe(true); + }); + + it("the name-gate is case-insensitive hex (an UPPER-CASE id shape is eligible, guard truth)", async () => { + // `isNodeSubshellId` accepts [0-9a-fA-F-]: uppercase is NOT a stray shape, + // and the sweep's rule is the guard's rule, pinned rather than assumed. + const upper = makePaneDir("AAAAAAAA-0000-4000-8000-000000000001", HOUR_MS + 60_000); + expect(await sweep([])).toEqual({ removed: 1 }); + expect(existsSync(upper)).toBe(false); + }); + + it("does not follow an id-named SYMLINK at a real dir elsewhere (lstat gate, never rm through it)", async () => { + const outside = join(dataDir, "outside"); + mkdirSync(outside); + writeFileSync(join(outside, "keep"), "not ssh garbage"); + symlinkSync(outside, join(sshDir, ID_SYMLINK)); + const t = (Date.now() - HOUR_MS - 60_000) / 1000; + lutimesSync(join(sshDir, ID_SYMLINK), t, t); // age the LINK (utimesSync would follow it to the target) + expect(await sweep([])).toEqual({ removed: 0 }); + expect(existsSync(outside)).toBe(true); // the target survives, contents and all + expect(existsSync(join(outside, "keep"))).toBe(true); + expect(existsSync(join(sshDir, ID_SYMLINK))).toBe(true); // and the link itself is left, not unlinked through + }); + + it("an absent ssh dir is a silent no-op (a node that never hosted an ssh pane)", async () => { + rmSync(sshDir, { recursive: true, force: true }); + expect(await sweep([])).toEqual({ removed: 0 }); + }); + + it("a tracked id does not shield another id's aged orphan (the gate is per-name)", async () => { + const tracked = makePaneDir(ID_TRACKED, HOUR_MS + 60_000); + const orphan = makePaneDir(ID_OUTSIDE, HOUR_MS + 60_000); + expect(await sweep([ID_TRACKED])).toEqual({ removed: 1 }); + expect(existsSync(tracked)).toBe(true); + expect(existsSync(orphan)).toBe(false); + }); + + it("the age floor is exclusive: YOUNGER keeps, exactly-one-hour-old goes", async () => { + const now = Date.now(); + // mtime pinned to EXACTLY `now - 1h`: the guard is "younger than an hour", + // so this boundary row is eligible (only strict youth is kept). + const dir = join(sshDir, ID_AGED); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config"), "Host *\n"); + const t = (now - ORPHAN_SSH_DIR_MIN_AGE_MS) / 1000; + utimesSync(dir, t, t); + expect(await sweep([], now)).toEqual({ removed: 1 }); + expect(existsSync(dir)).toBe(false); + }); +}); diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 447fde0ae..55342ee8c 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -28,6 +28,7 @@ import { execSetLogLevel } from "./set-log-level.js"; import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; +import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; import { execTransferWrite } from "./transfer-write.js"; import { execTreeManifest } from "./tree-manifest.js"; @@ -50,7 +51,9 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * commands `archive_create`, `file_read`, `transfer_write`, `archive_extract` * and `tree_manifest` (spec 2026-10-01 §4, protocol 15), and * `set_ssh_enabled` (spec 2026-10-07 §4.3, protocol 16 — the SSH gate's plane- - * to-machine write). Any + * to-machine write), and the two SSH read arms `ssh_discover_aliases` and + * `ssh_resolve_config` (spec 2026-10-07 §5, protocol 17 — both refuse on the + * local gate mirror before any lookup or spawn). Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. * @@ -127,6 +130,10 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return execSetMaintenance(ctx, cmd); case "set_ssh_enabled": return execSetSshEnabled(ctx, cmd); + case "ssh_discover_aliases": + return await execSshDiscoverAliases(ctx); + case "ssh_resolve_config": + return await execSshResolveConfig(ctx, cmd); case "set_server_url": return await execSetServerUrl(ctx, cmd); case "update": diff --git a/apps/node/agent/src/commands/launch.ts b/apps/node/agent/src/commands/launch.ts index 3f2a3c664..e299e5331 100644 --- a/apps/node/agent/src/commands/launch.ts +++ b/apps/node/agent/src/commands/launch.ts @@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { assembleHarnessCommand, + buildSshConfigPath, enforceMode, exitHookFor, findBinary, @@ -37,8 +38,9 @@ import { reportMaintenance, startExitWatcher } from "./report.js"; * Start one harness subshell on this node. * * Step order is contractual (interfaces, brief §Task 4): id gate → allowlist - * gate → argv + binary resolution → MCP file (policy-gated) → meta record → - * pane argv assembly → `newSubshell` (rollback on throw) → log attach + * gate → argv + binary resolution → MCP file (policy-gated) → ssh config + * (byte-derived path, policy-gated; spec 2026-10-07 decision 4) → meta record + * → pane argv assembly → `newSubshell` (rollback on throw) → log attach * (strict by default; `bestEffortLog` downgrades the whole attach region) → * optional resize (cosmetic) → exit watcher. A strict log-attach throw * PROPAGATES (the dispatcher answers `{ok:false}`) exactly like @@ -131,6 +133,34 @@ export async function execLaunch(ctx: CommandContext, cmd: Cmd<"launch">): Promi await enforceMode(cmd.mcp.path, 0o600); // Task-2 re-tightening: umask cannot leak bits here } + // (3b) SSH pane config (spec 2026-10-07 decision 4). The MCP block above is + // the posture this mirrors — file-placement is a NODE control, not plugin + // knowledge — with one sharper rule: the MCP path is the plane's to name, + // the ssh path is not. This machine derives it + // (`buildSshConfigPath(dataDir, id)`), and refuses unless the frame's + // `configPath` is BYTE-equal to that derivation. The plane may name nothing + // else: `buildSshConfigPath` composes from the two facts each side already + // holds (dataDir, id), so the file always lands where this machine's own + // exit sweep and the plane's delete-side `subshellArtifacts` will both look + // for it — and a plane that could point the write anywhere would turn a + // signed launch into an arbitrary-file write. + if (cmd.ssh) { + const derivedPath = buildSshConfigPath(ctx.config.dataDir, cmd.subshellId); + if (cmd.ssh.configPath !== derivedPath) { + // A bare message, no path interpolation: the byte-equality failure is + // the whole fact, and the paths here are this machine's own layout. + return { ok: false, error: "ssh config path refused: not the derived path" }; + } + if (!(await pathAllowed(derivedPath, await realpathRoots([ctx.config.dataDir])))) { + return { ok: false, error: "ssh config path refused" }; + } + const dir = dirname(derivedPath); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await enforceMode(dir, 0o700); + await writeFile(derivedPath, cmd.ssh.fileContent, { mode: 0o600 }); + await enforceMode(derivedPath, 0o600); // umask cannot leak bits past this + } + // (4) Record meta FIRST: path policy + socket lookup must see the cwd even // while later steps are in flight (and the rollback below has something to // roll back). `record` creates /subshells with 0700. diff --git a/apps/node/agent/src/commands/report.ts b/apps/node/agent/src/commands/report.ts index 0b8c37d14..af53109d5 100644 --- a/apps/node/agent/src/commands/report.ts +++ b/apps/node/agent/src/commands/report.ts @@ -1,3 +1,6 @@ +import { rm } from "node:fs/promises"; +import { dirname } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; import type { NodeEvent, NodeMaintenanceWire, NodeSshEnabledWire } from "@internal/subshell-protocol"; import { log } from "../log.js"; import { readMaintenance, reportableMaintenance, writeMaintenance } from "../maintenance.js"; @@ -233,7 +236,9 @@ export function stopWatcher(ctx: CommandContext, subshellId: string): void { * it — or stops ANSWERING for {@link NODE_EXIT_UNREACHABLE_TICKS} consecutive * ticks (design §1: one blip never reports a live pane dead) — the shared tick * emits exactly one `exit` event and cleans up (unregister, - * forget the meta record, drop the subshell's tail pumps). Re-registering for + * forget the meta record, drop the subshell's tail pumps, and — for a pane + * whose meta named the ssh harness — best-effort remove its rendered ssh + * config dir, spec 2026-10-07 decision 4). Re-registering for * the same id REPLACES the old entry with a fresh registration token — a * relaunch on a restarted row rotates its socket, and the tick that * snapshotted the old pane sees the token mismatch and never reports the live @@ -374,6 +379,13 @@ async function reportDeath(ctx: CommandContext, socket: string, subshellId: stri // to report. (On the unreachable path the socket is not answering, so // this reads null — the same shape a dead server always produced.) const exitCode = (await ctx.tmux.paneExitCode(socket, subshellId)) ?? null; + // Read the meta's KIND here, before the forget below erases the record: + // the ssh sweep at the end of this sequence fires only for a pane whose + // record named the ssh harness (spec 2026-10-07 decision 4). Absence is + // not a kind — an ssh pane that lost its meta file keeps its config (a + // leftover file is swept by the delete path; wiping an unknown pane's + // directory is not). + const paneMeta = await ctx.meta.get(subshellId); ctx.watchers.delete(subshellId); // stop-first: at most one event per registration // LOAD-BEARING ORDER (spec 2026-09-14 §4.3): the flag goes out BEFORE the // death it explains. The plane serialises frames per socket, so a @@ -410,6 +422,22 @@ async function reportDeath(ctx: CommandContext, socket: string, subshellId: stri // Already gone is the outcome we wanted; anything else costs an idle // process, never correctness. } + // Sweep the ssh config (spec 2026-10-07 decision 4). The pane was its only + // reader; leaving it means a signed launch leaves a file forever. Gated on + // the meta kind read BEFORE the forget (only the ssh harness ever owned + // one), and on the SAME relaunch re-check that guards the tails and the + // reap: a relaunched pane owns the path now. The whole per-pane ssh DIR + // goes, not just the file — the launch may have added host keys beside the + // config. Best-effort by contract: a throwing rm costs a log line and + // NEVER refuses the death report or unwinds the sequence. + if (paneMeta?.harnessId === "ssh") { + const sshDir = dirname(buildSshConfigPath(ctx.config.dataDir, subshellId)); + try { + await rm(sshDir, { recursive: true, force: true }); + } catch (err) { + log(`ssh config sweep failed for ${subshellId}: ${err instanceof Error ? err.message : String(err)}`); + } + } } } 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..50091fae8 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-aliases.ts @@ -0,0 +1,73 @@ +import { defaultSshConfigPath, discoverSshAliases, resolveSshAliasConfig } from "@internal/pane-runtime"; +import { type JsonValue, parseNodeSshAliasList, parseNodeSshResolveOutcome } from "@internal/subshell-protocol"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import type { Cmd, CommandContext, CommandResult } from "./context.js"; +import { connectingHomeDir, resolveSshBin, SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +/** + * The two human-config SSH executors that touch no connection state (spec + * 2026-10-07 §5/§7): + * + * - `ssh_discover_aliases` — bounded config parse answering NAMES only + * (discovery answers names, never 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 on the outcome's `connectingAccount`: the review step + * needs the connecting OS account named alongside the resolved + * destination. 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. + * + * EVERY arm consults this machine's ssh-enabled mirror FIRST (§4.3's gate + * doctrine): a machine whose local copy is not ON answers + * {@link SSH_GATE_REFUSAL} before any binary lookup, any config read, or any + * spawn — regardless of what the plane believes about it. The mirror is + * fail-closed, so absent and unreadable refuse exactly like off. + * + * The disclosure the resolve step owes the human (the engine's note in + * pane-runtime `ssh-resolve.ts`: a `Match exec` the bounded config walk + * cannot see still executes locally under `ssh -G`) belongs to the + * human-facing surface, never the node: in this tier it rides the plane's + * resolve and launch route descriptions (`/api/ssh/resolve`, + * `/api/ssh/launch`), and the launcher UI copy ships with Plan 3. + */ + +/** Does this machine permit SSH right now? (the one question every arm asks first) */ +function gateOpen(ctx: CommandContext): boolean { + return sshAllowed(readSshEnabled(ctx.config.dataDir)); +} + +/** Execute `ssh_discover_aliases`. */ +export async function execSshDiscoverAliases(ctx: CommandContext): Promise { + if (!gateOpen(ctx)) return { ok: false, error: SSH_GATE_REFUSAL }; + 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( + ctx: CommandContext, + cmd: Cmd<"ssh_resolve_config">, +): Promise { + if (!gateOpen(ctx)) return { ok: false, error: SSH_GATE_REFUSAL }; + 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-shared.ts b/apps/node/agent/src/commands/ssh-shared.ts new file mode 100644 index 000000000..b6d895798 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-shared.ts @@ -0,0 +1,45 @@ +import { homedir } from "node:os"; +import { findBinary } from "@internal/pane-runtime"; + +/** + * Shared plumbing for the SSH command arms (spec 2026-10-07 §5). + * + * 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 (inversion spec §5: the argv + * is built on the control plane, the binary is resolved here; the plane + * approves the destination and composes the command, this machine answers + * which ssh(1) 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) + * — PATH first through the ladder, these as the fallbacks it names. + * + * The gate comes BEFORE any of this: every SSH arm consults the local + * ssh-enabled mirror first (spec §4.3), so nothing here runs on a machine + * that has not been switched on. `SSH_GATE_REFUSAL` (ssh-aliases.ts) states + * the one refusal every arm shares. + */ + +/** 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"]; + +/** + * The gate's own words, spelled once: a machine whose local mirror is not ON + * refuses EVERY SSH command with exactly this, before any binary lookup or + * spawn. The plane's row is not consulted here — the mirror is the fact this + * process acts on, and it fails closed (absent and unreadable both refuse). + */ +export const SSH_GATE_REFUSAL = "ssh disabled on this node"; + +/** 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/daemon.ts b/apps/node/agent/src/daemon.ts index bfd1cfc11..a2e9fc255 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -40,6 +40,7 @@ import { createRetentionPass, PANE_LOG_RETENTION_PASS_MS, resolveLogRetention } import { noteSweepScheduled } from "./retention-settings.js"; import { collectRuntime } from "./runtime.js"; import { selfInvokePrefix } from "./self-invoke.js"; +import { sweepOrphanSshDirs } from "./ssh-dir-retention.js"; import { SubshellMetaStore } from "./subshell-meta.js"; import { completeUpdate, revertAfterRefusal } from "./update.js"; import { NODE_VERSION } from "./version.js"; @@ -584,6 +585,26 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom }, deps.retentionMs ?? PANE_LOG_RETENTION_PASS_MS); retentionTimer.unref?.(); } + // Orphaned per-pane ssh config dirs (spec 2026-10-07 decision 4's + // machine-side half): `remove_paths` unlinks FILES, so a delete-after- + // death removed `//config` and left the DIR behind forever. The + // agent drops those dirs here — boot pass plus the same hourly beat as the + // retention timer above, unref'd and fire-and-forget like it (and like the + // transfer sweep), because a wedged fs probe must not cost the node its + // first connection. Deliberately NOT inside the `forever` gate above it: + // the pane-log keep-forever pair is an operator choice about TRANSCRIPTS, + // and it says nothing about a dead pane's empty config dir — that sweep's + // only grace is the one-hour launch-window floor inside the pass itself. + const sweepSshDirs = (): Promise<{ removed: number }> => sweepOrphanSshDirs(config.dataDir, ctx.meta, Date.now()); + void sweepSshDirs().catch((err: unknown) => { + log(`ssh dir sweep failed: ${err instanceof Error ? err.message : String(err)}`); + }); + const sshDirSweepTimer = setInterval(() => { + void sweepSshDirs().catch((err: unknown) => { + log(`ssh dir sweep failed: ${err instanceof Error ? err.message : String(err)}`); + }); + }, deps.retentionMs ?? PANE_LOG_RETENTION_PASS_MS); + sshDirSweepTimer.unref?.(); // SERIAL command executor (spec §3.4): verified commands queue here so pane // effects land in arrival order across the whole daemon life — surviving // reconnects. A command verified pre-`seqTracker.reset` executing post-reset diff --git a/apps/node/agent/src/ssh-dir-retention.ts b/apps/node/agent/src/ssh-dir-retention.ts new file mode 100644 index 000000000..7ab02eac1 --- /dev/null +++ b/apps/node/agent/src/ssh-dir-retention.ts @@ -0,0 +1,135 @@ +import { lstat, readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { log } from "./log.js"; +import { pathAllowed } from "./path-policy.js"; +import { isSubshellId, type SubshellMetaStore } from "./subshell-meta.js"; + +/** + * Node-side sweep of orphaned per-pane ssh config dirs — the agent's own + * pass over `/ssh/` (spec 2026-10-07 ssh-anywhere, decision 4's + * machine-side half). + * + * **Why this exists on the node at all.** An ssh pane's generated config is + * a DIRECTORY (`/ssh//config`), but the plane-commanded + * `remove_paths` executor unlinks FILES only — so a delete-after-death on an + * AGENT node removed the config and left the empty per-pane dir forever + * (the local twin of that leak was closed plane-side in the same wave; this + * is the machine's copy of the rule, same doctrine as + * `pane-log-retention.ts`). Nothing else ever names these paths: the id is + * retired, the row is gone, and the dir outlives every fact that explains it. + * + * The rules, each inherited from the pane-log sweep's doctrines: + * + * - **Only immediate children of `/ssh` whose name passes + * `isSubshellId`.** Nothing else in the tree is ours; a name that is not + * a pane id is nobody's to remove. The gate is hex digits and hyphens + * only (`/^[0-9a-fA-F-]{1,64}$/`, uppercase hex included), so a name + * carries no separator and no dot: the join cannot escape the dir, and + * `pathAllowed` + * re-checks anyway — the `remove_paths` rule, which is what refuses a + * symlinked `ssh` parent pointing outside the data dir. + * - **Never follow a symlink** (`lstat` first: a leaf whose name looks like + * an id is skipped with a log line, and a `rm -rf` never runs through it). + * - **A tracked pane's dir survives** whatever its mtime says: `meta.list()` + * is the record of live AND queued panes (the exit watcher forgets only on + * death), so a running pane's config is untouchable even mid-restart. + * - **Age floor, not a retention window:** a dir younger than one hour is + * kept. This guards the `execLaunch` write-before-meta-record window — a + * pane that launched seconds ago has a dir and no meta yet, and the + * tracked-pane rule alone could not save it. Unlike the pane-log sweep + * there is no operator window here and no keep-forever pair: a dead pane's + * empty config dir is pure garbage, and one hour is the grace, not a + * policy. That is also why the daemon schedules this OUTSIDE the log + * sweep's `forever` gate. + * - **Total by construction:** every fs failure costs one log line and the + * pass continues; an absent `ssh` dir is a silent no-op (a node that has + * never hosted an ssh pane). + */ + +const HOUR_MS = 3_600_000; + +/** Younger than this, the dir is kept: the launch write→meta-record grace. */ +export const ORPHAN_SSH_DIR_MIN_AGE_MS = HOUR_MS; + +/** The dir the sweep walks. File-private: every consumer goes through {@link sweepOrphanSshDirs}, which names it itself. */ +function sshDirsDir(dataDir: string): string { + return join(dataDir, "ssh"); +} + +/** + * Remove per-pane ssh config dirs no pane owns anymore. + * + * @param dataDir - the node's data dir; the sweep touches only `/ssh/`. + * @param meta - the launch records; `list()` IS "live or queued here" — a + * recorded id's dir is never garbage. (Narrowed to `list` exactly as + * `pane-log-retention.ts` narrows it, so a fake-meta test needs no store.) + * @param nowMs - epoch-ms clock; the age floor is judged against THIS, so a + * test pins the boundary without sleeping. + * @returns the number of dirs removed. Never rejects on fs failures (only a + * throwing `meta.list` can reject, same posture as the pane-log sweep, and + * the daemon's `.catch` covers that). + */ +export async function sweepOrphanSshDirs( + dataDir: string, + meta: Pick, + nowMs: number, +): Promise<{ removed: number }> { + const dir = sshDirsDir(dataDir); + let names: string[]; + try { + names = await readdir(dir); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTDIR") { + log(`ssh dir sweep: could not read ${dir}: ${err instanceof Error ? err.message : String(err)}`); + } + return { removed: 0 }; + } + const candidates = names.filter((n) => isSubshellId(n)); + if (candidates.length === 0) return { removed: 0 }; // nothing id-shaped — skip even the meta read + + // The tracked set FIRST: live AND queued panes are never garbage, whatever + // their dir says. A record with a dead socket (node was offline for the + // death) keeps the dir until the death report lands — the NEXT pass then + // ages it out; unknown-tracked is kept, not guessed. + const tracked = new Set((await meta.list()).map((m) => m.subshellId)); + + let removed = 0; + for (const name of candidates) { + if (tracked.has(name)) continue; + const path = join(dir, name); + let st; + try { + st = await lstat(path); + } catch (err) { + // A dir that vanished under us is the sweep's job done by someone else. + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + log(`ssh dir sweep: could not stat ${name}: ${err instanceof Error ? err.message : String(err)}`); + } + continue; + } + if (st.isSymbolicLink()) { + // An id-named symlink is not ours to follow: log and leave it (a + // hostile or accidental link out of the tree must not be `rm -rf`ed + // through — the pane-log sweep's symlink-leaf refusal, made explicit). + log(`ssh dir sweep: ${name} is a symlink; not following it`); + continue; + } + if (st.mtimeMs > nowMs - ORPHAN_SSH_DIR_MIN_AGE_MS) continue; // the launch write→meta grace + if (!(await pathAllowed(path, [dataDir]))) { + // The name gate makes this unreachable for a plain child; it is the + // belt for a symlinked `ssh` PARENT (realpath lands outside the data + // dir), which a recursive delete must never follow. + log(`ssh dir sweep: ${path} failed the path policy; left in place`); + continue; + } + try { + await rm(path, { recursive: true, force: true }); + removed += 1; + } catch (err) { + log(`ssh dir sweep: could not remove ${name}: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (removed > 0) log(`ssh dir sweep: removed ${removed} orphaned config dir(s)`); + return { removed }; +} diff --git a/apps/server/api/src/api/__tests__/backups-route.test.ts b/apps/server/api/src/api/__tests__/backups-route.test.ts index 00982ee1c..386732e6f 100644 --- a/apps/server/api/src/api/__tests__/backups-route.test.ts +++ b/apps/server/api/src/api/__tests__/backups-route.test.ts @@ -287,10 +287,18 @@ describe("admin archive endpoints", () => { (await app.fetch(authedRequest(`/api/admin/backups/jobs/${id}`, admin.token, { method: "DELETE" }))).status, ).toBe(200); expect((await app.fetch(authedRequest(`/api/admin/backups/download/${id}`, admin.token))).status).toBe(409); + // The cancel retires the job record on the async cleanup path, NOT inside + // the DELETE response, so the 404 arrives a beat later. CI's throttled + // 0.4-CPU slice starved the old fixed 100x10ms (~1s) budget past ~1.8s and + // this test flaked twice across the ssh stack (#336, #337) while passing on + // rerun - a runner-speed-dependent budget, which is never deterministic. A + // wall deadline is: it exits the instant 404 lands and only spends the extra + // room when cleanup is genuinely slow. let status = 200; - for (let attempt = 0; attempt < 100 && status !== 404; attempt++) { + const goneBy = Date.now() + 8_000; + while (status !== 404 && Date.now() < goneBy) { status = (await app.fetch(authedRequest(`/api/admin/backups/jobs/${id}`, admin.token))).status; - if (status !== 404) await Bun.sleep(10); + if (status !== 404) await Bun.sleep(20); } expect(status).toBe(404); }); diff --git a/apps/server/api/src/api/__tests__/installed-plugin-launch.test.ts b/apps/server/api/src/api/__tests__/installed-plugin-launch.test.ts index 4648ce50a..e4d6d1734 100644 --- a/apps/server/api/src/api/__tests__/installed-plugin-launch.test.ts +++ b/apps/server/api/src/api/__tests__/installed-plugin-launch.test.ts @@ -121,7 +121,7 @@ afterAll(async () => { }); describe("an installed plugin resolves on the plane's side of a launch", () => { - it("detectSpecs ships acme's lookup rule to nodes, beside the six built-ins", () => { + it("detectSpecs ships acme's lookup rule to nodes, beside the seven built-ins", () => { const specs = detectSpecs(); expect(specs.find((s) => s.id === "acme")).toEqual({ id: "acme", @@ -130,9 +130,10 @@ describe("an installed plugin resolves on the plane's side of a launch", () => { knownPaths: [], }); // The built-ins did not move (the shadow/merge rule costs nothing), and - // the sixth is here too — terminal's SHELL rule reaching every node is - // what makes a clean machine launchable there. - for (const id of ["claude-code", "codex", "hermes", "opencode", "pi", "terminal"]) { + // terminal's and ssh's rules are here too: terminal's SHELL rule is what + // makes a clean machine launchable there, and ssh's is the ladder the + // ssh launcher's resolved binary comes from. + for (const id of ["claude-code", "codex", "hermes", "opencode", "pi", "ssh", "terminal"]) { expect(specs.some((s) => s.id === id)).toBe(true); } }); diff --git a/apps/server/api/src/api/channels/__tests__/channels-route.test.ts b/apps/server/api/src/api/channels/__tests__/channels-route.test.ts index 7734f9f83..5a109b055 100644 --- a/apps/server/api/src/api/channels/__tests__/channels-route.test.ts +++ b/apps/server/api/src/api/channels/__tests__/channels-route.test.ts @@ -442,4 +442,108 @@ describe("channels route", () => { setNudgeTransportForTests(null); } }); + + it("nudge NEVER types into an ssh pane (raw remote shell, owner-only input, spec §5.4)", async () => { + nudges.length = 0; + class FakeTmux extends TmuxRunner { + override async sendInput(socket: string, subshellName: string, input: string): Promise { + nudges.push({ socket, name: subshellName, text: input }); + } + } + setNudgeTransportForTests(new FakeTmux()); + try { + await registerIdentity(aliceToken); + await call(channelRoutes, "/api/channels", aliceToken, postJson({ name: "sshwake" })); + + // A RUNNING ssh-pane row: alive, with a tmux socket, snapshot column + // set — every fact the plain nudge guard reads passes; only `ssh` + // distinguishes it. (A tmux-less or dead row would be skipped for the + // ordinary reasons and prove nothing about the §5.4 carve-out.) + async function sshPane(id: string): Promise { + createdSubshells.push(id); + await new SubshellsRepository(db).create({ + id, + userId: bob, + presetId: null, + harnessId: "ssh", + name: "s", + workingDir: "/tmp", + tmuxSocket: `sock-${id}`, + alive: 1, + ssh: JSON.stringify({ destination: "host.example", user: "theo", port: 22 }), + }); + const key = await issueSubshellToken(id, bob); + const { publicJwk } = await generateKeypair(); + await identityRoutes.fetch( + new Request("http://localhost:3080/api/identities", { + method: "POST", + headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, + body: JSON.stringify({ publicKey: publicJwk }), + }), + ); + await channelRoutes.fetch( + new Request("http://localhost:3080/api/channels/sshwake/members", { + method: "POST", + headers: { authorization: `Bearer ${key}` }, + }), + ); + } + + const sshId = crypto.randomUUID(); + const plainId = crypto.randomUUID(); + await sshPane(sshId); + // A plain running recipient pane posted to in the SAME breath: it must + // still be nudged (the ssh skip is targeted, not a blanket stop). + createdSubshells.push(plainId); + await new SubshellsRepository(db).create({ + id: plainId, + userId: bob, + presetId: null, + harnessId: "claude-code", + name: "p", + workingDir: "/tmp", + tmuxSocket: `sock-${plainId}`, + alive: 1, + }); + const plainKey = await issueSubshellToken(plainId, bob); + const { publicJwk: plainJwk } = await generateKeypair(); + await identityRoutes.fetch( + new Request("http://localhost:3080/api/identities", { + method: "POST", + headers: { authorization: `Bearer ${plainKey}`, "content-type": "application/json" }, + body: JSON.stringify({ publicKey: plainJwk }), + }), + ); + await channelRoutes.fetch( + new Request("http://localhost:3080/api/channels/sshwake/members", { + method: "POST", + headers: { authorization: `Bearer ${plainKey}` }, + }), + ); + + await call( + channelRoutes, + "/api/channels/sshwake/posts", + aliceToken, + postJson({ + envelope: envelope("go", [`sess:${sshId}`, `sess:${plainId}`]), + recipientIds: [`sess:${sshId}`, `sess:${plainId}`], + nudge: true, + }), + ); + + // The ssh pane receives NOTHING (typing the cue into a raw shell under + // the owner's ssh identity is input, and input there is owner-only); + // the plain pane receives its usual inert line. + expect(nudges.map((n) => n.name)).toEqual([plainId]); + expect(nudges[0].text).toContain("#sshwake"); + + for (const n of [sshId, plainId]) { + const row = await new SubshellsRepository(db).findById(n); + if (row?.apiKeyId) authDatabase().run("DELETE FROM apikey WHERE id = ?", [row.apiKeyId]); + } + } finally { + setNudgeTransportForTests(null); + } + }); }); diff --git a/apps/server/api/src/api/routes.ts b/apps/server/api/src/api/routes.ts index 93b011a5d..150769651 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 { sshRoutes } from "@/api/ssh/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,12 @@ 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 launcher (every `/api/ssh` act is gated on a NODE row, +// so it belongs with the machine basket it asks about). Its own group (not an +// eighth `.use()` on the pane basket) keeps the composed type shallow enough +// to compile — the depth budget the grouping header exists to protect. +const nodeRoutes = new Elysia().use(nodesRoutes).use(transferRoutes).use(sshRoutes); const commsRoutes = new Elysia().use(notificationsRoutes).use(devicesRoutes).use(channelRoutes); diff --git a/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts new file mode 100644 index 000000000..b2c7056b3 --- /dev/null +++ b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts @@ -0,0 +1,691 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { + buildSshConfigPath, + renderSshConfigContents, + sshDestinationToken, + sshOptionTokens, +} from "@internal/pane-runtime"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { sshRoutes } from "@/api/ssh/index.js"; +import { db } from "@/db/index.js"; +import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.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 { LOCAL_NODE_ID, type NodeTable } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { getHeld, holdConnection, releaseHeld, resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { attachScriptedNode, ok, SCRIPTED_DATA_DIR, statDirEcho } from "@/test-helpers/scripted-node.js"; + +/** + * The `/api/ssh` surface (spec 2026-10-07 §4.3/§5/§7) — the gate doctrine, the + * refusal shapes, and the compose the launch answers with. + * + * The properties this matrix exists to pin, each by its own case: + * - **Gate order**: the plane's row + `nodeCanSsh` answer lands BEFORE any + * `sendCommand`; a held agent is refused before the resolve round trip; + * discovery, resolve and launch all sit behind the same gate. + * - **Refusal placement**: `POST /resolve` answers a refusal outcome IN THE + * DATA (200, tier-1 grammar); `POST /launch` and `PUT /saved-hosts` refuse a + * refusal-shaped outcome with 422 carrying `{outcome}`. + * - **Compose**: the launch frame carries the config at the NODE's derived + * path, the rendered bytes, and the option tail `-F … -- host`; the row + * carries the approved snapshot; `SSH_AUTH_SOCK` rides only when the + * snapshot names a socket. + * - **Saved hosts**: lists never carry another owner's rows; a foreign delete + * is the same 404 as an absent one; preferences store the id and the read + * tolerates the node vanishing. + * - **Audit**: `ssh.launch` lands on SUCCESS only, with `{nodeId, destination, + * subshellId}` and nothing else; saved-host CRUD audits nothing (prompts precedent). + * + * The agent answers come from the scripted node (the real `sendCommand` chain, + * the real frame parser) — the `ssh`/`resolve` frames this surface emits are + * what the shipped agent would switch on. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); + +const pw = "ssh-api-1"; +const ownerEmail = `ssha-owner-${crypto.randomUUID()}@subshell.local`; +const editorEmail = `ssha-editor-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `ssha-other-${crypto.randomUUID()}@subshell.local`; +const adminEmail = `ssha-admin-${crypto.randomUUID()}@subshell.local`; +let ownerId: string; +let editorId: string; +let ownerCookie: string; +let editorCookie: string; +let otherCookie: string; +let adminCookie: string; + +/** The agent node this file's launch cases run on (SSH row switched ON). */ +const NODE_ON = `n-ssh-on-${crypto.randomUUID()}`; +/** Same machine with the SSH row OFF — the gate's negative fixture. */ +const NODE_OFF = `n-ssh-off-${crypto.randomUUID()}`; + +const nodes = new NodesRepository(db); + +/** An approved snapshot in its wire spelling (the shape `parseSshConnectionSnapshot` rebuilds). */ +function snapshot(over: Record = {}): Record { + return { + alias: "work", + host: "example.test", + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...over, + }; +} +const CANON = "example.test:22"; +const ACCEPTED = snapshot(); +const REFUSED = { accepted: false, code: "unsupported_setting", settings: ["ProxyCommand"] }; + +function sshFetch(path: string, init: { method?: string; body?: unknown; cookie?: string; bearer?: string } = {}) { + const headers: Record = { "content-type": "application/json" }; + if (init.cookie) headers.cookie = `better-auth.session_token=${init.cookie}`; + if (init.bearer) headers.authorization = `Bearer ${init.bearer}`; + return app.fetch( + new Request(`http://localhost:3099${path}`, { + method: init.method ?? "GET", + headers, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }), + ); +} + +const aliases = (node: string, cookie: string) => sshFetch(`/api/ssh/aliases?node=${node}`, { cookie }); +const resolve = (node: string, alias: string, cookie: string) => + sshFetch("/api/ssh/resolve", { method: "POST", body: { node, alias }, cookie }); +const launch = (body: Record, cookie: string) => + sshFetch("/api/ssh/launch", { method: "POST", body, cookie }); + +async function auditRows(): Promise< + { id: string; targetId: string | null; metadata: Record | null }[] +> { + const rows = await db + .selectFrom("auditEvents") + .select(["id", "targetId", "metadataJson"]) + .where("action", "=", "ssh.launch") + .execute(); + return rows.map((r) => ({ + id: r.id, + targetId: r.targetId, + metadata: r.metadataJson ? JSON.parse(r.metadataJson) : null, + })); +} + +async function mkNode(id: string, sshEnabled: boolean, owner = ownerId): Promise { + await nodes.create({ id, ownerUserId: owner, name: `sshapi-${id.slice(0, 20)}`, kind: "agent", status: "offline" }); + if (sshEnabled) await nodes.setSshEnabled(id, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + // A fresh detection that found the ssh binary — the launch's per-node + // usability gate reads it exactly as the picker does (spec §6.2). + await db + .updateTable("nodes") + .set({ + inventoryJson: JSON.stringify([{ harnessId: "ssh", installed: true, binaryPath: "/usr/bin/ssh", version: "1" }]), + inventoryAt: new Date().toISOString(), + } as never) + .where("id", "=", id) + .execute(); + return (await nodes.findById(id)) as NodeTable; +} + +/** The scripted node every gate-order case observes from the outside. */ +function silentScript(nodeId: string) { + return attachScriptedNode(nodeId, { + ssh_discover_aliases: () => ({ aliases: ["work"], includeCycle: false, truncated: false }), + ssh_resolve_config: (cmd) => + cmd.type === "ssh_resolve_config" && cmd.alias === "work" ? { accepted: true, snapshot: ACCEPTED } : REFUSED, + launch: ok, + stat_dir: statDirEcho, + }); +} + +const createdSubshellIds: string[] = []; +const createdNodeIds: string[] = []; +const emails: string[] = []; + +beforeAll(async () => { + await setupAuthTables(); + const mk = async (email: string, role: "user" | "admin") => + await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role, + }); + ownerId = await mk(ownerEmail, "user"); + editorId = await mk(editorEmail, "user"); + await mk(otherEmail, "user"); + await mk(adminEmail, "admin"); + emails.push(ownerEmail, editorEmail, otherEmail, adminEmail); + ownerCookie = await signIn(ownerEmail, pw); + editorCookie = await signIn(editorEmail, pw); + otherCookie = await signIn(otherEmail, pw); + adminCookie = await signIn(adminEmail, pw); + await ensureLocalNode(db); + await mkNode(NODE_ON, true); + await mkNode(NODE_OFF, false); + await new NodeSharesRepository(db).replaceForNode( + NODE_ON, + [{ granteeUserId: editorId, permission: "edit" }], + ownerId, + ); +}); + +afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await new SubshellsRepository(db).delete(id).catch(() => {}); + await db.deleteFrom("sshSavedHosts").execute(); + for (const id of createdNodeIds.concat(NODE_ON, NODE_OFF)) await nodes.deleteById(id).catch(() => {}); + await db + .updateTable("nodes") + .set({ sshEnabled: 0, sshEnabledAt: null } as never) + .where("id", "=", LOCAL_NODE_ID) + .execute(); + await db.deleteFrom("settings").where("key", "like", "ssh.defaultNode:%").execute(); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +describe("/api/ssh gate order (row + nodeCanSsh BEFORE any sendCommand)", () => { + it("a node whose row is OFF never receives a command: aliases, resolve and launch all 403 ssh_gate_off", async () => { + const scripted = silentScript(NODE_OFF); + try { + for (const res of [ + await aliases(NODE_OFF, ownerCookie), + await resolve(NODE_OFF, "work", ownerCookie), + await launch({ node: NODE_OFF, destination: "work" }, ownerCookie), + ]) { + expect(res.status).toBe(403); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GATE_OFF); + } + // The load-bearing assertion: the gate answered before the machine was + // asked. A sendCommand anywhere in the path would have put a frame here. + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("a held agent is refused with the update remedy before any resolve; even ahead of destination validation", async () => { + const scripted = silentScript(NODE_ON); + const held = holdConnection(NODE_ON, scripted.conn, { + reason: "protocol-mismatch", + agentVersion: "0.0.1", + protocolVersion: 12, + os: "linux", + arch: "x64", + onIdle: () => {}, + }); + try { + const res = await aliases(NODE_ON, ownerCookie); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe(BackendErrorCodes.NODE_PROTOCOL_HELD); + expect(body.message.toLowerCase()).toContain("update"); + // Held BEFORE the destination check: an unsafe token still answers 409. + expect((await launch({ node: NODE_ON, destination: "-oProxyCommand=x" }, ownerCookie)).status).toBe(409); + expect(scripted.cmdTypes()).toEqual([]); + } finally { + if (getHeld(NODE_ON)) releaseHeld(NODE_ON, held.conn); + scripted.detach(); + } + }); + + it("the machine's owner acts; an edit grantee is 403 (visible, not theirs), a stranger is 404 (never a leak)", async () => { + const scripted = silentScript(NODE_ON); + try { + // Row ON, but SSH USE is owner-only: the grantee sees the node (it is on + // their screen) and is refused, not hidden. + expect((await aliases(NODE_ON, editorCookie)).status).toBe(403); + expect((await resolve(NODE_ON, "work", editorCookie)).status).toBe(403); + // The admin boost is instance-wide EDIT, never ownership — SSH use stays + // with the machine's owner (spec §4.3: broker an OS account's keys). + expect((await aliases(NODE_ON, adminCookie)).status).toBe(403); + expect((await aliases(NODE_ON, otherCookie)).status).toBe(404); + expect((await launch({ node: NODE_ON, destination: "work" }, otherCookie)).status).toBe(404); + // An unknown id answers the same 404 a foreign one did (no existence oracle). + expect((await aliases(`n-never-${crypto.randomUUID()}`, ownerCookie)).status).toBe(404); + } finally { + scripted.detach(); + } + }); + + it("bearer credentials are refused at the door: this surface is browser sessions only", async () => { + // A real subshell token (its own row, its owner's key) — the actor kind is + // refused regardless of the owner it resolves to. + const subshellId = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: subshellId, + userId: ownerId, + harnessId: "terminal", + name: "bearer-probe", + workingDir: tmpdir(), + tmuxSocket: null, + nodeId: LOCAL_NODE_ID, + }); + createdSubshellIds.push(subshellId); + const key = await issueSubshellToken(subshellId, ownerId); + // An empty cookie is just a failed authentication (401), before any actor kind exists. + expect((await sshFetch(`/api/ssh/aliases?node=${NODE_ON}`, { cookie: "" })).status).toBe(401); + const res = await sshFetch(`/api/ssh/aliases?node=${NODE_ON}`, { bearer: key }); + expect(res.status).toBe(403); + }); +}); + +describe("/api/ssh/resolve (refusal rides IN THE DATA)", () => { + it("an accepted alias returns the full outcome, and the machine got exactly one resolve frame", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await resolve(NODE_ON, "work", ownerCookie); + expect(res.status).toBe(200); + const body = (await res.json()) as { accepted: boolean; snapshot?: { host: string } }; + expect(body.accepted).toBe(true); + expect(body.snapshot?.host).toBe("example.test"); + expect(scripted.cmdsOf("ssh_resolve_config").map((c) => c.alias)).toEqual(["work"]); + } finally { + scripted.detach(); + } + }); + + it("a refused resolution is a 200 carrying the refusal (settings named), never an error status", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await resolve(NODE_ON, "proxy-box", ownerCookie); + expect(res.status).toBe(200); + const body = (await res.json()) as { accepted: boolean; code: string; settings: string[] }; + expect(body.accepted).toBe(false); + expect(body.code).toBe("unsupported_setting"); + expect(body.settings).toEqual(["ProxyCommand"]); + } finally { + scripted.detach(); + } + }); + + it("an unsafe destination is 400 alias_unsafe and never reaches the machine", async () => { + const scripted = silentScript(NODE_ON); + try { + for (const alias of ["-x", "-oProxyCommand=evil", "has space", "escape"]) { + const res = await resolve(NODE_ON, alias, ownerCookie); + expect(res.status).toBe(400); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.ALIAS_UNSAFE); + } + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("an offline agent answers 409 NODE_OFFLINE, a machine-level refusal answers 502 SSH_NODE_REFUSED", async () => { + // No socket at all: the row says ON, the machine is not there. + const resOffline = await resolve(NODE_ON, "work", ownerCookie); + expect(resOffline.status).toBe(409); + expect(((await resOffline.json()) as { code: string }).code).toBe(BackendErrorCodes.NODE_OFFLINE); + // Attached but answering `ok:false` (no ssh binary / gate mirror says no): + // the machine's own refusal maps to the 502 family, verbatim text NOT echoed. + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => new Error("ssh binary missing: ssh"), + }); + try { + const res = await resolve(NODE_ON, "work", ownerCookie); + expect(res.status).toBe(502); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe(BackendErrorCodes.SSH_NODE_REFUSED); + expect(body.message).not.toContain("ssh binary missing"); + } finally { + scripted.detach(); + } + }); +}); + +describe("POST /api/ssh/launch", () => { + it("refuses a refusal-shaped outcome with 422 carrying {outcome}, and launches nothing", async () => { + const scripted = silentScript(NODE_ON); + const rowsBefore = (await db.selectFrom("subshells").select("id").execute()).length; + try { + const res = await launch({ node: NODE_ON, destination: "proxy-box" }, ownerCookie); + expect(res.status).toBe(422); + const body = (await res.json()) as { outcome: { accepted: boolean; code: string; settings: string[] } }; + expect(body.outcome.accepted).toBe(false); + expect(body.outcome.code).toBe("unsupported_setting"); + expect(body.outcome.settings).toEqual(["ProxyCommand"]); + // Nothing was written: no row, no audit, no recency touch. + expect((await db.selectFrom("subshells").select("id").execute()).length).toBe(rowsBefore); + expect(scripted.countOf("launch")).toBe(0); + expect(await auditRows()).toHaveLength(0); + } finally { + scripted.detach(); + } + }); + + it("an unsafe destination is 400 alias_unsafe before any resolve frame", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await launch({ node: NODE_ON, destination: "-x" }, ownerCookie); + expect(res.status).toBe(400); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.ALIAS_UNSAFE); + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("launches the ssh pane: the frame carries the config at the NODE's derived path, the option tail, the agent socket env; the row keeps the snapshot", async () => { + const snap = snapshot({ authAgentSocket: "/run/user/501/ssh-agent.sock" }); + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: snap }), + launch: ok, + stat_dir: statDirEcho, + }); + const beforeIds = new Set((await auditRows()).map((r) => r.id)); + try { + const res = await launch({ node: NODE_ON, destination: "work", name: "desk" }, ownerCookie); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: Record }; + createdSubshellIds.push(subshell.id as string); + expect(subshell.harnessId).toBe("ssh"); + expect(subshell.name).toBe("desk"); + expect(subshell.nodeOffline).toBe(false); + + const parsed = snap; // what the agent answered is what the row must hold + const row = await new SubshellsRepository(db).findById(subshell.id as string); + expect(row).toBeDefined(); + expect(JSON.parse(String(row?.ssh))).toEqual(parsed); // the snapshot column IS the kind fact + expect(row?.presetId).toBeNull(); + expect(row?.nodeId).toBe(NODE_ON); + expect(row?.restartOnExit).toBe(0); // auto-restart is forced off for ssh panes (decision 7) + + const cmd = scripted.cmdsOf("launch")[0] as Record & { + ssh?: { configPath: string; fileContent: string }; + argv?: string[]; + subshellEnv?: Record; + preset?: { flags?: string[] }; + }; + expect(cmd.ssh?.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, subshell.id as string)); // the node's dataDir, not the server's + expect(cmd.ssh?.fileContent).toBe(renderSshConfigContents(parsed as never)); + // Handoff 4's tail: every option token, then `--`, then the bare host. + const configPath = buildSshConfigPath(SCRIPTED_DATA_DIR, subshell.id as string); + expect(cmd.argv?.slice(1)).toEqual([ + ...sshOptionTokens(parsed as never, configPath), + "--", + sshDestinationToken(parsed as never), + ]); + expect(cmd.preset?.flags?.[0]).toBe("-F"); + // Decision 3: the snapshot's agent socket rides the pane env ONLY when named. + expect(cmd.subshellEnv?.SSH_AUTH_SOCK).toBe("/run/user/501/ssh-agent.sock"); + } finally { + scripted.detach(); + } + // Audit: success lands exactly one ssh.launch row with the three named facts. + const added = (await auditRows()).filter((r) => !beforeIds.has(r.id)); + expect(added).toHaveLength(1); + expect(added[0].targetId).toBe(NODE_ON); + expect(added[0].metadata).toEqual({ nodeId: NODE_ON, destination: CANON, subshellId: createdSubshellIds.at(-1) }); + }); + + it("a snapshot naming no agent socket leaves SSH_AUTH_SOCK out of the pane env", async () => { + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: ACCEPTED }), + launch: ok, + stat_dir: statDirEcho, + }); + try { + const res = await launch({ node: NODE_ON, destination: "work" }, ownerCookie); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: { id: string } }; + createdSubshellIds.push(subshell.id); + const cmd = scripted.cmdsOf("launch")[0] as { subshellEnv?: Record }; + expect(cmd.subshellEnv?.SSH_AUTH_SOCK).toBeUndefined(); + } finally { + scripted.detach(); + } + }); + + it("a successful launch records the recency row (saved_at stays null until the human saves)", async () => { + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: ACCEPTED }), + launch: ok, + stat_dir: statDirEcho, + }); + try { + const res = await launch({ node: NODE_ON, destination: "work" }, ownerCookie); + expect(res.status).toBe(201); + await res.json(); + const rows = await db + .selectFrom("sshSavedHosts") + .selectAll() + .where("destination", "=", CANON) + .where("ownerUserId", "=", ownerId) + .execute(); + expect(rows).toHaveLength(1); + expect(rows[0].savedAt).toBeNull(); + expect(rows[0].alias).toBe("work"); + expect(rows[0].nodeId).toBe(NODE_ON); + } finally { + scripted.detach(); + } + }); +}); + +describe("/api/ssh/saved-hosts", () => { + it("PUT resolves then upsert-marks-saved, and audits NOTHING (prompts precedent)", async () => { + const scripted = silentScript(NODE_ON); + const before = await db.selectFrom("auditEvents").select("id").execute(); + try { + const res = await sshFetch("/api/ssh/saved-hosts", { + method: "PUT", + body: { node: NODE_ON, destination: "work" }, + cookie: ownerCookie, + }); + expect(res.status).toBe(200); + const row = (await res.json()) as { destination: string; savedAt: string | null; alias: string | null }; + expect(row.destination).toBe(CANON); + expect(row.savedAt).not.toBeNull(); + expect(row.alias).toBe("work"); + } finally { + scripted.detach(); + } + const after = await db.selectFrom("auditEvents").select("id").execute(); + expect(after.length).toBe(before.length); // saved-host CRUD records nothing, ever + }); + + it("PUT refuses a refusal-shaped outcome with 422 {outcome} and writes no row", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await sshFetch("/api/ssh/saved-hosts", { + method: "PUT", + body: { node: NODE_ON, destination: "proxy-box" }, + cookie: ownerCookie, + }); + expect(res.status).toBe(422); + const body = (await res.json()) as { outcome: { code: string } }; + expect(body.outcome.code).toBe("unsupported_setting"); + const rows = await db.selectFrom("sshSavedHosts").selectAll().where("ownerUserId", "=", ownerId).execute(); + expect(rows.find((r) => r.destination === "proxy-box")).toBeUndefined(); + } finally { + scripted.detach(); + } + }); + + it("GET lists the caller's own saved and recent rows only — never another owner's", async () => { + // Owner A saves; owner B (a second human with their own node) must see + // none of A's rows, and vice versa. + await db + .insertInto("sshSavedHosts") + .values({ + id: crypto.randomUUID(), + ownerUserId: ownerId, + destination: "a-private.test:22", + alias: "mine", + nodeId: NODE_ON, + savedAt: "2026-10-07T10:00:00.000Z", + lastConnectAt: "2026-10-07T10:00:00.000Z", + } as never) + .execute(); + const resA = await sshFetch("/api/ssh/saved-hosts", { cookie: ownerCookie }); + expect(resA.status).toBe(200); + const viewA = (await resA.json()) as { + saved: { destination: string }[]; + recent: unknown[]; + defaultNodeId: string | null; + }; + expect(viewA.saved.map((r) => r.destination)).toContain("a-private.test:22"); + + const resB = await sshFetch("/api/ssh/saved-hosts", { cookie: editorCookie }); + const viewB = (await resB.json()) as { saved: { destination: string }[]; recent: unknown[] }; + expect(viewB.saved.map((r) => r.destination)).not.toContain("a-private.test:22"); + expect(viewB.recent).toEqual([]); + }); + + it("DELETE removes the owner's row (204) and a foreign id answers the same 404 as an absent one", async () => { + const row = await db + .insertInto("sshSavedHosts") + .values({ + id: crypto.randomUUID(), + ownerUserId: ownerId, + destination: "to-delete.test:22", + alias: null, + nodeId: NODE_ON, + savedAt: "2026-10-07T10:00:00.000Z", + lastConnectAt: "2026-10-07T10:00:00.000Z", + } as never) + .returningAll() + .executeTakeFirstOrThrow(); + const foreign = await sshFetch(`/api/ssh/saved-hosts/${row.id}`, { method: "DELETE", cookie: editorCookie }); + expect(foreign.status).toBe(404); // refused like an absent row — the id is no oracle + const own = await sshFetch(`/api/ssh/saved-hosts/${row.id}`, { method: "DELETE", cookie: ownerCookie }); + expect(own.status).toBe(204); + const again = await sshFetch(`/api/ssh/saved-hosts/${row.id}`, { method: "DELETE", cookie: ownerCookie }); + expect(again.status).toBe(404); + }); +}); + +describe("/api/ssh/preferences (default connecting machine)", () => { + async function patchPreferences(cookie: string, defaultNodeId: string | null) { + return sshFetch("/api/ssh/preferences", { method: "PATCH", body: { defaultNodeId }, cookie }); + } + + it("stores a visible node's id, reads it back on the saved-hosts view, and null clears it", async () => { + const set = await patchPreferences(ownerCookie, NODE_ON); + expect(set.status).toBe(200); + expect(((await set.json()) as { defaultNodeId: string | null }).defaultNodeId).toBe(NODE_ON); + const view = (await (await sshFetch("/api/ssh/saved-hosts", { cookie: ownerCookie })).json()) as { + defaultNodeId: string | null; + }; + expect(view.defaultNodeId).toBe(NODE_ON); + const cleared = await patchPreferences(ownerCookie, null); + expect(((await cleared.json()) as { defaultNodeId: string | null }).defaultNodeId).toBeNull(); + }); + + it("an invisible or unknown node id is 404 (validation happens at the write)", async () => { + expect((await patchPreferences(ownerCookie, "no-such-node")).status).toBe(404); + expect((await patchPreferences(editorCookie, NODE_OFF)).status).toBe(404); // the editor cannot see NODE_OFF + }); + + it("a node that vanishes after the write still reads back as the stored id (the SPA decides)", async () => { + const transient = `n-vanish-${crypto.randomUUID()}`; + createdNodeIds.push(transient); + await nodes.create({ + id: transient, + ownerUserId: ownerId, + name: `sshapi-vanish-${transient.slice(0, 8)}`, + kind: "agent", + status: "offline", + }); + await patchPreferences(ownerCookie, transient); + await nodes.deleteById(transient); + const view = (await (await sshFetch("/api/ssh/saved-hosts", { cookie: ownerCookie })).json()) as { + defaultNodeId: string | null; + }; + expect(view.defaultNodeId).toBe(transient); + }); +}); + +describe("the local node answers in-process (no agent socket exists to reach)", () => { + // NOTE on HOME: Bun's `os.homedir()` reads HOME at PROCESS START and caches + // it (measured on 1.4.2), so a test cannot swap `process.env.HOME` to feed + // the discovery engine a fixture `~`. The alias-list CONTENTS are therefore + // whatever the server account's own config holds (machine-dependent; the + // discovery engine's fixture-parsed contents are pinned in + // pane-runtime's `ssh-discover` suite). What this case owns is the plane + // half: the gate is asked BEFORE the filesystem is read, and `local` answers + // through the in-process runtime rather than an RPC. + let homeDir: string; + let previousSshPath: string | undefined; + + beforeAll(async () => { + homeDir = mkdtempSync(join(tmpdir(), "subshell-ssh-home-")); + // A stub ssh that answers `-G` like a clean OpenSSH would for the manual + // destination (no forwarding lines, one hostname, a chosen user, a + // non-default port): the resolution engine here is the SAME code the + // agent runs, fed this deterministic answer. + const stub = join(homeDir, "ssh-stub.sh"); + writeFileSync( + stub, + '#!/bin/sh\nprintf "host verify.example\\nhostname verify.example\\nport 2222\\nuser stubuser\\n"\nexit 0\n', + ); + chmodSync(stub, 0o755); + previousSshPath = process.env.SUBSHELL_SSH_PATH; + process.env.SUBSHELL_SSH_PATH = stub; // findBinary's first rung, like every harness override + }); + + afterAll(() => { + if (previousSshPath === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = previousSshPath; + rmSync(homeDir, { recursive: true, force: true }); + }); + + it("local SSH off answers 403 before the account's config is read; the admin opens it and the in-process discovery answers", async () => { + const gated = await aliases(LOCAL_NODE_ID, adminCookie); + expect(gated.status).toBe(403); // local ships OFF (spec §4.3) + + await nodes.setSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + const res = await aliases(LOCAL_NODE_ID, adminCookie); + expect(res.status).toBe(200); + const body = (await res.json()) as { aliases: string[]; includeCycle: boolean; truncated: boolean }; + // The SHAPE is the contract here (sorted names + the two cycle/truncation + // facts); contents belong to whoever's account runs the server. + expect(Array.isArray(body.aliases)).toBe(true); + expect(body.includeCycle).toBe(false); + expect(body.truncated).toBe(false); + // A member holds Everyone/edit on `local` — visibility, never SSH use. + expect((await aliases(LOCAL_NODE_ID, otherCookie)).status).toBe(403); + }); + + it("local resolve runs the same engine in-process: the -G answer becomes an approved snapshot", async () => { + await nodes.setSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + const res = await resolve(LOCAL_NODE_ID, "verify.example", adminCookie); + expect(res.status).toBe(200); + const body = (await res.json()) as { + accepted: boolean; + snapshot?: { host: string; port: number; user: string | null; alias: string }; + connectingAccount?: string; + }; + expect(body.accepted).toBe(true); + expect(body.snapshot?.host).toBe("verify.example"); + expect(body.snapshot?.port).toBe(2222); + expect(body.snapshot?.user).toBe("stubuser"); + expect(body.snapshot?.alias).toBe("verify.example"); + expect(body.connectingAccount).toBeDefined(); + }); +}); diff --git a/apps/server/api/src/api/ssh/aliases.route.ts b/apps/server/api/src/api/ssh/aliases.route.ts new file mode 100644 index 000000000..338ccc1f1 --- /dev/null +++ b/apps/server/api/src/api/ssh/aliases.route.ts @@ -0,0 +1,58 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { apiModels } from "@/schema/index.js"; +import { sshListAliases } from "@/services/ssh-launch.service.js"; + +/** + * `GET /api/ssh/aliases?node=` — the NAMES of the machine's usable SSH + * aliases (spec 2026-10-07 §5: discovery returns names, never config file + * contents). Cookie-only; the gate (`nodeCanSsh` over the plane's row) and + * the held-machine refusal run BEFORE the machine is asked anything, so a + * machine the caller may not SSH through never sees a frame. + * + * A machine-level failure maps by kind: offline 409, an app too old for the + * ssh commands 409 naming the update, no answer 409, the machine's own + * `ok:false` 502 (its verbatim text is logged, never echoed). + */ +export const sshAliasesRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/aliases", + async ({ query, user, actor }) => { + requireCookieActor(actor, "SSH alias discovery is restricted to browser sessions"); + const answer = await sshListAliases(user.id, query.node); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return answer.value; + }, + { + query: t.Object({ + node: t.String({ + minLength: 1, + description: "Node id to read aliases from ('local' = the control-plane host)", + }), + }), + response: { + 200: t.Object({ + aliases: t.Array(t.String({ description: "Alias name (sorted, deduplicated, concrete names only)" }), { + description: "Usable alias NAMES, at most 500", + }), + includeCycle: t.Boolean({ description: "An include cycle was detected; the list is what parsed before it" }), + truncated: t.Boolean({ description: "The alias cap was hit; more exist" }), + }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "sshListAliases", + tags: ["ssh"], + description: + "Lists the connecting machine's usable SSH alias names (its own config, parsed bounded). Owner-only and off by default; a machine whose SSH is not switched on refuses the command", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/index.ts b/apps/server/api/src/api/ssh/index.ts new file mode 100644 index 000000000..0b4e086de --- /dev/null +++ b/apps/server/api/src/api/ssh/index.ts @@ -0,0 +1,20 @@ +import { Elysia } from "elysia"; +import { sshAliasesRoute } from "@/api/ssh/aliases.route.js"; +import { sshLaunchRoute } from "@/api/ssh/launch.route.js"; +import { sshResolveRoute } from "@/api/ssh/resolve.route.js"; +import { sshSavedHostsRoutes } from "@/api/ssh/saved-hosts.route.js"; + +/** + * `/api/ssh` — the launcher surface (spec 2026-10-07 §5/§7, plan 2 Task 7): + * discovery, resolution, the gated launch, and the caller's own destination + * ledger. One Elysia instance per endpoint file (the `api/nodes/` shape the + * file-size rule grew this surface into), all cookie-actor, all gated through + * `nodeCanSsh` over the plane's own node row BEFORE any machine is asked + * anything — the refusal order is the contract, pinned in + * `__tests__/ssh-routes.test.ts`. + */ +export const sshRoutes = new Elysia({ prefix: "/api/ssh" }) + .use(sshAliasesRoute) + .use(sshResolveRoute) + .use(sshLaunchRoute) + .use(sshSavedHostsRoutes); diff --git a/apps/server/api/src/api/ssh/launch.route.ts b/apps/server/api/src/api/ssh/launch.route.ts new file mode 100644 index 000000000..9e4cca3b8 --- /dev/null +++ b/apps/server/api/src/api/ssh/launch.route.ts @@ -0,0 +1,80 @@ +import { normalizeLabel } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SubshellSchema } from "@/api/models.js"; +import { SshDestinationField, SshResolveRefusalViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { contextPlugin } from "@/plugins/context.plugin.js"; +import { apiModels } from "@/schema/index.js"; +import { sshLaunch } from "@/services/ssh-launch.service.js"; + +/** + * `POST /api/ssh/launch` `{node, destination, name?}` — open an interactive + * SSH pane to one destination (spec 2026-10-07 §5): gate (row + owner, before + * anything), resolve the destination on the machine (the SAME path a discovery + * answer takes; an unsafe token 400s first), refuse a refusal-shaped outcome + * with **422 `{outcome}`** (the endpoint that acts does not carry a refusal in + * a 200: a pane must never exist for a destination nobody approved), then + * compose the config at the machine's derived path and launch the ssh harness + * through the ordinary create path (presetless, working dir = the launch + * node's home, auto-restart off by inheritance). + * + * No `prompt` field: an ssh launch types nothing (decision 7's compose: + * reconnecting is not a task to submit). The audit `ssh.launch` lands after + * the pane exists, never before. + */ +export const sshLaunchRoute = new Elysia() + .use(contextPlugin) + .use(authGuard) + .use(apiModels) + .post( + "/launch", + async ({ body, user, actor, ctx, status }) => { + requireCookieActor(actor, "SSH launch is restricted to browser sessions"); + const answer = await sshLaunch({ + viewerId: user.id, + nodeId: body.node, + destination: body.destination, + // The same label rule every human-chosen name takes (idempotent + // through the manager's choke-point re-normalization). + name: body.name === undefined ? undefined : normalizeLabel(body.name, 120), + subshells: ctx.services.subshells, + }); + if (!answer.ok) { + // The 422 is the ONE arm returned rather than thrown: its body is the + // resolve outcome, not an error envelope. Everything else is a coded + // refusal the handler maps with its standard status. + const r = answer.refusal; + if (r.status !== 422) return throwCodedRefusal(r); + return status(422, { outcome: r.outcome }); + } + const subshell = await ctx.services.subshells.getSubshell(user.id, answer.value.subshellId, actor); + return status(201, { subshell }); + }, + { + body: t.Object({ + node: t.String({ minLength: 1, description: "Connecting machine ('local' = the control-plane host)" }), + destination: SshDestinationField, + name: t.Optional(t.String({ minLength: 1, maxLength: 120, description: "Pane display name" })), + }), + response: { + 201: t.Object({ subshell: SubshellSchema }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + // The resolve refused: the body is the outcome, in the shape the + // launcher screen already knows how to render from `/resolve`. + 422: t.Object({ + outcome: SshResolveRefusalViewSchema, + }), + 502: "ApiErrorResponse", + }, + detail: { + operationId: "sshLaunchPane", + tags: ["ssh"], + description: + "Opens an interactive SSH pane from the connecting machine to one approved destination. Owner-only and off by default; a destination whose resolution refused answers 422 carrying the outcome and launches nothing. Resolution evaluates the account's own SSH config with `ssh -G`; a `Match exec` hidden from the bounded config walk can run a local command during that evaluation, so approving a destination also approves running its resolution", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/resolve.route.ts b/apps/server/api/src/api/ssh/resolve.route.ts new file mode 100644 index 000000000..47ff7ef71 --- /dev/null +++ b/apps/server/api/src/api/ssh/resolve.route.ts @@ -0,0 +1,54 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SshDestinationField, SshResolveOutcomeViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { apiModels } from "@/schema/index.js"; +import { sshResolveDestination } from "@/services/ssh-launch.service.js"; + +/** + * `POST /api/ssh/resolve` `{node, alias}` — resolve one destination token on + * the connecting machine (`ssh -G` through the frozen engine) and answer the + * OUTCOME. + * + * **A refusal is a 200 carrying `accepted: false`** (tier-1 grammar): "this + * config needs a ProxyCommand" is a fact the human reads and goes and edits, + * which settings blocked — not a transport error. The endpoint that ACTS + * (`/launch`, saved-hosts PUT) is where a refusal-shaped outcome becomes a + * 422; here nothing is launched and nothing is stored. + * + * Same gate as every ssh surface (row + owner, before any command), and the + * destination takes the wire-grammar shape check FIRST: an unsafe token is + * 400 and never reaches the machine. + */ +export const sshResolveRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .post( + "/resolve", + async ({ body, user, actor }) => { + requireCookieActor(actor, "SSH resolution is restricted to browser sessions"); + const answer = await sshResolveDestination(user.id, body.node, body.alias); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return answer.value; + }, + { + body: t.Object({ + node: t.String({ minLength: 1, description: "Node to resolve on ('local' = the control-plane host)" }), + alias: SshDestinationField, + }), + response: { + 200: SshResolveOutcomeViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "sshResolveDestination", + tags: ["ssh"], + description: + "Resolves one destination token into the approved connection snapshot (or the named refusal, which is a successful answer to read). Runs on the connecting machine; nothing is launched or stored by this call. Resolution evaluates the account's own SSH config with `ssh -G`; a `Match exec` hidden from the bounded config walk can run a local command during that evaluation, so approving a destination also approves running its resolution", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/saved-hosts.route.ts b/apps/server/api/src/api/ssh/saved-hosts.route.ts new file mode 100644 index 000000000..a814ef5a0 --- /dev/null +++ b/apps/server/api/src/api/ssh/saved-hosts.route.ts @@ -0,0 +1,182 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { + SshDestinationField, + SshResolveRefusalViewSchema, + SshSavedHostViewSchema, + throwCodedRefusal, +} from "@/api/ssh/ssh-views.js"; +import { apiErrorBody } from "@/lib/api-error.js"; +import { apiModels } from "@/schema/index.js"; +import { + sshRemoveSavedHost, + sshSavedHostsView, + sshSaveHost, + sshSetDefaultNode, +} from "@/services/ssh-launch.service.js"; + +/** + * The launcher screen's own data (spec 2026-10-07 §7): the owner's saved and + * recently-connected destinations plus the default connecting machine. + * + * The rows are per-owner like prompts: a foreign row is invisible and its id + * answers the same 404 an absent one does (the ownership axis, docs/ + * security.md §3), and CRUD here audits NOTHING — a preference row is not an + * event, the prompts precedent (the ACT the ledger records is `ssh.launch`, + * audited by the service). + * + * `PUT` resolves the destination first (the ledger is keyed by the CANONICAL + * resolved destination, so an edited alias can never silently re-point a + * saved row), which means the write carries the launch's gate: a machine the + * caller may not SSH through cannot hold their saved entry, and a + * refusal-shaped outcome is the same 422 `{outcome}` a launch answers. + */ +export const sshSavedHostsRoutes = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/saved-hosts", + async ({ user, actor }) => { + requireCookieActor(actor, "SSH saved hosts are restricted to browser sessions"); + return await sshSavedHostsView(user.id); + }, + { + response: { + 200: t.Object({ + saved: t.Array(SshSavedHostViewSchema, { description: "Rows a human saved, newest save first (max 20)" }), + recent: t.Array(SshSavedHostViewSchema, { + description: "Most-recently-connected destinations, saved or not, newest first (max 20)", + }), + defaultNodeId: t.Nullable( + t.String({ description: "Stored default connecting machine id; resolves to null when none was chosen" }), + { + description: + "The caller's default connecting machine, or null. A node deleted since reads back as the stored id; the client decides", + }, + ), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, + detail: { + operationId: "listSshSavedHosts", + tags: ["ssh"], + description: "Lists the caller's saved and recently-used SSH destinations and their default connecting machine", + }, + }, + ) + .put( + "/saved-hosts", + async ({ body, user, actor, status }) => { + requireCookieActor(actor, "SSH saved hosts are restricted to browser sessions"); + const answer = await sshSaveHost({ + viewerId: user.id, + nodeId: body.node, + destination: body.destination, + alias: body.alias, + }); + if (!answer.ok) { + // A refusal-shaped outcome refuses the SAVE with the same 422 body a + // launch answers (the saved key comes from the resolution; there is + // nothing to store without it). Everything else is a coded refusal. + const r = answer.refusal; + if (r.status !== 422) return throwCodedRefusal(r); + return status(422, { outcome: r.outcome }); + } + return answer.value; + }, + { + body: t.Object({ + node: t.String({ minLength: 1, description: "Machine to resolve the destination on (the gate owner)" }), + destination: SshDestinationField, + alias: t.Optional( + t.String({ + minLength: 1, + maxLength: 253, + description: "Display label override; absent keeps the resolved snapshot's own alias", + }), + ), + }), + response: { + 200: SshSavedHostViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 422: t.Object({ outcome: SshResolveRefusalViewSchema }), + 502: "ApiErrorResponse", + }, + detail: { + operationId: "saveSshHost", + tags: ["ssh"], + description: + "Saves one destination for the caller (resolves it first; the key is the canonical host:port, never the alias). Refusal-shaped outcomes answer 422 carrying the outcome and store nothing", + }, + }, + ) + .delete( + "/saved-hosts/:id", + async ({ params, user, actor, status, set }) => { + requireCookieActor(actor, "SSH saved hosts are restricted to browser sessions"); + const removed = await sshRemoveSavedHost(user.id, params.id); + if (!removed) { + // Foreign AND absent, one 404: the id is not an existence oracle. + return status(404, apiErrorBody({ code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Saved host not found" })); + } + // 204 with NO body at all, set on `set` and returned undefined: a + // `status(204, null)` body trips the fetch Response constructor (204 + // may not carry one) and Elysia would serialize the null anyway. + set.status = 204; + }, + { + params: t.Object({ id: t.String({ description: "Saved-host row id" }) }), + response: { + // 204 is deliberately NOT declared: a declared schema makes Elysia + // validate the (absent) body and answer 400 for a successful delete. + // The handler sets `set.status = 204` and returns nothing; OpenAPI + // reads the empty success from the detail description below. + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "deleteSshSavedHost", + tags: ["ssh"], + description: + "Deletes one of the caller's saved destinations (204 no body on success; a foreign row is the same 404 as an absent one)", + }, + }, + ) + .patch( + "/preferences", + async ({ body, user, actor }) => { + requireCookieActor(actor, "SSH preferences are restricted to browser sessions"); + const answer = await sshSetDefaultNode(user.id, body.defaultNodeId); + if (!answer.ok) return throwCodedRefusal(answer.refusal); // the one arm it can carry is the write-time 404 + return { defaultNodeId: answer.value }; + }, + { + body: t.Object({ + defaultNodeId: t.Nullable( + t.String({ minLength: 1, description: "Node id to default the launcher's machine picker to" }), + { description: "null clears the default" }, + ), + }), + response: { + 200: t.Object({ + defaultNodeId: t.Nullable(t.String({ description: "The stored default machine id (null = none)" })), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "setSshPreferences", + tags: ["ssh"], + description: + "Sets (or clears) the caller's default connecting machine; the write validates the node is one the caller can see, a later deletion leaves the stored id readable", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/ssh-views.ts b/apps/server/api/src/api/ssh/ssh-views.ts new file mode 100644 index 000000000..2489168cb --- /dev/null +++ b/apps/server/api/src/api/ssh/ssh-views.ts @@ -0,0 +1,129 @@ +import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; +import { t } from "elysia"; +import type { SshRefusal } from "@/services/ssh-launch.service.js"; + +/** + * The wire views of the `/api/ssh` surface, shared by the routes that carry + * them (the resolve outcome rides its own endpoint, the launch's 422, and the + * save's 422; the saved-host row rides GET and PUT). One definition per + * shape — the schemas are the API contract, and two copies of a contract + * drift. + */ + +/** One ProxyJump hop as the frozen snapshot spells it. */ +export const SshHopViewSchema = t.Object({ + host: t.String({ description: "Hop hostname (bracketed IPv6 literal kept as given)" }), + user: t.Nullable(t.String({ description: "Hop user; null means the connecting account's own default" })), + port: t.Number({ description: "Hop port, resolved" }), +}); + +/** The approved connection snapshot, exactly as `SshConnectionSnapshotWire` serializes. */ +export const SshSnapshotViewSchema = t.Object({ + alias: t.String({ description: "The config token the human chose (display and review context only)" }), + host: t.String({ description: "Resolved destination hostname" }), + user: t.Nullable(t.String({ description: "Destination user; null means 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 machine" }), { + description: "Identity file references (paths, never key contents)", + }), + certificateFiles: t.Array(t.String({ description: "Absolute certificate-file path on the connecting machine" }), { + description: "Certificate file references (paths only)", + }), + authAgentSocket: t.Nullable(t.String({ description: "Absolute agent-socket path; null runs with no agent" }), { + description: "The authentication-agent socket to use, or null", + }), + knownHostsFiles: t.Array(t.String({ description: "Absolute known-hosts path on the connecting machine" }), { + description: "Known-hosts files to consult (paths only)", + }), + hostKeyAlias: t.Nullable(t.String({ description: "OpenSSH HostKeyAlias when the config sets one" }), { + description: "The name strict host checking looks up, or null", + }), + proxyJumps: t.Array(SshHopViewSchema, { description: "Bounded ProxyJump chain, outermost first" }), + proxyCommand: t.Null({ + description: "Always null: a resolved ProxyCommand makes a destination refusable, never renderable", + }), + forwards: t.Null({ description: "Always null: any resolved forwarding refuses the snapshot" }), + tunnels: t.Null({ description: "Always null: any resolved tunnel refuses the snapshot" }), + localCommands: t.Null({ description: "Always null: any resolved local command refuses the snapshot" }), + remoteCommand: t.Null({ description: "Always null: a resolved RemoteCommand refuses the snapshot" }), + sendEnv: t.Null({ description: "Always null: a resolved SendEnv refuses the snapshot" }), + setEnv: t.Null({ description: "Always null: a resolved SetEnv refuses the snapshot" }), + escapes: t.Null({ description: "Always null: live escape characters refuse the snapshot" }), +}); + +/** The `accepted:false` arm: a successful answer whose CONTENT is a refusal. */ +export const SshResolveRefusalViewSchema = t.Object({ + accepted: t.Literal(false, { + description: "Resolution refused: the config needs more than the approved normalization can run", + }), + code: t.String({ + description: + "The named limitation (SshErrorCode): unsupported_setting, config_missing, config_ambiguous, proxy_chain_too_long, host_key_unknown, …", + }), + settings: t.Array( + t.String({ description: "Config keyword that blocked acceptance (a ProxyCommand row, a LocalForward row, …)" }), + { + description: "What blocked; empty when the code names the whole cause", + }, + ), +}); + +/** `POST /api/ssh/resolve`'s answer: the tier-1 outcome, refusal IN THE DATA. */ +export const SshResolveOutcomeViewSchema = t.Union([ + t.Object( + { + accepted: t.Literal(true, { description: "The destination normalized cleanly into an approved snapshot" }), + snapshot: SshSnapshotViewSchema, + connectingAccount: t.Optional( + t.String({ + description: "The connecting account's OS user name, when the machine could report it (display only)", + }), + ), + }, + { description: "An approved snapshot the human reviews, then saves or launches" }, + ), + SshResolveRefusalViewSchema, +]); + +/** One saved/recency row as its owner sees it. */ +export const SshSavedHostViewSchema = t.Object({ + id: t.String({ description: "Row id (uuid)" }), + destination: t.String({ description: "Canonical host:port (or user@host:port), the resolved destination as key" }), + alias: t.Nullable(t.String({ description: "Display token (the alias typed or discovered); never a key" })), + nodeId: t.String({ description: "The connecting machine of the most recent launch to this destination" }), + savedAt: t.Nullable(t.String({ description: "ISO 8601 the human saved it; null = recency-only row" })), + lastConnectAt: t.String({ description: "ISO 8601 of the most recent launch to this destination" }), +}); + +/** The shared destination-field schema (1..253 chars; the deeper shape rule lives in the service, next to the resolve that enforces it). */ +export const SshDestinationField = t.String({ + minLength: 1, + maxLength: 253, + description: + "Destination token: an alias from the machine's config or a concrete host. Same path either way: it is resolved on the connecting machine before anything launches.", +}); + +/** + * Throw a CODED service refusal as the structured `ApiError` the global + * handler serializes (the `NODE_IN_MAINTENANCE` posture: an expected refusal + * mapped at the service boundary, doNotLog so the 4xx never fills the error + * log). The status rides the code's own definition, and every code here is + * defined with exactly the status the gate doctrine gives it (403 gate, 404 + * invisible, 409 held/offline, 400 unsafe token, 502 machine-refused) — the + * pairing is in `error-codes.ts`, not re-derived here. + * + * The 422 arm — the resolve outcome an ACTING endpoint refuses — is NOT an + * error envelope (its body is `{outcome}`) and belongs to the `status(422,…)` + * return of the launch/save routes; reaching this helper with one is a + * routing bug in this file's own callers, so it answers 500 rather than + * inventing a body. + */ +export function throwCodedRefusal(refusal: SshRefusal): never { + if (refusal.status === 422) { + throwApiError({ + code: BackendErrorCodes.INTERNAL_SERVER_ERROR, + message: "SSH refusal routed to the wrong surface", + }); + } + throwApiError({ code: refusal.code, message: refusal.message, doNotLog: true }); +} diff --git a/apps/server/api/src/api/subshells/__tests__/restart-ssh-refusal.route.test.ts b/apps/server/api/src/api/subshells/__tests__/restart-ssh-refusal.route.test.ts new file mode 100644 index 000000000..a3aef3d1a --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/restart-ssh-refusal.route.test.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { tmpdir } from "node:os"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { subshellRoutes } from "@/api/subshells/index.js"; +import { db } from "@/db/index.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; + +/** + * An ssh pane refuses `POST /:id/restart` with a named 409 (decision 7): its + * "session" lives on the far end of a socket, so a restart would spawn a NEW + * ssh process the human never asked to reconnect — the launcher is the way + * back in. The refusal is keyed to the `subshells.ssh` column (the kind fact, + * migration 0048), asked AFTER the visibility gate (a stranger still gets the + * 404) and BEFORE anything the restart path writes. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(subshellRoutes); +const subshells = new SubshellsRepository(db); + +const pw = "ssh-nores-1"; +const ownerEmail = `sshnr-owner-${crypto.randomUUID()}@subshell.local`; +const strangerEmail = `sshnr-str-${crypto.randomUUID()}@subshell.local`; +let ownerCookie: string; +let strangerCookie: string; +let ownerId: string; +const created: string[] = []; + +function seedSshRow(id: string, ssh: string | null, nodeId = LOCAL_NODE_ID): Promise { + created.push(id); + return subshells.create({ + id, + userId: ownerId, + harnessId: "ssh", + name: "ssh pane", + workingDir: tmpdir(), + tmuxSocket: null, + status: "terminated", + alive: 0, + nodeId, + ssh, + }); +} + +function restart(id: string, cookie: string) { + return app.fetch( + new Request(`http://localhost:3099/api/subshells/${id}/restart`, { + method: "POST", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${cookie}` }, + }), + ); +} + +beforeAll(async () => { + await setupAuthTables(); + const mk = async (email: string) => + await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerId = await mk(ownerEmail); + await mk(strangerEmail); + ownerCookie = await signIn(ownerEmail, pw); + strangerCookie = await signIn(strangerEmail, pw); +}); + +afterAll(async () => { + for (const id of created) await subshells.delete(id).catch(() => {}); + for (const mail of [ownerEmail, strangerEmail]) await deleteUserByEmailOrId(mail); +}); + +describe("ssh panes refuse restart (decision 7)", () => { + it("a row whose snapshot column is set is refused with the named 409 before anything restarts", async () => { + const id = crypto.randomUUID(); + await seedSshRow(id, '{"host":"example.test"}'); + + const res = await restart(id, ownerCookie); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe(BackendErrorCodes.SSH_NO_RESTART); + expect(body.message.toLowerCase()).toContain("launch"); // the remedy: reconnect from the launcher + const row = await subshells.findById(id); + expect(row?.status).toBe("terminated"); // nothing moved + }); + + it("a stranger gets the ordinary 404 on an ssh row (the gate runs first; the refusal is no oracle)", async () => { + const id = crypto.randomUUID(); + await seedSshRow(id, '{"host":"example.test"}'); + expect((await restart(id, strangerCookie)).status).toBe(404); + }); + + it("a non-ssh row is NOT refused by this rule (the offline node still answers its own NODE_OFFLINE 409)", async () => { + const id = crypto.randomUUID(); + await seedSshRow(id, null, "node-gone-offline"); + const res = await restart(id, ownerCookie); + expect(res.status).toBe(409); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.NODE_OFFLINE); + }); +}); diff --git a/apps/server/api/src/api/subshells/__tests__/subshell-exec-ssh.route.test.ts b/apps/server/api/src/api/subshells/__tests__/subshell-exec-ssh.route.test.ts new file mode 100644 index 000000000..3b76d54bd --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/subshell-exec-ssh.route.test.ts @@ -0,0 +1,226 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; + +/** + * `POST /api/subshells/:id/exec` on SSH panes (spec 2026-10-07 §5.4): exec + * TYPES into the pane, so it carries the input rule verbatim. The kind check + * rides the same `#gate` follow-up as `sendSubshellInput`, and it precedes + * every frame AND every read: the refusal never even quiet-probes the log. + * + * The grant rules, the sentinel machinery and the scripted-log idiom are + * `subshell-exec-route.test.ts`'s, copied rather than shared so that suite + * stays byte-identical; what differs here is the row's kind. The first case + * re-pins the ordinary terminal row + edit grantee from this suite's own + * door, because the rule must not graze panes whose snapshot column is NULL. + */ +import { subshellRoutes } from "@/api/subshells/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.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 { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { + attachScriptedNode, + ok, + type ScriptedHandler, + type ScriptedNode, + statDirEcho, +} from "@/test-helpers/scripted-node.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +const app = new Elysia().use(errorHandlerPlugin).use(subshellRoutes); + +const nodes = new NodesRepository(db); +const subshells = new SubshellsRepository(db); +const shares = new SubshellSharesRepository(db); + +/** What a RUNNING pane's node answers; the exec suites extend it with `log_read`. */ +const LIFECYCLE = { stat_dir: statDirEcho, launch: ok }; + +/** A minimal but truthful snapshot JSON; the exec door reads presence, not content. */ +const SNAPSHOT = JSON.stringify({ destination: "host.example", user: "theo", port: 22 }); + +const enc = new TextEncoder(); +const b64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64"); +const SENTINEL_FRAME_RE = /__xcomm_([0-9a-f]{16})_DONE/; + +/** The scripted pane's log as a function of the typed frames (the exec suite's idiom). */ +function scriptedExecLog(typed: string[]): string { + const sentinelFrame = typed[2]; + if (typed.length < 4 || sentinelFrame === undefined) return ""; + const token = SENTINEL_FRAME_RE.exec(sentinelFrame)?.[1]; + if (!token) return ""; + return `out line\n__xcomm_${token}_DONE rc=0\n$ `; +} + +function windowOver(text: string, fromByte: number, maxBytes: number) { + const full = enc.encode(text); + const end = Math.min(full.byteLength, fromByte + maxBytes); + return { bytes_b64: b64(full.subarray(fromByte, end)), next: end, size: full.byteLength }; +} + +function execLogRead(typed: string[]): ScriptedHandler { + return (cmd) => { + if (cmd.type !== "log_read") return new Error(`exec-ssh sim: wrong cmd ${cmd.type}`); + return windowOver(scriptedExecLog(typed), cmd.fromByte, cmd.maxBytes); + }; +} + +function typingInto(typed: string[]): ScriptedHandler { + return (cmd) => { + if (cmd.type === "input") typed.push(cmd.data); + return undefined; + }; +} + +const fourFramesTyped = (sim: ScriptedNode) => sim.cmdsOf("input").map((c) => c.data).length === 4; + +describe("POST /api/subshells/:id/exec on ssh panes (spec §5.4)", () => { + const ownerEmail = `exssh-owner-${crypto.randomUUID()}@subshell.local`; + const granteeEmail = `exssh-grantee-${crypto.randomUUID()}@subshell.local`; + const pw = "execssh-pass-1"; + let ownerId: string; + let ownerCookie: string; + let granteeId: string; + let granteeCookie: string; + let node: string; + const createdSubshellIds: string[] = []; + + async function exec(id: string, opts: { cookie?: string; bearer?: string; body: unknown }) { + const headers = new Headers({ "content-type": "application/json" }); + if (opts.cookie) headers.set("cookie", `better-auth.session_token=${opts.cookie}`); + if (opts.bearer) headers.set("authorization", `Bearer ${opts.bearer}`); + return app.fetch( + new Request(`http://localhost:3080/api/subshells/${id}/exec`, { + method: "POST", + headers, + body: JSON.stringify(opts.body), + }), + ); + } + + /** A RUNNING row written straight to the table (kind chosen by the caller). */ + async function directRow(overrides: { + name: string; + userId?: string; + harnessId?: string; + ssh?: string | null; + }): Promise { + const id = crypto.randomUUID(); + createdSubshellIds.push(id); + await subshells.create({ + id, + userId: overrides.userId ?? ownerId, + presetId: null, + harnessId: overrides.harnessId ?? "terminal", + name: overrides.name, + workingDir: "/tmp", + tmuxSocket: `exssh-sock-${id}`, + nodeId: node, + status: "running", + alive: 1, + ssh: overrides.ssh ?? null, + }); + return id; + } + + const errorBody = async (res: Response) => (await res.json()) as { code: string; message: string }; + + beforeAll(async () => { + await setupAuthTables(); + resetNodeRegistryForTests(); + await ensureLocalNode(db); + const users = new UsersRepository(db); + ownerId = await users.createUser({ + email: ownerEmail, + name: ownerEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + granteeId = await users.createUser({ + email: granteeEmail, + name: granteeEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerCookie = await signIn(ownerEmail, pw); + granteeCookie = await signIn(granteeEmail, pw); + + node = crypto.randomUUID(); + await nodes.create({ id: node, ownerUserId: ownerId, name: `exssh-${node.slice(0, 8)}`, kind: "agent" }); + }); + + afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await db.deleteFrom("subshells").where("id", "=", id).execute(); + await nodes.deleteById(node); + for (const email of [ownerEmail, granteeEmail]) await deleteUserByEmailOrId(email); + }); + + it("an ordinary terminal row + edit grantee is unchanged (the kind rule must not graze NULL)", async () => { + const typed: string[] = []; + const sim = attachScriptedNode(node, { ...LIFECYCLE, input: typingInto(typed), log_read: execLogRead(typed) }); + try { + const id = await directRow({ name: "exssh-ordinary" }); + await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); + const res = await exec(id, { cookie: granteeCookie, body: { command: "echo hi" } }); + expect(res.status).toBe(200); + expect(fourFramesTyped(sim)).toBe(true); + } finally { + sim.detach(); + } + }); + + it("an ssh row + the OWNER cookie 200s and types the four frames (the harness is a terminal too)", async () => { + const typed: string[] = []; + const sim = attachScriptedNode(node, { ...LIFECYCLE, input: typingInto(typed), log_read: execLogRead(typed) }); + try { + const id = await directRow({ name: "exssh-owner", harnessId: "ssh", ssh: SNAPSHOT }); + const res = await exec(id, { cookie: ownerCookie, body: { command: "echo hi" } }); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; exitCode: number | null }; + expect(body.status).toBe("completed"); + expect(body.exitCode).toBe(0); + expect(fourFramesTyped(sim)).toBe(true); + } finally { + sim.detach(); + } + }); + + it("an ssh row + an EDIT grantee 403s SSH_OWNER_INPUT_ONLY before any frame or read", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "exssh-grantee", harnessId: "ssh", ssh: SNAPSHOT }); + await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); + const res = await exec(id, { cookie: granteeCookie, body: { command: "echo hi" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + // The refusal precedes even the quiet probes: nothing reached the pane + // and nothing was read from it. + expect(sim.countOf("input")).toBe(0); + expect(sim.countOf("log_read")).toBe(0); + } finally { + sim.detach(); + } + }); + + it("an ssh row + its OWN pane key 403s (decision 5: the pane must not type into itself)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "exssh-selfkey", harnessId: "ssh", ssh: SNAPSHOT }); + const key = await issueSubshellToken(id, ownerId); + const res = await exec(id, { bearer: key, body: { command: "echo hi" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + expect(sim.countOf("input")).toBe(0); + } finally { + sim.detach(); + } + }); +}); diff --git a/apps/server/api/src/api/subshells/__tests__/subshell-input-ssh.route.test.ts b/apps/server/api/src/api/subshells/__tests__/subshell-input-ssh.route.test.ts new file mode 100644 index 000000000..864abfba5 --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/subshell-input-ssh.route.test.ts @@ -0,0 +1,205 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; + +/** + * `POST /api/subshells/:id/input` on an SSH pane (spec 2026-10-07 §5.4, + * decision 5): the pane's session lives on a remote machine under the + * OWNER's ssh identity, so only the owner's own account may type there. The + * `subshells.ssh` column is the kind fact; the refusal is the named 403 code + * `SSH_OWNER_INPUT_ONLY`, asked AFTER the ordinary gate (a stranger still + * gets the 404; a `view` grantee still gets the plain 403) and BEFORE + * anything is typed. + * + * The decisive case is the bearer one: raw REST resolves a pane's own + * subshell key as its OWNER (boost/shares off), so a bare owner comparison + * would let an ssh pane type into itself. `bearerActor` is what refuses it. + * + * The sibling suite (`subshell-input-route.test.ts`) pins the ordinary-pane + * rules; the first case here re-pins the ordinary row + edit grantee from the + * SSH suite's own door, because the rule must not graze panes whose snapshot + * column is NULL. + */ +import { subshellRoutes } from "@/api/subshells/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.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 { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { attachScriptedNode, ok, type ScriptedNode, statDirEcho } from "@/test-helpers/scripted-node.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +const app = new Elysia().use(errorHandlerPlugin).use(subshellRoutes); + +const nodes = new NodesRepository(db); +const subshells = new SubshellsRepository(db); +const shares = new SubshellSharesRepository(db); + +/** What a RUNNING pane's node answers; refusals must leave it untouched. */ +const LIFECYCLE = { stat_dir: statDirEcho, launch: ok, input: ok }; + +/** A minimal but truthful snapshot JSON; the input door reads presence, not content. */ +const SNAPSHOT = JSON.stringify({ destination: "host.example", user: "theo", port: 22 }); + +describe("POST /api/subshells/:id/input on ssh panes (spec §5.4)", () => { + const ownerEmail = `inssh-owner-${crypto.randomUUID()}@subshell.local`; + const granteeEmail = `inssh-grantee-${crypto.randomUUID()}@subshell.local`; + const pw = "inputssh-pass-1"; + let ownerId: string; + let ownerCookie: string; + let granteeId: string; + let granteeCookie: string; + let node: string; // agent node with a per-test scripted agent attached + const createdSubshellIds: string[] = []; + + async function input(id: string, opts: { cookie?: string; bearer?: string; body: unknown }) { + const headers = new Headers({ "content-type": "application/json" }); + if (opts.cookie) headers.set("cookie", `better-auth.session_token=${opts.cookie}`); + if (opts.bearer) headers.set("authorization", `Bearer ${opts.bearer}`); + return app.fetch( + new Request(`http://localhost:3080/api/subshells/${id}/input`, { + method: "POST", + headers, + body: JSON.stringify(opts.body), + }), + ); + } + + /** A RUNNING row written straight to the table (kind chosen by the caller). */ + async function directRow(overrides: { + name: string; + userId?: string; + harnessId?: string; + ssh?: string | null; + }): Promise { + const id = crypto.randomUUID(); + createdSubshellIds.push(id); + await subshells.create({ + id, + userId: overrides.userId ?? ownerId, + presetId: null, + harnessId: overrides.harnessId ?? "terminal", + name: overrides.name, + workingDir: "/tmp", + tmuxSocket: `inssh-sock-${id}`, + nodeId: node, + status: "running", + alive: 1, + ssh: overrides.ssh ?? null, + }); + return id; + } + + const errorBody = async (res: Response) => (await res.json()) as { code: string; message: string }; + const inputBytes = (sim: ScriptedNode) => sim.cmdsOf("input").map((c) => c.data); + + beforeAll(async () => { + await setupAuthTables(); + resetNodeRegistryForTests(); + await ensureLocalNode(db); + const users = new UsersRepository(db); + ownerId = await users.createUser({ + email: ownerEmail, + name: ownerEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + granteeId = await users.createUser({ + email: granteeEmail, + name: granteeEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerCookie = await signIn(ownerEmail, pw); + granteeCookie = await signIn(granteeEmail, pw); + + node = crypto.randomUUID(); + await nodes.create({ id: node, ownerUserId: ownerId, name: `inssh-${node.slice(0, 8)}`, kind: "agent" }); + }); + + afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await db.deleteFrom("subshells").where("id", "=", id).execute(); + await nodes.deleteById(node); + for (const email of [ownerEmail, granteeEmail]) await deleteUserByEmailOrId(email); + }); + + it("an ordinary row + edit grantee is unchanged (the kind rule must not graze NULL)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "inssh-ordinary" }); + await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); + const res = await input(id, { cookie: granteeCookie, body: { text: "still types", submit: false } }); + expect(res.status).toBe(200); + expect(inputBytes(sim)).toEqual(["still types"]); + } finally { + sim.detach(); + } + }); + + it("an ssh row + the OWNER cookie 200s and types (the owner rule is a permission, not a lockout)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "inssh-owner", harnessId: "ssh", ssh: SNAPSHOT }); + const res = await input(id, { cookie: ownerCookie, body: { text: "owner types" } }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(inputBytes(sim)).toEqual(["owner types", "\r"]); + } finally { + sim.detach(); + } + }); + + it("an ssh row + an EDIT grantee 403s SSH_OWNER_INPUT_ONLY and types nothing", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "inssh-grantee", harnessId: "ssh", ssh: SNAPSHOT }); + await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); + const res = await input(id, { cookie: granteeCookie, body: { text: "grantee types" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + expect(sim.countOf("input")).toBe(0); + } finally { + sim.detach(); + } + }); + + it("an ssh row + its OWN pane key 403s (decision 5: the pane must not type into itself)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "inssh-selfkey", harnessId: "ssh", ssh: SNAPSHOT }); + const key = await issueSubshellToken(id, ownerId); + // Raw REST resolves a pane's own key as its OWNER (boost/shares off), + // so the owner comparison alone would ADMIT this; `bearerActor` is the + // explicit second refusal decision 5 names. + const res = await input(id, { bearer: key, body: { text: "self type" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + expect(sim.countOf("input")).toBe(0); + } finally { + sim.detach(); + } + }); + + it("an ssh row + a SIBLING pane's key 403s too (the §4 owner-resolution is still a bearer act)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const ordinaryPane = await directRow({ name: "inssh-sib-src" }); // the token's own row + const sshPane = await directRow({ name: "inssh-sib-dest", harnessId: "ssh", ssh: SNAPSHOT }); + const key = await issueSubshellToken(ordinaryPane, ownerId); + // The input route's §4 surface admits the owner's OTHER rows to a pane + // key; the ssh kind fence closes exactly that door for ssh panes. + const res = await input(sshPane, { bearer: key, body: { text: "sibling types" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + expect(sim.countOf("input")).toBe(0); + } finally { + sim.detach(); + } + }); +}); diff --git a/apps/server/api/src/api/subshells/input-subshell.route.ts b/apps/server/api/src/api/subshells/input-subshell.route.ts index 9d7b5bd77..2c0b98697 100644 --- a/apps/server/api/src/api/subshells/input-subshell.route.ts +++ b/apps/server/api/src/api/subshells/input-subshell.route.ts @@ -39,10 +39,14 @@ const InputOkSchema = t.Object({ * MCP `send_to_subshell` tool rides). An `edit` act, like terminal input * on the live attach socket: a `view` grantee 403s, a foreign row 404s, and a * bearer pane key acts as its OWNER with boost and shares off (its own and its - * owner's other subshells), the same per-subshell rule restart follows. A - * non-running row is 409 SUBSHELL_NOT_RUNNING and an offline agent node 409 - * NODE_OFFLINE, both before anything is typed. `{ ok: true }` means the bytes - * were accepted by the pane's machine, not that the pane acted on them. + * owner's other subshells), the same per-subshell rule restart follows. An SSH + * pane is the exception to that bearer-as-owner resolution: its input is the + * owner's account ALONE (an `edit` grantee or the pane's own key 403s + * SSH_OWNER_INPUT_ONLY), because the far side runs under the owner's ssh + * identity (spec 2026-10-07 §5.4). A non-running row is 409 + * SUBSHELL_NOT_RUNNING and an offline agent node 409 NODE_OFFLINE, both before + * anything is typed. `{ ok: true }` means the bytes were accepted by the pane's + * machine, not that the pane acted on them. */ export const inputSubshellRoute = new Elysia() .use(contextPlugin) 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 c5c3ce828..6acba4c3c 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 @@ -392,17 +392,21 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. + // WHY 0046+0047+0048 together: this era's three migrations are the tail of + // the registered ledger, so the prefix runs contiguously through 0045 and + // boot re-applies all three. Advancing LATEST_BACKUP_MIGRATION (0048) means + // the strip set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "DROP TABLE backup_recovery; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DELETE FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "DROP TABLE backup_recovery; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; DELETE FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "CREATE TABLE backup_recovery(user_id TEXT PRIMARY KEY REFERENCES user(id)); ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; UPDATE migration_restore_probe SET value='newer destination';", + "CREATE TABLE backup_recovery(user_id TEXT PRIMARY KEY REFERENCES user(id)); ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; UPDATE migration_restore_probe SET value='newer destination';", ); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); @@ -415,7 +419,9 @@ test("real entry: available older upgrade snapshot restores and migrates before const offline = new Database(instance.databasePath, { readonly: true }); expect( offline - .query("SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled')") + .query( + "SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts')", + ) .all(), ).toEqual([]); offline.close(); @@ -424,10 +430,14 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts') ORDER BY name", ) .all(), - ).toEqual([{ name: "0046-backup-recovery" }, { name: "0047-node-ssh-enabled" }]); + ).toEqual([ + { name: "0046-backup-recovery" }, + { name: "0047-node-ssh-enabled" }, + { name: "0048-ssh-launch-and-saved-hosts" }, + ]); 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", @@ -437,6 +447,12 @@ test("real entry: available older upgrade snapshot restores and migrates before .query("SELECT name FROM pragma_table_info('nodes') WHERE name IN ('ssh_enabled','ssh_enabled_at') ORDER BY name") .all(), ).toEqual([{ name: "ssh_enabled" }, { name: "ssh_enabled_at" }]); + expect(migrated.query("SELECT name FROM sqlite_schema WHERE name='ssh_saved_hosts'").get()).toEqual({ + name: "ssh_saved_hosts", + }); + expect(migrated.query("SELECT name FROM pragma_table_info('subshells') WHERE name='ssh'").get()).toEqual({ + name: "ssh", + }); 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 25a4a654e..e47050718 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -47,6 +47,7 @@ import * as oauthCallbackBaseMigration from "@/db/migrations/0044-oauth-callback import * as nodeReregistrationMigration from "@/db/migrations/0045-node-reregistration.js"; import * as backupRecoveryMigration from "@/db/migrations/0046-backup-recovery.js"; import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; /** * Runs all pending Kysely migrations against the app database. @@ -136,6 +137,9 @@ export async function runMigrations(): Promise { // The per-node SSH capability gate: a flag plus its changedAt stamp, // default OFF so an upgrade never turns SSH egress on anywhere (spec 4.3). "0047-node-ssh-enabled": sshEnabledMigration, + // The SSH launcher tier: the per-owner destination ledger and the + // pane's approved snapshot in `subshells.ssh` (spec 2026-10-07 §7). + "0048-ssh-launch-and-saved-hosts": sshLaunchMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts b/apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts new file mode 100644 index 000000000..b2a529766 --- /dev/null +++ b/apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts @@ -0,0 +1,46 @@ +import type { Kysely } from "kysely"; + +/** + * The SSH launcher tier (spec 2026-10-07 §7): `ssh_saved_hosts`, the per-owner + * destination ledger, and `subshells.ssh`, the pane's approved snapshot. + * + * The ledger is keyed by the RESOLVED canonical destination (the spelling + * `sshCanonicalDestination` in the db-types file produces), never by the + * alias, so an edited alias cannot silently re-point a saved row. + * `subshells.ssh` is NULL for + * every pane that is not an SSH terminal, which is exactly what the + * owner-only-input rule reads: the value's presence is the trigger. + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("ssh_saved_hosts") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull().references("user.id").onDelete("cascade")) + .addColumn("destination", "text", (col) => col.notNull()) + .addColumn("alias", "text") + .addColumn("node_id", "text", (col) => col.notNull()) + .addColumn("saved_at", "text") + .addColumn("last_connect_at", "text", (col) => col.notNull()) + .execute(); + // The key: one row per owner per canonical destination. An insert that + // arrives at a standing key is the service's refresh, not a second entry. + await db.schema + .createIndex("idx_ssh_saved_hosts_owner_destination") + .unique() + .on("ssh_saved_hosts") + .columns(["owner_user_id", "destination"]) + .execute(); + // The list read is one owner's recents, newest first: the recency column is + // `last_connect_at`, refreshed on every launch, DESC spelled in the index. + await db.schema + .createIndex("idx_ssh_saved_hosts_owner_recent") + .on("ssh_saved_hosts") + .columns(["owner_user_id", "last_connect_at desc"]) + .execute(); + await db.schema.alterTable("subshells").addColumn("ssh", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("subshells").dropColumn("ssh").execute(); + await db.schema.dropTable("ssh_saved_hosts").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts b/apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts new file mode 100644 index 000000000..e76257d4c --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts @@ -0,0 +1,187 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; +import * as profileDefaultFlagMigration from "@/db/migrations/0010-profile-default-flag.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as subshellRenameMigration from "@/db/migrations/0019-subshell-rename.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; +import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; + +interface MigrationDatabase { + [table: string]: Record; + ssh_saved_hosts: Record; + subshells: Record; + user: Record; +} + +/** + * The SSH launcher tier's storage (spec 2026-10-07 §7): the `ssh_saved_hosts` + * destination ledger (keyed on the canonical destination, unique per owner, + * recency ordered) and `subshells.ssh`, the nullable snapshot column whose + * presence is the owner-only-input rule's trigger. + */ +describe("migration 0048-ssh-launch-and-saved-hosts", () => { + let dbFile: string; + let db: Kysely; + + /** Insert a subshell row the way the pre-0048 repository did - no ssh column at all. */ + const legacySubshell = (id: string) => + db + .insertInto("subshells") + .values({ + id, + user_id: "u1", + profile_id: "p1", + harness_id: "claude-code", + name: id, + working_dir: "/tmp", + }) + .execute(); + + /** A ledger row as the (future) service writes it: full key, live node, connect stamp. */ + const savedHost = (id: string, destination: string, ownerUserId = "u1") => + db + .insertInto("ssh_saved_hosts") + .values({ + id, + owner_user_id: ownerUserId, + destination, + alias: null, + node_id: "local", + saved_at: null, + last_connect_at: "2026-10-07T00:00:00.000Z", + }) + .execute(); + + beforeAll(async () => { + dbFile = `/tmp/subshell-0048-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + // The FK target: better-auth's user table, minimally shaped so + // `references("user.id")` resolves on this throwaway file (same posture + // as the 0040 test). + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@subshell.local')`.execute(db); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + await subshellRenameMigration.up(db); + // Written BEFORE the column exists - the upgrade case: a pre-ssh pane reads NULL. + await legacySubshell("pre-existing"); + await sshLaunchMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("creates ssh_saved_hosts with the ledger's seven columns", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('ssh_saved_hosts')`.execute(db); + expect(cols.rows.map((c) => c.name)).toEqual([ + "id", + "owner_user_id", + "destination", + "alias", + "node_id", + "saved_at", + "last_connect_at", + ]); + }); + + it("adds the nullable ssh snapshot column; a pre-existing pane reads NULL", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('subshells')`.execute(db); + expect(cols.rows.map((c) => c.name)).toContain("ssh"); + const r = await sql<{ ssh: string | null }>`SELECT ssh FROM subshells WHERE id = 'pre-existing'`.execute(db); + expect(r.rows[0].ssh).toBeNull(); + // A launch row stores its approved snapshot as JSON text and reads back. + const snapshot = JSON.stringify({ host: "example.test", port: 22, user: null }); + await sql`UPDATE subshells SET ssh = ${snapshot} WHERE id = 'pre-existing'`.execute(db); + const back = await sql<{ ssh: string }>`SELECT ssh FROM subshells WHERE id = 'pre-existing'`.execute(db); + expect(back.rows[0].ssh).toBe(snapshot); + await sql`UPDATE subshells SET ssh = NULL WHERE id = 'pre-existing'`.execute(db); + }); + + it("carries the unique (owner_user_id, destination) index as the key", async () => { + const lists = await sql<{ name: string; unique: number }>` + SELECT name, "unique" FROM pragma_index_list('ssh_saved_hosts') + `.execute(db); + const unique = lists.rows.find((i) => i.name === "idx_ssh_saved_hosts_owner_destination"); + expect(unique).toEqual({ name: "idx_ssh_saved_hosts_owner_destination", unique: 1 }); + await savedHost("h1", "dev@example.test:22"); + // The same destination under one owner is the SAME row's subject, never a second one. + await expect(savedHost("h2", "dev@example.test:22")).rejects.toThrow(); + // ...and under another owner it is a different person's ledger entry. + await sql`INSERT INTO user (id, name, email) VALUES ('u2', 'Other', 'u2@subshell.local')`.execute(db); + await savedHost("h3", "dev@example.test:22", "u2"); + const keys = await sql<{ owner_user_id: string }>` + SELECT owner_user_id FROM ssh_saved_hosts WHERE destination = 'dev@example.test:22' ORDER BY owner_user_id + `.execute(db); + expect(keys.rows.map((r) => r.owner_user_id)).toEqual(["u1", "u2"]); + }); + + it("carries the recency index, last_connect_at DESC for one owner's list read", async () => { + const lists = await sql<{ name: string }>`SELECT name FROM pragma_index_list('ssh_saved_hosts')`.execute(db); + expect(lists.rows.map((i) => i.name)).toContain("idx_ssh_saved_hosts_owner_recent"); + // The direction is the point: the list read is newest-first, pinned in the DDL. + const ddl = await sql<{ sql: string }>` + SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_ssh_saved_hosts_owner_recent' + `.execute(db); + expect(ddl.rows[0].sql).toMatch(/"last_connect_at" desc/); + }); + + it("requires the key parts and cascades the owner away", async () => { + // last_connect_at is NOT NULL: every row is a connect record, saved or not. + await expect( + db + .insertInto("ssh_saved_hosts") + .values({ + id: "h-no-stamp", + owner_user_id: "u1", + destination: "x@y.test:22", + alias: null, + node_id: "local", + saved_at: null, + } as never) + .execute(), + ).rejects.toThrow(); + // Deleting the account drops its ledger (FK cascade, like every owner column). + await savedHost("h-cascade", "gone@example.test:22", "u2"); + await sql`DELETE FROM user WHERE id = 'u2'`.execute(db); + const left = await sql<{ id: string }>`SELECT id FROM ssh_saved_hosts WHERE owner_user_id = 'u2'`.execute(db); + expect(left.rows).toEqual([]); + }); + + it("down drops the table and the snapshot column and leaves the panes", async () => { + await sshLaunchMigration.down(db); + const tables = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ssh_saved_hosts' + `.execute(db); + expect(tables.rows).toEqual([]); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('subshells')`.execute(db); + expect(cols.rows.map((c) => c.name)).not.toContain("ssh"); + const left = await sql<{ id: string }>`SELECT id FROM subshells`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["pre-existing"]); + }); +}); + +/** + * The canonical destination spelling (spec 2026-10-07 §7): `host:port` with an + * optional `user@` prefix. The key of the ledger, so its three pinned shapes + * are pinned here, at its only home. + */ +describe("sshCanonicalDestination", () => { + it("prefixes the user when the snapshot carries one", () => { + expect(sshCanonicalDestination({ host: "h", port: 22, user: "u" })).toBe("u@h:22"); + }); + + it("spells a null user as a bare host:port, never an empty prefix", () => { + expect(sshCanonicalDestination({ host: "h", port: 2222, user: null })).toBe("h:2222"); + }); + + it("passes an already-bracketed IPv6 host through untouched", () => { + expect(sshCanonicalDestination({ host: "[::1]", port: 22, user: null })).toBe("[::1]:22"); + }); +}); diff --git a/apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts b/apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts new file mode 100644 index 000000000..a9814f282 --- /dev/null +++ b/apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts @@ -0,0 +1,181 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { SshSavedHostsRepository } from "@/db/repositories/ssh-saved-hosts.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; + +/** + * The per-owner SSH destination ledger (spec 2026-10-07 §7). The properties + * this suite exists to pin: + * + * - the key is (owner, canonical destination): `touch` REFRESHES recency on + * an existing row rather than adding a second one, and two owners may hold + * the same destination as two rows; + * - recency (`last_connect_at`) rides EVERY launch whether or not the row was + * ever saved; `saved_at` is only what the human gave (null = recency-only); + * - the list reads never carry another owner's rows, and `remove` refuses a + * foreign id exactly like an absent one (the ownership axis, docs/security.md §3). + */ + +const repo = new SshSavedHostsRepository(db); +const emails: string[] = []; +const inserted: string[] = []; +let ownerA = ""; +let ownerB = ""; + +const at = (iso: string) => ({ at: iso }); + +async function mkUser(tag: string): Promise { + const email = `sh-repo-${tag}-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("sh-repo-1"), + role: "user", + }); +} + +beforeAll(async () => { + await setupAuthTables(); + ownerA = await mkUser("a"); + ownerB = await mkUser("b"); +}); + +afterAll(async () => { + for (const id of inserted) await db.deleteFrom("sshSavedHosts").where("id", "=", id).execute(); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +describe("ssh-saved-hosts repository", () => { + it("touch inserts a recency-only row and REFRESHES it on the same key (one row, moved node + stamp)", async () => { + const destination = "example.test:22"; + await repo.touch({ + ownerUserId: ownerA, + destination, + nodeId: "node-1", + alias: "work", + ...at("2026-10-07T10:00:00.000Z"), + }); + const row = await db + .selectFrom("sshSavedHosts") + .selectAll() + .where("destination", "=", destination) + .executeTakeFirstOrThrow(); + inserted.push(row.id); + expect(row.ownerUserId).toBe(ownerA); + expect(row.nodeId).toBe("node-1"); + expect(row.alias).toBe("work"); + expect(row.savedAt).toBeNull(); + expect(row.lastConnectAt).toBe("2026-10-07T10:00:00.000Z"); + + await repo.touch({ ownerUserId: ownerA, destination, nodeId: "node-2", ...at("2026-10-07T12:00:00.000Z") }); + const rows = await db.selectFrom("sshSavedHosts").selectAll().where("destination", "=", destination).execute(); + expect(rows).toHaveLength(1); // refreshed, not doubled + expect(rows[0].id).toBe(row.id); // the SAME row + expect(rows[0].nodeId).toBe("node-2"); // the machine of the most recent launch + expect(rows[0].lastConnectAt).toBe("2026-10-07T12:00:00.000Z"); + // An absent alias does NOT wipe the standing one (the display token outlives a bare-host launch). + expect(rows[0].alias).toBe("work"); + }); + + it("two owners may hold the same canonical destination as two rows", async () => { + const destination = "shared.test:2222"; + await repo.touch({ ownerUserId: ownerA, destination, nodeId: "node-1", ...at("2026-10-07T10:00:00.000Z") }); + await repo.touch({ ownerUserId: ownerB, destination, nodeId: "node-1", ...at("2026-10-07T10:00:00.000Z") }); + const rows = await db.selectFrom("sshSavedHosts").selectAll().where("destination", "=", destination).execute(); + expect(rows).toHaveLength(2); + inserted.push(...rows.map((r) => r.id)); + }); + + it("markSaved stamps saved_at and returns the row (upsert on an existing recency-only key)", async () => { + const destination = "save.test:22"; + await repo.touch({ ownerUserId: ownerA, destination, nodeId: "node-1", ...at("2026-10-07T09:00:00.000Z") }); + const first = await db + .selectFrom("sshSavedHosts") + .select("id") + .where("destination", "=", destination) + .where("ownerUserId", "=", ownerA) + .executeTakeFirstOrThrow(); + inserted.push(first.id); + + const row = await repo.markSaved({ + ownerUserId: ownerA, + destination, + nodeId: "node-1", + alias: "lab", + ...at("2026-10-07T11:00:00.000Z"), + }); + expect(row.id).toBe(first.id); // saved the SAME row the launch had touched + expect(row.savedAt).toBe("2026-10-07T11:00:00.000Z"); + expect(row.alias).toBe("lab"); + expect(row.lastConnectAt).toBe("2026-10-07T11:00:00.000Z"); + }); + + it("listSaved reads only saved rows, newest-saved first; listRecent reads everything by recency", async () => { + // Destinations tagged per case: the earlier cases' rows exist too, and a + // whole-table ordering assertion would depend on THEIR stamps. + const tag = crypto.randomUUID().slice(0, 8); + await repo.markSaved({ + ownerUserId: ownerA, + destination: `${tag}-old.test:22`, + nodeId: "n", + ...at("2026-10-07T08:00:00.000Z"), + }); + await repo.markSaved({ + ownerUserId: ownerA, + destination: `${tag}-new.test:22`, + nodeId: "n", + ...at("2026-10-07T14:00:00.000Z"), + }); + await repo.touch({ + ownerUserId: ownerA, + destination: `${tag}-just.test:22`, + nodeId: "n", + ...at("2026-10-07T15:00:00.000Z"), + }); + const saved = await repo.listSaved(ownerA); + const recent = await repo.listRecent(ownerA); + const savedDests = saved.map((r) => r.destination).filter((d) => d.startsWith(tag)); + // Only rows a human gave (savedAt non-null); the recency-only row is absent. + expect(savedDests).toEqual([`${tag}-new.test:22`, `${tag}-old.test:22`]); + expect(savedDests).not.toContain(`${tag}-just.test:22`); + // Recency lists everything, newest connect first; the unsaved launch leads. + expect(recent[0]?.destination).toBe(`${tag}-just.test:22`); + const recentDests = recent.map((r) => r.destination); + expect(recentDests).toContain(`${tag}-old.test:22`); + inserted.push(...saved.map((r) => r.id), ...recent.map((r) => r.id)); + }); + + it("listRecent caps at 20 by default and honors an explicit limit", async () => { + const tag = `cap-${crypto.randomUUID().slice(0, 8)}`; + for (let i = 0; i < 25; i++) { + await repo.touch({ + ownerUserId: ownerA, + destination: `${tag}-${i}.test:22`, + nodeId: "n", + ...at(`2026-10-0${(i % 7) + 1}T00:00:0${i % 10}.000Z`), + }); + } + const rows = await db.selectFrom("sshSavedHosts").select("id").where("destination", "like", `${tag}-%`).execute(); + inserted.push(...rows.map((r) => r.id)); + expect(await repo.listRecent(ownerA)).toHaveLength(20); + expect(await repo.listRecent(ownerA, 5)).toHaveLength(5); + }); + + it("remove deletes the owner's row; a foreign id and an absent id both answer false", async () => { + const destination = "remove.test:22"; + const row = await repo.markSaved({ + ownerUserId: ownerA, + destination, + nodeId: "n", + ...at("2026-10-07T10:00:00.000Z"), + }); + expect(await repo.remove(ownerB, row.id)).toBe(false); // foreign: refused like an absent one (no existence oracle) + expect(await db.selectFrom("sshSavedHosts").select("id").where("id", "=", row.id).executeTakeFirst()).toBeDefined(); + expect(await repo.remove(ownerA, row.id)).toBe(true); + expect(await repo.remove(ownerA, row.id)).toBe(false); // already gone + expect(await repo.remove(ownerA, "never-an-id")).toBe(false); + }); +}); diff --git a/apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts b/apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts new file mode 100644 index 000000000..afb28185b --- /dev/null +++ b/apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts @@ -0,0 +1,141 @@ +import { sql } from "kysely"; +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { SshSavedHostTable } from "@/db/types/ssh-saved-hosts.db-types.js"; + +/** + * Repository for the per-owner SSH destination ledger (spec 2026-10-07 §7, + * migration 0048). The whole design fits in two sentences: a row's key is + * (owner, canonical destination) so an edited alias can never re-point a + * saved entry, and every launch refreshes recency whether or not a human ever + * saved the row. + * + * The `New*` insert shape lives HERE, beside the repository, rather than in + * `ssh-saved-hosts.db-types.ts` (the shape task-6 recorded as missing): every + * column is NOT NULL-with-no-default except `alias`/`saved_at`, so an insert + * is the full row and nothing is defaulted — there is no partial-write + * vocabulary to type. `touch` and `markSaved` are its only callers. + */ +export type NewSshSavedHost = SshSavedHostTable; + +/** The caller-composed facts one write carries; `at` is the caller's stamp (the repository owns no clock). */ +interface LedgerWrite { + ownerUserId: string; + /** canonical `host:port` / `user@host:port` from `sshCanonicalDestination` — handoff 1: ONLY fields off a validated snapshot */ + destination: string; + /** the connecting machine of THIS write — the row's node always follows the most recent launch */ + nodeId: string; + /** display token; absent on update KEEPS the standing one (a bare-host launch does not erase an alias) */ + alias?: string | null; + /** ISO 8601 the caller stamps as the act's time */ + at: string; +} + +const RECENT_LIMIT = 20; + +export class SshSavedHostsRepository extends BaseRepository { + /** + * Record a launch: upsert on (owner, destination) and refresh recency. + * Never touches `saved_at` — that column is what the human gave and only + * {@link markSaved} sets it. + */ + async touch(write: LedgerWrite): Promise { + await this.db + .insertInto("sshSavedHosts") + .values({ + id: crypto.randomUUID(), + ownerUserId: write.ownerUserId, + destination: write.destination, + alias: write.alias ?? null, + nodeId: write.nodeId, + savedAt: null, + lastConnectAt: write.at, + }) + .onConflict((oc) => + oc.columns(["ownerUserId", "destination"]).doUpdateSet({ + nodeId: write.nodeId, + lastConnectAt: write.at, + // COALESCE, not a plain set: an omitted alias must leave the display + // token the human saw standing (the key is the destination; the + // alias is what the list labels it with). + alias: sql`COALESCE(excluded.alias, ssh_saved_hosts.alias)`, + }), + ) + .execute(); + } + + /** + * Save a destination (upsert-mark-saved): the same refresh as {@link touch} + * plus `saved_at`, returning the row so the route can answer with it. + */ + async markSaved(write: LedgerWrite): Promise { + await this.db + .insertInto("sshSavedHosts") + .values({ + id: crypto.randomUUID(), + ownerUserId: write.ownerUserId, + destination: write.destination, + alias: write.alias ?? null, + nodeId: write.nodeId, + savedAt: write.at, + lastConnectAt: write.at, + }) + .onConflict((oc) => + oc.columns(["ownerUserId", "destination"]).doUpdateSet({ + nodeId: write.nodeId, + // Re-saving re-stamps the human's own act (it is a fresh save); + // recency rides with it, same as a launch. + savedAt: write.at, + lastConnectAt: write.at, + alias: sql`COALESCE(excluded.alias, ssh_saved_hosts.alias)`, + }), + ) + .execute(); + const row = await this.db + .selectFrom("sshSavedHosts") + .selectAll() + .where("ownerUserId", "=", write.ownerUserId) + .where("destination", "=", write.destination) + .executeTakeFirst(); + // The upsert just wrote the key; a row that is not there now is a dropped + // connection, not a state. + if (!row) throw new Error(`ssh saved-host row vanished mid-write (${write.ownerUserId}/${write.destination})`); + return row; + } + + /** The owner's SAVED rows (a human stamped `saved_at`), newest save first, capped. */ + async listSaved(ownerUserId: string, limit = RECENT_LIMIT): Promise { + return await this.db + .selectFrom("sshSavedHosts") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("savedAt", "is not", null) + .orderBy("savedAt", "desc") + .limit(limit) + .execute(); + } + + /** The owner's most-recently-connected destinations (saved or not), newest first, capped. */ + async listRecent(ownerUserId: string, limit = RECENT_LIMIT): Promise { + return await this.db + .selectFrom("sshSavedHosts") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .orderBy("lastConnectAt", "desc") + .limit(limit) + .execute(); + } + + /** + * Delete one of the owner's rows. A foreign id and an absent id answer the + * same `false` — the caller renders both as the 404 the ownership axis + * demands (ids are never existence oracles, docs/security.md §3). + */ + async remove(ownerUserId: string, id: string): Promise { + const res = await this.db + .deleteFrom("sshSavedHosts") + .where("id", "=", id) + .where("ownerUserId", "=", ownerUserId) + .executeTakeFirst(); + return Number(res.numDeletedRows ?? 0n) > 0; + } +} diff --git a/apps/server/api/src/db/types/index.ts b/apps/server/api/src/db/types/index.ts index 075613c5c..d7d997127 100644 --- a/apps/server/api/src/db/types/index.ts +++ b/apps/server/api/src/db/types/index.ts @@ -17,6 +17,7 @@ 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 { SshSavedHostTable } from "@/db/types/ssh-saved-hosts.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 +58,5 @@ export interface Database { identities: IdentityTable; notificationsSubscriptions: NotificationSubscriptionTable; deviceTokens: DeviceTokenTable; + sshSavedHosts: SshSavedHostTable; } diff --git a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts new file mode 100644 index 000000000..1070089f5 --- /dev/null +++ b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts @@ -0,0 +1,48 @@ +/** + * One saved or recently-used SSH destination for one owner (spec 2026-10-07 + * §7). Keyed by the RESOLVED canonical destination so an edited alias can + * never silently re-point a saved row. `saved_at` is what the human gave it; + * recency is `last_connect_at`, which every launch refreshes whether or not + * the row was ever saved. + */ +export interface SshSavedHostTable { + /** Unique row id (uuid) */ + id: string; + /** Owning user id (FK to users, ON DELETE CASCADE) */ + ownerUserId: string; + /** + * The canonical key: `user@host:port` for a resolved snapshot that carries + * a user, `host:port` (no user prefix) when the snapshot's user is null, + * the latter meaning the connecting account's own default. The spelling + * rule is {@link sshCanonicalDestination}; both writers import it; the list reads back + * the stored strings. It has no other home. Unique per owner + * (idx_ssh_saved_hosts_owner_destination). + */ + destination: string; + /** display-only alias (the config token typed or discovered); never used as a key */ + alias: string | null; + /** the connecting machine used for the most recent launch to this destination */ + nodeId: string; + /** ISO 8601 the human saved this destination (null = recency-only row) */ + savedAt: string | null; + /** ISO 8601 of the most recent launch to this destination */ + lastConnectAt: string; +} + +/** + * Spell the canonical destination that keys {@link SshSavedHostTable}: + * `host:port`, prefixed with `user@` when the resolved snapshot carries a + * user. The host is passed through as given - an IPv6 literal arrives + * already bracketed (`[::1]`) and the port suffix is what disambiguates it. + */ +export function sshCanonicalDestination({ + host, + port, + user, +}: { + host: string; + port: number; + user: string | null; +}): string { + return user === null ? `${host}:${port}` : `${user}@${host}:${port}`; +} diff --git a/apps/server/api/src/db/types/subshells.db-types.ts b/apps/server/api/src/db/types/subshells.db-types.ts index 4c10e155f..aba033df6 100644 --- a/apps/server/api/src/db/types/subshells.db-types.ts +++ b/apps/server/api/src/db/types/subshells.db-types.ts @@ -78,6 +78,13 @@ export interface SubshellTable { * later act rewrites it. See migration 0039. */ crossAgent: number; + /** + * an SSH-terminal pane carries its approved snapshot here; the value's + * presence is the owner-only-input rule's trigger (spec §5.4). JSON-encoded + * `SshConnectionSnapshotWire`; never serialized to clients, the launch + * answer included. See migration 0048. + */ + ssh: string | null; /** * DEPRECATED (spec 2026-09-03): the terminal history cap moved per-USER * (`user_meta.terminal_replay_lines`, migration 0020). This column is @@ -113,6 +120,7 @@ export type NewSubshell = Omit< | "lastPushUrgency" | "nodeId" | "crossAgent" + | "ssh" | "terminalReplayLines" > & { /** Preset launched with; omitted = NULL (presetless launch). Defaults on insert. */ @@ -121,6 +129,8 @@ export type NewSubshell = Omit< nodeId?: string; /** 1 = agent-launched cross-agent comms pane; omitted = DB default 0 (human) */ crossAgent?: number; + /** SSH snapshot JSON for an ssh pane; omitted = NULL = not an ssh pane (migration 0048) */ + ssh?: string | null; /** Per-subshell terminal replay cap; omitted = NULL = instance default */ terminalReplayLines?: number | null; harnessSessionId?: string | null; @@ -142,6 +152,10 @@ export type NewSubshell = Omit< /** * The patch shape for row updates. `crossAgent` is excluded beside `id` and * `userId` because provenance is written exactly once, by the create path: - * "no later act un-stamps it" is a type fact, not a convention. + * "no later act un-stamps it" is a type fact, not a convention. `ssh` is + * excluded for the same reason (spec 2026-10-07 decision 5): the approved + * snapshot is written once at create, and it IS the input rule's kind fact, + * the restart refusal's trigger, and the delete sweep's pointer. No later + * act may rewrite or clear it, so no update can name it. */ -export type SubshellUpdate = Partial>; +export type SubshellUpdate = Partial>; diff --git a/apps/server/api/src/lib/__tests__/ssh-pane-access.test.ts b/apps/server/api/src/lib/__tests__/ssh-pane-access.test.ts new file mode 100644 index 000000000..e2aee8a57 --- /dev/null +++ b/apps/server/api/src/lib/__tests__/ssh-pane-access.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import type { SubshellUpdate } from "@/db/types/subshells.db-types.js"; +import { type PaneInputRow, paneInputAllowed } from "@/lib/ssh-pane-access.js"; + +/** + * `paneInputAllowed` is the ONE ssh input predicate (spec 2026-10-07 §5.4, + * plan decision 5). The GRANT half (edit/view, the bearer switch-off) lives + * in `#gate` and the attach resolver as always; this composes with them. + * + * Compile-time pin in the last block: `SubshellUpdate` must NOT carry `ssh`, + * evaluated by `verify-types` (bun test strips types and runs the assignment + * harmlessly). The `@ts-expect-error` fails the build the moment `ssh` + * reappears in the patch shape, which is the drift being bounded: the + * snapshot is written exactly once, by the create path, like the + * `crossAgent` exclusion it mirrors. + */ + +const OWNER = "u-owner"; +const STRANGER = "u-stranger"; + +const ordinary: PaneInputRow = { ssh: null, userId: OWNER }; +const sshRow: PaneInputRow = { ssh: '{"destination":"host.example"}', userId: OWNER }; + +describe("paneInputAllowed (spec §5.4, decision 5)", () => { + it("admits every actor on an ordinary pane (the grant gate is not this predicate's question)", () => { + // The regression direction: for ssh === null the predicate is true for + // everyone, so every existing door behaves exactly as it did before. + expect(paneInputAllowed(ordinary, OWNER, false)).toBe(true); // owner, cookie + expect(paneInputAllowed(ordinary, STRANGER, false)).toBe(true); // a grantee, cookie + expect(paneInputAllowed(ordinary, OWNER, true)).toBe(true); // the pane's own key (bearer) + expect(paneInputAllowed(ordinary, STRANGER, true)).toBe(true); // a bearer resolving as another owner + }); + + it("admits the owner's human account on an ssh pane", () => { + expect(paneInputAllowed(sshRow, OWNER, false)).toBe(true); + }); + + it("refuses any other account on an ssh pane, however they got past the grant gate", () => { + // An `edit` grantee passes the grant gate; the kind rule still refuses. + expect(paneInputAllowed(sshRow, STRANGER, false)).toBe(false); + }); + + it("refuses a bearer machine credential on an ssh pane, EVEN one resolving as the owner", () => { + // The whole point of the `bearerActor` input: raw REST resolves a pane's + // own subshell key as its OWNER (boost/shares off), so a bare owner + // comparison would let an ssh pane type into itself (decision 5). + expect(paneInputAllowed(sshRow, OWNER, true)).toBe(false); + expect(paneInputAllowed(sshRow, STRANGER, true)).toBe(false); + }); + + it("is decided by the column's presence, not its content", () => { + // The kind fact is "the column is not NULL". Any non-null content is an + // ssh pane (minimal, or otherwise odd): the SAME owner/non-owner split + // holds whatever the bytes are, because snapshot validation belongs to + // the launch boundary, not the input door. + for (const ssh of ['{"destination":"host.example"}', "{}", "not-json"]) { + expect(paneInputAllowed({ ssh, userId: OWNER }, OWNER, false)).toBe(true); + expect(paneInputAllowed({ ssh, userId: OWNER }, STRANGER, false)).toBe(false); + } + // Only NULL reads as an ordinary pane. + expect(paneInputAllowed({ ssh: null, userId: OWNER }, STRANGER, false)).toBe(true); + }); +}); + +describe("SubshellUpdate excludes ssh (frozen at create, like crossAgent)", () => { + it("a patch naming `ssh` is a type error (pinned by verify-types)", () => { + const patch: SubshellUpdate = {}; + // @ts-expect-error `ssh` is excluded from the update-patch shape + patch.ssh = '{"destination":"host.example"}'; + void patch; + }); +}); diff --git a/apps/server/api/src/lib/ssh-pane-access.ts b/apps/server/api/src/lib/ssh-pane-access.ts new file mode 100644 index 000000000..64d1bddd2 --- /dev/null +++ b/apps/server/api/src/lib/ssh-pane-access.ts @@ -0,0 +1,48 @@ +import type { SubshellTable } from "@/db/types/subshells.db-types.js"; + +/** + * The two facts {@link paneInputAllowed} reads: the row's snapshot column (the + * kind fact) and the owner. `SubshellTable` is assignable to it; the narrow + * shape lets the WS path and tests hand over a partial row without pulling the + * whole record into the predicate's contract. + */ +export type PaneInputRow = Pick; + +/** + * The owner-only input rule for ssh panes (spec 2026-10-07 §5.4 / plan + * decision 5). It answers ONE question so every input door (REST input, exec, + * MCP-via-REST, the WS keystroke) asks it the same way: + * + * - An ordinary pane (`ssh === null`) is admitted unconditionally. This + * predicate is the ssh carve-out ONLY; the grant logic (`#gate` for REST, the + * attach resolver for WS) decides edit/view for ordinary panes exactly as + * before, and this predicate never narrows it. + * - An ssh pane admits input ONLY from its owner account, and NEVER from a + * bearer machine credential. The second half is why `bearerActor` is a + * separate input and not folded into the id comparison: raw REST resolves a + * pane's OWN subshell key as its owner (boost and shares off), so a bare + * `actorUserId === row.userId` would let an ssh pane type into itself — the + * exact slip decision 5 closes. A system key resolves through the human gate + * as the `system` service user (never the row's owner), so it is refused by + * the id test regardless; `bearerActor` makes the refusal explicit. + * + * The decision is keyed to the COLUMN'S PRESENCE (`subshells.ssh`, migration + * 0048), the pane's kind fact — not its content. Snapshot validation belongs + * to the launch boundary, not the input door. + * + * @param row - The subshell's kind + owner (a full `SubshellTable` fits) + * @param actorUserId - The account the caller resolves as (row's owner for a + * bearer subshell key on raw REST; the row's owner for a scoped WS token too, + * which is why `bearerActor` must carry the machine-ness) + * @param bearerActor - True for any machine credential: a `subshell-key` or + * `system-key` on REST, or a scoped WS token. False for a human (cookie + * session, or a cookie-minted unscoped WS token). + * @returns True when input may proceed on kind grounds; the caller has ALREADY + * applied its own grant/visibility gate + */ +export function paneInputAllowed(row: PaneInputRow, actorUserId: string, bearerActor: boolean): boolean { + // Ordinary pane: the ssh rule is silent, the existing grant logic governs. + if (row.ssh === null) return true; + // An ssh pane: owner account only, and never a bearer machine credential. + return !bearerActor && actorUserId === row.userId; +} diff --git a/apps/server/api/src/services/__tests__/helpers/node-fakes.ts b/apps/server/api/src/services/__tests__/helpers/node-fakes.ts index a29e788b1..06dbfd882 100644 --- a/apps/server/api/src/services/__tests__/helpers/node-fakes.ts +++ b/apps/server/api/src/services/__tests__/helpers/node-fakes.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; import type { LaunchPlan, NodeLauncher } from "@/services/nodes/node-launcher.js"; import { attachConnection, detachConnection, type NodeFacts, type NodeSocket } from "@/services/nodes/node-registry.js"; @@ -107,10 +108,15 @@ export class FakeNodeLauncher implements NodeLauncher { /** * Mirrors {@link RemoteLauncher.subshellArtifacts}: the delete-time triple, * with the MCP path composed under `nodeOnline()`'s default `/node-data` - * dataDir (the same source the manager's inline delete reads). + * dataDir (the same source the manager's inline delete reads), plus the + * ssh config at the node-derived path for an ssh pane (spec 2026-10-07 + * decision 4) — the fake takes the row's kind flag exactly the real + * launcher does, so a test can pin WHO passes it. */ - subshellArtifacts(id: string): string[] { - return [this.logPath(id), `/node-data/mcp/${id}.json`, this.metaArtifactPath(id)]; + subshellArtifacts(id: string, sshPane = false): string[] { + const artifacts = [this.logPath(id), `/node-data/mcp/${id}.json`, this.metaArtifactPath(id)]; + if (sshPane) artifacts.push(buildSshConfigPath("/node-data", id)); + return artifacts; } async readLogTail(): Promise<{ lines: string[]; truncated: boolean }> { return { lines: [], truncated: false }; diff --git a/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts b/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts new file mode 100644 index 000000000..0ca430c04 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "bun:test"; +import { buildSshConfigPath, renderSshConfigContents } from "@internal/pane-runtime"; +import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; +import { composeSshLaunch, isWireSafeSshDestination } from "@/services/ssh-launch.service.js"; + +/** + * The compose half of the ssh launch service (spec 2026-10-07 §5.2, decisions + * 3/4, handoff 4): the pure function that turns an APPROVED snapshot plus the + * target machine's dataDir into everything the launch needs. The route matrix + * (`api/ssh/__tests__/ssh-routes.test.ts`) pins when it runs; this file pins + * WHAT it returns, byte-exactly, because the agent re-derives the path and + * refuses a single-byte mismatch. + */ + +const SNAP = (over: Partial = {}): SshConnectionSnapshotWire => ({ + alias: "work", + host: "example.test", + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...over, +}); + +const ID = "11111111-2222-4333-8444-555555555555"; + +describe("isWireSafeSshDestination", () => { + it("accepts aliases and bare hosts; refuses the option-like, the blank, the oversized and anything with whitespace or control characters", () => { + for (const ok of ["work", "example.test", "dev@10.0.0.7", "a".repeat(253), "10.0.0.7", "[::1]", "box_1"]) { + expect(isWireSafeSshDestination(ok)).toBe(true); + } + for (const bad of [ + "", // blank answers 400 at the wire schema, and false here all the same + "-x", + "-oProxyCommand=evil", + "has space", + "tab\tsep", + "esc\x07ape", + "a".repeat(254), // past SSH_NAME_MAX_CHARS: the wire grammar's own cap + "new\nline", + ]) { + expect(isWireSafeSshDestination(bad)).toBe(false); + } + }); +}); + +describe("composeSshLaunch", () => { + it("composes the config at the target machine's derived path, with the rendered bytes and the full option tail", () => { + const snapshot = SNAP(); + const composed = composeSshLaunch({ snapshot, targetDataDir: "/home/scripted/.subshell", subshellId: ID }); + const configPath = buildSshConfigPath("/home/scripted/.subshell", ID); + expect(composed.configPath).toBe(configPath); + expect(composed.fileContent).toBe(renderSshConfigContents(snapshot)); + // Handoff 4, verbatim: option tokens (with `-F` first) then `--` then the host. + expect(composed.presetFlags).toEqual(["-F", configPath, "-p", "22", "--", "example.test"]); + // No agent socket named means no SSH_AUTH_SOCK added — decision 3's scope + // rule: non-ssh panes never carry it, and neither does an ssh pane that + // resolved without one. + expect(composed.extraPaneEnv).toBeUndefined(); + }); + + it("the LOCAL machine's dataDir is the server's own, and the byte-derived path is the one LocalLauncher enforces", () => { + const composed = composeSshLaunch({ snapshot: SNAP(), targetDataDir: SUBSHELL_SERVER_DATA_DIR, subshellId: ID }); + expect(composed.configPath).toBe(subshellSshConfigPath(ID)); + }); + + it("a snapshot naming an agent socket carries it as SSH_AUTH_SOCK; a user, non-default port, alias and jump chain ride the option tokens", () => { + const snapshot = SNAP({ + user: "dev", + port: 2222, + hostKeyAlias: "bastion", + authAgentSocket: "/run/user/501/ssh-agent.sock", + proxyJumps: [{ host: "jump.example", user: "j", port: 22 }], + }); + const composed = composeSshLaunch({ snapshot, targetDataDir: "/home/n/.subshell", subshellId: ID }); + expect(composed.extraPaneEnv).toEqual({ SSH_AUTH_SOCK: "/run/user/501/ssh-agent.sock" }); + const flags = composed.presetFlags; + expect(flags.slice(0, 2)).toEqual(["-F", composed.configPath]); + expect(flags).toContain("-p"); + expect(flags[flags.indexOf("-p") + 1]).toBe("2222"); + expect(flags).toContain("-l"); + expect(flags[flags.indexOf("-l") + 1]).toBe("dev"); + expect(flags).toContain("HostKeyAlias=bastion"); + expect(flags.some((f) => f.startsWith("ProxyJump=") && f.includes("j@jump.example"))).toBe(true); + expect(flags.slice(-2)).toEqual(["--", "example.test"]); + }); + + it("refuses a snapshot the frozen grammar cannot express, rather than render it (the belt beside the RPC validator)", () => { + const forged = { ...SNAP(), host: "-oProxyCommand=evil" } as unknown as SshConnectionSnapshotWire; + expect(() => composeSshLaunch({ snapshot: forged, targetDataDir: "/home/n/.subshell", subshellId: ID })).toThrow(); + }); +}); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts index 498d18be6..7529a2151 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts @@ -24,6 +24,7 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; @@ -185,6 +186,7 @@ beforeAll(async () => { await crossAgentMigration.up(dbHandle); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(dbHandle); // presets launch trio (0042) - repository create enumerates it await presetCrossCommOptInMigration.up(dbHandle); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(dbHandle); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) presetsRepo = new PresetsRepository(dbHandle); subshellsRepo = new SubshellsRepository(dbHandle); presetId = await seedPreset(presetsRepo); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts index 779ac50cf..69ead9c15 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts @@ -22,6 +22,7 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -145,6 +146,7 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(db); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) presets = new PresetsRepository(db); subshells = new SubshellsRepository(db); presetId = await seedPreset(presets); 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..f05409fbc 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 @@ -20,6 +20,7 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -87,6 +88,7 @@ beforeAll(async () => { await crossAgentMigration.up(dbHandle); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(dbHandle); // presets launch trio (0042) - repository create enumerates it await presetCrossCommOptInMigration.up(dbHandle); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(dbHandle); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) presetsRepo = new PresetsRepository(dbHandle); subshellsRepo = new SubshellsRepository(dbHandle); presetId = await seedPreset(presetsRepo); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts new file mode 100644 index 000000000..ed35602ea --- /dev/null +++ b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts @@ -0,0 +1,368 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { db } from "@/db/index.js"; +import { runMigrations } from "@/db/migrate.js"; +import { PresetsRepository } from "@/db/repositories/presets.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { FakeNodeLauncher, nodeOnline } from "@/services/__tests__/helpers/node-fakes.js"; +import { prepareLocalPlugins } from "@/services/nodes/local-plugins.js"; +import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; +import { EMPTY_PRESET } from "@/services/preset-definition.js"; +import { SubshellManagerService } from "@/services/subshell-manager.service.js"; + +/** + * The manager's ssh launch plumbing (Task 7): what `createSubshell` does with + * the ssh service's composed facts, and which row fact the delete sweep reads. + * + * The composition itself (`composeSshLaunch`) and its call from the HTTP + * surface are pinned elsewhere (`ssh-launch.service.test.ts`, + * `api/ssh/__tests__/ssh-routes.test.ts`). This file pins the three manager + * promises: the `presetFlags` launch preset is a FRESH object (EMPTY_PRESET + * is shared and frozen — a mutation there would leak ssh flags into every + * later presetless launch), the composed ssh member and the merged pane env + * reach the {@link LaunchPlan} untouched, and `subshells.ssh` (migration + * 0048's JSON column) is the snapshot's home. + * + * The delete-path half pins the kind fact: `row.ssh !== null`, NOT + * `row.harnessId === "ssh"` — a hand-built row that names the ssh harness + * without a snapshot gets no config cleanup, and a snapshot on any harness + * row gets it. + * + * The third describe pins the DEATH-transition sweep: a local ssh pane going + * dead through the shared `#applyDeath` removes its config dir even when the + * exit report's own sweep never fired (the lost-`pane-died`-hook case), and an + * agent row going dead there sweeps nothing on this host. + */ + +const OWNER = "u-ssh-plumb"; +const testDir = mkdtempSync(join(tmpdir(), "subshell-mgr-ssh-")); +let subshellsRepo: SubshellsRepository; +const created: string[] = []; + +const SNAPSHOT: SshConnectionSnapshotWire = { + alias: "work", + host: "example.test", + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, +}; + +/** A live socket so the AGENT branch of `#planMcp` has `ready` facts to read. */ +function plumbOnline(): () => void { + return nodeOnline("node-plumb"); +} + +function mkManager(): { manager: SubshellManagerService; launcher: FakeNodeLauncher } { + const launcher = new FakeNodeLauncher(testDir); + const manager = new SubshellManagerService({ + subshells: subshellsRepo, + presets: new PresetsRepository(db), + launcher, + tokens: { issue: async () => "subshell_stub", revoke: async () => {} }, + audit: async () => {}, + }); + return { manager, launcher }; +} + +beforeAll(async () => { + await runMigrations(); + await prepareLocalPlugins(); + subshellsRepo = new SubshellsRepository(db); +}); + +afterAll(async () => { + for (const id of created) await subshellsRepo.delete(id).catch(() => {}); + rmSync(testDir, { recursive: true, force: true }); +}); + +describe("createSubshell ssh plumbing", () => { + it("threads the composed ssh member, the flag preset and the extra pane env into the LaunchPlan, and writes the snapshot column", async () => { + const id = crypto.randomUUID(); + created.push(id); + const { manager, launcher } = mkManager(); + const configPath = buildSshConfigPath("/home/plumb/.subshell", id); + const flags = ["-F", configPath, "-p", "22", "--", "example.test"]; + const off = plumbOnline(); + const created_ = await manager.createSubshell({ + subshellId: id, + userId: OWNER, + harnessId: "ssh", + presetId: null, + workingDir: tmpdir(), + nodeId: "node-plumb", + ssh: { configPath, fileContent: "Host *\n", snapshot: SNAPSHOT }, + presetFlags: flags, + extraPaneEnv: { SSH_AUTH_SOCK: "/run/user/501/sock" }, + }); + off(); + expect(created_.id).toBe(id); // the caller's pre-assigned id IS the row id + + const plan = launcher.plans[0]; + expect(plan?.ssh).toEqual({ configPath, fileContent: "Host *\n" }); // the snapshot itself stays off the wire member + expect(plan?.preset.flags).toEqual(flags); + expect(plan?.subshellEnv.SSH_AUTH_SOCK).toBe("/run/user/501/sock"); + // The SUBSHELL_* infrastructure env is still there — the ssh pane is a + // token-holding row (decision 10), just with no MCP registration. + expect(plan?.subshellEnv.SUBSHELL_ID).toBe(id); + expect(plan?.mcp).toBeUndefined(); + + const row = await subshellsRepo.findById(id); + expect(JSON.parse(String(row?.ssh))).toEqual(SNAPSHOT); + expect(row?.harnessId).toBe("ssh"); + }); + + it("the flag preset is a fresh object — EMPTY_PRESET is shared and must stay flagless", async () => { + expect(EMPTY_PRESET.flags).toEqual([]); // frozen, and still empty + const id = crypto.randomUUID(); + created.push(id); + const { manager, launcher } = mkManager(); + const off = plumbOnline(); + await manager.createSubshell({ + subshellId: id, + userId: OWNER, + harnessId: "ssh", + presetId: null, + workingDir: tmpdir(), + nodeId: "node-plumb", + ssh: { configPath: buildSshConfigPath("/d", id), fileContent: "x\n", snapshot: SNAPSHOT }, + presetFlags: ["-F", "/d/ssh/x/config", "--", "h"], + }); + off(); + expect(EMPTY_PRESET.flags).toEqual([]); // untouched by the compose above + expect(launcher.plans[0]?.preset).not.toBe(EMPTY_PRESET); + }); + + it("an ordinary launch grows nothing: no ssh member, no SSH_AUTH_SOCK, NULL snapshot", async () => { + const id = crypto.randomUUID(); + created.push(id); + const { manager, launcher } = mkManager(); + await manager.createSubshell({ + subshellId: id, + userId: OWNER, + harnessId: "terminal", + presetId: null, + workingDir: tmpdir(), + }); + const plan = launcher.plans[0]; + expect(plan?.ssh).toBeUndefined(); + expect(plan?.subshellEnv.SSH_AUTH_SOCK).toBeUndefined(); + const row = await subshellsRepo.findById(id); + expect(row?.ssh).toBeNull(); // the column is the kind fact: NULL = not an ssh pane + }); + + it("a null agent socket adds no SSH_AUTH_SOCK (only a named socket rides)", async () => { + const id = crypto.randomUUID(); + created.push(id); + const { manager, launcher } = mkManager(); + const off = plumbOnline(); + await manager.createSubshell({ + subshellId: id, + userId: OWNER, + harnessId: "ssh", + presetId: null, + workingDir: tmpdir(), + nodeId: "node-plumb", + ssh: { configPath: buildSshConfigPath("/d", id), fileContent: "x\n", snapshot: SNAPSHOT }, + presetFlags: ["--", "h"], + }); + off(); + expect(launcher.plans[0]?.subshellEnv.SSH_AUTH_SOCK).toBeUndefined(); + }); +}); + +describe("a death transition sweeps the LOCAL ssh config dir (#applyDeath)", () => { + // The exit REPORT path (service.reportExit) sweeps on its own; this covers the + // gap it cannot: a `pane-died` hook LOST entirely, so no report ever fires and + // only the 60 s reconcile learns the process is gone. The reconcile drives the + // SAME shared `#applyDeath` that `applySelfReportedExit` flows into, so calling + // the manager directly (bypassing the service's sweep) proves the transition + // itself removes the dir. CI e2e spec 22 flaked when this was the only missing + // sweep: natural death whose hook was lost left /ssh/ on disk. + function localDir(id: string): string { + return join(SUBSHELL_SERVER_DATA_DIR, "ssh", id); + } + + it("a running LOCAL ssh pane going dead removes its config dir", async () => { + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + harnessId: "ssh", + name: "lost-hook row", + workingDir: tmpdir(), + tmuxSocket: `subshell-${id.slice(0, 8)}`, + status: "running", + alive: 1, + nodeId: LOCAL_NODE_ID, + ssh: JSON.stringify(SNAPSHOT), + }); + mkdirSync(localDir(id), { recursive: true }); + writeFileSync(subshellSshConfigPath(id), "Host *\n"); + const { manager } = mkManager(); + await manager.applySelfReportedExit(id, 0, new Date().toISOString()); + expect(existsSync(localDir(id))).toBe(false); // #applyDeath, not the service, removed it + const row = await subshellsRepo.findById(id); + expect(row?.alive).toBe(0); + }); + + it("an AGENT ssh pane going dead sweeps NOTHING on this host (the node cleans its own disk)", async () => { + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + harnessId: "ssh", + name: "remote lost-hook row", + workingDir: tmpdir(), + tmuxSocket: `subshell-${id.slice(0, 8)}`, + status: "running", + alive: 1, + nodeId: "node-remote-death", + ssh: JSON.stringify(SNAPSHOT), + }); + // A dir carrying the pane's id on THIS host's disk must survive: it is not + // this plane's to remove. #applyDeath's sweep guard is what declines it. + mkdirSync(localDir(id), { recursive: true }); + const { manager } = mkManager(); + await manager.applySelfReportedExit(id, 1, new Date().toISOString()); + // The death DID land through #applyDeath (proving the sweep's decline was + // exercised, not skipped because the transition never ran) yet the agent + // row's dir stands on this host. + expect((await subshellsRepo.findById(id))?.alive).toBe(0); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); + + it("the reconcile no-socket path sweeps too (it stamps alive->dead without #applyDeath)", async () => { + // The one alive->dead stamp that does NOT funnel through #applyDeath: a row + // with no socket cannot be alive, so the sweep fast-path retires it directly. + // It must still remove a local ssh pane's config dir, or the "whichever hand + // first notices removes it" guarantee has a hole. A dedicated user keeps + // reconcile() from touching any other test's (socketed, real-tmux) rows. + const id = crypto.randomUUID(); + created.push(id); + const user = "u-ssh-nosocket"; + await subshellsRepo.create({ + id, + userId: user, + harnessId: "ssh", + name: "no-socket local ssh", + workingDir: tmpdir(), + tmuxSocket: null, // -> the no-socket branch, before any tmux probe + status: "running", + alive: 1, + nodeId: LOCAL_NODE_ID, + ssh: JSON.stringify(SNAPSHOT), + }); + mkdirSync(localDir(id), { recursive: true }); + const { manager } = mkManager(); + await manager.reconcile(user); + expect(existsSync(localDir(id))).toBe(false); + expect((await subshellsRepo.findById(id))?.alive).toBe(0); + }); +}); + +describe("deleteSubshell reads the row's ssh COLUMN as the kind fact", () => { + it("a LOCAL row with a snapshot gets its config DIR swept — whatever its harness id claims", async () => { + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + // NOT "ssh": the column, not the id, says the pane owned a config. + harnessId: "terminal", + name: "snapshot row", + workingDir: tmpdir(), + tmuxSocket: null, + status: "terminated", + alive: 0, + nodeId: LOCAL_NODE_ID, + ssh: JSON.stringify(SNAPSHOT), + }); + // The dir the LocalLauncher would have written, on the SERVER's own disk + // (the sweep is byte-derived, not launcher-provided). + mkdirSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id), { recursive: true }); + writeFileSync(subshellSshConfigPath(id), "Host *\n"); + const { manager, launcher } = mkManager(); + expect(await manager.deleteSubshell(OWNER, id)).toBe(true); + // The config path is NO LONGER in the launcher's unlink list (a file unlink + // left the DIR); the sweep function took the whole directory instead. + expect(launcher.removedPaths.at(-1)).toEqual([launcher.logPath(id)]); + expect(existsSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id))).toBe(false); + }); + + it("an AGENT row with a snapshot passes the kind flag through subshellArtifacts", async () => { + const nodeId = "node-ssh-del"; + const off = nodeOnline(nodeId, []); + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + harnessId: "ssh", + name: "remote ssh pane", + workingDir: tmpdir(), + tmuxSocket: "subshell-x", + status: "terminated", + alive: 0, + nodeId, + ssh: JSON.stringify(SNAPSHOT), + }); + const { manager, launcher } = mkManager(); + expect(await manager.deleteSubshell(OWNER, id)).toBe(true); + // The ssh config rides the triple at the node-derived path (fake mirrors + // RemoteLauncher's composition from its `/node-data` facts). + expect(launcher.removedPaths.at(-1)).toEqual([ + launcher.logPath(id), + `/node-data/mcp/${id}.json`, + `/node-data/subshells/${id}.meta.json`, + buildSshConfigPath("/node-data", id), + ]); + off(); + }); + + it("a row that merely NAMES the ssh harness but holds no snapshot is not an ssh pane (the re-point's negative arm)", async () => { + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + harnessId: "ssh", + name: "harness-only row", + workingDir: tmpdir(), + tmuxSocket: null, + status: "terminated", + alive: 0, + nodeId: LOCAL_NODE_ID, + ssh: null, + }); + // A dir carrying the pane's NAME exists, but the row owns no config: the + // sweep's guard, not the launcher list, is what keeps it standing. + mkdirSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id), { recursive: true }); + const { manager, launcher } = mkManager(); + expect(await manager.deleteSubshell(OWNER, id)).toBe(true); + expect(launcher.removedPaths.at(-1)).toEqual([launcher.logPath(id)]); // no config path + expect(existsSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id))).toBe(true); // sweep refused + rmSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id), { recursive: true, force: true }); + }); +}); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts index 3c656bf01..d25377ae8 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts @@ -22,6 +22,7 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -114,6 +115,7 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(db); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) presets = new PresetsRepository(db); subshells = new SubshellsRepository(db); presetId = await seedPreset(presets); diff --git a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts index ca3082d61..72802b5f2 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts @@ -23,6 +23,7 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -154,6 +155,7 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(db); // 0048 subshells.ssh — SubshellsRepository.create writes it on EVERY create now (null for a non-ssh pane), and the delete sweep reads it as the kind fact (plan-2 Task 7) presetsRepo = new PresetsRepository(db); subshellsRepo = new SubshellsRepository(db); subshellManager = new SubshellManagerService({ diff --git a/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts b/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts new file mode 100644 index 000000000..220a8fae6 --- /dev/null +++ b/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts @@ -0,0 +1,118 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { runMigrations } from "@/db/migrate.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { SubshellsService } from "@/services/subshells.service.js"; +import { getLogger } from "@/utils/logger.js"; + +/** + * The LOCAL half of the ssh config cleanup (spec 2026-10-07 decision 4; + * plan-2 handoff 2): the exit-report path removes + * `/ssh/` when the row is a LOCAL pane whose + * snapshot column is set. Remote panes sweep on the agent (their own disk); + * this host must NEVER sweep an agent row's directory name off its own disk. + * + * It fires even when the row is already retired — a terminate kills the pane, + * the tmux `pane-died` hook races the retire, and whichever order they land + * in, the config's removal must be a sure thing (that is what Task 9's e2e + * asserts after terminate). + */ + +const OWNER = "u-ssh-exit"; +const subshellsRepo = new SubshellsRepository(getRequestlessContext().db); +const created: string[] = []; +let service: SubshellsService; + +const localDir = (id: string): string => join(SUBSHELL_SERVER_DATA_DIR, "ssh", id); + +async function seedPaneRow(over: { + id: string; + nodeId: string; + ssh: string | null; + status?: "running" | "terminated"; + alive?: number; +}): Promise { + created.push(over.id); + await subshellsRepo.create({ + id: over.id, + userId: OWNER, + harnessId: "ssh", + name: "ssh exit row", + workingDir: tmpdir(), + tmuxSocket: `subshell-${over.id.slice(0, 8)}`, + status: over.status ?? "running", + alive: over.alive ?? 1, + nodeId: over.nodeId, + ssh: over.ssh, + }); +} + +function makeConfigDir(id: string): void { + const dir = localDir(id); + mkdirSync(dir, { recursive: true }); + writeFileSync(buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id), "Host *\n"); +} + +beforeAll(async () => { + await runMigrations(); + const ctx = getRequestlessContext(); + service = new SubshellsService({ db: ctx.db, log: getLogger(), repos: ctx.repos }); +}); + +afterAll(async () => { + for (const id of created) { + rmSync(localDir(id), { recursive: true, force: true }); + await subshellsRepo.delete(id).catch(() => {}); + } +}); + +describe("the exit report sweeps the LOCAL ssh config dir", () => { + it("a running local ssh pane's death removes /ssh/ best-effort", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}' }); + makeConfigDir(id); + expect(existsSync(localDir(id))).toBe(true); + await service.reportExit(id, 0); + expect(existsSync(localDir(id))).toBe(false); // handoff 2: the DIR (dirname of the config path), not just the file + const row = await subshellsRepo.findById(id); + expect(row?.alive).toBe(0); // the manager's death transition ran as before + }); + + it("a row the terminate already retired still sweeps (the hook-vs-retire race is the e2e's case)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}', status: "terminated", alive: 0 }); + makeConfigDir(id); + await service.reportExit(id, null); + expect(existsSync(localDir(id))).toBe(false); + }); + + it("an AGENT row sweeps nothing on this host — the node cleans its own disk", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-ssh", ssh: '{"host":"example.test"}' }); + makeConfigDir(id); // a directory with the same NAME must survive: it is not ours + await service.reportExit(id, 1); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); + + it("a local row without a snapshot sweeps nothing (non-ssh panes have no config to clean)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: null }); + await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); + expect(existsSync(localDir(id))).toBe(false); + }); + + it("an already-dead row's report still converges and the sweep is idempotent on a missing dir", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}' }); + // No dir made at all: the sweep must not throw on a missing path. + await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); + await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); + }); +}); diff --git a/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts b/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts new file mode 100644 index 000000000..c79f85832 --- /dev/null +++ b/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts @@ -0,0 +1,143 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { runMigrations } from "@/db/migrate.js"; +import { PresetsRepository } from "@/db/repositories/presets.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { SubshellManagerService } from "@/services/subshell-manager.service.js"; +import { SubshellsService } from "@/services/subshells.service.js"; +import { getLogger } from "@/utils/logger.js"; + +/** + * The TERMINATE half of the ssh config sweep (spec 2026-10-07 decision 4; the + * e2e spec-22 finding): terminate revokes the pane's token synchronously with + * the kill, so the tmux `pane-died` hook's report can arrive 401 and its sweep + * never runs. The terminate verb therefore sweeps itself, through the SAME + * {@link sweepLocalSshDir} the exit report calls (one function, no drift) — + * and so does the maintenance kill, {@link SubshellManagerService.terminateForMaintenance}, + * which reaches the same teardown through the manager (no REST verb involved) + * and raced the identical way: a maintenance window used to leave every local + * ssh config dir behind. + * + * The rows here carry no tmux socket: the manager's kill is skipped, which is + * the point — the sweep is the killer's own act after the teardown, not the + * pane's last breath. + */ + +const OWNER = "u-ssh-term"; +const subshellsRepo = new SubshellsRepository(getRequestlessContext().db); +const created: string[] = []; +let service: SubshellsService; + +const localDir = (id: string): string => join(SUBSHELL_SERVER_DATA_DIR, "ssh", id); + +async function seedPaneRow(over: { id: string; nodeId: string; ssh: string | null }): Promise { + created.push(over.id); + await subshellsRepo.create({ + id: over.id, + userId: OWNER, + harnessId: "ssh", + name: "ssh terminate row", + workingDir: tmpdir(), + tmuxSocket: null, // no pane to kill: the test isolates the verb's sweep step + status: "running", + alive: 1, + nodeId: over.nodeId, + ssh: over.ssh, + }); +} + +function makeConfigDir(id: string): void { + const dir = localDir(id); + mkdirSync(dir, { recursive: true }); + writeFileSync(buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id), "Host *\n"); +} + +beforeAll(async () => { + await runMigrations(); + const ctx = getRequestlessContext(); + service = new SubshellsService({ db: ctx.db, log: getLogger(), repos: ctx.repos }); +}); + +afterAll(async () => { + for (const id of created) { + rmSync(localDir(id), { recursive: true, force: true }); + await subshellsRepo.delete(id).catch(() => {}); + } +}); + +describe("the terminate verb sweeps the LOCAL ssh config dir", () => { + it("terminating a local ssh row removes /ssh/ (the dying hook is not relied on)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}' }); + makeConfigDir(id); + expect(existsSync(localDir(id))).toBe(true); + expect(await service.terminateSubshell(OWNER, id, "cookie")).toEqual({ ok: true }); + expect(existsSync(localDir(id))).toBe(false); // file AND dir: the sweep is an rm -rf + const row = await subshellsRepo.findById(id); + expect(row?.status).toBe("terminated"); // the ordinary terminate still ran + }); + + it("terminating a local ORDINARY row touches nothing ssh-shaped (the guard, not luck)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: null }); + makeConfigDir(id); // a dir with the pane's NAME but no snapshot column: not its to remove + expect(await service.terminateSubshell(OWNER, id, "cookie")).toEqual({ ok: true }); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); + + it("terminating an AGENT ssh row sweeps nothing on this host (the node cleans its own disk)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-term", ssh: '{"host":"example.test"}' }); + makeConfigDir(id); // same NAME, not ours: the agent's watcher owns that disk + expect(await service.terminateSubshell(OWNER, id, "cookie")).toEqual({ ok: true }); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); +}); + +/** + * The MAINTENANCE kill reaches the same teardown through the manager with no + * REST verb in the loop (the maintenance window and lockdown both call + * `terminateForMaintenance` per row), so the verb's sweep never runs for it. + * Same harness as the verb cases above: no tmux socket, so the kill is + * skipped and the sweep is the act under test. + */ +describe("the maintenance kill sweeps the LOCAL ssh config dir", () => { + const manager = new SubshellManagerService({ + subshells: subshellsRepo, + presets: new PresetsRepository(getRequestlessContext().db), + audit: async () => {}, + notify: async () => {}, + }); + + it("terminateForMaintenance on a local ssh row removes /ssh/", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}' }); + makeConfigDir(id); + expect(existsSync(localDir(id))).toBe(true); + const row = await subshellsRepo.findById(id); + if (!row) throw new Error("seeded row vanished"); // the arg type is the full row + await manager.terminateForMaintenance(row); + expect(existsSync(localDir(id))).toBe(false); // file AND dir: the sweep is an rm -rf + const after = await subshellsRepo.findById(id); + expect(after?.status).toBe("terminated"); // the ordinary terminate still ran + }); + + it("terminateForMaintenance on an AGENT ssh row sweeps nothing on this host", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-maint", ssh: '{"host":"example.test"}' }); + makeConfigDir(id); // same NAME, not ours: the agent's watcher owns that disk + const row = await subshellsRepo.findById(id); + if (!row) throw new Error("seeded row vanished"); // the arg type is the full row + await manager.terminateForMaintenance(row); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); +}); diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index b897e6afb..284d9d864 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 = "0047-node-ssh-enabled"; +export const LATEST_BACKUP_MIGRATION = "0048-ssh-launch-and-saved-hosts"; /** 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/channels.service.ts b/apps/server/api/src/services/channels.service.ts index 3133c505e..80f725275 100644 --- a/apps/server/api/src/services/channels.service.ts +++ b/apps/server/api/src/services/channels.service.ts @@ -219,12 +219,15 @@ export class ChannelsService extends BaseService { // waiting at its prompt is WOKEN — the line names the tool and is // submitted, so the agent reads the post without being told to poll. // A mid-turn pane gets the old Enter-less cue (submitting into a busy - // harness corrupts the turn). Never peer content in either line. + // harness corrupts the turn). Never peer content in either line. An + // SSH pane is NEVER nudged: it is a raw shell under the owner's ssh + // identity that takes input from the owner alone (spec §5.4), and no + // agent sits behind it to read the cue. const subshells = this.repos.subshells; for (const recipientId of new Set(recipientIds)) { if (!recipientId.startsWith("sess:") || recipientId === principal) continue; const row = await subshells.findById(recipientId.slice("sess:".length)); - if (row?.alive !== 1 || !row.tmuxSocket) continue; + if (row?.alive !== 1 || !row.tmuxSocket || row.ssh !== null) continue; const waiting = row.waitingSince != null; const line = waiting ? `[#${channel.name}] subshell peer post, read it: call read_channel("${channel.name}")` diff --git a/apps/server/api/src/services/nodes/__tests__/argv-parity.test.ts b/apps/server/api/src/services/nodes/__tests__/argv-parity.test.ts index 1d3ba57d8..a0c55a9b4 100644 --- a/apps/server/api/src/services/nodes/__tests__/argv-parity.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/argv-parity.test.ts @@ -73,11 +73,15 @@ const SETTINGS_FIXTURES: Record> = { // that the matrix holds for inputs the plugin ignores, and a shell ignores // all of them. terminal: {}, + // SSH is the terminal launch pointed at ssh; like a shell it stores no + // settings keys, and the ssh option tokens ride `preset.flags`. Its + // "settings set" row is the empty object for the same reason terminal's is. + ssh: {}, }; /** * Derived, never hand-listed: a hand-synced list is itself a bypass, because - * a sixth built-in added to pane-runtime would simply not appear and the + * a new built-in added to pane-runtime would simply not appear and the * gate would silently shrink. What the registry ANSWERS is what the matrix * runs; the completeness test below fails loudly if a built-in failed to * CONSTRUCT (that case removes it from this list), and the per-id fixture @@ -257,11 +261,13 @@ for (const id of BUILTIN_IDS) { } /** - * Sanity: every built-in under test really is one of the six, and the + * Sanity: every built-in under test really is one of the seven, and the * registry is what production reads (`getHarness`, the same accessor * `subshell-manager` resolves launches through) — no copies. */ -test("argv parity: the matrix covers all six built-ins through the production registry", () => { +test("argv parity: the matrix covers all seven built-ins through the production registry", () => { const registryIds = BUILTIN_IDS.map((id) => getHarness(id)?.id); - expect(new Set(registryIds)).toEqual(new Set(["claude-code", "codex", "opencode", "hermes", "pi", "terminal"])); + expect(new Set(registryIds)).toEqual( + new Set(["claude-code", "codex", "opencode", "hermes", "pi", "ssh", "terminal"]), + ); }); diff --git a/apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts b/apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts new file mode 100644 index 000000000..b9aeec01f --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts @@ -0,0 +1,166 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { existsSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath, getHarness, TmuxRunner, tmuxSocketFor } from "@internal/pane-runtime"; +import { TRUE_BINARY } from "@/__tests__/helpers/true-binary.js"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { LocalLauncher } from "../local-launcher.js"; +import type { LaunchPlan } from "../node-launcher.js"; +import { subshellLogPath, subshellSshConfigPath } from "../subshell-paths.js"; + +/** + * Task 5 (spec 2026-10-07 decision 4): the LOCAL twin of the agent's ssh + * config write. `LocalLauncher.launch` materializes the rendered config under + * the SERVER's own dataDir, at the same derived path the ssh service composed, + * BEFORE the pane spawns — and `subshellArtifacts` names it for the delete + * sweep only when the caller says the pane was an ssh pane. + * + * Scripted tmux (no real spawn): the write is pure fs, and the spawn-side + * contract is exactly "the file was there when tmux was dialed". + */ + +const SSH_CONTENT = "Host *\n StrictHostKeyChecking yes\n"; +const pid = process.pid; + +/** Counts the spawn and records whether the config existed at spawn time. */ +class ScriptedTmux extends TmuxRunner { + spawns = 0; + pipes = 0; + configAtSpawn: boolean | null = null; + readonly #watchPath: () => string; + constructor(watchPath: () => string) { + super(); + this.#watchPath = watchPath; + } + override newSubshell(): void { + this.spawns++; + this.configAtSpawn = existsSync(this.#watchPath()); + } + override pipePane(): void { + this.pipes++; + } +} + +const sshHarness = getHarness("ssh"); +if (!sshHarness) throw new Error("ssh harness plugin missing from the registry"); + +// An arrow const (not a hoisted function declaration): the module-level +// narrowing of `sshHarness` survives into it (the pattern +// local-launcher.test.ts uses for the pi harness). +const sshPlan = (id: string, over: Partial = {}): LaunchPlan => ({ + id, + socket: tmuxSocketFor(id), + harness: sshHarness, + binary: TRUE_BINARY, + cwd: tmpdir(), + preset: { + name: "p", + description: null, + env: {}, + flags: ["-F", buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id), "--"], + settings: null, + configIsolation: false, + restartOnExit: false, + }, + subshellName: "s", + subshellEnv: {}, + ...over, +}); + +function cleanup(id: string): void { + rmSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id), { recursive: true, force: true }); + void Bun.file(subshellLogPath(id)) + .unlink() + .catch(() => {}); +} + +describe("LocalLauncher ssh config write (spec 2026-10-07 decision 4)", () => { + it("launch(plan with ssh): config at the derived path, 0600 in a 0700 dir, content byte-equal, written BEFORE the spawn", async () => { + const id = `launcher-ssh-${pid}`; + const configPath = buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id); + const scripted = new ScriptedTmux(() => configPath); + const launcher = new LocalLauncher({ tmux: scripted }); + try { + await launcher.launch(sshPlan(id, { ssh: { configPath, fileContent: SSH_CONTENT } })); + expect(scripted.spawns).toBe(1); + expect(scripted.configAtSpawn).toBe(true); // the file existed when tmux was dialed + expect(await Bun.file(configPath).text()).toBe(SSH_CONTENT); + expect(statSync(configPath).mode & 0o777).toBe(0o600); + const parent = configPath.slice(0, configPath.lastIndexOf("/")); + expect(statSync(parent).mode & 0o777).toBe(0o700); + } finally { + cleanup(id); + } + }); + + it("the derived path is the single composition: subshellSshConfigPath == buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id)", () => { + const id = "11111111-2222-4333-8444-555555555555"; + expect(subshellSshConfigPath(id)).toBe(buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id)); + }); + + it("configPath mismatch: launch throws, nothing written, nothing spawned", async () => { + const id = `launcher-ssh-mismatch-${pid}`; + const scripted = new ScriptedTmux(() => buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id)); + const launcher = new LocalLauncher({ tmux: scripted }); + try { + await expect( + launcher.launch( + sshPlan(id, { + ssh: { + configPath: buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, `${id}-elsewhere`), + fileContent: SSH_CONTENT, + }, + }), + ), + ).rejects.toThrow(/ssh config path/i); + expect(scripted.spawns).toBe(0); // refused before the spawn + expect(existsSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id))).toBe(false); + } finally { + cleanup(id); + } + }); + + it("no ssh member: the launch creates no ssh dir for an ordinary pane", async () => { + const id = `launcher-ssh-absent-${pid}`; + const scripted = new ScriptedTmux(() => ""); + const launcher = new LocalLauncher({ tmux: scripted }); + try { + await launcher.launch(sshPlan(id)); + expect(scripted.spawns).toBe(1); + expect(existsSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id))).toBe(false); + } finally { + cleanup(id); + } + }); + + it("subshellArtifacts: the ssh config path rides the list for ssh panes only", () => { + const id = `launcher-ssh-artifacts-${pid}`; + const launcher = new LocalLauncher({ tmux: new ScriptedTmux(() => "") }); + expect(launcher.subshellArtifacts(id)).toEqual([subshellLogPath(id)]); + expect(launcher.subshellArtifacts(id, true)).toEqual([ + subshellLogPath(id), + buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id), + ]); + }); + + it("delete round-trip: launch writes it, removeArtifacts(subshellArtifacts(id, true)) unlinks it", async () => { + const id = `launcher-ssh-delete-${pid}`; + const configPath = buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id); + const launcher = new LocalLauncher({ tmux: new ScriptedTmux(() => configPath) }); + try { + await launcher.launch(sshPlan(id, { ssh: { configPath, fileContent: SSH_CONTENT } })); + expect(existsSync(configPath)).toBe(true); + await launcher.removeArtifacts(launcher.subshellArtifacts(id, true)); + expect(existsSync(configPath)).toBe(false); + } finally { + cleanup(id); + } + }); +}); + +afterAll(() => { + // Defensive: the per-case cleanups own the paths; this sweeps any leftover + // from a mid-test throw so the shared test dataDir stays tidy. + rmSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh"), { recursive: true, force: true }); +}); diff --git a/apps/server/api/src/services/nodes/__tests__/local-plugins.test.ts b/apps/server/api/src/services/nodes/__tests__/local-plugins.test.ts index 2f8055d65..50db36aa0 100644 --- a/apps/server/api/src/services/nodes/__tests__/local-plugins.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/local-plugins.test.ts @@ -57,6 +57,7 @@ describe("the control-plane host's plugins", () => { "netbird", "opencode", "pi", + "ssh", "tailscale", "terminal", ]); diff --git a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts index ed438d787..4b9a256d2 100644 --- a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts @@ -352,6 +352,28 @@ describe("launch", () => { expect(cmd.bestEffortLog).toBe(true); }); + it("an ssh plan ships the frame's ssh member verbatim; a plain plan carries no ssh key at all", async () => { + // spec 2026-10-07 decision 4: the launcher is a CARRIER. The path inside + // the member was composed by the launch service from this node's + // `ready.dataDir`; the agent re-derives it and refuses a byte-mismatch, + // so nothing here recomputes or rewrites it. + const h = makeHarness(); + const ssh = { configPath: `/home/u/.subshell/ssh/${SUB}/config`, fileContent: "Host *\n" }; + await h.launcher.launch({ ...planBase(), id: SUB, ssh }); + const cmd = h.calls[0]?.cmd as Extract; + expect(cmd.ssh).toEqual(ssh); + // The member is protocol-17 wire shape: it must survive the JSON round + // trip through the frame parser the node runs before dispatch. + expect(parseNodeCommandBody(JSON.parse(JSON.stringify(cmd)))).not.toBeNull(); + + // The mcp lesson, applied to ssh: an absent block stays ABSENT on the + // object (a conditional spread), never an explicit-undefined key. + const h2 = makeHarness(); + await h2.launcher.launch(planBase()); + const plain = h2.calls[0]?.cmd as Extract; + expect("ssh" in plain).toBe(false); + }); + it("a remote launch carries an argv whose binary slot is the placeholder", async () => { // The REAL pi plugin, not the stub: argv is the plugin's dialect, and the // resolve rule must be the pi manifest's detect block passed straight @@ -986,6 +1008,31 @@ describe("removeArtifacts", () => { }); }); +describe("subshellArtifacts sshPane (spec 2026-10-07 decision 4)", () => { + const NODE_SUB = "11111111-2222-4333-8444-555555555555"; + const triple = [ + `/home/u/.subshell/subshells/${NODE_SUB}.log`, + `/home/u/.subshell/mcp/${NODE_SUB}.json`, + `/home/u/.subshell/subshells/${NODE_SUB}.meta.json`, + ]; + + it("names the derived ssh config path only when the caller says the pane was an ssh pane", () => { + const h = makeHarness(); + expect(h.launcher.subshellArtifacts(NODE_SUB)).toEqual(triple); + // The path template is the SAME pane-runtime composition the launch-side + // configPath used; on the node it is composed from `ready.dataDir`. + expect(h.launcher.subshellArtifacts(NODE_SUB, true)).toEqual([ + ...triple, + `/home/u/.subshell/ssh/${NODE_SUB}/config`, + ]); + }); + + it("offline stays [] even for an ssh pane (no facts, no layout to name paths from)", () => { + const h = makeHarness(null); + expect(h.launcher.subshellArtifacts(NODE_SUB, true)).toEqual([]); + }); +}); + describe("offline short-circuit (no facts ⇒ no send)", () => { it("facts-dependent members fail before the wire; never-throws members stay soft", async () => { const h = makeHarness(null); diff --git a/apps/server/api/src/services/nodes/local-launcher.ts b/apps/server/api/src/services/nodes/local-launcher.ts index 4df5513db..6dd9d0b0c 100644 --- a/apps/server/api/src/services/nodes/local-launcher.ts +++ b/apps/server/api/src/services/nodes/local-launcher.ts @@ -1,8 +1,10 @@ -import { chmodSync, existsSync, type FSWatcher, mkdirSync, unlinkSync, watch } from "node:fs"; +import { chmodSync, existsSync, type FSWatcher, mkdirSync, unlinkSync, watch, writeFileSync } from "node:fs"; import { homedir } from "node:os"; +import { dirname } from "node:path"; import { stripAnsi } from "@internal/backend-errors"; import { buildHarnessCommand, + enforceMode, exitHookFor, type HarnessPlugin, paneEnvFor, @@ -13,7 +15,7 @@ import { probePaneLogLaunch } from "@/services/mcp-resolve.js"; import { logger } from "@/utils/logger.js"; import { readLogTailFrom, TAIL_POLL_MS } from "./log-tail.js"; import type { LaunchPlan, NodeLauncher } from "./node-launcher.js"; -import { subshellLogDir, subshellLogPath } from "./subshell-paths.js"; +import { subshellLogDir, subshellLogPath, subshellSshConfigPath } from "./subshell-paths.js"; /** Today's exact tmux/fs behavior behind the launcher seam (spec §6.3). */ export class LocalLauncher implements NodeLauncher { @@ -52,6 +54,16 @@ export class LocalLauncher implements NodeLauncher { * logged (debug) and swallowed, exactly like the pre-seam revive did. */ async launch(plan: LaunchPlan): Promise { + // SSH pane config (spec 2026-10-07 decision 4): whoever spawns the pane + // writes the file, and for a local row that is THIS process — under the + // SERVER's own dataDir, before the pane exists. The path is DERIVED, not + // trusted: a plan whose `configPath` is not byte-equal to + // `subshellSshConfigPath(id)` is a caller bug and throws before anything + // is composed or spawned (the same posture the agent's launch block + // refuses with). `SSH_AUTH_SOCK` needs no handling here: the pane's + // command IS the ssh process, so the service's merged `plan.subshellEnv` + // rides `buildHarnessCommand`'s env layers unchanged. + if (plan.ssh) await this.#writeSshConfig(plan.id, plan.ssh); const cmd = buildHarnessCommand( plan.harness, plan.binary, @@ -107,6 +119,31 @@ export class LocalLauncher implements NodeLauncher { } } + /** + * Materialize one ssh pane's rendered config on the SERVER's own disk: + * 0700 dir, 0600 file, {@link enforceMode} on both (mkdir's/writeFile's + * `mode` is clamped by the umask and applies only to segments actually + * created — the same re-tightening `ensureLogDirMode` and the agent's MCP + * block exist for). The path is this machine's OWN derivation; a plan + * naming anything else throws before any byte is written. + */ + async #writeSshConfig(id: string, ssh: { configPath: string; fileContent: string }): Promise { + const sshPath = subshellSshConfigPath(id); + if (ssh.configPath !== sshPath) { + // Byte-equality, same posture as the agent's launch block: the plane + // may name nothing else, and a mismatch here is a composition bug in + // the caller, not a user-reachable state. + throw new Error( + `ssh config path mismatch for ${id}: plan names ${JSON.stringify(ssh.configPath)}, the derived path is ${JSON.stringify(sshPath)}`, + ); + } + const dir = dirname(sshPath); + mkdirSync(dir, { recursive: true }); + await enforceMode(dir, 0o700); + writeFileSync(sshPath, ssh.fileContent, { mode: 0o600 }); + await enforceMode(sshPath, 0o600); + } + /** * Ensure the pane log's parent dir exists before pipe-pane: tmux runs * `cat >> ` once, and a missing parent dir makes it die silently — @@ -368,12 +405,25 @@ export class LocalLauncher implements NodeLauncher { } /** - * The local machine owns exactly one per-subshell file: the pipe-pane replay - * log (the MCP config under `subshellMcpConfigPath` is a control-plane file — - * `deleteSubshell` unlinks it directly for local and remote rows alike). + * The local machine owns the pipe-pane replay log, and — for an ssh pane + * only — the rendered config `launch` wrote under the server's dataDir + * (spec 2026-10-07 decision 4). The MCP config under `subshellMcpConfigPath` + * is a control-plane file — `deleteSubshell` unlinks it directly for local + * and remote rows alike. + * + * `sshPane` is the ROW's kind fact: only the caller that holds the row can + * say whether this pane owns a config. Every non-ssh caller gets the + * pre-ssh list unchanged. The ssh arm serves the launcher seam: on an + * AGENT row the node launcher's twin arm adds the config FILE path to the + * machine's `remove_paths` list, and the dir itself is the machine's to + * take (the agent's exit sweep, then the orphan sweep in + * `ssh-dir-retention.ts`). A LOCAL ssh row never reaches this list: the + * manager's local branch names `logPath` directly and lets + * `sweepLocalSshDir` rm-rf the whole per-pane dir on every control-plane + * teardown path (unlinking the file and leaving the dir is not a removal). */ - subshellArtifacts(id: string): string[] { - return [this.logPath(id)]; + subshellArtifacts(id: string, sshPane = false): string[] { + return sshPane ? [this.logPath(id), subshellSshConfigPath(id)] : [this.logPath(id)]; } /** Best-effort unlink of artifact paths (absent files are not errors). */ diff --git a/apps/server/api/src/services/nodes/node-launcher.ts b/apps/server/api/src/services/nodes/node-launcher.ts index 978aa7fa6..6eaf41d60 100644 --- a/apps/server/api/src/services/nodes/node-launcher.ts +++ b/apps/server/api/src/services/nodes/node-launcher.ts @@ -20,6 +20,17 @@ export interface LaunchPlan { subshellEnv: Record; /** MCP registration (dialect computed control-side) */ mcp?: McpRegistration; + /** + * The ssh pane's rendered config (spec 2026-10-07 decision 4): content the + * ssh service composed from the approved snapshot, plus the path + * `buildSshConfigPath(, id)` — derived, never chosen. + * `RemoteLauncher` ships the member verbatim on the launch frame; the agent + * re-derives the path from its OWN dataDir and refuses a byte-mismatch. + * `LocalLauncher` re-derives from the server's own dataDir, throws on a + * mismatch (a caller bug, the same posture), and writes the file (0600 in a + * 0700 dir) before spawning. Absent on every non-ssh pane. + */ + ssh?: { configPath: string; fileContent: string }; /** * Absolute path ON THE TARGET machine where `RemoteLauncher` ships * `mcp.fileContent` — composed by the caller from the node's `ready.dataDir` @@ -189,8 +200,14 @@ export interface NodeLauncher { * The node-side files a subshell owns — what delete removes. `[]` when the * machine cannot answer: an agent with no live `ready` facts has no readable * layout to name paths from (its artifacts age out with the node, §5.6). + * + * `sshPane` is the row's kind fact (spec 2026-10-07 decision 4): the + * rendered ssh config lives at the machine-derived path + * `/ssh//config`, and ONLY a caller holding the row can say + * the pane owns one. Callers pass the row's truth; `false` (the default) + * yields exactly the pre-ssh list, so every existing caller is unchanged. */ - subshellArtifacts(id: string): string[]; + subshellArtifacts(id: string, sshPane?: boolean): string[]; /** Best-effort deletion of artifact paths from {@link subshellArtifacts}. */ removeArtifacts(paths: string[]): Promise; } diff --git a/apps/server/api/src/services/nodes/remote-launcher.ts b/apps/server/api/src/services/nodes/remote-launcher.ts index 1ba130d57..45fe90e89 100644 --- a/apps/server/api/src/services/nodes/remote-launcher.ts +++ b/apps/server/api/src/services/nodes/remote-launcher.ts @@ -1,4 +1,4 @@ -import type { HarnessPlugin } from "@internal/pane-runtime"; +import { buildSshConfigPath, type HarnessPlugin } from "@internal/pane-runtime"; import { HARNESS_BINARY_PLACEHOLDER, type NodeCommandBody, @@ -300,6 +300,14 @@ export class RemoteLauncher implements NodeLauncher { // Narrowed by the gate above: required since protocol 3, and always // present here. resolve: plan.harness.detectSpec, + // The ssh pane's rendered config (spec 2026-10-07 decision 4): carried + // VERBATIM — `configPath` was composed by the launch service from this + // node's `ready.dataDir` with the same `buildSshConfigPath` the agent + // runs, and the AGENT re-derives and refuses a byte-mismatch, so this + // launcher neither recomputes nor rewrites. Conditional spread, never + // `ssh: undefined` on a plain plan (the frame's parser refuses an + // explicit-undefined optional; the mcp comment above is the precedent). + ...(plan.ssh ? { ssh: { configPath: plan.ssh.configPath, fileContent: plan.ssh.fileContent } } : {}), }; try { await this.#send(cmd, LAUNCH_TIMEOUT_MS); @@ -536,18 +544,42 @@ export class RemoteLauncher implements NodeLauncher { return factsPath(this.#requireFacts(), `mcp/${id}.json`); } + /** + * The ssh pane's rendered config ON THE NODE: `/ssh//config` + * — the delete-side twin of the `launch.ssh.configPath` the launcher ships + * and the agent's byte-derived write target. Composed from the same + * `ready.dataDir` as the MCP/meta paths; it is the SAME pane-runtime + * {@link buildSshConfigPath} the node runs at write time, so the unlink + * lands exactly where the write landed (spec 2026-10-07 decision 4). The + * delete-time `remove_paths` unlinks the config FILE; the whole per-pane ssh + * DIR is swept by the agent's exit watcher on natural death (report.ts). + * Class-local (not on {@link NodeLauncher}) like {@link metaArtifactPath} — + * the concept is agent-side layout. Throws {@link NoLiveConnectionError} + * offline (sync member, same shape as {@link logPath}). + */ + sshArtifactPath(id: string): string { + assertNodePathId(id); + return buildSshConfigPath(this.#requireFacts().dataDir, id); + } + /** * The three files a subshell leaves on the node — log + MCP config + the * agent's own meta record, in the order the pre-seam `deleteSubshell` block * pushed them (spec §6.4). Empty when the node has no live `ready` facts: * no facts, no layout to name paths from, and the artifacts age out with * the node — the same offline skip every pre-seam path made individually. - * The id guard rides through the three composed paths, so there is no - * fourth interpolation to forget. + * The id guard rides through the composed paths, so there is no extra + * interpolation to forget. + * + * `sshPane` (the row's kind fact, spec 2026-10-07 decision 4) adds the + * rendered config as a fourth path; every non-ssh caller gets the triple + * unchanged. */ - subshellArtifacts(id: string): string[] { + subshellArtifacts(id: string, sshPane = false): string[] { if (!this.#facts()) return []; - return [this.logPath(id), this.mcpArtifactPath(id), this.metaArtifactPath(id)]; + const artifacts = [this.logPath(id), this.mcpArtifactPath(id), this.metaArtifactPath(id)]; + if (sshPane) artifacts.push(this.sshArtifactPath(id)); + return artifacts; } /** diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts new file mode 100644 index 000000000..9156e5396 --- /dev/null +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -0,0 +1,98 @@ +import { + type NodeSshAliasListResult, + type NodeSshResolveOutcomeWire, + parseNodeSshAliasList, + parseNodeSshResolveOutcome, +} from "@internal/subshell-protocol"; +import { NodeRpcError, sendCommand } from "@/services/nodes/node-rpc.js"; + +/** + * The two ssh discovery/resolve commands as plane-side RPC wrappers + * (spec 2026-10-07 §5). Each is `sendCommand` plus ITS frozen result parser: + * the value these return is a validated plain object (or the function throws), + * so no caller can branch on a machine answer it has not parsed. + * + * **The outcome's `accepted:false` arm is NOT an error.** "This config needs a + * ProxyCommand" is a successful resolution the human must read (§2's refusal + * doctrine), and it travels IN THE DATA; these wrappers return the whole + * outcome and refuse only what the transport or the wire grammar refused. + * + * Every failure leaves as one {@link SshRpcError} whose `kind` says which + * door closed, so the service maps statuses without re-deriving them from + * error sentences: + * - `offline` — no live socket (or it dropped before the frame was sent); + * - `unsupported` — the connected agent does not know the command (an old + * app; the remedy is the node update); + * - `timeout` — the machine did not answer inside the RPC deadline; + * - `refused` — the agent answered `ok:false` (its own mirror gate, a missing + * ssh binary, …); `detail` carries the agent's verbatim string for the LOG, + * never for a response body; + * - `malformed` — `ok:true` with a payload the frozen validator rejects (a + * protocol violation; treated as a refusal, loudly). + */ + +/** Which door closed on a machine-level ssh RPC failure. */ +export type SshRpcFailure = "offline" | "unsupported" | "timeout" | "refused" | "malformed"; + +export class SshRpcError extends Error { + readonly kind: SshRpcFailure; + readonly nodeId: string; + /** The agent's `error` string verbatim on a `refused` failure (log/diagnosis only). */ + readonly detail: string | undefined; + + constructor(kind: SshRpcFailure, message: string, nodeId: string, detail?: string) { + super(message); + this.name = "SshRpcError"; + this.kind = kind; + this.nodeId = nodeId; + this.detail = detail; + } +} + +/** Map the RPC layer's failure onto the ssh surface's failure kind. */ +function mapRpcError(nodeId: string, err: NodeRpcError): SshRpcError { + const kind: SshRpcFailure = + err.code === "offline" + ? "offline" + : err.code === "unsupported" + ? "unsupported" + : err.code === "timeout" + ? "timeout" + : "refused"; + return new SshRpcError(kind, err.message, nodeId, err.detail); +} + +/** Ask one machine for the NAMES of its usable SSH aliases (never config contents). */ +export async function sshDiscover(nodeId: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_discover_aliases" }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshAliasList(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed alias list`, nodeId); + } + return parsed; +} + +/** + * Ask one machine to resolve one destination token into the approved + * snapshot (or its named refusal, which is a SUCCESSFUL answer). + */ +export async function sshResolve(nodeId: string, alias: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_resolve_config", alias }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshResolveOutcome(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed resolve outcome`, nodeId); + } + return parsed; +} diff --git a/apps/server/api/src/services/nodes/subshell-paths.ts b/apps/server/api/src/services/nodes/subshell-paths.ts index f3b3fb3ea..bec98b45f 100644 --- a/apps/server/api/src/services/nodes/subshell-paths.ts +++ b/apps/server/api/src/services/nodes/subshell-paths.ts @@ -1,3 +1,4 @@ +import { buildSshConfigPath } from "@internal/pane-runtime"; import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; /** @@ -20,3 +21,17 @@ export function subshellLogDir(): string { export function subshellLogPath(id: string): string { return `${subshellLogDir()}/${id}.log`; } + +/** + * The rendered ssh config for one pane ON THE SERVER's own disk — the local + * twin of the node-side path the agent derives from its own dataDir + * (spec 2026-10-07 decision 4). The composition is the pane-runtime + * {@link buildSshConfigPath} itself, not a re-spelling of its template: + * plane and node agree byte-for-byte because they call ONE function, and the + * `LocalLauncher` launch path refuses a plan whose `configPath` disagrees + * with this derivation. Throws on an id outside the composition guard, like + * every other call site. + */ +export function subshellSshConfigPath(id: string): string { + return buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id); +} diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts new file mode 100644 index 000000000..ca2d9840d --- /dev/null +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -0,0 +1,540 @@ +import { rmSync } from "node:fs"; +import { homedir as osHomedir } from "node:os"; +import { dirname } from "node:path"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { + buildSshConfigPath, + discoverSshAliases, + findBinary, + getHarness, + renderSshConfigContents, + resolveSshAliasConfig, + sshDestinationToken, + sshOptionTokens, +} from "@internal/pane-runtime"; +import { + type NodeSshAliasListResult, + type NodeSshResolveOutcomeWire, + parseNodeSshAliasList, + parseSshConnectionSnapshot, + SSH_NAME_MAX_CHARS, + type SshConnectionSnapshotWire, +} from "@internal/subshell-protocol"; +import { loadNodeGate, type NodeGate } from "@/api/nodes/node-gate.js"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { db } from "@/db/index.js"; +import { SettingsRepository } from "@/db/repositories/settings.repository.js"; +import { SshSavedHostsRepository } from "@/db/repositories/ssh-saved-hosts.repository.js"; +import { LOCAL_NODE_ID, type NodeTable } from "@/db/types/nodes.db-types.js"; +import type { SshSavedHostTable } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import type { SubshellTable } from "@/db/types/subshells.db-types.js"; +import { nodeCanSsh } from "@/lib/node-access.js"; +import { audit } from "@/services/audit.js"; +import { getHeld, getLive } from "@/services/nodes/node-registry.js"; +import { SshRpcError, sshDiscover, sshResolve } from "@/services/nodes/ssh-rpc.js"; +import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; +import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import type { SubshellsService } from "@/services/subshells.service.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The SSH launcher surface (spec 2026-10-07 §5/§7, plan 2 Task 7): the gate, + * the resolve trip, the compose, the launch, the ledger, the audit. + * + * **Responsibilities in order, each pinned by `api/ssh/__tests__/ssh-routes.test.ts`:** + * 1. GATE: `nodeCanSsh` answers from the plane's OWN row and the caller's + * access BEFORE any `sendCommand` — the predicate says yes/no, this file + * names the cause in the message (spec §12); the code is one + * `SSH_GATE_OFF` for both causes. A held agent node is refused with the + * update remedy before any resolve (a held socket answers nothing but + * `update`, and a launch aimed at it is a frame the agent will never see). + * 2. The destination is validated with the wire grammar's own alias rule + * BEFORE the machine is asked; discovery answers and hand-typed tokens + * take the SAME resolve path (`ssh_resolve_config`), so nothing composes a + * snapshot the resolver did not approve. + * 3. The refusal shape splits by endpoint, per the frozen doctrine: `resolve` + * answers a refusal outcome IN THE DATA (200, a fact the human reads); + * `launch` and `saved-hosts PUT` refuse it (422 `{outcome}`), because + * acting on an unapproved destination is the act they are. + * 4. Compose happens only against a snapshot re-validated by + * `parseSshConnectionSnapshot` at THIS trust boundary (the RPC validator + * already ran; the renderer's own entry check is the third copy — the + * tier-1 grammar doc names re-validation at each boundary the + * load-bearing defense, not the plane's approval). + * 5. The audit `ssh.launch` lands AFTER a successful launch, never before — + * the same posture as `subshell.create`, which the manager records only + * once the spawn returned. A refused resolve, a thrown create, an offline + * machine: the trail stays silent about the act that did not happen. + * Saved-host CRUD audits nothing (the prompts precedent: a preference row + * is not an event). + */ + +/** The refusal union the routes render verbatim (a returned `status()` body, never a thrown). */ +export type SshRefusal = + | { status: 400 | 403 | 404 | 409 | 502; code: BackendErrorCodes; message: string } + | { status: 422; outcome: Extract }; + +/** The service answer shape: a value, or the refusal the route returns as-is. */ +export type SshAnswer = { ok: true; value: T } | { ok: false; refusal: SshRefusal }; + +function ok(value: T): SshAnswer { + return { ok: true, value }; +} + +function codedRefusal(status: 400 | 403 | 404 | 409 | 502, code: BackendErrorCodes, message: string): SshAnswer { + return { ok: false, refusal: { status, code, message } }; +} + +function refusedRefusal(outcome: Extract): SshAnswer { + return { ok: false, refusal: { status: 422, outcome } }; +} + +/** + * The destination as the wire grammar reads an alias (ssh-frames.ts + * `isAliasName`, same numbers): 1..{@link SSH_NAME_MAX_CHARS}, no leading + * `-`, no whitespace or control characters. Discovery answers and manual + * tokens take this SAME path before any machine is asked — the tier-1 + * injection posture, applied at the surface rather than trusted to the + * renderer's last-station check. + */ +export function isWireSafeSshDestination(value: string): boolean { + return value.length > 0 && value.length <= SSH_NAME_MAX_CHARS && !value.startsWith("-") && !/\s|\p{Cc}/u.test(value); +} + +/** + * Everything the ssh launch needs, composed from an APPROVED snapshot, the + * target machine's dataDir, and the new pane's id (spec 2026-10-07 decision + * 4: the path is DERIVED from the two facts each end already holds). The + * derived path rides the launch frame's ssh member, but only as a CLAIM the + * other end never trusts: `apps/node/agent/src/commands/launch.ts` re-derives + * it from its own dataDir and the pane id and refuses unless the frame's + * `configPath` is byte-equal (the LocalLauncher byte-checks in-process), so + * naming the path on the wire grants no write the machine did not choose. + * Handoff 4's exact tail: every option token, then `--`, + * then the bare host; `SSH_AUTH_SOCK` joins the pane env only when the + * snapshot names a socket (decision 3's scoped exception). + * + * Throws (an impossible-state guard, never a user path) when the snapshot + * fails the grammar re-check at this boundary, or the id is outside the + * path-composition guard. + */ +export function composeSshLaunch(args: { + snapshot: SshConnectionSnapshotWire; + targetDataDir: string; + subshellId: string; +}): { + configPath: string; + fileContent: string; + presetFlags: string[]; + extraPaneEnv?: Record; +} { + const approved = parseSshConnectionSnapshot(args.snapshot); + if (approved === null) throw new Error("ssh compose: snapshot failed grammar re-validation"); + const configPath = buildSshConfigPath(args.targetDataDir, args.subshellId); + const fileContent = renderSshConfigContents(approved); + const presetFlags = [...sshOptionTokens(approved, configPath), "--", sshDestinationToken(approved)]; + return approved.authAgentSocket === null + ? { configPath, fileContent, presetFlags } + : { configPath, fileContent, presetFlags, extraPaneEnv: { SSH_AUTH_SOCK: approved.authAgentSocket } }; +} + +/* ------------------------------------------------------------------ */ +/* config lifecycle: the LOCAL sweep, ONE function for every teardown */ +/* point (spec 2026-10-07 decision 4; plan-2 handoff 2) */ +/* ------------------------------------------------------------------ */ + +/** + * Best-effort `rm -rf` of the per-pane ssh config dir a LOCAL ssh pane wrote + * on this disk: `/ssh/` — the dirname of the + * byte-derived config path the LocalLauncher wrote into. ONE function, called + * from every site where such a pane stops existing, so no copy can drift: + * + * - the shared death transition (the manager's `#applyDeath`), the DETERMINISTIC + * driver: the 60 s reconcile reaches it whenever the process is gone, so a + * lost `pane-died` hook can no longer leave the dir behind (e2e spec 22's + * intermittent miss); + * - the pane's own death report (the service's `reportExit`), which fires even + * when the row is already retired: a terminate kills the pane, the tmux + * `pane-died` hook races the retire stamp, and whichever order they land in + * the config must be gone once the report has been heard; + * - the create rollback: a LOCAL ssh launch that wrote its config then threw + * past the spawn retires the row here, and the half-built dir must go with it; + * - the terminate verb, AFTER the kill: terminate revokes the pane's token + * synchronously with the kill, so the dying hook's report can arrive 401 + * and run no sweep at all (e2e spec 22) — the hand that kills must also + * remove the dir; + * - the maintenance kill (the manager's `terminateForMaintenance`), which + * reaches the manager's own kill directly, never passing through the + * service verb above: without its own sweep a maintenance window left + * every local ssh config dir behind; + * - the delete path: the row is gone before the sweep could ever ride the + * exit hook, and the artifacts list must not carry the config path either, + * because removing the file and leaving the DIR behind is not a removal. + * + * The guard is the row's OWN facts (`local` node ∧ the snapshot column, + * migration 0048) applied HERE, once, so callers hand over the row they + * already hold unconditionally. Agent rows are skipped by rule, not by + * accident — their config lives on the NODE's disk and the agent's own exit + * watcher unlinks it; removing a directory named after a remote pane off THIS + * server would be this host deleting a file that is not its (the delete + * path's per-node launcher doctrine, same reason). + * + * Best-effort by shape: any failure (missing dir, an id outside the path + * guard, a refusing filesystem) costs one log line and nothing else — the + * teardown must land regardless, and a stale config in a dir whose name is a + * dead pane's id is garbage, not a hazard. + */ +export function sweepLocalSshDir(row: Pick | null | undefined): void { + if (!row || row.nodeId !== LOCAL_NODE_ID || row.ssh === null) return; + try { + rmSync(dirname(subshellSshConfigPath(row.id)), { recursive: true, force: true }); + } catch (err) { + logger.withError(err).warn(`ssh config sweep failed for local pane ${row.id}`); + } +} + +/* ------------------------------------------------------------------ */ +/* the gate (spec §4.3: the plane's row, then the held check, BEFORE */ +/* any command; naming WHY is this surface's copy, per §12) */ +/* ------------------------------------------------------------------ */ + +/** The acting user's SSH-usable gate on one node, or the refusal that closes the request. */ +async function gateSshNode(viewerId: string, nodeId: string): Promise> { + // Invisible and absent collapse to one 404 (the node-gate doctrine: an + // invisible node must never answer 403, so ids cannot be probed). + const gate = await loadNodeGate(viewerId, nodeId); + if (!gate) return codedRefusal(404, BackendErrorCodes.NOT_FOUND_ERROR, "Node not found"); + const sshEnabled = gate.row.sshEnabled === 1; + const serverAccountEnabled = await serverSubshellsEnabled(db); + if ( + !nodeCanSsh({ kind: gate.row.kind, access: gate.access, isAdmin: gate.isAdmin, serverAccountEnabled, sshEnabled }) + ) { + return codedRefusal(403, BackendErrorCodes.SSH_GATE_OFF, gateCause(gate.row, serverAccountEnabled)); + } + // Held (spec 2026-09-15 §5.3) means the plane will not speak that socket; + // every ssh command would die at the transport anyway, and the honest + // answer names the one remedy that works: the node update. + if (gate.row.kind === "agent" && getHeld(nodeId)) { + return codedRefusal( + 409, + BackendErrorCodes.NODE_PROTOCOL_HELD, + "This machine is running a Subshell version this server does not speak, so it is held until it is updated. Update it from its machine page, then retry.", + ); + } + return ok(gate); +} + +/** The true cause, named. One `SSH_GATE_OFF` code; the message says which door. */ +function gateCause(row: NodeTable, serverAccountEnabled: boolean): string { + const machine = row.kind === "local" ? "this host" : "this machine"; + if (row.sshEnabled !== 1) { + return row.kind === "local" + ? `Subshell SSH is off for ${machine}. An admin can switch it on from this machine's settings.` + : `Subshell SSH is off for ${machine}. Its owner can switch it on from this machine's settings.`; + } + if (row.kind === "local" && !serverAccountEnabled) { + return `Using ${machine} as a launch target is switched off in the server settings, so SSH panes cannot start there either.`; + } + return `Subshell SSH on ${machine} is reserved to its owner; a shared machine stays the owner's to SSH from.`; +} + +/** The machine refused or could not answer; the code family, never the agent's own text. */ +function rpcRefusal(err: SshRpcError): SshAnswer { + if (err.kind === "offline") { + return codedRefusal( + 409, + BackendErrorCodes.NODE_OFFLINE, + "That machine has no live connection right now; bring its Subshell app online and retry.", + ); + } + if (err.kind === "unsupported") { + return codedRefusal( + 409, + BackendErrorCodes.NODE_OUTDATED, + "The Subshell app on that machine is too old to speak SSH. Update it from its machine page.", + ); + } + if (err.kind === "timeout") { + return codedRefusal( + 409, + BackendErrorCodes.NODE_UNREACHABLE, + "That machine did not answer the SSH request in time; check its connection and retry.", + ); + } + // refused / malformed: the machine's own answer (`ssh disabled on this + // node`, no ssh binary, a payload that failed the frozen validator). Its + // verbatim text goes to the LOG, never to the response (the + // files-remote-browse posture: agent refusal strings are not client API). + logger.withError(err).warn(`ssh rpc ${err.kind} failed against node ${err.nodeId}: ${err.detail ?? err.message}`); + return codedRefusal( + 502, + BackendErrorCodes.SSH_NODE_REFUSED, + "The connecting machine refused the SSH request. Check that SSH is switched on there and that ssh is installed.", + ); +} + +/* ------------------------------------------------------------------ */ +/* transports: the agent answers over the signed RPC; `local` runs */ +/* the SAME pane-runtime engines in-process (no agent socket exists — */ +/* ssh-frames.ts spells the commands transport-agnostic for exactly */ +/* this, and the server account's gate is the DB row, not a mirror). */ +/* ------------------------------------------------------------------ */ + +async function nodeAliases(row: NodeTable): Promise> { + if (row.kind === "local") { + const found = parseNodeSshAliasList(discoverSshAliases({ homeDir: osHomedir() })); + if (!found) { + // Unreachable by construction (the discovery engine is OURS); refuse + // loudly rather than pass anything the wire validator rejected. + return codedRefusal(502, BackendErrorCodes.SSH_NODE_REFUSED, "This host could not produce its alias list."); + } + return ok(found); + } + try { + return ok(await sshDiscover(row.id)); + } catch (err) { + if (err instanceof SshRpcError) return rpcRefusal(err); + throw err; + } +} + +async function nodeResolve(row: NodeTable, destination: string): Promise> { + if (row.kind === "local") { + // The same ladder the agent runs (decision 2: the rule travels, the path + // is the machine's own) — for `local`, this process IS the machine. + const spec = getHarness("ssh")?.detectSpec; + const sshBin = spec ? await findBinary(spec.binaryName, spec.envOverride, spec.knownPaths) : null; + if (sshBin === null) { + return codedRefusal( + 502, + BackendErrorCodes.SSH_NODE_REFUSED, + "This host has no ssh binary installed, so it cannot resolve a destination.", + ); + } + return ok(await resolveSshAliasConfig(destination, { sshBin, homeDir: osHomedir() })); + } + try { + return ok(await sshResolve(row.id, destination)); + } catch (err) { + if (err instanceof SshRpcError) return rpcRefusal(err); + throw err; + } +} + +/** The node's dataDir at the compose instant, or null (the offline class). */ +function targetDataDir(row: NodeTable): string | null { + // local: the server's own dir (LocalLauncher byte-checks this exact + // derivation); agent: the ready-reported `dataDir`, which the AGENT + // re-derives from itself and refuses a byte-mismatch on. + return row.kind === "local" ? SUBSHELL_SERVER_DATA_DIR : (getLive(row.id)?.agent?.dataDir ?? null); +} + +/* ------------------------------------------------------------------ */ +/* endpoints */ +/* ------------------------------------------------------------------ */ + +/** `GET /api/ssh/aliases` — the machine's usable alias names (gate, then the transport). */ +export async function sshListAliases(viewerId: string, nodeId: string): Promise> { + const gate = await gateSshNode(viewerId, nodeId); + if (!gate.ok) return gate; + return await nodeAliases(gate.value.row); +} + +/** + * `POST /api/ssh/resolve` — the destination's outcome. A refusal rides IN THE + * DATA (200): which setting blocked is a fact the human reads, not a failure + * of this request. + */ +export async function sshResolveDestination( + viewerId: string, + nodeId: string, + destination: string, +): Promise> { + const gate = await gateSshNode(viewerId, nodeId); + if (!gate.ok) return gate; + if (!isWireSafeSshDestination(destination)) { + return codedRefusal(400, BackendErrorCodes.ALIAS_UNSAFE, UNSAFE_DESTINATION_COPY); + } + return await nodeResolve(gate.value.row, destination); +} + +const UNSAFE_DESTINATION_COPY = + "That destination is not a token ssh can be given safely: no leading dash, no whitespace or control characters, at most 253 characters."; + +/** + * `POST /api/ssh/launch` — gate, resolve, compose, launch, ledger, audit. + * The pane arrives through the ordinary create path (`harnessId: "ssh"`, + * presetless: `presetFlags` CARRY the launch, the working directory gets the + * launch node's home by the spec-2026-10-01 terminal default), and the ssh + * member + snapshot ride the plumbing this task added to `createSubshell`. + * `restartOnExit` inherits the preset — a presetless launch inherits nothing + * (0): ssh panes are forced off auto-restart by construction, decision 7's + * other half. + */ +export async function sshLaunch(args: { + viewerId: string; + nodeId: string; + destination: string; + name?: string; + /** The REQUEST-scoped subshells service (the create path is its own). */ + subshells: Pick; +}): Promise> { + const gate = await gateSshNode(args.viewerId, args.nodeId); + if (!gate.ok) return gate; + const row = gate.value.row; + if (!isWireSafeSshDestination(args.destination)) { + return codedRefusal(400, BackendErrorCodes.ALIAS_UNSAFE, UNSAFE_DESTINATION_COPY); + } + const resolved = await nodeResolve(row, args.destination); + if (!resolved.ok) return resolved; + if (!resolved.value.accepted) return refusedRefusal(resolved.value); + const snapshot = resolved.value.snapshot; // parseNodeSshResolveOutcome already narrowed + rebuilt it + const dataDir = targetDataDir(row); + if (dataDir === null) { + // Between the resolve and here the machine dropped, or it connected + // without reporting `ready` facts yet — there is no dataDir to derive a + // path from, so nothing was composed or spawned. + return codedRefusal( + 409, + BackendErrorCodes.NODE_OFFLINE, + "That machine has not reported its data directory yet; retry once it is fully connected.", + ); + } + const subshellId = crypto.randomUUID(); + const composed = composeSshLaunch({ snapshot, targetDataDir: dataDir, subshellId }); + // The create can still refuse (node switched off, harness gone, a + // lockdown, a maintenance window) — those ride the global error handler + // with their own codes. They land here, AFTER the resolve: the honest + // order, since this act's gate (SSH use) is strictly narrower than the + // launch's and every refusal below writes no pane. + const created = await args.subshells.createSubshell({ + userId: args.viewerId, + harnessId: "ssh", + presetId: null, + // The presetless-terminal default (spec 2026-10-01 §2): the launch node's + // home, resolved by the create path itself — exactly what the wizard's + // "connect from this machine" journey wants as the pane's starting cwd. + workingDir: undefined, + name: args.name, + nodeId: row.id, + machineActor: false, // the routes are cookie-only (requireCookieActor) + crossAgent: false, + subshellId, + ssh: { configPath: composed.configPath, fileContent: composed.fileContent, snapshot }, + presetFlags: composed.presetFlags, + ...(composed.extraPaneEnv ? { extraPaneEnv: composed.extraPaneEnv } : {}), + }); + // Recency FIRST (a bookkeeping refresh the human already sees as part of the + // launch), then the audit — both AFTER the pane exists. The ledger is + // best-effort (a failed touch must not turn a live pane into an error, the + // recentPaths posture); the audit sink never throws by contract. + const destination = sshCanonicalDestination({ host: snapshot.host, port: snapshot.port, user: snapshot.user }); + const now = new Date().toISOString(); + await new SshSavedHostsRepository(db) + .touch({ ownerUserId: args.viewerId, destination, nodeId: row.id, alias: snapshot.alias, at: now }) + .catch((err: unknown) => { + logger.withError(err).warn(`ssh saved-hosts recency touch failed for ${args.viewerId}/${destination}`); + }); + // Success-only, matching the manager's own `subshell.create` posture (which + // is recorded after the spawn returned): a refusal writes nothing to that + // trail, and an act that failed to spawn is not an event. + await audit({ + actorUserId: args.viewerId, + action: "ssh.launch", + targetType: "node", + targetId: row.id, + metadataJson: JSON.stringify({ nodeId: row.id, destination, subshellId: created.id }), + }); + return ok({ subshellId: created.id }); +} + +/** + * `PUT /api/ssh/saved-hosts` — save a destination: the SAME gate + resolve + * (the canonical key must come from a validated snapshot's fields, handoff + * 1), refusal-shaped outcomes refuse with 422 exactly as a launch would. + * Unaudited (prompts precedent: this is a preference row, not an event). + */ +export async function sshSaveHost(args: { + viewerId: string; + nodeId: string; + destination: string; + /** Explicit display token; absent falls back to the snapshot's alias. */ + alias?: string; +}): Promise> { + const gate = await gateSshNode(args.viewerId, args.nodeId); + if (!gate.ok) return gate; + if (!isWireSafeSshDestination(args.destination)) { + return codedRefusal(400, BackendErrorCodes.ALIAS_UNSAFE, UNSAFE_DESTINATION_COPY); + } + const resolved = await nodeResolve(gate.value.row, args.destination); + if (!resolved.ok) return resolved; + if (!resolved.value.accepted) return refusedRefusal(resolved.value); + const snapshot = resolved.value.snapshot; + const row = await new SshSavedHostsRepository(db).markSaved({ + ownerUserId: args.viewerId, + destination: sshCanonicalDestination({ host: snapshot.host, port: snapshot.port, user: snapshot.user }), + nodeId: gate.value.row.id, + alias: args.alias ?? snapshot.alias, + at: new Date().toISOString(), + }); + return ok(row); +} + +/** `DELETE /api/ssh/saved-hosts/:id` — false for foreign AND absent (one 404). */ +export async function sshRemoveSavedHost(viewerId: string, id: string): Promise { + return await new SshSavedHostsRepository(db).remove(viewerId, id); +} + +/** The GET payload of the launcher screen: the owner's rows plus their default machine. */ +export async function sshSavedHostsView(viewerId: string): Promise<{ + saved: SshSavedHostTable[]; + recent: SshSavedHostTable[]; + defaultNodeId: string | null; +}> { + const repo = new SshSavedHostsRepository(db); + const [saved, recent, defaultNodeId] = await Promise.all([ + repo.listSaved(viewerId), + repo.listRecent(viewerId), + getSshDefaultNode(viewerId), + ]); + return { saved, recent, defaultNodeId }; +} + +/* ------------------------------------------------------------------ */ +/* preferences: the default connecting machine, a per-user setting */ +/* ------------------------------------------------------------------ */ + +/** + * The preference lives in the instance `settings` table under a per-user + * NAMESPACED key because that is the only JSON key/value store this schema + * has and a migration for one string was not on Task 7's map: `user_meta` + * columns each need their own migration, and 0048 (the ledger + the snapshot + * column) was frozen before preferences were wired. The namespace keeps two + * users' defaults from sharing a row; nothing renders `settings` rows raw, so + * the key shape is storage detail, not API surface. + */ +const sshDefaultNodeKey = (userId: string): string => `ssh.defaultNode:${userId}`; + +export async function getSshDefaultNode(userId: string): Promise { + return (await new SettingsRepository(db).get(sshDefaultNodeKey(userId), null)) ?? null; +} + +/** + * `PATCH /api/ssh/preferences`. The WRITE validates the node exists and is + * visible to the caller (404 otherwise — the ordinary invisibility, never a + * "you may not" leak). The READ path (above) deliberately returns the stored + * id WITHOUT a visibility read: a node deleted under a saved default must not + * silently clear a preference the human chose, and a preference that + * re-validates on every page load would make the launcher's first GET + * machine-visibility-coupled; the SPA resolves the id against its node list + * and decides what to show. + */ +export async function sshSetDefaultNode(viewerId: string, nodeId: string | null): Promise> { + if (nodeId !== null) { + const gate = await loadNodeGate(viewerId, nodeId); + if (!gate) return codedRefusal(404, BackendErrorCodes.NOT_FOUND_ERROR, "Node not found"); + } + await new SettingsRepository(db).set(sshDefaultNodeKey(viewerId), nodeId); + return ok(nodeId); +} diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index d33dc3811..daea235e1 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -17,6 +17,7 @@ import { type NodeProbeEntry, normalizeLabel, parseNodeProbeEntries, + type SshConnectionSnapshotWire, } from "@internal/subshell-protocol"; import { harnessUsable } from "@/api/harness-utils.js"; import type { PresetsRepository } from "@/db/repositories/presets.repository.js"; @@ -58,6 +59,7 @@ import { subshellLogPath } from "@/services/nodes/subshell-paths.js"; import { getNotifyService, type NotifyKind } from "@/services/notify.service.js"; import { EMPTY_PRESET, parsePreset } from "@/services/preset-definition.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; import { issueSubshellToken, revokeSubshellToken } from "@/services/subshell-tokens.js"; import { logger } from "@/utils/logger.js"; @@ -128,6 +130,23 @@ const defaultNotify: (subshellId: string, kind: NotifyKind) => Promise = a await getNotifyService().notifySubshell(id, kind); }; +/** + * The composed half of an ssh-pane launch (spec 2026-10-07 decision 4, Task 7 + * plumbing): the derived config path, the rendered bytes, and the APPROVED + * snapshot the row keeps. `subshells.ssh` = `JSON.stringify(snapshot)` is + * written by `createSubshell`; the LaunchPlan member carries only + * `configPath`/`fileContent` (the snapshot never rides the wire — the + * launcher re-derives the path and the renderer produced the bytes). + */ +export interface SshLaunchPlumbing { + /** `buildSshConfigPath(, )` — derived, never chosen. */ + configPath: string; + /** `renderSshConfigContents(snapshot)`. */ + fileContent: string; + /** The snapshot the resolver approved (grammar-validated at each boundary already). */ + snapshot: SshConnectionSnapshotWire; +} + /** * Orchestrates agent subshells: validate inputs, spawn a tmux-backed harness, * stream output to a per-subshell log file, and reconcile DB state with what @@ -317,6 +336,10 @@ export class SubshellManagerService { notify, nodeId, crossAgent, + ssh, + presetFlags, + extraPaneEnv, + subshellId, }: { userId: string; /** Harness plugin to launch — required, with or without a preset. */ @@ -356,7 +379,40 @@ export class SubshellManagerService { * silent. Only creation writes it; a restart reuses the row. */ crossAgent?: number; + /** + * The composed ssh pane (Task 7 plumbing): the row gets + * `ssh = JSON.stringify(snapshot)`, the LaunchPlan gets the config member, + * and the launch is refused nothing else — an ssh pane is a terminal-type + * harness whose flags arrive through `presetFlags`. + */ + ssh?: SshLaunchPlumbing; + /** + * The launch preset's flags when a caller composes them (the ssh service + * does; no one else does today). Present ⇒ the launch preset is + * `{ ...EMPTY_PRESET, flags }` — a FRESH object, since EMPTY_PRESET is the + * shared frozen constant every other presetless launch reads. Ignored when + * a preset row is also given (the row wins: presetFlags exists for the + * presetless compose path, not to edit somebody's saved preset). + */ + presetFlags?: string[]; + /** + * Env additions merged into the pane's `subshellEnv` at the compose site + * (spec 2026-10-07 decision 3: an ssh pane's `SSH_AUTH_SOCK` — the pane's + * command IS the ssh process, so the pane env is the ssh env). Merged + * AFTER the preset-independent `SUBSHELL_*` block, before the launch. + */ + extraPaneEnv?: Record; + /** + * Pre-assigned row id, for callers that must compose id-derived facts + * BEFORE the row exists (the ssh config path is derived from the pane's + * id; Task 7). Shape-guarded; production callers pass nothing and get the + * `crypto.randomUUID()` they always got. + */ + subshellId?: string; }): Promise<{ id: string; tmuxSocket: string; apiKey: string; promptDelivered: boolean }> { + if (subshellId !== undefined && !/^[a-zA-Z0-9_-]{1,64}$/.test(subshellId)) { + throw new Error("createSubshell: subshellId is outside the id grammar"); + } const presetRow = presetId ? await this.#presets.findById(presetId) : undefined; if (presetId && (!presetRow || presetRow.userId !== userId)) { throw new Error("Preset not found"); @@ -373,13 +429,21 @@ export class SubshellManagerService { const launcher = this.#launcherFor(targetNode); const realPath = await launcher.validateWorkingDir(workingDir); await assertDirAllowed(targetNode, realPath); - const preset = presetRow ? parsePreset(presetRow) : EMPTY_PRESET; + const preset = presetRow + ? parsePreset(presetRow) + : presetFlags !== undefined + ? // Fresh object every time: EMPTY_PRESET is the shared frozen constant + // every presetless launch reads, and mutating it (or handing out the + // shared reference with new flags hung on it) would leak ssh option + // tokens into unrelated panes. + { ...EMPTY_PRESET, flags: [...presetFlags] } + : EMPTY_PRESET; const binary = await launcher.resolveBinary(harness); if (!binary) { throw new Error(`Harness "${harness.name}" is not installed on this machine.`); } - const id = crypto.randomUUID(); + const id = subshellId ?? crypto.randomUUID(); const socket = tmuxSocketFor(id); // Display name vs LAUNCH name (titling spec 2026-09-03): only a name a // HUMAN chose travels to the pane command — an unnamed create passes "" @@ -423,6 +487,11 @@ export class SubshellManagerService { // How the pane was OPENED, recorded once; the rail's category and the // caller's bell default both read this. 0 = the DB default, human. crossAgent: crossAgent === 1 ? 1 : 0, + // The ssh snapshot column IS the pane's kind fact (migration 0048): the + // owner-only input rule, the restart refusal and the delete sweep all + // read its presence. NULL for every pane this call did not compose from + // an approved snapshot. + ssh: ssh ? JSON.stringify(ssh.snapshot) : null, }); // The token is minted AFTER the row exists (issueSubshellToken writes the @@ -448,6 +517,10 @@ export class SubshellManagerService { // means nothing on the node; the agent's own dataDir is the truth there. subshellEnv.SUBSHELL_DATA_DIR = facts.dataDir; } + // Caller-composed env (Task 7: the ssh pane's SSH_AUTH_SOCK, decision 3) + // merges after the SUBSHELL_* block — `subshellEnv` is a per-launch + // object built just above, never a shared constant, so assigning is safe. + if (extraPaneEnv) Object.assign(subshellEnv, extraPaneEnv); // Command assembly happens INSIDE launch, which runs inside this try: // a rejected env key throws there, and the row + token must roll back // like any other spawn failure below. @@ -464,6 +537,9 @@ export class SubshellManagerService { mcpConfigPath, harnessSession, reporter, + // The composed ssh member (spec 2026-10-07 decision 4). Conditional + // spread: absent on every non-ssh launch, byte-identical plan there. + ...(ssh ? { ssh: { configPath: ssh.configPath, fileContent: ssh.fileContent } } : {}), }); if (prompt?.trim()) { promptDelivered = await this.#deliverPrompt( @@ -486,6 +562,12 @@ export class SubshellManagerService { } catch { // kill is best-effort; the row + token rollback below must still run } + // A LOCAL ssh launch writes its config inside `launch`, before the spawn + // that threw here — so retiring the row must retire the dir too, or a + // half-built `/ssh/` leaks until a manual delete. The guard + // no-ops every non-local / non-ssh create; `targetNode` keeps a remote + // pane's dir on ITS node's disk untouched. + sweepLocalSshDir({ id, nodeId: targetNode, ssh: ssh ? JSON.stringify(ssh.snapshot) : null }); await this.#subshells.markTerminated(id, new Date().toISOString()); await this.#revokeTokenOrUnlink(id); publishLive({ kind: "subshell.changed", id }); @@ -936,12 +1018,12 @@ export class SubshellManagerService { * Terminate one subshell because its NODE entered maintenance * (spec 2026-09-14 §5.2). * - * {@link terminateSubshell} plus a push, and the push is the whole reason - * this exists as a second method rather than a flag: that path is - * deliberately silent because the operator clicked it, and here they did - * not. A node owner's window stops subshells belonging to everyone the node - * was shared with — people who cannot see the node, did not act, and would - * otherwise find a dead pane with no account of why. + * {@link terminateSubshell} plus a push and the local ssh config sweep, and + * the push is the whole reason this exists as a second method rather than a + * flag: that path is deliberately silent because the operator clicked it, + * and here they did not. A node owner's window stops subshells belonging to + * everyone the node was shared with — people who cannot see the node, did + * not act, and would otherwise find a dead pane with no account of why. * * The row's OWN `userId` is passed through, never an actor's: the terminate * path is owner-keyed and would silently skip every row but the caller's. @@ -959,10 +1041,20 @@ export class SubshellManagerService { * the claim too, which is right: nothing was running to stop, and its owner * heard about that death when it happened. * + * The sweep runs whether or not this call claimed the transition (it is an + * idempotent `rm -rf`, and the helper's guard no-ops every row that is not + * a LOCAL ssh pane): the kill inside {@link terminateSubshell} revokes the + * pane's token at the same moment, so the dying hook's report can arrive + * 401 and sweep nothing — exactly the race the terminate verb documented + * (e2e spec 22). The hand that kills removes the dir; without this, a + * maintenance window left every local ssh config dir behind. + * * @param row - the running row to stop, freshly read */ async terminateForMaintenance(row: SubshellTable): Promise { - if (await this.terminateSubshell(row.userId, row.id)) void this.#notify(row.id, "maintenance"); + const stopped = await this.terminateSubshell(row.userId, row.id); + sweepLocalSshDir(row); // guard: local node ∧ snapshot; agent rows skip (their disk, their watcher) + if (stopped) void this.#notify(row.id, "maintenance"); } /** @@ -998,15 +1090,27 @@ export class SubshellManagerService { // attach-replay artifact; the MCP config holds no secrets but nothing // should be left behind). The layout lives behind the launcher seam // (spec §6.4): a local row leaves exactly its replay log; an agent node - // names the triple (log + MCP config + the agent's own meta record — - // deliberately left behind by a kill, so the DELETE unlinks it) from its - // live `ready` facts, and an OFFLINE agent answers `[]`: no facts, no - // layout to name paths from, artifacts age out with the node (§5.6). + // names its triple (log + MCP config + the agent's own meta record — + // deliberately left behind by a kill, so the DELETE unlinks it) plus the + // ssh config at the node-derived path, from its live `ready` facts, and + // an OFFLINE agent answers `[]`: no facts, no layout to name paths from, + // artifacts age out with the node (§5.6). // The local short-circuit is row-keyed because a TEST launcher answers for // EVERY node id (the phase-0 suites) — a fake standing in for `local` // must keep receiving the local artifact set, never the agent triple. - const artifacts = row.nodeId === LOCAL_NODE_ID ? [launcher.logPath(id)] : launcher.subshellArtifacts(id); + // A LOCAL ssh pane's config is deliberately NOT in that list: it lives in + // its own directory under the server's dataDir, and unlinking the file + // while leaving the DIR is not a removal. `sweepLocalSshDir` (the one + // sweep function, shared with the exit report and terminate) removes the + // dir, keyed on the same row facts the old list entry consulted: the + // node id here, the snapshot column (`row.ssh !== null`, migration 0048) + // inside the guard — a row that merely NAMES the ssh harness has no + // config to find, and a snapshot under any other harness id still owns + // one. + const sshPane = row.ssh !== null; + const artifacts = row.nodeId === LOCAL_NODE_ID ? [launcher.logPath(id)] : launcher.subshellArtifacts(id, sshPane); await launcher.removeArtifacts(artifacts); + sweepLocalSshDir(row); // guard: local node ∧ snapshot; agent rows skip (their disk, their watcher) // And the generated MCP config (no secrets, but nothing to leave behind). try { unlinkSync(subshellMcpConfigPath(id)); @@ -1334,6 +1438,13 @@ export class SubshellManagerService { publishLive({ kind: "subshell.changed", id: row.id }); logger.info(`subshell process absent (no socket): ${row.id}`); await this.#notifyDeath(row); + // This no-socket branch stamps alive→dead directly rather than + // through #applyDeath, so the ssh-config sweep that lives there must + // be repeated here (guard no-ops every non-local / non-ssh row). A + // real ssh pane always carries a socket at create, so this is the + // fixture-shaped belt that keeps "whichever hand first notices + // removes the dir" true for EVERY alive→dead stamp, not most. + sweepLocalSshDir(row); } } continue; @@ -1469,16 +1580,19 @@ export class SubshellManagerService { } /** - * The shared death transition — the ONE place a running row is stamped - * alive→dead. Every entry point that learns of a death calls this: the - * sweep (local tmux probe or the agent `probe`/`exit` census), an agent - * `exit` event, and the `subshells_report` reconnect census. Idempotent by - * construction: the stamp + death push fire only while the FRESH row is - * still `running` with `alive: 1`, and a row a restart owns - * (`restartInFlight`) is off-limits. After the (possible) transition the - * auto-restart ladder runs for parked rows, and a subshell that will never - * come back — opted out or backoff-exhausted — has its bearer revoked here. - * The cached preview dies with the pane. + * The shared death transition — where a probe- or report-driven death is + * stamped alive→dead AND the pane's artifacts (preview cache, ssh config dir, + * idle tmux server) are cleared. Every entry point that learns a LIVE pane is + * gone calls this: the sweep (local tmux probe or the agent `probe`/`exit` + * census), an agent `exit` event, and the `subshells_report` reconnect census. + * (The reconcile's no-socket fast path — a row that cannot be alive — stamps + * directly rather than routing here, and repeats the ssh sweep so no + * alive→dead path misses it.) Idempotent by construction: the stamp + death + * push fire only while the FRESH row is still `running` with `alive: 1`, and a + * row a restart owns (`restartInFlight`) is off-limits. After the (possible) + * transition the auto-restart ladder runs for parked rows, and a subshell that + * will never come back — opted out or backoff-exhausted — has its bearer + * revoked here. * @param row - the row as the caller saw it; only its id must still be true * @param opts.exitCode - the observed pane exit code (null = never seen) * @param opts.endedAt - ISO stamp for the death (the agent's clock for remote events) @@ -1518,6 +1632,15 @@ export class SubshellManagerService { // "Never" means opted out OR the backoff limit was exhausted (the // sweep gives up at that count, so the bearer would linger otherwise). const restarted = await this.maybeAutoRestart(fresh); + // The ssh config dir is a pane artifact like the preview cache and the idle + // tmux server below: it dies with the pane, HERE, in the ONE shared + // transition. Doing it only from the tmux `pane-died` hook's self-report + // left a gap — the hook is best-effort, and a report lost to a 401 or a + // dropped exec left the dir behind until a manual teardown; the 60 s + // reconcile reaches this same line whenever the process is gone, so this is + // the deterministic sweep. Guarded on `!restarted` (never clobber a live + // re-render) and by the helper itself (local ∧ ssh; every other row no-ops). + if (!restarted) sweepLocalSshDir(fresh); if (!restarted && (fresh.restartOnExit !== 1 || fresh.backoffCount >= 5)) { // Terminal: this subshell will never come back, so its bearer must // not linger. Revoke failures here must not abort the sweep for the diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index b24feb566..a3af12539 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -18,6 +18,7 @@ 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"; import { loadNodeAccess, type NodeAccessDeps, nodeCanLaunch, nodeCanLaunchOn } from "@/lib/node-access.js"; +import { paneInputAllowed } from "@/lib/ssh-pane-access.js"; import { type Access, accessAtLeast, loadSubshellAccess, resolveSubshellAccess } from "@/lib/subshell-access.js"; import { BaseService, type CommonServiceParams } from "@/services/base.service.js"; import { publishLive } from "@/services/live-bus.js"; @@ -38,11 +39,13 @@ import { import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; import { getNotifyService } from "@/services/notify.service.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; import { PROMPT_POLL_MS, PROMPT_SETTLE_TIMEOUT_MS, RestartInFlightSwapError, readSubshellLogWindow, + type SshLaunchPlumbing, SubshellManagerService, } from "@/services/subshell-manager.service.js"; import { extendSubshellToken, subshellTokenTtlSeconds } from "@/services/subshell-tokens.js"; @@ -412,6 +415,10 @@ export class SubshellsService extends BaseService { nodeId, machineActor, crossAgent, + ssh, + presetFlags, + extraPaneEnv, + subshellId, }: { /** Owner of the new subshell (never taken from the body). */ userId: string; @@ -449,6 +456,19 @@ export class SubshellsService extends BaseService { * they can still turn it on from the pane's menu. */ crossAgent: boolean; + /** + * The composed ssh pane (Task 7 plumbing, spec 2026-10-07 §5/decision 4), + * forwarded to the manager untouched: the snapshot column, the LaunchPlan + * config member, the flags preset, the merged pane env, the pre-assigned + * id the config path was derived from. The ssh-launch service + * (`services/ssh-launch.service.ts`) is the only caller that passes these; + * every other door (REST create, MCP, mobile) launches panes the ordinary + * way and passes none of them. + */ + ssh?: SshLaunchPlumbing; + presetFlags?: string[]; + extraPaneEnv?: Record; + subshellId?: string; }): Promise<{ id: string; tmuxSocket: string; promptDelivered: boolean }> { // Lockdown is the FIRST question the instance asks of every create // (operator ask 2026-09-24): it answers valid bodies and invalid ones @@ -611,6 +631,13 @@ export class SubshellsService extends BaseService { // OFF — agent-to-agent comms are not news a human needs rung for — // and it stays togglable exactly like any other pane's. notify: !crossAgent, + // The ssh plumbing rides to the manager untouched (its own doc there + // says what each part writes); `undefined` everywhere else keeps the + // pre-Task-7 launch byte-identical. + ...(ssh ? { ssh } : {}), + ...(presetFlags !== undefined ? { presetFlags } : {}), + ...(extraPaneEnv !== undefined ? { extraPaneEnv } : {}), + ...(subshellId !== undefined ? { subshellId } : {}), }) .catch(rethrowLaunchRefusal); // Feed the picker's Recents (and the new-subshell form's pre-fill) from @@ -662,14 +689,32 @@ export class SubshellsService extends BaseService { * or on any enrolled node, since both reach this plane the same way. * * Thin by design: the route has already established that this is the - * subshell's own key, and the manager owns the one death transition the - * sweep also runs through — so nothing here decides anything, it only - * carries the timestamp. + * subshell's own key, and the manager owns the one death transition. The + * row read below decides nothing either — it only feeds the shared LOCAL + * ssh config sweep (its own guard skips every other row shape); the death + * transition itself carries just the timestamp. + * + * The sweep here is DELIBERATELY unconditional, not deferred to the + * transition below: `applySelfReportedExit` returns early when the row is + * already retired, and the hook-vs-retire race (a terminate kills the pane, + * its `pane-died` report lands after the row is gone) must still remove the + * config. The complementary gap — a `pane-died` hook lost entirely, so no + * report ever reaches here — is closed by the sweep in the shared + * `#applyDeath`, which the 60 s reconcile drives off the process being gone. * * @param id - the subshell whose pane exited * @param exitCode - tmux's `#{pane_dead_status}`, null when it could not be read */ async reportExit(id: string, exitCode: number | null): Promise { + // The read is part of the best-effort sweep too: a throwing row read costs + // a log line, never the death transition below (the pre-helper code had the + // read inside its try for exactly this). + sweepLocalSshDir( + await this.repos.subshells.findById(id).catch((err: unknown) => { + logger.withError(err).warn(`ssh config sweep skipped for pane ${id}; the row read failed`); + return null; + }), + ); await this.#manager.applySelfReportedExit(id, exitCode, new Date().toISOString()); } @@ -862,6 +907,29 @@ export class SubshellsService extends BaseService { return { ok: true }; } + /** + * The ssh owner-only carve-out on an input act, applied AFTER the visibility + * + grant gate (so a stranger still gets the ordinary 404, and a `view` + * grantee the ordinary 403). An ssh pane runs a real shell under the OWNER's + * ssh identity on the far machine, so its input is the owner's alone: an + * `edit` grantee who is not the owner is refused, and so is any bearer + * machine credential (a pane must not type into itself). Ordinary panes + * (`ssh === null`) pass through untouched. The rule lives in + * {@link paneInputAllowed}, shared with the WS keystroke door (spec + * 2026-10-07 §5.4, decision 5). + * @throws ApiError 403 SSH_OWNER_INPUT_ONLY, before anything is typed. + */ + #requireInputAuthority(row: SubshellTable, viewerId: string, actor: GuardActor): void { + // A bearer actor is a `subshell-key` OR a `system-key`; a human is `cookie`. + if (!paneInputAllowed(row, viewerId, actor !== "cookie")) { + throwApiError({ + code: BackendErrorCodes.SSH_OWNER_INPUT_ONLY, + message: "SSH panes accept input from their owner only", + doNotLog: true, + }); + } + } + /** * Types text into a RUNNING pane over REST (spec 2026-09-25 MCP DX), the * input the live attach socket already carries, given an HTTP door for @@ -898,6 +966,9 @@ export class SubshellsService extends BaseService { * nothing is typed into a row that is not there), and 409 * NODE_OFFLINE when its agent node has no live connection (the * create/restart mapper again). + * @throws ApiError 403 SSH_OWNER_INPUT_ONLY when the pane is an ssh pane and + * the caller is not its owner (or is a bearer credential), before the + * launcher is resolved and nothing is typed. */ async sendSubshellInput( viewerId: string, @@ -907,6 +978,7 @@ export class SubshellsService extends BaseService { actor: GuardActor, ): Promise<{ ok: true }> { const { row } = await this.#gate(viewerId, id, "edit", actor); + this.#requireInputAuthority(row, viewerId, actor); // The TWO facts, both required — a lesson from the live incident // (2026-09-25): `status` is the lifecycle INTENT and a pane that exited // on its own parks at `status: "running"` with `alive: 0` (parked is what @@ -957,7 +1029,9 @@ export class SubshellsService extends BaseService { * a terminal; 409 EXEC_IN_FLIGHT when another exec already holds the * pane's lease; 409 EXEC_PANE_BUSY when the quiet probe finds the * log growing. All four refuse before anything is typed, and any - * launcher refusal maps through the create/restart mapper. + * launcher refusal maps through the create/restart mapper. Exec on + * an ssh pane also carries the input rule: a non-owner 403s + * SSH_OWNER_INPUT_ONLY before even the first quiet probe (spec §5.4). */ async execInTerminal( viewerId: string, @@ -967,6 +1041,10 @@ export class SubshellsService extends BaseService { actor: GuardActor, ): Promise { const { row } = await this.#gate(viewerId, id, "edit", actor); + // The ssh carve-out precedes the running/harness/quiet checks: exec TYPES, + // so it carries the input rule verbatim, and a non-owner never reaches the + // first quiet probe (spec 2026-10-07 §5.4). + this.#requireInputAuthority(row, viewerId, actor); if (row.status !== "running" || row.alive !== 1) { throwApiError({ code: BackendErrorCodes.SUBSHELL_NOT_RUNNING, @@ -1226,6 +1304,19 @@ export class SubshellsService extends BaseService { prompt?: string, ): Promise<{ id: string; tmuxSocket: string; promptDelivered: boolean }> { const { row } = await this.#gate(viewerId, id, "edit", actor); + // An ssh pane refuses restart (spec 2026-10-07 decision 7): its session + // lives on the far end of a socket, and a restart would spawn a NEW ssh + // process the human never asked to reconnect — the launcher is the way + // back in. Keyed to the `subshells.ssh` column (the kind fact, migration + // 0048), asked AFTER the visibility gate so a stranger still gets the + // ordinary 404, and before anything this path reads or writes. + if (row.ssh !== null) { + throwApiError({ + code: BackendErrorCodes.SSH_NO_RESTART, + message: "SSH panes are not restarted; launch a new one to reconnect", + doNotLog: true, + }); + } // Lockdown is instance-wide, so it is asked before anything machine-shaped // is read. It names no machine, per the restart path's own rule (the row's // owner is the only guaranteed viewer of this refusal). And a stopped row's @@ -1353,6 +1444,13 @@ export class SubshellsService extends BaseService { async terminateSubshell(viewerId: string, id: string, actor: GuardActor): Promise<{ ok: true }> { const { row } = await this.#gate(viewerId, id, "edit", actor); await this.#manager.terminateSubshell(row.userId, id); + // AFTER the kill, the same sweep the dying hook runs — and it cannot be + // left TO the hook: terminate revokes the pane's token synchronously with + // the kill, so the `pane-died` report can arrive 401 and never sweep + // (e2e spec 22). The hand that killed removes the dir; the hook, whenever + // it lands, stays the second sure thing (its sweep is idempotent). The + // helper's guard no-ops this for every non-local-ssh row. + sweepLocalSshDir(row); return { ok: true }; } diff --git a/apps/server/api/src/ws/__tests__/attach-resolve.test.ts b/apps/server/api/src/ws/__tests__/attach-resolve.test.ts index a7cd9ccdb..df7e675bb 100644 --- a/apps/server/api/src/ws/__tests__/attach-resolve.test.ts +++ b/apps/server/api/src/ws/__tests__/attach-resolve.test.ts @@ -403,6 +403,78 @@ describe("resolveAttach", () => { }); }); +describe("resolveAttach canInput: the ssh owner-only keystroke rule (spec §5.4)", () => { + // The WS door is `handleSubshellMessage`'s `data.canInput`, and BOTH attach + // paths now carry the value `resolveAttach` computes here. So this is where + // the ssh carve-out is decided for keystrokes; the socket just obeys. A + // keystroke is DROPPED (no error frame) when it refuses, exactly as a `view` + // grantee's is — that is the WS posture, so `canInput` is the whole surface. + + /** A row whose `ssh` column is set (the kind fact, migration 0048). */ + async function seedSshRow(): Promise<{ id: string; userId: string }> { + const { repos } = getRequestlessContext(); + seq += 1; + const row = await repos.subshells.create({ + id: crypto.randomUUID(), + userId: `u-attach-resolve-${seq}`, + presetId: "p-test", + harnessId: "ssh", + name: "attach-ssh-input", + workingDir: "/tmp", + tmuxSocket: "subshell-attach-ssh", + ssh: JSON.stringify({ destination: "host.example", user: "theo", port: 22 }), + }); + return { id: row.id, userId: row.userId }; + } + + const canInput = async (query: string): Promise => { + const out = await resolveAttach(request(query)); + if (!out.ok) throw new Error(`attach refused: ${JSON.stringify(out)}`); + return out.canInput; + }; + + it("an ordinary pane + an edit grantee types (canInput unchanged from the grant)", async () => { + const row = await seedRow(); + const { repos } = getRequestlessContext(); + const guest = `u-attach-ssh-editor-${seq}`; + await repos.subshellShares.replaceForSubshell(row.id, [{ granteeUserId: guest, permission: "edit" }], row.userId); + expect(await canInput(`subshell=${row.id}&token=${issueWsToken(guest)}`)).toBe(true); + }); + + it("an ssh pane + the OWNER (unbound token) types", async () => { + const row = await seedSshRow(); + // `issueWsToken(userId)` with no subshell is a cookie-minted unbound + // identity: the human's own keystrokes, so bearerActor is false. + expect(await canInput(`subshell=${row.id}&token=${issueWsToken(row.userId)}`)).toBe(true); + }); + + it("an ssh pane + an EDIT grantee is refused (access says edit, canInput says no)", async () => { + const row = await seedSshRow(); + const { repos } = getRequestlessContext(); + const guest = `u-attach-ssh-blocked-${seq}`; + await repos.subshellShares.replaceForSubshell(row.id, [{ granteeUserId: guest, permission: "edit" }], row.userId); + const out = await resolveAttach(request(`subshell=${row.id}&token=${issueWsToken(guest)}`)); + expect(out.ok).toBe(true); + // The grant is genuinely `edit` — the refusal is the ssh kind, not access. + if (out.ok) { + expect(out.access).toBe("edit"); + expect(out.canInput).toBe(false); + } + }); + + it("an ssh pane + its OWN scoped key is refused (decision 5: no self-typing)", async () => { + const row = await seedSshRow(); + // A scoped token records the OWNER as its identity, so `access` resolves to + // owner; only `bearerActor` closes the self-typing door. + const out = await resolveAttach(request(`subshell=${row.id}&token=${issueWsToken(row.userId, row.id)}`)); + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.access).toBe("owner"); + expect(out.canInput).toBe(false); + } + }); +}); + describe("resolveAttach clears the unseen push (spec 2026-09-23)", () => { const email = `attach-unseen-${crypto.randomUUID()}@subshell.local`; const pw = "attach-unseen-pass-1"; diff --git a/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts b/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts index ed163bd5d..778fb664f 100644 --- a/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts +++ b/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts @@ -231,7 +231,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { scriptHappy(sim); const { ws, sent } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "tail_start on the wire"); // Command ORDER through the real sendCommand: liveness, the tail's JOIN @@ -284,7 +284,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("log_read", { bytes_b64: "", next: 0, size: 0 }); const { ws, sent, closed } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "tail armed at offset 0"); // Terminal frames only: the socket also carries `viewers` presence now. @@ -310,7 +310,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { // still starts at EOF (12) — history ships inside the capture, never as // re-played log bytes. await setOwnerCap(2); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "tail at EOF"); expect(sim.cmdsOf("capture")).toEqual([{ type: "capture", subshellId: SID, lines: 2 }]); expect((sim.cmdsOf("tail_start")[0] as { fromByte: number }).fromByte).toBe(12); @@ -324,7 +324,14 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("capture", "SCREEN"); scriptLogRead(sim, { bytes: "l1\nl2\nl3\n", size: 12 }); await setOwnerCap(9999); - await attachRemoteSubshellWs(fakeBrowser().ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs( + fakeBrowser().ws, + attachRow(), + new RemoteLauncher(NODE_ID), + "owner", + attachParams(), + true, + ); await until(() => sim.cmdsOf("capture").length === 1, "capture (cap 200)"); expect((sim.cmdsOf("capture")[0] as { lines?: number }).lines).toBe(200); @@ -334,7 +341,14 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim2.answer("capture", "SCREEN"); scriptLogRead(sim2, { bytes: "l1\nl2\nl3\n", size: 12 }); await setOwnerCap(0); - await attachRemoteSubshellWs(fakeBrowser().ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs( + fakeBrowser().ws, + attachRow(), + new RemoteLauncher(NODE_ID), + "owner", + attachParams(), + true, + ); await until(() => sim2.cmdsOf("capture").length === 1, "capture (cap 1)"); expect((sim2.cmdsOf("capture")[0] as { lines?: number }).lines).toBe(1); }); @@ -357,6 +371,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 132, rows: 43 } }), + true, ); await until(() => sim.cmdTypes().includes("tail_start"), "tail armed"); // Resize lands ahead of the capture so the replay matches the client @@ -405,6 +420,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 132, rows: 43 } }), + true, ); await until(() => sim.cmdTypes().includes("capture"), "capture"); // Exactly ONE resize — the fit. The nudge would add 133-then-back. @@ -426,6 +442,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 132, rows: 43 } }), + true, ); await until(() => sim.cmdTypes().includes("capture"), "capture"); expect(sim.cmdsOf("resize")).toEqual([{ type: "resize", subshellId: SID, cols: 132, rows: 43 }]); @@ -446,7 +463,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("pane_cursor", { x: 4, y: 2 }); const { ws, sent } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sent.length > 0, "replay"); // tmux's 0-based (4,2) becomes the 1-based CUP ESC[3;5H, and the replay // frame — the browser's first paint — must end with it. @@ -457,7 +474,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { it("refuses 4004 'node offline' when the node has no live connection (nothing hits the wire)", async () => { const { ws, sent, closed } = fakeBrowser(); // No attachConnection for NODE_ID — the registry says offline. - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); expect(closed).toEqual([{ code: 4004, reason: "node offline" }]); expect(sent).toEqual([]); }); @@ -467,7 +484,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("probe", [{ subshellId: SID, alive: false, exitCode: 1 }]); const { ws, sent, closed } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); expect(closed).toEqual([{ code: 4004, reason: "subshell not running" }]); expect(sent).toEqual([]); expect(sim.cmdTypes()).toEqual(["probe"]); // no capture, no reads, no tail @@ -480,7 +497,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("capture", fail("can't find session: pane gone")); // tmux stderr, verbatim const { ws, sent, closed } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); expect(closed).toEqual([{ code: 4004, reason: "subshell not running" }]); expect(sent).toEqual([]); // The pump is armed before the capture (join-point rule), so a refusal @@ -497,7 +514,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { scriptHappy(sim); const { ws, sent, closed } = fakeBrowser(() => 4 * 1024 * 1024 + 1); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "tail armed"); // Terminal frames only: the socket also carries `viewers` presence now. expect(sent.filter((f) => !f.includes('"type":"viewers"'))).toEqual([ @@ -522,7 +539,7 @@ describe("attachRemoteSubshellWs — input/resize/cleanup ride the shared handle scriptHappy(sim); const { ws } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "edit", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "edit", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "attached"); handleSubshellMessage(ws, JSON.stringify({ type: "input", data: "ls\r" })); @@ -539,7 +556,29 @@ describe("attachRemoteSubshellWs — input/resize/cleanup ride the shared handle scriptHappy(sim); const { ws } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "view", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "view", attachParams(), false); + await until(() => sim.cmdTypes().includes("tail_start"), "attached"); + + handleSubshellMessage(ws, JSON.stringify({ type: "input", data: "rm -rf /\r" })); + handleSubshellMessage(ws, JSON.stringify({ type: "resize", cols: 80, rows: 24 })); + await until(() => sim.cmdsOf("resize").length === 1, "resize through"); + expect(sim.cmdsOf("input")).toEqual([]); + }); + + it("canInput:false at edit access types NOTHING (the ssh carve-out arrives as the required argument, not a grant re-derivation)", async () => { + // The §5.4 posture split: `access` still says `edit` for an ssh pane's + // grantee (they may WATCH, and would type on an ordinary pane); the ONE + // fact that stops them is `canInput:false`, computed by `resolveAttach` + // and passed here. The relay must not read the grant to decide input — + // a fallback that did would reopen the ssh relay door to exactly this + // caller. Task 8's `attach-resolve.test.ts` ("an ssh pane + an EDIT + // grantee is refused (access says edit, canInput says no)") pins the + // COMPUTATION; this pins the CONSUMPTION. + const sim = makeNodeSim(); + scriptHappy(sim); + const { ws } = fakeBrowser(); + + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "edit", attachParams(), false); await until(() => sim.cmdTypes().includes("tail_start"), "attached"); handleSubshellMessage(ws, JSON.stringify({ type: "input", data: "rm -rf /\r" })); @@ -553,7 +592,7 @@ describe("attachRemoteSubshellWs — input/resize/cleanup ride the shared handle scriptHappy(sim); const { ws } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "attached"); cleanupSubshellWs(ws); @@ -595,7 +634,14 @@ describe("attachRemoteSubshellWs — input/resize/cleanup ride the shared handle ); const { ws } = fakeBrowser(); - const attaching = attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + const attaching = attachRemoteSubshellWs( + ws, + attachRow(), + new RemoteLauncher(NODE_ID), + "owner", + attachParams(), + true, + ); await until(() => sim.cmdTypes().includes("capture"), "parked in the capture"); cleanupSubshellWs(ws); // browser vanished mid-attach release?.("SCREEN"); @@ -637,6 +683,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 120, rows: 40 } }), + true, ); // The laptop alone: the pane holds ITS size. expect(sim.cmdsOf("resize").at(-1)).toEqual({ type: "resize", subshellId: SID, cols: 120, rows: 40 }); @@ -648,6 +695,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 60, rows: 20 } }), + true, ); // The joiner's attach really did drive the pane... @@ -683,6 +731,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 132, rows: 43 } }), + true, ); const geometry = sent @@ -716,6 +765,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 120, rows: 40 } }), + true, ); await attachRemoteSubshellWs( pocketed.ws, @@ -723,6 +773,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 40, rows: 12 }, deviceLabel: "Phone", hidden: true }), + true, ); // The hidden joiner takes no part: the pane stays at the laptop's size. @@ -754,6 +805,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 40, rows: 12 } }), + true, ); cleanupSubshellWs(ws); // browser gone before the attach assigned anything await attaching; @@ -779,9 +831,9 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => const first = fakeBrowser(); const second = fakeBrowser(); - await attachRemoteSubshellWs(first.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(first.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "first tail"); - await attachRemoteSubshellWs(second.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(second.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); expect(sim.cmdTypes().filter((t) => t === "tail_start")).toHaveLength(1); diff --git a/apps/server/api/src/ws/attach-resolve.ts b/apps/server/api/src/ws/attach-resolve.ts index 0bde440ba..90b6aeb56 100644 --- a/apps/server/api/src/ws/attach-resolve.ts +++ b/apps/server/api/src/ws/attach-resolve.ts @@ -15,6 +15,7 @@ import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; import { resolveCookieSession } from "@/lib/session-cookie.js"; +import { paneInputAllowed } from "@/lib/ssh-pane-access.js"; import { type Access, accessAtLeast, loadSubshellAccess } from "@/lib/subshell-access.js"; import { accountDisabled } from "@/services/account-status.js"; import { backupPasswordChangeRequired } from "@/services/backup-admin-recovery.js"; @@ -47,6 +48,16 @@ export interface AttachResolved { row: SubshellTable; /** The caller's access to it — `view` watches, `edit`/`owner` may type. */ access: Access; + /** + * Whether this socket may TYPE — the ONE WS keystroke authority, decided + * here and carried to both attach paths (local + remote) so they stop each + * deriving it from `access` alone. `edit`/`owner` grant AND the ssh + * owner-only carve-out ({@link paneInputAllowed}): an ssh pane drops a + * non-owner's or a scoped (bearer-minted) token's keystrokes exactly as it + * drops a `view` grantee's (spec 2026-10-07 §5.4). For an ordinary pane + * this is exactly `accessAtLeast(access, "edit")`. + */ + canInput: boolean; /** What the client declared on its URL. */ params: AttachParams; /** @@ -179,6 +190,16 @@ export async function resolveAttach(input: AttachRequest): Promise { const { size, deviceLabel, hidden, wireMode } = params; if (!getLive(row.nodeId)) { @@ -157,8 +169,12 @@ export async function attachRemoteSubshellWs( // keys the plane→node input hold by it. nodeId: row.nodeId, logFile: "", - // Only `edit`/`owner` may type into the pane; a `view` grantee watches. - canInput: accessAtLeast(access, "edit"), + // Whether THIS socket may type: `resolveAttach`'s one authority (edit/owner + // grant AND the ssh owner-only carve-out, spec §5.4), taken verbatim. No + // `access`-derived fallback: that pre-§5.4 rule would silently reopen the + // ssh relay door to a caller that omitted the argument, so the argument is + // required and an omission is a type error. + canInput, capacity: size ?? undefined, hidden, // Presence identity: who this viewer is in the `viewers` frame. The id diff --git a/apps/server/api/src/ws/subshell-ws.ts b/apps/server/api/src/ws/subshell-ws.ts index 5d7003183..1d9549f08 100644 --- a/apps/server/api/src/ws/subshell-ws.ts +++ b/apps/server/api/src/ws/subshell-ws.ts @@ -1,7 +1,6 @@ import { parseClientFrame } from "@internal/subshell-protocol"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; 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"; @@ -92,7 +91,7 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { ws.close(resolved.code, resolved.reason); return; } - const { row, access, params, attendsPush } = resolved; + const { row, access, canInput, params, attendsPush } = resolved; // Stash WHO authenticated onto the socket before either path continues — // the local `Object.assign` and the remote relay's both build their own // `data` literals, and this channel is what lets an account disable find @@ -115,7 +114,7 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { // 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 as RemoteLauncher, access, params, canInput); return; } // Split out so the rest of this function sees a non-null socket, and so the @@ -157,8 +156,9 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { // session's holds. nodeId: row.nodeId, logFile: subshellLogPath(row.id), - // Only `edit`/`owner` may type into the pane; a `view` grantee watches. - canInput: accessAtLeast(access, "edit"), + // Whether THIS socket may type: `edit`/`owner` grant AND the ssh owner-only + // carve-out, both folded at the one authority (`resolveAttach`). + canInput, // This viewer's own capacity, from the connect URL. One input to the // shared decision below — never applied on its own. capacity: params.size ?? undefined, diff --git a/apps/server/web/src/lib/__tests__/subshell-compat.test.ts b/apps/server/web/src/lib/__tests__/subshell-compat.test.ts index dd1b92cc0..ebd06f1cb 100644 --- a/apps/server/web/src/lib/__tests__/subshell-compat.test.ts +++ b/apps/server/web/src/lib/__tests__/subshell-compat.test.ts @@ -222,6 +222,25 @@ describe("defaultAgentId", () => { const plugins = [plugin({ id: "claude-code", name: "Claude Code", installed: false }), TERM]; expect(defaultAgentId(buildAgentOptions(plugins, null), plugins, null)).toBe("terminal"); }); + it("a second terminal-type plugin does not steal the shell default", () => { + // The clean-machine regression (CI e2e 2026-10-08): the ssh plugin is + // type `terminal`, its binary exists on nearly every host, and the + // catalog arrives id-sorted, so `ssh` stands BEFORE `terminal` and the + // old last-resort `usable[0]` defaulted a first launch to a bare `ssh` + // that exits without a destination. The plain shell stays the fallback. + const SSH = plugin({ id: "ssh", name: "SSH", type: "terminal" }); + const plugins = [SSH, TERM]; + expect(defaultAgentId(buildAgentOptions(plugins, null), plugins, null)).toBe("terminal"); + // And it stays so under an agent catalog too: the agent tier still wins. + const withAgent = [SSH, TERM, CLAUDE]; + expect(defaultAgentId(buildAgentOptions(withAgent, null), withAgent, null)).toBe("claude-code"); + }); + it("an ssh-only catalog still defaults to ssh", () => { + // The shell tier is a preference, not a filter: when the plain shell is + // not usable, the only pickable plugin must still fill the field. + const SSH = plugin({ id: "ssh", name: "SSH", type: "terminal" }); + expect(defaultAgentId(buildAgentOptions([SSH], null), [SSH], null)).toBe("ssh"); + }); it("null when nothing is usable — and on an empty catalog", () => { const plugins = [plugin({ id: "x", name: "X", installed: false })]; expect(defaultAgentId(buildAgentOptions(plugins, HERE), plugins, null)).toBeNull(); diff --git a/apps/server/web/src/lib/subshell-compat.tsx b/apps/server/web/src/lib/subshell-compat.tsx index 893a5b243..3d773ee47 100644 --- a/apps/server/web/src/lib/subshell-compat.tsx +++ b/apps/server/web/src/lib/subshell-compat.tsx @@ -101,15 +101,27 @@ export function buildAgentOptions(plugins: readonly LaunchAgent[], node: Node | ); } +/** The built-in plain shell, by plugin id; the fallback tier's favorite. */ +const TERMINAL_PLUGIN_ID = "terminal"; + /** * The agent the picker should hold when the user has not chosen one: the most * recent subshell's agent when it is still usable, else the first usable - * NON-terminal agent (a shell is a fallback, not the headline), else anything - * usable, else null (nothing to fill — the picklist greys everything and the - * dead-end hints carry the story). + * NON-terminal agent (a shell is a fallback, not the headline), else the + * plain shell, else anything usable, else null (nothing to fill — the + * picklist greys everything and the dead-end hints carry the story). + * + * The shell tier is not paranoia about order; it is the ssh plugin (CI e2e + * 2026-10-08): `ssh` is type `terminal`, its binary is on nearly every host, + * and the catalog arrives id-sorted, so on a clean machine `usable[0]` was + * SSH, not Terminal — a first launch defaulted to a bare `ssh` that exits + * without a destination. The wizard's own subtitle names the fallback ("A + * plain terminal is always available with nothing to install"), so the rule + * reads its id, the same named plugin. * * @param options - output of {@link buildAgentOptions} for the current pair - * @param plugins - the plugin rows, for the terminal lookup by id + * @param plugins - the plugin rows, for the agent-harness type lookup (the + * shell tier reads its id off `options`, not these rows) * @param recentHarnessId - harnessId of the user's most recent subshell, null when none */ export function defaultAgentId( @@ -125,9 +137,15 @@ export function defaultAgentId( // wrong the moment a third did: a network plugin is not a slower agent, and // "not a terminal" would have made one the headline default. An absent // `type` (a payload older than the field) therefore no longer WINS the - // first tier — it still reaches the `usable[0]` fallback below, so nothing - // becomes unpickable, it just stops outranking a declared agent. - return (usable.find((o) => typeById.get(o.value) === "agent-harness") ?? usable[0])?.value ?? null; + // first tier — it still reaches the fallbacks below, so nothing becomes + // unpickable, it just stops outranking a declared agent. + return ( + ( + usable.find((o) => typeById.get(o.value) === "agent-harness") ?? + usable.find((o) => o.value === TERMINAL_PLUGIN_ID) ?? + usable[0] + )?.value ?? null + ); } /** diff --git a/bun.lock b/bun.lock index 587fef3a9..92cdf474a 100644 --- a/bun.lock +++ b/bun.lock @@ -470,6 +470,7 @@ "@subshell-ai/plugin-netbird": "workspace:*", "@subshell-ai/plugin-opencode": "workspace:*", "@subshell-ai/plugin-pi": "workspace:*", + "@subshell-ai/plugin-ssh": "workspace:*", "@subshell-ai/plugin-tailscale": "workspace:*", "@subshell-ai/plugin-terminal": "workspace:*", }, @@ -588,6 +589,18 @@ "typescript": "7.0.2", }, }, + "packages/plugins/ssh": { + "name": "@subshell-ai/plugin-ssh", + "version": "1.0.0", + "devDependencies": { + "@internal/tsconfig": "workspace:*", + "@subshell-ai/plugin-api": "workspace:*", + "@types/bun": "1.3.14", + "hash-runner": "4.0.0", + "tsdown": "0.22.14", + "typescript": "7.0.2", + }, + }, "packages/plugins/tailscale": { "name": "@subshell-ai/plugin-tailscale", "version": "1.0.1", @@ -1500,6 +1513,8 @@ "@subshell-ai/plugin-pi": ["@subshell-ai/plugin-pi@workspace:packages/plugins/pi"], + "@subshell-ai/plugin-ssh": ["@subshell-ai/plugin-ssh@workspace:packages/plugins/ssh"], + "@subshell-ai/plugin-tailscale": ["@subshell-ai/plugin-tailscale@workspace:packages/plugins/tailscale"], "@subshell-ai/plugin-terminal": ["@subshell-ai/plugin-terminal@workspace:packages/plugins/terminal"], diff --git a/docs/security.md b/docs/security.md index 95bfb308f..39bbdfede 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2280,7 +2280,14 @@ Audit events are written, grouped by family: `node.logging.update`, `node.update`, `node.update.unknown`, `node.shares_set`, `node.local_share_changed`, and `node.service` (with `{ verb: "restart", forced }` metadata; the restart - verb shares its action with the rest of the service surface). + verb shares its action with the rest of the service surface). The SSH + launcher's act lands in this family too as `ssh.launch` (spec 2026-10-07 + §12): metadata `{ nodeId, destination, subshellId }`, naming which machine + dialed out to which canonical `host:port` and the pane that came of it, and + never a config value, path list, or key material (§13). It is written AFTER + the pane spawned, success only, the same posture as `subshell.create`, and + saved-host CRUD audits nothing (the prompts precedent: a preference row is + not an event). - **The server process**: `server.config.update`, `server.restart`, `server.logging.update`, `server.autostart.update`, `server.update`, and `server.supervision.request`, the last one best-effort and written by the diff --git a/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md b/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md new file mode 100644 index 000000000..4c1aa100b --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md @@ -0,0 +1,478 @@ +# SSH Anywhere - Plan 2: The Launcher Tier Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship Milestone 1's machine half: an operator whose node has `ssh_enabled` on can launch an interactive SSH-terminal pane at any sshd host from the API - alias discovery, `ssh -G` resolve, a rendered `-F` config, a scoped agent socket, and owner-only input - with a CI e2e proving it against a real sshd. + +**Architecture:** The plane composes ssh invocations from approved snapshots (the grammar ported in tier 1), ships them over the existing signed `sendCommand` transport and the existing `launch` frame, and the node owns only its own facts: which binary answers `ssh`, where its files live. Discovery/resolve are two new node commands; the pane itself is a new built-in `ssh` harness of type `terminal`, so streaming, logging, capture, and sharing are the ordinary pane path untouched. The reference lineage on `feat/ssh-support` (read with `git show feat/ssh-support:`, available in this linked worktree) carries the Gate A renderer and agent handlers this plan adapts to interactive use. + +**Tech Stack:** Bun + TypeScript, Elysia + Kysely (SQLite), tmux, OpenSSH, `@internal/pane-runtime`, `@internal/subshell-protocol`, Playwright-free e2e (REST + direct tmux probes per existing e2e specs). + +## Global Constraints + +- Work ONLY in `/home/theo/projects/wt-ssh-anywhere-2` (branch `feat/ssh-anywhere-2`). Never touch `/home/theo/projects/subshell` or `/home/theo/projects/wt-ssh-anywhere`. +- Tests run with `env -u SHELLOPTS -u BASHOPTS bun test `; check the reported file count (bun silently skips missing paths). +- No `NODE_PROTOCOL_VERSION` value changes except the one bump Task 4 specifies. +- No `await import()` anywhere; static imports only. +- No U+2014 or U+2013 in any prose or shipped string (`lint:prose` at the boundary). +- Key material, challenges, signatures, and config file CONTENT never enter logs, argv, audit rows, or notifications (spec §13). Audit metadata names ids and hosts only. +- `shellQuote` (from `@internal/subshell-ai/plugin-api`, re-exported by `@internal/pane-runtime` as `shellQuote` - import from whichever site the surrounding file already uses) on every token of the pane command string; the pane launch IS a shell string. +- Every wire-carrying snapshot is re-validated with `parseSshConnectionSnapshot` at each trust boundary (frame parse, agent receive, renderer entry) - the tier-1 grammar doc calls this the load-bearing defense, not the plane's approval. +- Package versions pinned; after any `bun add` run `bun run syncpack-fix` then `bun install`; a workspace version change to `bun.lock` needs `bun run lint:lockfile:fix`. +- Migration additions must move `LATEST_BACKUP_MIGRATION` and the older-snapshot fixture strip-set in the same commit (tier 1's boundary lesson, commit 0ebd6cee). +- Focused verification while iterating (`bunx turbo verify-types --filter=`, `bunx biome check `, the touched test files); the full `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test` only at Task 10. +- Commit as theo@suteki.nu; do not push except where a task says so. + +## File Structure (map) + +``` +packages/subshell-protocol/src/ + node-frames.ts # MODIFY: ssh command arms, launch.ssh field, version bump note + node-results.ts # MODIFY: parseNodeSshAliasList, parseNodeSshResolveOutcome + __tests__/node-frames.test.ts # MODIFY: ssh arms + launch.ssh cases +packages/pane-runtime/src/ + ssh/ssh-render.ts # CREATE: interactive config + option-token builders (adapted port) + ssh/__tests__/ssh-render.test.ts # CREATE: golden renders, refusal cases + registry.ts # MODIFY: register the ssh harness + index.ts # MODIFY: named exports of the render builders +packages/plugins/ssh/ # CREATE: built-in ssh harness (type terminal) +apps/node/agent/src/ + commands/ssh-shared.ts # CREATE: sshBin ladder + connecting home + commands/ssh-aliases.ts # CREATE: the two discovery/resolve handlers (gated) + commands/index.ts # MODIFY: two dispatch arms + commands/launch.ts # MODIFY: ssh config write block + commands/report.ts # MODIFY: ssh config cleanup at exit-watch observation + __tests__/commands-ssh.test.ts # CREATE: handler matrix incl. gate refusal + __tests__/launch-ssh.test.ts # CREATE: config write modes + skip-when-absent +apps/server/api/src/ + db/migrations/0048-ssh-launch-and-saved-hosts.ts # CREATE + db/migrations/__tests__/0048-...test.ts # CREATE + db/types/ssh-saved-hosts.db-types.ts # CREATE + db/types/subshells.db-types.ts # MODIFY: ssh column + db/repositories/ssh-saved-hosts.repository.ts # CREATE + services/nodes/ssh-rpc.ts # CREATE: sendCommand wrappers + validators + services/ssh-launch.service.ts # CREATE: gate, resolve, compose, audit + services/subshell-manager.service.ts # MODIFY: createSubshell sshSnapshot param + services/nodes/local-launcher.ts # MODIFY: write ssh config pre-spawn (local) + services/nodes/remote-launcher.ts # MODIFY: ship ssh block on the frame + services/nodes/node-launcher.ts # MODIFY: LaunchPlan.ssh + api/ssh/ # CREATE: index + aliases/resolve/launch/saved-hosts routes + lib/ssh-pane-access.ts # CREATE: the one ssh-input predicate + services/subshells.service.ts # MODIFY: sendSubshellInput + execInTerminal owner-only rule + ws/attach-resolve.ts # MODIFY: resolveAttach canInput ssh carve-out + commands/__tests__/backup-cli-subprocess.test.ts # MODIFY: fixture strip-set (0047+0048) + services/backups/database.ts # MODIFY: LATEST_BACKUP_MIGRATION +docs/security.md # MODIFY §10: ssh.launch +.claude/rules/security-context.md # MODIFY: mirror the §10 line +e2e/tests/22-ssh-terminal.spec.ts # CREATE: the M1 proof +.changeset/ssh-launcher-tier.md # CREATE at Task 10 +``` + +## Decisions this plan has already made (do not relitigate in implementation) + +1. **No new launch pipeline.** The ssh pane is a terminal-type harness whose `preset.flags` are the ssh option tokens. `buildCommand` = `[binary, ...flags]`, exactly the terminal plugin's shape. Working dir = the existing presetless-terminal "launch node's home" default (spec 2026-10-01 machinery already in `subshells.service.ts:494`). +2. **The plane renders, the node executes.** `renderSshConfigContents` + option-token builder live in `@internal/pane-runtime` and run on the plane (which already imports pane-runtime for `LocalLauncher`); the node contributes only binary resolution (the `resolve` rule travels, the path is the node's own ladder) and file placement under its own `dataDir`. +3. **`SSH_AUTH_SOCK` rides `subshellEnv`.** The pane's command IS the ssh process, so the pane env is the ssh env; adding the snapshot's agent socket to `subshellEnv` for ssh launches only is the scoped exception spec §5.4 names. Non-ssh panes never carry it (`curatedEnv` untouched). Password/keyboard-interactive auth is NOT disabled: an interactive pane may answer a password prompt like any terminal (the tier-1 BatchMode/key-only posture belonged to supervised runs). +4. **Config file lifecycle:** written by whoever spawns (agent `execLaunch` block mirroring the MCP-file block; `LocalLauncher.launch` for local), at `/ssh//config` (0600 in a 0700 dir). Cleaned at pane death by the exit path: the agent's exit watcher unlinks the derived path when the pane's meta names harness `ssh`; delete-time cleanup rides the existing `remove_paths`/`subshellArtifacts` seam (paths under `dataDir` are policy-legal there - verified `basics.ts:260` permits any `pathAllowed` name, not only `.log`). +5. **Input authority:** `subshells.ssh` (nullable JSON snapshot column) is the kind fact. Input (REST input, exec, MCP-via-REST, WS live frames) refuses when `row.ssh !== null` unless the acting principal is the row's OWNER account. Note the two resolutions: a bearer subshell key on a REST route resolves as its owner (boost/shares off), so an ssh pane's own key would otherwise slip through the owner comparison - the rule therefore also refuses `actor === "subshell-key"` explicitly: an ssh pane must not type into itself. +6. **Protocol bump 16 -> 17** (Task 4): a tier-1 agent (protocol 16, no ssh arms) that ignores the launch frame's `ssh` member would spawn a bare `ssh` pane with no `-F` config - the ambiguity §4.3's gate doctrine refuses to live with. Lagging agents are HELD (only `update`) until crossed; server-first sequencing ships in the changeset, same sentence class as tier 1's. +7. **Restarts:** an ssh pane refuses `POST /:id/restart` with a named 409 (reconnect from the launcher instead). Auto-restart rows (`restartOnExit`) are forced off at create for ssh panes. +8. **Audit:** one new event `ssh.launch`, metadata `{ nodeId, destination, subshellId }`. Saved-host CRUD is unaudited (the prompts precedent). +9. **Discovery/resolve/gate on the node:** every ssh node command first consults the tier-1 fail-closed mirror (`readSshEnabled`/`sshAllowed` in `apps/node/agent/src/ssh-enabled.ts`); a machine answers `ssh disabled on this node` regardless of what the plane believes. +10. **MCP:** ssh panes get their subshell token (row infra + exit hook) but NO MCP registration file and no MCP dialect - the remote shell hosts no local process that could use it. + +--- + +### Task 1: Protocol - ssh command arms, result validators, launch `ssh` field + +**Files:** +- Modify: `packages/subshell-protocol/src/node-frames.ts` (union near `:650`, parser near the `case "remove_paths"` arm at `:1538`) +- Modify: `packages/subshell-protocol/src/node-results.ts` (append the two validators; the file already imports `parseSshConnectionSnapshot`, `isSshErrorCode`, `SSH_MAX_DISCOVERED_ALIASES`, and the ssh result types) +- Modify: `packages/subshell-protocol/src/__tests__/node-frames.test.ts` (or the file's existing per-command-case test home - locate by grepping `case "remove_paths"` in `__tests__/`) +- Test: `packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts` (CREATE) + +**Interfaces:** +- Consumes: `SshNodeCommandBody` + `parseSshNodeCommandBody` + `SSH_COMMAND_TYPES` from `./ssh-frames.js` (already on this branch, previously unwired - this task IS the wiring), `SshConnectionSnapshotWire` + `parseSshConnectionSnapshot` from `./ssh-config.js`, `NodeSshAliasListResult` + `NodeSshResolveOutcomeWire` from `./ssh-results.js`, fixture helpers `makeAliasList` / `makeResolveOk` / `makeResolveRefused` from `./__tests__/fixtures/ssh-fixtures.ts` (already present). +- Produces: `parseNodeCommandBody` answers `ssh_discover_aliases` / `ssh_resolve_config`; the `launch` arm accepts optional `ssh?: { configPath: string; fileContent: string }` (fileContent ≤ `SSH_CONFIG_FILE_MAX_BYTES`, a new const in `ssh-limits.ts` = 65536); `parseNodeSshAliasList` / `parseNodeSshResolveOutcome` exported from `node-results.js` and through the package barrel `src/index.ts`. + +- [ ] **Step 1: Read the reference verbatim** + +```bash +git show feat/ssh-support:packages/subshell-protocol/src/node-results.ts | sed -n '548,589p' +git show feat/ssh-support:packages/subshell-protocol/src/node-frames.ts | sed -n '1681,1685p' +``` + +The first block is the two parsers to port (drop nothing; their JSDoc is load-bearing). The second is the delegation arm shape. + +- [ ] **Step 2: Write the failing tests** + +Create `packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { parseNodeSshAliasList, parseNodeSshResolveOutcome } from "../node-results.js"; +import { makeAliasList, makeResolveOk, makeResolveRefused } from "./fixtures/ssh-fixtures.js"; + +describe("parseNodeSshAliasList", () => { + test("narrows a well-formed answer", () => { + expect(parseNodeSshAliasList(makeAliasList({ aliases: ["box-a", "box-b"] }))).toEqual({ + aliases: ["box-a", "box-b"], + includeCycle: false, + truncated: false, + }); + }); + test("refuses a past-cap list whole (the truncated flag depends on the cap)", () => { + expect(parseNodeSshAliasList(makeAliasList({ aliases: Array.from({ length: 501 }, (_, i) => `h${i}`) }))).toBeNull(); + }); + test("refuses non-boolean flags and non-string members", () => { + expect(parseNodeSshAliasList({ aliases: ["x"], includeCycle: "no", truncated: false })).toBeNull(); + expect(parseNodeSshAliasList({ aliases: [1], includeCycle: false, truncated: false })).toBeNull(); + }); +}); + +describe("parseNodeSshResolveOutcome", () => { + test("accepted arm runs the FULL snapshot validator (no unvalidated snapshot enters the plane)", () => { + const ok = makeResolveOk(); + expect(parseNodeSshResolveOutcome(ok)).not.toBeNull(); + const tampered = structuredClone(ok); + (tampered as { snapshot: { host: string } }).snapshot.host = "-oProxyCommand=evil"; + expect(parseNodeSshResolveOutcome(tampered)).toBeNull(); + }); + test("connectingAccount is optional text, never another type", () => { + expect(parseNodeSshResolveOutcome(makeResolveOk())?.accepted === true).toBe(true); + const withAccount = structuredClone(makeResolveOk()) as { connectingAccount?: unknown }; + withAccount.connectingAccount = 7; + expect(parseNodeSshResolveOutcome(withAccount)).toBeNull(); + }); + test("refused arm names a known code and string settings", () => { + expect(parseNodeSshResolveOutcome(makeResolveRefused())).toEqual({ + accepted: false, + code: "unsupported_setting", + settings: ["ProxyCommand"], + }); + expect(parseNodeSshResolveOutcome({ accepted: false, code: "not_a_code", settings: [] })).toBeNull(); + }); +}); +``` + +Extend the existing `parseNodeCommandBody` test home with cases (match that file's local style - it tests one JSON object per case): + +```ts +test("ssh discovery/resolve arms delegate to the ssh grammar", () => { + expect(parseNodeCommandBody({ type: "ssh_discover_aliases" })).toEqual({ type: "ssh_discover_aliases" }); + expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "box-a" })).toEqual({ + type: "ssh_resolve_config", + alias: "box-a", + }); + // alias hygiene lives in ssh-frames' parser (port-verified): option-like and whitespace are refused there + expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "-x" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "a b" })).toBeNull(); +}); + +test("launch accepts an ssh block and refuses malformed ones", () => { + // Reuse this file's existing well-formed launch fixture; add the ssh member to a clone: + // { ...launchFixture, ssh: { configPath: "/d/ssh/s1/config", fileContent: "Host *\n" } } -> parses, + // configPath "relative" -> null; fileContent past SSH_CONFIG_FILE_MAX_BYTES -> null; ssh: "x" -> null. +}); +``` + +(For the launch cases, copy this test file's existing minimal-valid `launch` object verbatim as the base clone; do not hand-roll a new one.) + +- [ ] **Step 3: Run to verify failure** + +`env -u SHELLOPTS -u BASHOPTS bun test packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts` - FAIL (exports missing). Confirm the file COUNTS as 1 file. + +- [ ] **Step 4: Implement** + +In `ssh-limits.ts`, append: + +```ts +/** Largest ssh_config the wire carries in one `launch.ssh.fileContent` member (a rendered snapshot config is hundreds of bytes; this bounds a hostile plane). */ +export const SSH_CONFIG_FILE_MAX_BYTES = 65_536; +``` + +In `node-frames.ts`: add to the `NodeCommandBody` union beside the other arms: + +```ts + | { type: "ssh_discover_aliases" } + | { type: "ssh_resolve_config"; alias: string } +``` + +In `parseNodeCommandBody`, beside `case "remove_paths":`: + +```ts + 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); +``` + +Import `parseSshNodeCommandBody` from `./ssh-frames.js`. In the `launch` arm's validator add (beside the `mcp` member's handling): + +```ts + if ("ssh" in value) { + const ssh = value.ssh as unknown; + if ( + typeof ssh !== "object" || + ssh === null || + typeof (ssh as { configPath?: unknown }).configPath !== "string" || + !(ssh as { configPath: string }).configPath.startsWith("/") || + (ssh as { configPath: string }).configPath.length > SSH_PATH_MAX_CHARS || + typeof (ssh as { fileContent?: unknown }).fileContent !== "string" || + (ssh as { fileContent: string }).fileContent.length > SSH_CONFIG_FILE_MAX_BYTES + ) { + return null; + } + } +``` + +and carry `...( "ssh" in value ? { ssh: value.ssh as { configPath: string; fileContent: string } } : {} )` into the returned narrowed object. Add `ssh?: { configPath: string; fileContent: string }` to the TS type of the launch arm with a doc comment naming the 0600 + node-side-path-policy contract. + +In `node-results.ts`, port the two validators from the reference verbatim (Step 1), fixing only the import sites (this branch's `ssh-errors.ts` exports `isSshErrorCode` already) and the section banner: `/* ssh discovery/resolution (the tier-1 wire types, dispatched from here on) */`. Export both from `src/index.ts`. + +- [ ] **Step 5: Run to verify pass** + +`env -u SHELLOPTS -u BASHOPTS bun test packages/subshell-protocol/src/__tests__/` - all green, count the files. Then `bunx turbo verify-types --filter=@internal/subshell-protocol`. + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat(protocol): ssh command arms delegate to the tier-1 grammar, ssh launch block, result validators join node-results" +``` + +--- + +### Task 2: pane-runtime - the interactive renderer + +**Files:** +- Create: `packages/pane-runtime/src/ssh/ssh-render.ts` +- Create: `packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts` +- Modify: `packages/pane-runtime/src/index.ts` (named exports - the ssh barrel pattern from tier 1's Task 2; this module imports NO `node:` builtins except NONE after dropping `sshChildEnv` - keep it pure so it stays Metro-safe) + +**Interfaces:** +- Consumes: `parseSshConnectionSnapshot`, `SshConnectionSnapshotWire`, `SshHopWire`, `SSH_PATH_MAX_CHARS` from `@internal/subshell-protocol`; `shellQuote` from `../plugin-api` re-export (`../shell.ts` or wherever the ssh dir's siblings import it from - `ssh-discover.ts` shows the local spelling). +- Produces (consumed by Tasks 5-7): `renderSshConfigContents(snapshot): string`, `sshOptionTokens(snapshot): string[]` (the PLACEHOLDER-free option argv: everything after the binary), `sshDestinationToken(snapshot): string` (the `-- host` tail as one argv entry `host`), and `buildSshConfigPath(dataDir: string, subshellId: string): string` = `${dataDir}/ssh/${subshellId}/config` (rejects a non-absolute dataDir / an id failing `/^[a-zA-Z0-9_-]{1,64}$/` by THROWING - it is an impossible-state guard at composition sites, matching the codebase's path-composition doctrine). + +- [ ] **Step 1: Read the reference in full** + +```bash +git show feat/ssh-support:packages/pane-runtime/src/ssh/ssh-render.ts +git show feat/ssh-support:packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts +``` + +- [ ] **Step 2: Write the failing tests** (`ssh-render.test.ts`) + +Adapt the reference tests to the interactive policy. Required cases (write full assertions in the reference's style; the three policy changes below are the ONLY deviations): + +```ts +test("Host * carries the interactive-terminal policy, not BatchMode", () => { + const out = renderSshConfigContents(baseSnapshot()); + expect(out).toContain(" StrictHostKeyChecking accept-new"); + expect(out).not.toContain("BatchMode"); + expect(out).not.toContain("PasswordAuthentication no"); + expect(out).not.toContain("UserKnownHostsFile /dev/null"); // default known_hosts is the M1 authority (spec §9) + expect(out).toContain(" ForwardAgent no"); + expect(out).toContain(" ControlPath none"); + expect(out).toContain(" EscapeChar none"); + expect(out).toContain(" RemoteCommand none"); + expect(out).toContain(" CanonicalizeHostname no"); +}); +test("known-hosts refs render when the snapshot names them", () => { + const out = renderSshConfigContents(baseSnapshot({ knownHostsFiles: ["/home/theo/.ssh/known_hosts"] })); + expect(out).toContain(" UserKnownHostsFile /home/theo/.ssh/known_hosts"); +}); +test("identity/cert refs render quoted only when they need quoting", () => { + /* port the reference's configPathValue quoting cases verbatim */ +}); +test("sshOptionTokens is destination-scoped only", () => { + const tokens = sshOptionTokens(baseSnapshot({ port: 2222, user: "root", proxyJumps: [{ host: "j1", user: null, port: 22 }] })); + expect(tokens).toEqual(["-F", "/data/ssh/s1/config", "-p", "2222", "-l", "root", "-o", "ProxyJump=j1"]); +}); +test("refuses an unrenderable snapshot at the last station before argv", () => { + expect(() => sshOptionTokens({ ...baseSnapshot(), host: "-oProxyCommand=x" } as never)).toThrow(/not renderable/); +}); +``` + +- [ ] **Step 3: Run to verify failure** - FAIL (module absent). + +- [ ] **Step 4: Implement `ssh-render.ts`** + +Port the reference file with these exact deltas (everything else - the header, `assertRenderable`, `configPathValue`, the doc discipline - carries verbatim): + +1. `MANDATORY_POLICY`: remove `["BatchMode","yes"]`, `["PasswordAuthentication","no"]`, `["KbdInteractiveAuthentication","no"]`, `["HostbasedAuthentication","no"]`; change `StrictHostKeyChecking` to `"accept-new"`; drop the reference's GSSAPI comment but KEEP `["GSSAPIAuthentication","no"]`. Header comment for the array: "the M1 interactive-terminal policy (spec 2026-10-07 §5.2/§9): every hop stays confined (forwarding, control sockets, local commands, the escape menu all die here because ssh spawns ProxyJump children that re-read THIS file), while the session itself stays a terminal: auth methods are the server's to offer and a changed host key is OpenSSH's own hard block." +2. `knownHostsLines`: `if (files.length === 0) return [];` with the comment "absent renders nothing: ssh's own default `~/.ssh/known_hosts` is the M1 trust store (§9) - the tier's product wrote /dev/null because its BatchMode posture made silence fail closed; here silence IS the policy." +3. Split the reference's `buildSshInvocation`: `sshOptionTokens(snapshot)` returns everything after the binary EXCEPT the trailing `["--", host]` (`-p` always, `-l` only when set, HostKeyAlias/ProxyJump only when set) and `sshDestinationToken(snapshot)` returns `snapshot.host`. Both call `assertRenderable`. `renderSshConfigContents` carries verbatim (minus delta 1/2). +4. Do NOT port `sshChildEnv`, `remoteCommandLine`, `-tt`/`forceTty` handling, or the `SshInvocationInput` type (this tier's pane has no remote command and allocates its PTY through tmux, so `ssh` auto-requests a tty; `-F`/option tokens ride the argv, the config path is composed by the caller into `-F`). +5. `buildSshConfigPath` as specified in Produces, with the doc: "the derived per-pane config path is composed here so plane and node agree byte-for-byte from the two facts each already holds (dataDir, subshellId) - no path ever travels the wire." + +Add to `packages/pane-runtime/src/index.ts` the three/four named exports beside the tier-1 ssh exports. + +- [ ] **Step 5: Run to verify pass** - `env -u SHELLOPTS -u BASHOPTS bun test packages/pane-runtime/src/ssh/` green; `bunx turbo verify-types --filter=@internal/pane-runtime`; then `bunx turbo build --filter=@internal/pane-runtime` (the Metro trap rule: this module adds no `node:` import - a grep proves it). + +- [ ] **Step 6: Commit** - `git commit -am "feat(pane-runtime): interactive ssh renderer - every-hop confinement, terminal auth left to the server"` + +--- + +### Task 3: The built-in `ssh` harness plugin + +**Files:** +- Create: `packages/plugins/ssh/package.json`, `packages/plugins/ssh/src/index.ts`, `packages/plugins/ssh/src/__tests__/ssh.test.ts`, `packages/plugins/ssh/icon.svg`, `packages/plugins/ssh/tsconfig.json`, `packages/plugins/ssh/turbo.json` (all copied structure from `packages/plugins/terminal`, renamed) +- Modify: `packages/pane-runtime/src/registry.ts` (import + built-in list, beside the terminal entry at `:61`) +- Modify: `packages/pane-runtime/src/generated/embedded-plugins.ts` (the embedded-bytes list the registry falls back to - copy how `terminal` is carried; if the file is generated by a script, run the script) +- Modify: root `bun.lock` + any workspace glob is already covered by `packages/plugins/*` (verify, else add) +- Test: registry lists `ssh` among built-ins (extend the registry's existing built-in census test - grep `terminal` in `packages/pane-runtime/src/__tests__/` to find it) + +**Interfaces:** +- Consumes: `@subshell-ai/plugin-api` types exactly as the terminal plugin does. +- Produces: harness `id: "ssh"`, `type: "terminal"`, `detect: { binaryName: "ssh", envOverride: "SUBSHELL_SSH_PATH", knownPaths: ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh", "/opt/homebrew/bin/ssh"] }`, `capabilities: []`, `buildCommand({binary, preset, extraFlags}) => [binary, ...preset.flags, ...(extraFlags ?? [])]`. + +- [ ] **Step 1: `cat packages/plugins/terminal/package.json packages/plugins/terminal/src/index.ts packages/plugins/terminal/turbo.json packages/plugins/terminal/tsconfig.json`; create the ssh package as a faithful rename** (id/name `"ssh"`/`"SSH"`; description "An interactive SSH session to any sshd host."; the detect block exactly as in Interfaces above - `envOverride` `SUBSHELL_SSH_PATH` matches `apps/node/agent`'s operator seam, ported in Task 4's `ssh-shared.ts`; keep the "why this plugin declares a detect block at all" doc-comment adapted: ssh IS a binary the same way). +- [ ] **Step 2: The failing census test** - extend the built-ins test to assert `"ssh"` is among `allHarnesses()` ids and `getHarness("ssh").type === "terminal"`; run, watch it fail. +- [ ] **Step 3: Wire registry.ts + embedded-plugins**, re-run green. +- [ ] **Step 4: `bun install` (workspace), `bunx turbo build`, verify-types for pane-runtime; run the plugins-ssh test file.** Confirm `bun run lint:lockfile` clean (workspace version field exception - run `bun run lint:lockfile:fix` if bun recorded the new workspace). +- [ ] **Step 5: Commit** - `"feat(plugins): built-in ssh harness - the terminal launch, pointed at ssh"` + +--- + +### Task 4: Agent - discovery/resolve handlers behind the SSH gate + protocol bump + +**Files:** +- Create: `apps/node/agent/src/commands/ssh-shared.ts`, `apps/node/agent/src/commands/ssh-aliases.ts` +- Modify: `apps/node/agent/src/commands/index.ts` (dispatch arms), `apps/node/agent/src/commands/context.ts` (the `Cmd` map gains the two types - find its definition by grep `export type Cmd`) +- Modify: `packages/subshell-protocol/src/node-frames.ts` (`NODE_PROTOCOL_VERSION` 16 to 17 + the changelog comment at `:100-150` per its own history rule) +- Test: Create `apps/node/agent/src/__tests__/commands-ssh.test.ts` + +**Interfaces:** +- Consumes: tier-1 gate (`readSshEnabled`, `sshAllowed` from `../ssh-enabled.js`), pane-runtime `discoverSshAliases` / `resolveSshAliasConfig` / `defaultSshConfigPath`, protocol `parseNodeSshAliasList` / `parseNodeSshResolveOutcome` (Task 1), `findBinary` from pane-runtime. +- Produces: `execSshDiscoverAliases(ctx): Promise`, `execSshResolveConfig(ctx, cmd): Promise`; both refuse `{ok:false, error:"ssh disabled on this node"}` unless the local mirror says ON (`sshAllowed(readSshEnabled(ctx.config.dataDir))`). + +- [ ] **Step 1: Read the reference handlers**: `git show feat/ssh-support:apps/node/agent/src/commands/ssh-shared.ts` and `...ssh-aliases.ts` in full (both are short; `ssh-shared.ts` was read during planning - `resolveSshBin` via `findBinary("ssh", "SUBSHELL_SSH_PATH", ["/usr/bin/ssh","/bin/ssh","/usr/local/bin/ssh","/opt/homebrew/bin/ssh"])`, `connectingHomeDir` = `process.env.HOME || homedir()`). +- [ ] **Step 2: Write the failing test file** covering, in the dispatch-test harness style of the existing `__tests__/commands-*.test.ts` (copy one's context-construction helper usage): gate file absent -> both handlers refuse by name; gate ON + fixture HOME containing a `.ssh/config` with two Host blocks -> discovery answers the sorted names through the validator; resolve with an injected fake `sshBin`... the resolve path spawns `ssh -G` - for the unit test drive ONLY discovery, and for resolve assert the refusal ordering (gate check precedes any spawn) plus the malformed-alias wire refusal at parse (Task 1 covered the parser; here: dispatch reaches the handler). The REAL `ssh -G` behavior against a fixture config is covered by the tier-1 resolver tests - do not re-test it here; the e2e (Task 9) exercises the composed path. +- [ ] **Step 3: Fail, then implement** both files, adapted: the reference's per-handler shape carries, plus the gate check FIRST (new, tier-2's §4.3 requirement) and the disclosure note: reference says disclosure belongs to the human-facing surface - re-point that comment at the tier-2 launch route (Task 7) which is where the disclosure rides this tier. +- [ ] **Step 4: Dispatch arms** in `commands/index.ts` + `Cmd` map entries; bump `NODE_PROTOCOL_VERSION = 17` with the history entry: `**16 -> 17 (this tier):** the ssh launch block and the two discovery/resolve commands. A lagging agent is HELD (update-only) until crossed, per the capability-gate doctrine in docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md §4.3; the desktop update path is the crossing.` Check every test that pins the version number (grep the worktree for `NODE_PROTOCOL_VERSION` asserts, e.g. the e2e `ready` frame builders and node-frames tests) and update each with a comment naming the bump - a test that hardcodes 16 now fails loudly, which is the point. +- [ ] **Step 5: Green run**: `env -u SHELLOPTS -u BASHOPTS bun test apps/node/agent/src/__tests__/commands-ssh.test.ts` + every test file touched in step 4. +- [ ] **Step 6: Commit** - `"feat(agent): gated ssh discovery/resolve; protocol 17 for the ssh launch grammar"` + +--- + +### Task 5: Launch path - config write, cleanup, both launchers, `LaunchPlan.ssh` + +**Files:** +- Modify: `apps/server/api/src/services/nodes/node-launcher.ts` (`LaunchPlan.ssh?: { configPath: string; fileContent: string }` with doc) +- Modify: `apps/server/api/src/services/nodes/local-launcher.ts` (`launch(plan)`: when `plan.ssh`, before spawn: ensure `/ssh/` 0700 + write config 0600 with `enforceMode` (import from pane-runtime - `fs-mode.ts`), then compose the command string from `buildCommand` output + `shellQuote` per token (the terminal path's existing composition - follow exactly how `LocalLauncher.launch` builds its pane command and reuse it), env gets `SSH_AUTH_SOCK` when the launch service placed it in `subshellEnv` (no new field); local cleanup at subshell delete rides the existing artifact-removal call in the manager's delete path (read `subshell-manager.service.ts` delete/removeArtifacts region - add the config path to the artifact list it already composes: find `subshellArtifacts`).) +- Modify: `apps/server/api/src/services/nodes/remote-launcher.ts` (`launch` composes the frame's `ssh: {configPath: plan.ssh.configPath, fileContent: plan.ssh.fileContent}` member) +- Modify: `apps/node/agent/src/commands/launch.ts` (after the maintenance gate and allowlist check (the check runs on `cmd.cwd` - unchanged: the ssh launch's cwd is the node home via the presetless-terminal default, subject to the operator's allowlist like any terminal pane, decision 1), and AFTER the placeholder substitution + BEFORE meta record (step order note in the file header is contractual - amend the header list too): a config-write block mirroring the MCP block's posture but with the DERIVED path: `const sshPath = buildSshConfigPath(ctx.config.dataDir, cmd.subshellId)`; refuse unless `cmd.ssh.configPath === sshPath` (byte-equality: the plane may name nothing else); `pathAllowed` against `[ctx.config.dataDir]`; mkdir 0700 + write 0600 + `enforceMode` both.) +- Modify: `apps/node/agent/src/commands/report.ts` (`startExitWatcher`'s observation site: when the pane's meta `harnessId === "ssh"`, after the death is recorded, best-effort `rm -rf /ssh/` - log-only failure, NEVER refuse; find the observation code by reading report.ts around `runExitWatchTick`) +- Test: `apps/node/agent/src/__tests__/launch-ssh.test.ts`; `apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts` (or the launcher's existing test home). + +**Interfaces:** +- Consumes: Task 1's launch frame `ssh` member; Task 2's `buildSshConfigPath`, `shellQuote`; existing `assembleHarnessCommand`/command-string composition in both launchers. +- Produces: `LaunchPlan.ssh`; agent-written 0600 config at the byte-derived path; death cleanup both sides. + +- [ ] **Step 1: Read the four sites in full** before editing: `local-launcher.ts` (whole file), `remote-launcher.ts` `launch`, `commands/launch.ts` (in context), `report.ts` exit-watcher region. Record their real line numbers in your report - this plan's line cites came from planning-time reads and the files move under you. +- [ ] **Step 2: Failing tests.** Agent side: `execLaunch` with a well-formed ssh block -> file exists with mode 0600 and parent 0700, content byte-equal; `configPath` mismatch -> refusal + no file; block absent -> no dir created. Local side: `launch(plan with ssh)` writes under a temp dataDir the same derived path (LocalLauncher's own fs helpers), and the delete-path artifact list includes it (assert the list function's output). Exit cleanup: agent watcher unit - fake pane meta harnessId "ssh" + a real temp file -> gone after the tick; harnessId "claude-code" -> file untouched. +- [ ] **Step 3: Implement per Files above.** Amend `commands/launch.ts`'s step-order docblock (it enumerates the contract - the new step enters at the position it lands). +- [ ] **Step 4: Green focused run** + `verify-types` for `@internal/server` and `@internal/node`; commit `"feat(launch): ssh config written by the spawning machine at the byte-derived path, removed with the pane"`. + +--- + +### Task 6: DB - migration 0048 (`ssh_saved_hosts` + `subshells.ssh`), backup ceiling + +**Files:** +- Create: `apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts`, `apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts`, `apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts` +- Modify: `apps/server/api/src/db/types/subshells.db-types.ts`, `apps/server/api/src/db/index.ts` (Kysely Db type), the migration list import file (find how 0047 registers), `apps/server/api/src/services/backups/database.ts` (`LATEST_BACKUP_MIGRATION = "0048-ssh-launch-and-saved-hosts"`), `apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts` (fixture strip-set now drops 0047 + 0048 rows/DDL from the ledger prefix - copy the pattern commit 0ebd6cee established; the older-snapshot fixture must remain a VALID contiguous prefix). +- Test-first per the tier-1 Task 4 pattern (the hand-built 0031-style test: fresh Kysely, run migrations, assert table + column exist, down() reverses). + +**Schema (final):** + +```ts +/** One saved or recently-used SSH destination for one owner (spec 2026-10-07 §7). Keyed + * by the RESOLVED canonical destination so an edited alias can never silently re-point + * a saved row. `saved_at` is what the human gave it; recency is `last_connect_at`, which + * every launch refreshes whether or not the row was ever saved. */ +export interface SshSavedHostTable { + id: string; // uuid + ownerUserId: string; // FK users, cascade + /** canonical `user@host:port` (user part = the resolved snapshot's effective user, or "" default spelled as the connecting account? NO: `host:port` with an empty user prefix when the snapshot's user is null) */ + destination: string; + /** display-only alias (the config token typed or discovered); never used as a key */ + alias: string | null; + /** the connecting machine used for the most recent launch to this destination */ + nodeId: string; + savedAt: string | null; + lastConnectAt: string; +} +// unique index (ownerUserId, destination); index (ownerUserId, lastConnectAt desc) +``` + +`subshells.ssh`: `string | null` - JSON-encoded `SshConnectionSnapshotWire` (the row's own approval, kept so the pane's kind and its re-render inputs outlive the request; never serialized to clients except the launch's own answer). Column comment: "an SSH-terminal pane carries its approved snapshot here; the value's presence is the owner-only-input rule's trigger (spec §5.4)." + +- [ ] Step 1 write the migration test (failing), Step 2 implement migration + types + registration, Step 3 advance the ceiling + rewrite the fixture strip-set (assert in the test's comment WHY: the ledger the fixture builds must stay contiguous through 0047), Step 4 green focused run (`env -u SHELLOPTS -u BASHOPTS bun test apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts`), Step 5 commit `"feat(db): ssh_saved_hosts + the subshells.ssh snapshot column (migration 0048)"`. + +--- + +### Task 7: Plane - the ssh service surface and `/api/ssh` routes + +**Files:** +- Create: `apps/server/api/src/services/nodes/ssh-rpc.ts`, `apps/server/api/src/services/ssh-launch.service.ts`, `apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts`, `apps/server/api/src/api/ssh/index.ts`, `api/ssh/aliases.route.ts`, `api/ssh/resolve.route.ts`, `api/ssh/launch.route.ts`, `api/ssh/saved-hosts.route.ts` +- Modify: `apps/server/api/src/api/index.ts` (mount `.use(sshRoutes)` on the EXISTING mounted app chain - do not create a new top-level Elysia instance: TS2589 on `App`, memory `elysia-app-type-depth-ceiling`), `apps/server/api/src/services/index.ts` (service registry, mirror ssh-enabled's absence there by following `nodes.service` wiring), `apps/server/api/src/api/models.ts` if response models are centralized (match the prompts routes' local-schema posture - co-located `t` schemas with descriptions are the rule). +- Test: `apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts` (cookie-auth harness like the nodes routes' tests; `sendCommand` injected/faked through the existing seam used by `set-node-ssh-enabled` tests). +- Docs: `docs/security.md` §10 + `.claude/rules/security-context.md`: add `ssh.launch` to the nodes-family line. + +**Route grammar (final; all cookie-actor, all after the existing auth guard):** + +``` +GET /api/ssh/aliases?node= -> 200 {aliases, includeCycle, truncated} | 403 {code: "ssh_gate_off", message names the machine's owner remedy} | 404 invisible | 502-ish mapped agent refusal +POST /api/ssh/resolve {node, alias} -> 200 NodeSshResolveOutcomeWire (refusal rides IN THE DATA per tier-1 grammar) +POST /api/ssh/launch {node, destination: string, name?: string} + -> 201 {subshell: SubshellView} ; refuses: 403 ssh_gate_off / 409 node_protocol_held / 422 {outcome: refusal} / 400 alias-unsafe +GET /api/ssh/saved-hosts -> {saved: [...], recent: [...], defaultNodeId: string|null} +PUT /api/ssh/saved-hosts {node, destination, alias?} -> upsert-mark-saved, returns the row +DELETE /api/ssh/saved-hosts/:id -> 204 (owner rows only; foreign 404) +PATCH /api/ssh/preferences {defaultNodeId: string|null} +``` + +**Service (`ssh-launch.service.ts`) responsibilities, in order, each pinned by test:** +1. Gate: `nodeCanSsh({kind, access, isAdmin, serverAccountEnabled, sshEnabled})` for the ACTING cookie user (its owner/admin-on-local answer straight from `loadNodeAccess` + the row); off -> the named refusal constant + copy (the predicate answers yes/no; this route names the cause, §14). +2. Held check: a held agent node -> 409 naming the update remedy. +3. `destination` is validated at the wire like an alias (1..253, no whitespace/control, no leading `-`); discovery answers and manual tokens take the SAME path: `sendCommand(node, {type:"ssh_resolve_config", alias: destination})`. +4. On `accepted:true`: the outcome's snapshot re-validated (the RPC wrapper already ran `parseNodeSshResolveOutcome`), then compose: `configPath = buildSshConfigPath(targetDataDir, newId)`; `fileContent = renderSshConfigContents(snapshot)`; `flags = sshOptionTokens(snapshot)` plus `-F configPath` FIRST; launch via `createSubshell({ userId, harnessId: "ssh", presetId: null, workingDir: `, name, nodeId })` with the two new plumbing params `ssh: { configPath, fileContent, snapshot }` and `presetFlags: flags`, both added to `createSubshell` in this task (see "manager plumbing" below). subshellEnv gains `SSH_AUTH_SOCK: snapshot.authAgentSocket` when non-null; `restartOnExit` forced off; row `ssh` = `JSON.stringify(snapshot)`. +5. Upsert the saved-host row (recency) and audit `ssh.launch` `{nodeId, destination, subshellId}` - AFTER a successful launch, never before (a refusal writes nothing to that trail beyond the pane-create trail the manager already handles... actually: audIT the launch even when it later fails to spawn? No: audit on success only, matching `subshell.create`'s own posture - name this in the code comment). + +**Manager plumbing (`subshell-manager.service.ts createSubshell`)**: extend the input object with `ssh?: { configPath: string; fileContent: string; snapshot: SshConnectionSnapshotWire }` and `presetFlags?: string[]`. Behavior: when `presetFlags` present, the launch preset is `{...EMPTY_PRESET, flags: presetFlags}` (fresh object - EMPTY_PRESET is a shared constant, never mutate); when `ssh` present, pass `ssh` into the `LaunchPlan` (both launcher impls), pass `SSH_AUTH_SOCK` merge into `subshellEnv` AT THE CALL SITE (the ssh service computes the merged env and passes it through createSubshell's existing env surface - if createSubshell does not accept env additions, this is the one place it gains a `extraPaneEnv?: Record` param; READ the function first and take the minimal honest plumbing), and write `ssh: JSON.stringify(...)` into the row insert. Prompt-typing: ssh launches never carry a prompt (the body schema carries no prompt field, Elysia strips unknowns; an ssh launch types nothing). + +- [ ] Step 1: the route/service tests (matrix above, faked `sendCommand` + faked node facts; assert gate order: gate-before-RPC (a node with the row off never receives a command); held-before-resolve; refusal-in-the-data 200 shape for resolve vs 422 for launch; saved-hosts list never carries another owner's rows; delete foreign -> 404; default node id set/clear.) +- [ ] Step 2: fail; implement repository (`touch(destination, nodeId, alias?)` upsert-refresh, `markSaved`, `listSaved/listRecent(limit 20)`, `remove`), rpc wrappers (`sshDiscover(sshNodeId)` / `sshResolve(...)` -> validated plain objects, throwing a named `SshRpcError` on a frame error), service, routes, mount; docs/security lines. +- [ ] Step 3: green focused + `bunx turbo build` (routes visible to treaty consumers) + `verify-types --filter=@internal/server`. +- [ ] Step 4: commit `"feat(api): the ssh launcher surface - gated discovery, resolve, launch, saved hosts"` + +--- + +### Task 8: Owner-only input for ssh panes (REST + MCP + WS) + +**Files:** +- Modify: `apps/server/api/src/services/subshells.service.ts` `sendSubshellInput` (the REST input door, and the MCP door through it: add the owner-only rule after the share check as a named 403 code `SSH_OWNER_INPUT_ONLY`; the input route's response map lists response types - add the new code there; watch the treaty consumers, rebuild) and `execInTerminal` (the SAME rule; exec TYPES, so the carve-out precedes the running checks) - put the predicate in ONE place: create `apps/server/api/src/lib/ssh-pane-access.ts` `export function paneInputAllowed(row: {ssh: string | null; userId: string}, actorUserId: string, bearerActor: boolean): boolean` with the rule + spec §5.4 doc, and call it from all three sites (input, exec, WS data) - "one question, one place." (As shipped, the predicate serves the two service verbs plus `resolveAttach`'s single `canInput`; the input route gained NO response-map key because 403 was already declared.) +- Modify: `apps/server/api/src/ws/attach-resolve.ts` `resolveAttach`: the `canInput` flag every input frame honors is computed from the same predicate (the WS resolves a cookie identity; live is human-only already, `bearerActor` false). +- Test: extend `apps/server/api/src/api/subshells/__tests__/subshell-input-route.test.ts` and the exec test home with: ssh row + edit grantee -> 403 (code named), ssh row + owner -> 200, ordinary row + edit grantee -> unchanged 200, ssh row + the pane's OWN key -> 403 (decision 5). + +- [ ] Steps 1-4: failing tests -> implement -> green -> commit `"feat(api): ssh panes take input from their owner alone (spec §5.4)"` + +--- + +### Task 9: e2e - the Milestone 1 proof + +**File:** Create `e2e/tests/22-ssh-terminal.spec.ts`. Read first: `e2e/tests/12-nodes.spec.ts` (backend boot + admin), `e2e/fixtures/sshd.ts` (exports - it already spawns a loopback sshd + keys + a config alias; reuse, do not rebuild), one existing spec's cleanup posture. + +**Scenario (all local node, no agent link):** boot the e2e backend with `HOME=` and `SSH_AUTH_SOCK=` in its env ONLY within this spec (start `ssh-agent` + `ssh-add` via Bun.spawn at spec start; write `/.ssh/config` naming the fixture sshd under alias `e2e-dest`; the sshd fixture's own key trust lands in `/.ssh/known_hosts` - check the fixture's exports for what it already provisions, and extend the FIXTURE (not the spec) if it does not, so the next spec inherits a truthful helper). Steps: register admin; `PUT /api/nodes/local/ssh-enabled {on:true}`; `GET /api/ssh/aliases?node=local` contains `e2e-dest`; `POST /api/ssh/launch {node:"local", destination:"e2e-dest"}` -> 201; poll the pane's log through the existing capture/read route for the sshd fixture's shell banner/marker (run a command by TYPING owner input: `echo SSH-PANE-OK`); assert the log contains it. Then: second user with `edit` grant -> input 403 code SSH_OWNER_INPUT_ONLY; owner terminate; assert the config dir is gone (`/ssh/` absent) via fs. + +- [ ] Step 1: write the spec; Step 2: run `env -u SHELLOPTS -u BASHOPTS bun run test:e2e 22-ssh-terminal` (e2e scripts live in package.json - find the invocation, run ONLY this spec file); iterate until green WITH the sshd binaries present (this host has them; if `sshd` cannot start unprivileged the fixture already handles skip-loud - do not paper a skip over a failure you could fix); Step 3: commit `"test(e2e): ssh pane end to end - gated launch, owner input typed over the wire, sharee refused, files cleaned"`. + +--- + +### Task 10: Boundary - full verification, changeset, PR + +- [ ] Full trio + prose + tests (verification.md): `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test` - fix everything; expect the tier-1-known pre-existing `e2e-cross-subshell` environmental failure ONLY (re-verify it still fails at base `c5a04be6` before blaming it, per the ledger's provenance rule). +- [ ] `.changeset/ssh-launcher-tier.md`: `@internal/server`, `@internal/node`, `@internal/subshell-protocol`, `@internal/pane-runtime` minor. Deployment sentence: server before nodes; a pre-17 agent is held (offline for everything but update) until it crosses on the update command; the SSH gate itself still defaults off everywhere - nothing changes for anyone until a node is enabled. +- [ ] `bun run lint:lockfile` clean; `git push -u origin feat/ssh-anywhere-2`; `gh pr create --base feat/ssh-anywhere-1` naming the pre-existing failure with its provenance. Do NOT merge, do NOT auto-merge (memory: gh auto-merge merges immediately). +- [ ] Whole-branch review loop (requesting-code-review; package via `git diff feat/ssh-anywhere-1...HEAD` redirected to a file; quote-verify every finding before acting; rounds until zero MAJOR+MINOR). diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 6349bff93..5889afbf0 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -50,9 +50,14 @@ its providers via the admin cookie at `POST /api/auth-providers` (whose save gat runs discovery against this fake), and no spec ever dials a real IdP. Every fixed port in `ports.ts` takes an env override (`E2E_PORT_BACKEND`, -`E2E_PORT_FAKE_REGISTRY`, `E2E_PORT_FAKE_IDP`, `E2E_PORT_ONBOARDING`) with the -committed defaults unchanged, the escape hatch for two suites running side by -side on one machine, which the defaults collide on. +`E2E_PORT_FAKE_REGISTRY`, `E2E_PORT_FAKE_IDP`, `E2E_PORT_ONBOARDING`, +`E2E_PORT_SSH`) with the committed defaults unchanged, the escape hatch for +two suites running side by side on one machine, which the defaults collide on. + +Spec `22` boots its own backend too (port 3201) for a reason the shared stack +cannot serve: the ssh tier reads the connecting account's HOME and bun caches +`os.homedir()` per process, so that child is SPAWNED with `HOME=` +plus the spec's `ssh-agent` socket, on its own temp DB/data dir/tmux base. 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 diff --git a/e2e/fixtures/sshd.ts b/e2e/fixtures/sshd.ts index f9c00e87b..0c77599f9 100644 --- a/e2e/fixtures/sshd.ts +++ b/e2e/fixtures/sshd.ts @@ -141,7 +141,7 @@ export async function startSshFixture(root: string, options: { envPath?: string // 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 + // lookup. The specs hand the agent `USER`/`LOGNAME` (see 22-ssh-terminal) 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"); diff --git a/e2e/ports.ts b/e2e/ports.ts index c4a21fcad..07c7451f3 100644 --- a/e2e/ports.ts +++ b/e2e/ports.ts @@ -26,6 +26,15 @@ * never repeat there. A clean-machine claim needs a clean instance, and this * port is where spec 15 boots one — its own temp DB, its own data dir, and * every agent-CLI override pointed at a file that does not exist. + * + * `ssh` belongs to spec 22's SECOND backend for the same shared-DB reason, + * plus one that is its own: the ssh tier reads the connecting account's HOME + * (its `~/.ssh/config` is the alias source and `ssh -G` runs as that account), + * and bun caches `os.homedir()` per process — so that backend must be SPAWNED + * with `HOME=` in its env, which the shared stack can never + * carry for the other specs. Its own temp DB, its own data dir (whose + * `ssh/` dir the spec asserts on through the filesystem), and its own + * tmux base. */ const envPort = (name: string, fallback: number): number => { const raw = process.env[name]; @@ -42,6 +51,7 @@ export const PORTS = { fakeRegistry: envPort("E2E_PORT_FAKE_REGISTRY", 3198), fakeIdp: envPort("E2E_PORT_FAKE_IDP", 3197), onboarding: envPort("E2E_PORT_ONBOARDING", 3200), + ssh: envPort("E2E_PORT_SSH", 3201), } as const; export const BASE_URL = `http://127.0.0.1:${PORTS.backend}`; diff --git a/e2e/tests/22-ssh-terminal.spec.ts b/e2e/tests/22-ssh-terminal.spec.ts new file mode 100644 index 000000000..f4c5a7349 --- /dev/null +++ b/e2e/tests/22-ssh-terminal.spec.ts @@ -0,0 +1,470 @@ +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { type APIRequestContext, expect, type PlaywrightWorkerArgs, test } from "@playwright/test"; +import { missingSshBins, SSH_FIXTURE_ALIAS, type SshFixture, startSshFixture } from "../fixtures/sshd"; +import { PORTS } from "../ports"; +import { shortTmuxBase } from "../stack"; + +/** + * Milestone 1's proof (spec 2026-10-07 §14, plan 2 Task 9): a gated SSH pane, + * end to end over REAL HTTP and a REAL sshd. No mocks anywhere on the path — + * the fixture daemon (fixtures/sshd.ts) answers the connection, the backend + * (this spec's own child, HOME pointed at the fixture account) resolves the + * alias with a real `ssh -G`, renders a real config onto disk, and the pane + * IS a real ssh session whose typed line is echoed by a real remote shell and + * read back through the pane-log route. + * + * Why its own backend (the ports.ts `ssh` comment): bun caches `os.homedir()` + * per process, and the ssh tier reads the connecting account's HOME for both + * discovery and resolution — so the backend must be SPAWNED with + * `HOME=`. The shared stack cannot carry that for the other + * specs, and mutating THIS process's HOME would not reach the server anyway. + * + * Why the assertions land where they do: + * - owner input types `echo SSH-PANE-$((1+1))-OK`; only the REMOTE SHELL's + * evaluation prints `SSH-PANE-2-OK`, so the log match proves the round trip + * (the typed line itself never spells the result), + * - the sharee refusal is a CODE assertion (403 `SSH_OWNER_INPUT_ONLY`) and + * the sharee's own text must be ABSENT from the log ("before anything is + * typed" is a promise the route makes), + * - the config-dir cleanup is checked through the FILESYSTEM: the backend is + * a child process, but the spec passed it the data dir and both run on this + * host, so `/ssh/` is directly observable here. + * + * Binaries: ssh / ssh-keygen / sshd must run unprivileged here. The fixture's + * own gate (`missingSshBins`) decides; a host without them SKIPS LOUDLY (the + * skip names the missing binaries), it never fails and never fakes a pass. + * The CI e2e job installs openssh-client + openssh-server at runtime (the + * "Ensure tmux, jq, and OpenSSH" step of test.yml); hosts without the + * binaries still skip loudly rather than fake a pass. + */ + +const ROOT = path.join(import.meta.dirname, "..", ".."); +const BACKEND_DIR = path.join(ROOT, "apps", "server", "api"); +const ORIGIN = `http://127.0.0.1:${PORTS.ssh}`; + +/** Boot + sshd + agent + a real tmux ssh pane: budgets on spec 12's scale. */ +const READY_TIMEOUT = 60_000; +const SPAWN_TIMEOUT = 60_000; +// The death sweep is a longer chain than a boot: typed `exit` -> the ssh +// process ends -> the tmux pane-died hook -> reportExit -> the server's local +// sweep rm. On a contended CI runner that whole chain overran the 60s boot +// budget once and passed on retry, so it gets its own, doubled. It never +// weakens the assertion: the dir must still be gone. +const SWEEP_TIMEOUT = 120_000; + +const missing = missingSshBins(); +test.skip(missing.length > 0, `SSH binaries absent on this host (${missing.join(", ")}) - spec 22 needs a real sshd`); + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +/** Polls `check` until true or the deadline; on timeout throws the label's current text. */ +async function pollUntil( + label: string | (() => string), + check: () => Promise | boolean, + timeoutMs = SPAWN_TIMEOUT, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await check()) return; + if (Date.now() > deadline) throw new Error(typeof label === "string" ? label : label()); + await sleep(500); + } +} + +interface SshInstance { + child: ReturnType; + /** The scratch root: config dir, temp DB, and the data dir whose `ssh/` the spec watches. */ + dir: string; + dataDir: string; + tmuxBase: string; +} + +/** Boots the spec's OWN backend on `PORTS.ssh`, with HOME + SSH_AUTH_SOCK in ITS env (see the header). */ +async function startSshBackend(fixtureHome: string, sshAuthSock: string): Promise { + // Same orphan guard as spec 15: afterAll-like teardowns run per attempt, so + // a dead retry's detached backend would hold :3201 for the next attempt. + stopSshBackend(); + const dir = mkdtempSync(path.join(tmpdir(), "subshell-e2e-ssh-")); + const dataDir = path.join(dir, "data"); + const tmuxBase = shortTmuxBase(); + mkdirSync(tmuxBase, { recursive: true }); + + const env: Record = { + ...process.env, + NODE_ENV: "development", + SUBSHELL_TEST_MODE: "false", + SUBSHELL_SERVER_CONFIG_DIR: dir, + SERVER_PORT: String(PORTS.ssh), + HOST: "127.0.0.1", + DATABASE_PATH: path.join(dir, "subshell.db"), + SUBSHELL_SERVER_DATA_DIR: dataDir, + APP_BASE_URL: ORIGIN, + BETTER_AUTH_SECRET: "e2e-secret-not-used-outside-tests-0000000000", + SUBSHELL_PLUGIN_REGISTRY_URL: `http://127.0.0.1:${PORTS.fakeRegistry}`, + SUBSHELL_RELEASE_URL: "", + TMUX_TMPDIR: tmuxBase, + // The two the ssh tier reads: HOME is the alias/config/known_hosts source + // (discovery and `ssh -G` run as this account), SSH_AUTH_SOCK is what the + // resolve may answer as the snapshot's agent socket. + HOME: fixtureHome, + SSH_AUTH_SOCK: sshAuthSock, + }; + // A developer inside tmux must not hand the backend their session's pane + // (spec 15's rule, same failure mode). + delete env.TMUX; + delete env.TMUX_PANE; + + const child = spawn("bun", ["run", "src/index.ts"], { + cwd: BACKEND_DIR, + detached: true, + stdio: process.env.E2E_VERBOSE ? "inherit" : "ignore", + env, + }); + child.unref(); + const inst: SshInstance = { child, dir, dataDir, tmuxBase }; + instance = inst; + + const deadline = Date.now() + READY_TIMEOUT; + for (;;) { + try { + const res = await fetch(`${ORIGIN}/api/setup/status`); + if (res.ok) { + const body = (await res.json()) as { needsSetup: boolean }; + if (!body.needsSetup) throw new Error("[e2e] ssh instance: port already set up (leaked run)"); + return inst; + } + } catch (err) { + if ((err as Error).message.includes("port already set up")) throw err as Error; + // Not listening yet. + } + if (Date.now() > deadline) throw new Error(`[e2e] ssh instance did not become ready at ${ORIGIN}`); + await sleep(300); + } +} + +/** The instance this attempt started (module-scope so the cleanup paths reach it across retries). */ +let instance: SshInstance | undefined; + +function stopSshBackend(): void { + const inst = instance; + if (!inst) return; + instance = undefined; + try { + if (inst.child.pid) process.kill(-inst.child.pid, "SIGTERM"); + } catch { + // Already gone. + } + // The ssh pane's tmux server detached away from the backend's process group + // (the stack.ts lesson), so kill by socket, then wipe the scratch dirs. + for (const uidDir of readdirSafe(inst.tmuxBase)) { + for (const socket of readdirSafe(path.join(inst.tmuxBase, uidDir))) { + try { + spawnSync("tmux", ["-S", path.join(inst.tmuxBase, uidDir, socket), "kill-server"]); + } catch { + // Best-effort per socket. + } + } + } + rmSync(inst.dir, { recursive: true, force: true }); + rmSync(path.dirname(inst.tmuxBase), { recursive: true, force: true }); +} + +function readdirSafe(dir: string): string[] { + try { + return readdirSync(dir); + } catch { + return []; + } +} + +/** Mode without the type bits (what the 0600/0700 assertions read). */ +function modeOf(p: string): number { + return statSync(p).mode & 0o777; +} + +const ADMIN = { name: "SSH Admin", email: "ssh-admin@subshell.test", password: "e2e-ssh-admin-pass-1" } as const; +const MEMBER_PASSWORD = "e2e-ssh-member-pass-1"; + +/** + * A fresh cookieless API context (spec 07's rule: a cookie-carrying /api/auth + * call without an Origin is a CSRF 403; the first sign-in passes the guard and + * seeds the jar, so each principal acts as itself). + */ +function freshCtx(playwright: PlaywrightWorkerArgs["playwright"]): Promise { + return playwright.request.newContext({ baseURL: ORIGIN, storageState: { cookies: [], origins: [] } }); +} + +test("ssh pane: gated launch to the fixture sshd, owner input echoes from the remote shell, an edit sharee is refused, the config dir is swept", async ({ + playwright, +}) => { + test.setTimeout(300_000); + const nonce = test.info().retry; + + const leaks: string[] = []; + let fixture: SshFixture | undefined; + let fixtureRoot: string | undefined; + let agentPid: number | undefined; + // The refs the finally reaches for; the consts inside the try are what the + // story calls (a let loses its non-undefined narrowing across the poll + // closures, a const does not). + let adminRef: APIRequestContext | undefined; + let memberRef: APIRequestContext | undefined; + let subshellId: string | undefined; + + // Trackers first: every allocation below is reclaimed by the finally even + // when the story fails halfway (spec 12's no-leaves posture). + try { + // ── The fixture sshd + its keys + the alias HOME it writes under the root. + fixtureRoot = mkdtempSync(path.join(tmpdir(), "subshell-e2e-sshtier-")); + fixture = await startSshFixture(fixtureRoot); + + // ── An ssh-agent holding the fixture's trusted key (plan-2 handoff): the + // backend's resolve reads SSH_AUTH_SOCK from the SERVER process env. The + // fixture's alias pins `IdentityAgent none`, so the pane dials with the + // IdentityFile key and the agent is the env fact the resolve sees, not the + // thing that authenticates - the agent-socket snapshot itself is pinned by + // the service's unit tests. + const agentOut = spawnSync("ssh-agent", ["-s"], { encoding: "utf8" }); + if (agentOut.status !== 0) throw new Error(`ssh-agent failed: ${agentOut.stderr}`); + const agentEnv = Object.fromEntries( + ["SSH_AUTH_SOCK", "SSH_AGENT_PID"].map((k) => [k, agentOut.stdout.match(new RegExp(`${k}=([^;]+)`))?.[1] ?? ""]), + ); + agentPid = Number(agentEnv.SSH_AGENT_PID); + if (!agentEnv.SSH_AUTH_SOCK || !Number.isInteger(agentPid) || agentPid <= 0) { + throw new Error(`ssh-agent output unparseable: ${agentOut.stdout.slice(0, 200)}`); + } + const add = spawnSync("ssh-add", [fixture.trustedKeyPath], { + env: { ...process.env, SSH_AUTH_SOCK: agentEnv.SSH_AUTH_SOCK }, + encoding: "utf8", + }); + if (add.status !== 0) throw new Error(`ssh-add failed: ${add.stderr}`); + + // ── The backend, spawned with the fixture HOME + the agent socket in ITS env. + const inst = await startSshBackend(fixture.sshConfigHome, agentEnv.SSH_AUTH_SOCK); + + // ── 1. Admin: the first registered account becomes admin (spec 01's rule + // on this fresh instance), through the same endpoint the wizard's first + // step posts. A cookieless context: the sign-up itself seeds the jar. + adminRef = await freshCtx(playwright); + const admin = adminRef; + const signUp = await admin.post("/api/auth/sign-up/email", { data: ADMIN }); + expect(signUp.ok(), await signUp.text()).toBe(true); + + // ── 2. The gate: SSH is OFF by default - discovery refuses before the + // switch, naming the one code for both gate causes. + const gated = await admin.get("/api/ssh/aliases?node=local"); + expect(gated.status(), await gated.text()).toBe(403); + expect(((await gated.json()) as { code: string }).code).toBe("SSH_GATE_OFF"); + + const enable = await admin.put("/api/nodes/local/ssh-enabled", { data: { on: true } }); + expect(enable.ok(), await enable.text()).toBe(true); + expect(((await enable.json()) as { sshEnabled: boolean }).sshEnabled).toBe(true); + + // ── 3. Discovery: the fixture's alias is the machine's own answer. + const aliases = await admin.get("/api/ssh/aliases?node=local"); + expect(aliases.ok(), await aliases.text()).toBe(true); + const { aliases: names } = (await aliases.json()) as { aliases: string[] }; + expect(names).toContain(SSH_FIXTURE_ALIAS); + + // ── 4. The launch: gate -> resolve (a real `ssh -G` as the fixture HOME) + // -> compose -> create. 201 carries the pane view; its id is the config + // dir's name. + const launch = await admin.post("/api/ssh/launch", { + data: { node: "local", destination: SSH_FIXTURE_ALIAS, name: `e2e-ssh-pane-${nonce}` }, + }); + expect(launch.status(), await launch.text()).toBe(201); + subshellId = ((await launch.json()) as { subshell: { id: string } }).subshell.id; + expect(subshellId).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + + // The pane is genuinely alive: the ssh process is the pane, so alive is + // the sshd handshake and the remote shell, not just a spawned tmux. + await pollUntil( + `ssh pane ${subshellId} never reached running+alive`, + async () => { + const res = await admin.get(`/api/subshells/${subshellId}`); + if (!res.ok()) return false; + const view = (await res.json()) as { status: string; alive: boolean }; + return view.status === "running" && view.alive === true; + }, + READY_TIMEOUT, + ); + + // The rendered config, byte-checked where it lives (decision 4's path, + // 0600 in a 0700 dir, and the M1 posture's own lines). + const sshDir = path.join(inst.dataDir, "ssh", subshellId); + const configPath = path.join(sshDir, "config"); + await pollUntil(`the rendered ssh config never appeared at ${configPath}`, () => existsSync(configPath)); + expect(modeOf(sshDir), "ssh pane dir").toBe(0o700); + expect(modeOf(configPath), "ssh pane config").toBe(0o600); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("StrictHostKeyChecking accept-new"); + expect(config).toContain(path.join(fixture.sshConfigHome, ".ssh", "known_hosts")); + expect(config).toContain(fixture.trustedKeyPath); + + // ── 5. Owner input, typed through the ordinary REST input seam, answered + // by the REMOTE shell: only bash's arithmetic prints the 2, so a match of + // SSH-PANE-2-OK proves bytes travelled both ways through the real ssh. + // + // Typed only once the DAEMON has started the shell, not when the row says + // alive: alive is stamped at create, and the pane's pty is still in the + // pre-raw line discipline during the ssh handshake, which swallows the + // Enter (observed: the text reached the remote prompt, the CR never did). + // "Starting session: shell" is sshd's own LogLevel-VERBOSE line - the + // fixture's truth source, environment-independent. The settle beat covers + // the remote shell's init before its line editor exists. + const daemonLog = fixture.logPath; + await pollUntil( + () => + `sshd never reported a shell for ${subshellId}; its log ends: ${readFileSync(daemonLog, "utf8").slice(-800)}`, + () => /Starting session: shell/.test(readFileSync(daemonLog, "utf8")), + READY_TIMEOUT, + ); + await sleep(1_500); + const typed = "echo SSH-PANE-$((1+1))-OK"; + const input = await admin.post(`/api/subshells/${subshellId}/input`, { data: { text: typed } }); + expect(input.ok(), await input.text()).toBe(true); + let lastLogView = ""; + await pollUntil( + () => + `the pane log never carried the remote shell's evaluated marker; last read: ${JSON.stringify(lastLogView.slice(-1200))}`, + async () => { + const res = await admin.get(`/api/subshells/${subshellId}/log`); + if (!res.ok()) { + lastLogView = `HTTP ${res.status()} ${(await res.text()).slice(0, 300)}`; + return false; + } + lastLogView = ((await res.json()) as { lines: string[] }).lines.join("\n"); + return lastLogView.includes("SSH-PANE-2-OK"); + }, + READY_TIMEOUT, + ).catch((err: Error) => { + // Failure-only diagnostics: the RAW pipe-pane file (the route's strip may + // differ from what the pane wrote) and the daemon's own log. Both live + // on paths this spec owns; the happy path never pays for them. + let raw = ""; + try { + raw = readFileSync(path.join(inst.dataDir, "subshells", `${subshellId}.log`), "utf8").slice(-3000); + } catch (e) { + raw = String(e); + } + let daemon = ""; + try { + daemon = fixture === undefined ? "(no fixture)" : readFileSync(fixture.logPath, "utf8").slice(-1500); + } catch (e) { + daemon = String(e); + } + throw new Error(`${err.message}\n--- raw log tail ---\n${raw}\n--- sshd log tail ---\n${daemon}`); + }); + + // ── 6. The owner-only input carve-out (spec §5.4): a second account with + // the ordinary EVERYONE `edit` grant may act on a plain pane, and must + // still be refused on an ssh pane - before anything is typed. + const memberEmail = `ssh-member-${nonce}@subshell.test`; + const created = await admin.post("/api/users", { + data: { email: memberEmail, name: memberEmail, password: MEMBER_PASSWORD, role: "user" }, + }); + expect(created.ok(), await created.text()).toBe(true); + const shared = await admin.put(`/api/subshells/${subshellId}/shares`, { + data: { shares: [{ granteeUserId: null, permission: "edit" }] }, + }); + expect(shared.ok(), await shared.text()).toBe(true); + + memberRef = await freshCtx(playwright); + const member = memberRef; + const memberSignIn = await member.post("/api/auth/sign-in/email", { + data: { email: memberEmail, password: MEMBER_PASSWORD }, + }); + expect(memberSignIn.ok(), await memberSignIn.text()).toBe(true); + // The grant is real: the member sees the pane at `edit` (the refusal below + // must be the SSH rule, not an invisibility or a missing grant). + const memberView = await member.get(`/api/subshells/${subshellId}`); + expect(memberView.ok(), await memberView.text()).toBe(true); + expect(((await memberView.json()) as { access: string }).access).toBe("edit"); + + const refused = await member.post(`/api/subshells/${subshellId}/input`, { + data: { text: `echo NOT-YOUR-PANE-${nonce}` }, + }); + expect(refused.status(), await refused.text()).toBe(403); + expect(((await refused.json()) as { code: string }).code).toBe("SSH_OWNER_INPUT_ONLY"); + + // The refusal typed NOTHING: give any raced write a beat, then read the + // whole log and require the sharee's marker to be absent. + await sleep(1_000); + const afterRefusal = await admin.get(`/api/subshells/${subshellId}/log`); + expect(afterRefusal.ok()).toBe(true); + expect(((await afterRefusal.json()) as { lines: string[] }).lines.join("\n")).not.toContain( + `NOT-YOUR-PANE-${nonce}`, + ); + + // ── 7. The config dir is swept at the pane's death (decision 4): the + // owner types `exit`, the ssh process ends on its own, and the tmux + // pane-died hook lands on reportExit - whose local branch rm -rfs + // /ssh/. Natural death is THE sweep path to drive: a + // terminate revokes the pane's token as it kills, so the hook it races + // can answer 401, while here the token is still live when the hook fires + // (measured: swept inside a second of the typed exit; the row parks at + // status running, alive false). The backend is a separate process but on + // THIS host with a data dir the spec itself chose, so the absence is + // checked straight off the disk - in-proc vs child: CHILD, fs still + // observable because the spec passed the path in. + const exitLine = await admin.post(`/api/subshells/${subshellId}/input`, { data: { text: "exit" } }); + expect(exitLine.ok(), await exitLine.text()).toBe(true); + await pollUntil(`ssh config dir ${sshDir} outlived the pane's own exit`, () => !existsSync(sshDir), SWEEP_TIMEOUT); + + // Retire the parked row (terminate accepts a dead pane, delete removes + // it; the delete path's unlink-only artifact list has nothing left to + // take, which is exactly what "swept" means here). + const terminated = await admin.post(`/api/subshells/${subshellId}/terminate`); + expect(terminated.ok(), await terminated.text()).toBe(true); + const removed = await admin.delete(`/api/subshells/${subshellId}`); + expect(removed.ok() || removed.status() === 404, await removed.text()).toBe(true); + subshellId = undefined; + } finally { + // Row first (while the admin ctx still works), then the principals, then + // the daemons. A mid-story failure leaves the pane alive on a tmux server + // that lives under THIS attempt's tmuxBase, so the socket sweep inside + // stopSshBackend() is what kills the ssh process when the row delete fails. + if (subshellId !== undefined && adminRef) { + try { + const res = await adminRef.post(`/api/subshells/${subshellId}/terminate`); + if (!res.ok() && res.status() !== 404) leaks.push(`cleanup terminate: HTTP ${res.status()}`); + await adminRef.delete(`/api/subshells/${subshellId}`); + } catch (err) { + // The instance is going away anyway; the temp dir rm below is the real backstop. + leaks.push(`cleanup row: ${String(err)}`); + } + } + for (const [label, p] of [ + ["admin ctx", adminRef], + ["member ctx", memberRef], + ] as const) { + try { + await p?.dispose(); + } catch (err) { + leaks.push(`${label} dispose: ${String(err)}`); + } + } + if (agentPid !== undefined) { + try { + process.kill(agentPid, "SIGTERM"); + } catch { + // Already gone. + } + } + try { + await fixture?.stop(); + } catch (err) { + leaks.push(`sshd fixture stop: ${String(err)}`); + } + try { + stopSshBackend(); + } catch (err) { + leaks.push(`backend stop: ${String(err)}`); + } + if (fixtureRoot !== undefined) rmSync(fixtureRoot, { recursive: true, force: true }); + if (leaks.length > 0) console.error(`[22-ssh-terminal] cleanup problems: ${leaks.join("; ")}`); + } +}); diff --git a/packages/backend-errors/src/__tests__/strip-ansi.test.ts b/packages/backend-errors/src/__tests__/strip-ansi.test.ts index b96ffef88..8d024662e 100644 --- a/packages/backend-errors/src/__tests__/strip-ansi.test.ts +++ b/packages/backend-errors/src/__tests__/strip-ansi.test.ts @@ -14,6 +14,20 @@ describe("stripAnsi", () => { expect(stripAnsi("\x1b]0;window title\x07body")).toBe("body"); }); + it("strips OSC sequences terminated by ST (ESC backslash), keeping the body", () => { + // Shell-integration output (e.g. OSC 3008/133) terminates with ST, not + // BEL. Measured in spec 22's pane: a BEL-only regex ran past the ST + // terminator and swallowed every byte up to the NEXT BEL, deleting the + // line the command had just printed. + expect(stripAnsi("\x1b]3008;start=abc;type=command\x1b\\SSH-PANE-2-OK\r\n\x1b]0;~\x07prompt")).toBe( + "SSH-PANE-2-OK\nprompt", + ); + }); + + it("leaves an unterminated OSC (no BEL, no ST) untouched rather than swallowing the tail", () => { + expect(stripAnsi("\x1b]66;never ended")).toBe("\x1b]66;never ended"); + }); + it("strips carriage returns", () => { expect(stripAnsi("line1\r\nline2")).toBe("line1\nline2"); }); diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index 132daef06..07df9a5f5 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -182,6 +182,51 @@ export enum BackendErrorCodes { * this act cannot invent, so it is refused before anything runs. */ NETWORK_UNCONFIGURED = "NETWORK_UNCONFIGURED", + /** + * A `/api/ssh` act on a machine SSH is not open for, or not open for THIS + * caller: the node row's flag is off, or the acting viewer is not the + * machine's owner (`nodeCanSsh`, spec 2026-10-07 §4.3). One code for both + * causes; the message names the true one, because naming WHY is the + * surface's job (spec §12) and the code is the machine-readable half. + */ + SSH_GATE_OFF = "SSH_GATE_OFF", + /** + * A `/api/ssh` act against a HELD agent: the plane refuses to speak that + * machine's protocol, so no ssh command can be sent and none is. The + * message names the remedy — update the node (spec 2026-10-07 decision 6). + */ + NODE_PROTOCOL_HELD = "NODE_PROTOCOL_HELD", + /** + * A `/api/ssh` destination (or alias) that the wire grammar cannot carry: + * blank, over the 253-char cap, leading `-` (option-like), or carrying + * whitespace/control characters. Refused before the machine is asked, so + * the token never reaches `ssh -G` or a config parse. + */ + ALIAS_UNSAFE = "ALIAS_UNSAFE", + /** + * The connecting machine answered an ssh command with its own `ok:false` + * (its ssh-enabled mirror says no, no ssh binary, a malformed answer): the + * plane's row cannot overrule the machine. The agent's own text is never + * echoed; the message names the human's remedy. + */ + SSH_NODE_REFUSED = "SSH_NODE_REFUSED", + /** + * `POST /api/subshells/:id/restart` on an ssh pane (spec 2026-10-07 + * decision 7): the pane's session lives on the far end of a socket, so a + * restart would spawn a new ssh process nobody asked to reconnect. Launch + * again from the launcher instead. Keyed to the `subshells.ssh` column. + */ + SSH_NO_RESTART = "SSH_NO_RESTART", + /** + * A pane-input act (REST input, exec, MCP-via-REST, WS keystroke) on an + * ssh pane by a caller who is not the row's OWNER account, or by ANY + * machine credential (spec 2026-10-07 §5.4, decision 5: the far side of an + * ssh pane runs under the owner's own trust; the pane's key must not type + * into itself either). Keyed to the `subshells.ssh` column like the + * restart refusal; the WS door drops the frame rather than answering this + * code, exactly as it drops a `view` grantee's keystrokes. + */ + SSH_OWNER_INPUT_ONLY = "SSH_OWNER_INPUT_ONLY", } /** @@ -421,4 +466,31 @@ export const BackendErrorCodeDefs = { message: "Configure this network before continuing", statusCode: 409, }, + [BackendErrorCodes.SSH_GATE_OFF]: { + message: "Subshell SSH is off on this machine", + statusCode: 403, + }, + [BackendErrorCodes.NODE_PROTOCOL_HELD]: { + message: "This node is held for speaking a protocol this server does not", + statusCode: 409, + }, + [BackendErrorCodes.ALIAS_UNSAFE]: { + message: "That SSH destination is not a safe token", + statusCode: 400, + }, + // The machine's own refusal is not a client error and not a server fault: + // the request was valid, the connecting host answered no. 502 says the + // upstream (the node) refused, which is exactly the shape. + [BackendErrorCodes.SSH_NODE_REFUSED]: { + message: "The connecting machine refused the SSH request", + statusCode: 502, + }, + [BackendErrorCodes.SSH_NO_RESTART]: { + message: "SSH panes are not restarted; launch again to reconnect", + statusCode: 409, + }, + [BackendErrorCodes.SSH_OWNER_INPUT_ONLY]: { + message: "SSH panes accept input from their owner only", + statusCode: 403, + }, }; diff --git a/packages/backend-errors/src/strip-ansi.ts b/packages/backend-errors/src/strip-ansi.ts index 10ae7a2f9..572cdeaf6 100644 --- a/packages/backend-errors/src/strip-ansi.ts +++ b/packages/backend-errors/src/strip-ansi.ts @@ -4,9 +4,9 @@ * output, so the two consumers must agree on what counts as escape noise. * * Strips CSI sequences (`ESC [ ... final-byte`, including DEC private modes - * like `ESC [ ? 1049 h`), OSC title sequences (`ESC ] ... BEL`), and carriage - * returns. Safe for arbitrary terminal output; exported as a pure function so - * it can be unit-tested. + * like `ESC [ ? 1049 h`), OSC sequences (`ESC ] ...` ended by either BEL or + * ST = `ESC \`), and carriage returns. Safe for arbitrary terminal output; + * exported as a pure function so it can be unit-tested. */ /** @@ -19,9 +19,17 @@ */ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI CSI escape sequences const csiRegex = /\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g; -/** OSC title sequences (BEL-terminated). */ -// biome-ignore lint/suspicious/noControlCharactersInRegex: OSC title sequences (BEL-terminated) -const oscRegex = /\x1b\][^\x07]*\x07/g; +/** + * OSC strings, terminated either way: BEL (xterm's titles) or ST = `ESC \` + * (ECMA-48's terminator, what shell-integration output like OSC 133/3008 + * emits). A BEL-only scan ran PAST an ST terminator and swallowed every byte + * up to the next BEL — measured deleting a whole printed line in spec 22's + * ssh pane. Lazy matching ends the sequence at the nearest terminator, and + * one with neither stays untouched: a swallowed tail is worse than a stray + * escape. + */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: OSC terminators (BEL and ST) +const oscRegex = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g; export function stripAnsi(s: string): string { return s.replace(csiRegex, "").replace(oscRegex, "").replace(/\r/g, ""); diff --git a/packages/pane-runtime/package.json b/packages/pane-runtime/package.json index dce553ba0..138772168 100644 --- a/packages/pane-runtime/package.json +++ b/packages/pane-runtime/package.json @@ -35,6 +35,7 @@ "@subshell-ai/plugin-netbird": "workspace:*", "@subshell-ai/plugin-opencode": "workspace:*", "@subshell-ai/plugin-pi": "workspace:*", + "@subshell-ai/plugin-ssh": "workspace:*", "@subshell-ai/plugin-tailscale": "workspace:*", "@subshell-ai/plugin-terminal": "workspace:*" }, diff --git a/packages/pane-runtime/src/__tests__/built-in-api-versions.test.ts b/packages/pane-runtime/src/__tests__/built-in-api-versions.test.ts index f98cf587f..5cc7e73fd 100644 --- a/packages/pane-runtime/src/__tests__/built-in-api-versions.test.ts +++ b/packages/pane-runtime/src/__tests__/built-in-api-versions.test.ts @@ -51,7 +51,7 @@ describe("built-in plugin manifests", () => { // built-in whose `subshell` block went missing — fails loudly here rather // than passing vacuously over a shorter list. Another built-in updates // this number on purpose. - expect(manifests.length).toBe(10); + expect(manifests.length).toBe(11); for (const m of manifests) { expect(m.apiVersion, `${m.name} declares a stale subshell.apiVersion`).toBe(PLUGIN_API_VERSION); } diff --git a/packages/pane-runtime/src/__tests__/built-in-icons.test.ts b/packages/pane-runtime/src/__tests__/built-in-icons.test.ts index 875400ba7..922b9dd3a 100644 --- a/packages/pane-runtime/src/__tests__/built-in-icons.test.ts +++ b/packages/pane-runtime/src/__tests__/built-in-icons.test.ts @@ -89,9 +89,9 @@ describe("iconsLeftOutOfTarball", () => { describe("built-in plugin icon manifests", () => { it("pins the number of plugins declaring an icon, so a silent disappearance is loud", () => { - // Eight of the ten built-ins ship a mark (headscale and cloudflare-tunnel + // Nine of the eleven built-ins ship a mark (headscale and cloudflare-tunnel // declare none). A new icon-bearing built-in updates this number on purpose. - expect(readBuiltInManifests().filter((m) => m.icon).length).toBe(8); + expect(readBuiltInManifests().filter((m) => m.icon).length).toBe(9); }); it("declares no icon that is absent from the npm `files` list", () => { diff --git a/packages/pane-runtime/src/__tests__/embedded-plugins.test.ts b/packages/pane-runtime/src/__tests__/embedded-plugins.test.ts index f47428a3b..74095a0aa 100644 --- a/packages/pane-runtime/src/__tests__/embedded-plugins.test.ts +++ b/packages/pane-runtime/src/__tests__/embedded-plugins.test.ts @@ -23,6 +23,7 @@ const BUILT_INS = [ "netbird", "opencode", "pi", + "ssh", "tailscale", "terminal", ] as const; diff --git a/packages/pane-runtime/src/__tests__/installed-overlay.test.ts b/packages/pane-runtime/src/__tests__/installed-overlay.test.ts index 88153062b..b4d586493 100644 --- a/packages/pane-runtime/src/__tests__/installed-overlay.test.ts +++ b/packages/pane-runtime/src/__tests__/installed-overlay.test.ts @@ -30,7 +30,7 @@ import type { PresetDefinition } from "../types.js"; * built-in-only without any code in it saying so). */ -const BUILT_IN_IDS = ["claude-code", "codex", "hermes", "opencode", "pi", "terminal"]; +const BUILT_IN_IDS = ["claude-code", "codex", "hermes", "opencode", "pi", "ssh", "terminal"]; /** Minimal complete preset for `buildCommand` calls in these tests. */ const PRESET: PresetDefinition = { name: "p", env: {}, flags: [], settings: null, configIsolation: false }; diff --git a/packages/pane-runtime/src/__tests__/plugins-seed.test.ts b/packages/pane-runtime/src/__tests__/plugins-seed.test.ts index df44bfd7c..36b251e2c 100644 --- a/packages/pane-runtime/src/__tests__/plugins-seed.test.ts +++ b/packages/pane-runtime/src/__tests__/plugins-seed.test.ts @@ -33,10 +33,11 @@ describe("seedBuiltIns", () => { "netbird", "opencode", "pi", + "ssh", "tailscale", "terminal", ]); - expect((await listInstalled(dir)).length).toBe(10); + expect((await listInstalled(dir)).length).toBe(11); }); it("does NOTHING once a seed has COMPLETED, even with the directory emptied", async () => { @@ -244,8 +245,8 @@ describe("prepareInstalledPlugins", () => { const dir = join(parent, "fresh-home"); mkdirSync(dir); await expect(prepareInstalledPlugins(dir)).resolves.toBeUndefined(); - // Six harnesses + four network plugins (cloudflare-tunnel, headscale, + // Seven harnesses + four network plugins (cloudflare-tunnel, headscale, // netbird, tailscale). - expect(await listInstalled(dir)).toHaveLength(10); + expect(await listInstalled(dir)).toHaveLength(11); }); }); diff --git a/packages/pane-runtime/src/__tests__/registry.test.ts b/packages/pane-runtime/src/__tests__/registry.test.ts index 20367d4ff..43613ed8a 100644 --- a/packages/pane-runtime/src/__tests__/registry.test.ts +++ b/packages/pane-runtime/src/__tests__/registry.test.ts @@ -21,7 +21,7 @@ describe("the built-in registry", () => { allHarnesses() .map((h) => h.id) .sort(), - ).toEqual(["claude-code", "codex", "hermes", "opencode", "pi", "terminal"]); + ).toEqual(["claude-code", "codex", "hermes", "opencode", "pi", "ssh", "terminal"]); }); it("ships with nothing broken", () => { @@ -55,6 +55,20 @@ describe("the built-in registry", () => { expect(getHarness("hermes")?.name).toBe("Hermes Agent"); }); + it("ships ssh as a terminal-type harness carrying the ssh detect rule", () => { + // The ssh pane is the terminal launch pointed at ssh (plan 2 decision 1): + // TYPE is what keeps it out of every agent-only path, and the detect rule + // travels to nodes as DATA (the `launch.ssh` rung resolves the node's own + // binary through exactly this spec). + const ssh = getHarness("ssh"); + expect(ssh?.type).toBe("terminal"); + expect(ssh?.detectSpec).toEqual({ + binaryName: "ssh", + envOverride: "SUBSHELL_SSH_PATH", + knownPaths: ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh", "/opt/homebrew/bin/ssh"], + }); + }); + it("every built-in's declared capabilities match what it implements", () => { // The loader refuses a third-party plugin that gets this wrong, so a // built-in that got it wrong would be held to a lower standard than the diff --git a/packages/pane-runtime/src/index.ts b/packages/pane-runtime/src/index.ts index 919629169..59034617f 100644 --- a/packages/pane-runtime/src/index.ts +++ b/packages/pane-runtime/src/index.ts @@ -149,6 +149,12 @@ export { type SshWalkBudget, walkSshConfig, } from "./ssh/ssh-discover.js"; +export { + buildSshConfigPath, + renderSshConfigContents, + sshDestinationToken, + sshOptionTokens, +} from "./ssh/ssh-render.js"; export { parseProxyHop, resolveSshAliasConfig, diff --git a/packages/pane-runtime/src/registry.ts b/packages/pane-runtime/src/registry.ts index 7bace8b16..4a8630a7b 100644 --- a/packages/pane-runtime/src/registry.ts +++ b/packages/pane-runtime/src/registry.ts @@ -14,6 +14,7 @@ import hermesFactory, { manifest as hermesManifest } from "@subshell-ai/plugin-h import netbirdFactory, { manifest as netbirdManifest } from "@subshell-ai/plugin-netbird"; import opencodeFactory, { manifest as opencodeManifest } from "@subshell-ai/plugin-opencode"; import piFactory, { manifest as piManifest } from "@subshell-ai/plugin-pi"; +import sshFactory, { manifest as sshManifest } from "@subshell-ai/plugin-ssh"; import tailscaleFactory, { manifest as tailscaleManifest } from "@subshell-ai/plugin-tailscale"; import terminalFactory, { manifest as terminalManifest } from "@subshell-ai/plugin-terminal"; import { adaptPlugin } from "./plugin-adapter.js"; @@ -59,6 +60,10 @@ const BUILT_INS: BuiltIn[] = [ { manifest: piManifest, factory: piFactory }, { manifest: codexManifest, factory: codexFactory }, { manifest: terminalManifest, factory: terminalFactory }, + // The ssh pane is the terminal launch pointed at ssh: same TYPE, same + // `[binary, ...flags]` shape, the connection described entirely by the + // preset's option tokens (plan 2 decision 1). + { manifest: sshManifest, factory: sshFactory }, // The NETWORK built-ins (spec 2026-09-15, phases 2–3 of 2026-09-16) go in // the same list because the store is one store; `build()` sorts them into // the network half by manifest type. Name order among themselves, which is diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts index db45fab8c..2631e3636 100644 --- a/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts @@ -125,7 +125,7 @@ describe("discoverSshAliases", () => { expect(found.truncated).toBe(true); }); - it("sees Match exec in the walk (the local-execution signal §2 refuses on)", () => { + it("sees Match exec in the walk (the local-execution signal spec 2026-10-07 §5.2 refuses by name at resolve)", () => { 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); 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..be8634104 --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { buildSshConfigPath, renderSshConfigContents, sshDestinationToken, sshOptionTokens } from "../ssh-render.js"; +import { makeSnapshot } from "./helpers.js"; + +/** + * The renderer is where the M1 every-hop policy becomes bytes (spec + * 2026-10-07 §5.2/§9). The tests below are the policy's regression net: every + * retained confinement clause ("host-key trust with accept-new, no agent/X11 + * forwarding, no forwards, no local commands, no escapes, no multiplexing, no + * imported RemoteCommand/SendEnv/SetEnv") must appear in the rendered + * artifact, the option argv must carry ONLY destination-scoped facts, and the + * interactive posture must show: no BatchMode, no refused auth methods, no + * /dev/null trust stand-in. + */ + +/** The base snapshot: a grammatically valid approved snapshot; override any field. */ +const baseSnapshot = makeSnapshot; + +describe("renderSshConfigContents", () => { + const config = renderSshConfigContents(baseSnapshot()); + + it("Host * carries the interactive-terminal policy, not BatchMode", () => { + const out = renderSshConfigContents(baseSnapshot()); + expect(out).toContain(" StrictHostKeyChecking accept-new"); + expect(out).not.toContain("BatchMode"); + expect(out).not.toContain("PasswordAuthentication no"); + expect(out).not.toContain("UserKnownHostsFile /dev/null"); // default known_hosts is the M1 authority (spec §9) + expect(out).toContain(" ForwardAgent no"); + expect(out).toContain(" ControlPath none"); + expect(out).toContain(" EscapeChar none"); + expect(out).toContain(" RemoteCommand none"); + expect(out).toContain(" CanonicalizeHostname no"); + }); + + it("carries the retained every-hop policy under Host * (which the jump children read too)", () => { + expect(config).toContain("Host *"); + const required: [string, string][] = [ + ["StrictHostKeyChecking", "accept-new"], + ["ForwardAgent", "no"], + ["ForwardX11", "no"], + ["ForwardX11Trusted", "no"], + ["ClearAllForwardings", "yes"], + ["Tunnel", "no"], + ["PermitRemoteOpen", "none"], + ["PermitLocalCommand", "no"], + ["RemoteCommand", "none"], + ["EscapeChar", "none"], + ["ControlMaster", "no"], + ["ControlPath", "none"], + ["GSSAPIAuthentication", "no"], + ["VerifyHostKeyDNS", "no"], + ["CanonicalizeHostname", "no"], + ]; + for (const [key, value] of required) { + expect(config).toContain(` ${key} ${value}`); + } + // The interactive session offers every auth method the SERVER offers: the + // tier's refusals must not come back (BatchMode's comment says why). + expect(config).not.toContain("KbdInteractiveAuthentication no"); + expect(config).not.toContain("HostbasedAuthentication no"); + // 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("known-hosts refs render when the snapshot names them", () => { + const out = renderSshConfigContents(baseSnapshot({ knownHostsFiles: ["/home/theo/.ssh/known_hosts"] })); + expect(out).toContain(" UserKnownHostsFile /home/theo/.ssh/known_hosts"); + }); + + it("renders no UserKnownHostsFile at all when the snapshot names none (silence IS the policy, §9)", () => { + const out = renderSshConfigContents(baseSnapshot({ knownHostsFiles: [] })); + expect(out).not.toContain("UserKnownHostsFile"); + }); + + it("renders identity/certificate refs under Host * so EVERY hop authenticates from approved material", () => { + const rendered = renderSshConfigContents( + baseSnapshot({ 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("identity/cert refs render quoted only when they need quoting", () => { + const rendered = renderSshConfigContents( + baseSnapshot({ + 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 = { ...baseSnapshot(), proxyCommand: "nc attacker 4444" } as unknown as ReturnType< + typeof baseSnapshot + >; + expect(() => renderSshConfigContents(smuggled)).toThrow("not renderable"); + }); +}); + +describe("sshOptionTokens", () => { + it("sshOptionTokens is destination-scoped only", () => { + const tokens = sshOptionTokens( + baseSnapshot({ port: 2222, user: "root", proxyJumps: [{ host: "j1", user: null, port: 22 }] }), + "/data/ssh/s1/config", + ); + expect(tokens).toEqual(["-F", "/data/ssh/s1/config", "-p", "2222", "-l", "root", "-o", "ProxyJump=j1"]); + }); + + it("points -F at the rendered file and passes ONLY destination-scoped options", () => { + const tokens = sshOptionTokens( + baseSnapshot({ hostKeyAlias: "app02-web", port: 2222 }), + "/var/lib/subshell/ssh/runs/r1/config", + ); + expect(tokens.slice(0, 2)).toEqual(["-F", "/var/lib/subshell/ssh/runs/r1/config"]); + expect(tokens).toContain("-p"); + expect(tokens[tokens.indexOf("-p") + 1]).toBe("2222"); + expect(tokens).toContain("-l"); + expect(tokens[tokens.indexOf("-l") + 1]).toBe("deploy"); + expect(tokens).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(tokens.every((a) => !a.startsWith("StrictHostKeyChecking") && !a.startsWith("ForwardAgent"))).toBe(true); + // the pane's PTY comes from tmux, so ssh auto-requests a remote tty: no -tt rides. + expect(tokens).not.toContain("-tt"); + // the `--` terminator and the host are NOT option tokens (sshDestinationToken carries the tail). + expect(tokens).not.toContain("--"); + expect(tokens).not.toContain("app-02.example.com"); + }); + + it("omits -l when the snapshot names no user, and HostKeyAlias when it names none", () => { + const tokens = sshOptionTokens(baseSnapshot({ user: null, hostKeyAlias: null }), "/x/config"); + expect(tokens).not.toContain("-l"); + expect(tokens.every((a) => !a.startsWith("HostKeyAlias="))).toBe(true); + expect(tokens[0]).toBe("-F"); + expect(tokens[2]).toBe("-p"); // -p is ALWAYS explicit: the port is a resolved fact, never ssh's guess + }); + + it("normalizes the whole ProxyJump chain into the approved hop list", () => { + const tokens = sshOptionTokens( + baseSnapshot({ + proxyJumps: [ + { host: "jump.example.com", user: "ops", port: 22 }, + { host: "[2001:db8::1]", user: null, port: 2222 }, + ], + }), + "/x/config", + ); + expect(tokens[tokens.indexOf("ProxyJump=ops@jump.example.com,[2001:db8::1]:2222") - 1]).toBe("-o"); + expect(tokens).not.toContain("-W"); // the chain is ssh's mechanism, ours is the approved list + }); + + it("refuses an option-like or empty config path", () => { + expect(() => sshOptionTokens(baseSnapshot(), "-o evil")).toThrow(/absolute/); + expect(() => sshOptionTokens(baseSnapshot(), "")).toThrow(/absolute/); + }); + + it("refuses an unrenderable snapshot at the last station before argv", () => { + expect(() => + sshOptionTokens({ ...baseSnapshot(), host: "-oProxyCommand=x" } as never, "/data/ssh/s1/config"), + ).toThrow(/not renderable/); + expect(() => + sshOptionTokens( + { ...baseSnapshot(), proxyJumps: [{ host: "-jump", user: null, port: 22 }] } as never, + "/data/ssh/s1/config", + ), + ).toThrow(/not renderable/); + }); +}); + +describe("sshDestinationToken", () => { + it("is the bare host, undecorated (the `--` tail is one argv entry)", () => { + expect(sshDestinationToken(baseSnapshot())).toBe("app-02.example.com"); + expect(sshDestinationToken(baseSnapshot({ host: "[2001:db8::1]", user: "root", port: 2222 }))).toBe( + "[2001:db8::1]", + ); + }); + + it("refuses an unrenderable snapshot", () => { + expect(() => sshDestinationToken({ ...baseSnapshot(), host: "-oProxyCommand=x" } as never)).toThrow( + /not renderable/, + ); + }); +}); + +describe("buildSshConfigPath", () => { + it("composes the derived path from the two facts each side already holds", () => { + expect(buildSshConfigPath("/data", "s1")).toBe("/data/ssh/s1/config"); + expect(buildSshConfigPath("/var/lib/subshell", "00000000-0000-4000-8000-000000000001")).toBe( + "/var/lib/subshell/ssh/00000000-0000-4000-8000-000000000001/config", + ); + }); + + it("throws on a relative or empty dataDir (impossible-state guard, never a composed path)", () => { + expect(() => buildSshConfigPath("data", "s1")).toThrow(/absolute/); + expect(() => buildSshConfigPath("", "s1")).toThrow(/absolute/); + }); + + it("throws on an id outside the grammar", () => { + expect(() => buildSshConfigPath("/d", "")).toThrow(/subshellId/); + expect(() => buildSshConfigPath("/d", "s 1")).toThrow(/subshellId/); + expect(() => buildSshConfigPath("/d", "../escape")).toThrow(/subshellId/); + expect(() => buildSshConfigPath("/d", "a".repeat(65))).toThrow(/subshellId/); + expect(buildSshConfigPath("/d", "a".repeat(64))).toBe(`/d/ssh/${"a".repeat(64)}/config`); + }); +}); + +describe("Metro purity", () => { + it("ssh-render.ts imports no node: builtin (the pane-runtime barrel rule)", () => { + const source = readFileSync(join(import.meta.dir, "..", "ssh-render.ts"), "utf8"); + expect(source).not.toMatch(/from\s+"node:/); + expect(source).not.toMatch(/require\(\s*"node:/); + }); +}); diff --git a/packages/pane-runtime/src/ssh/ssh-discover.ts b/packages/pane-runtime/src/ssh/ssh-discover.ts index 42cb47a4b..8c577c541 100644 --- a/packages/pane-runtime/src/ssh/ssh-discover.ts +++ b/packages/pane-runtime/src/ssh/ssh-discover.ts @@ -50,7 +50,7 @@ export interface SshConfigWalk { 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). */ + /** A `Match exec` line was seen anywhere in the walk (the local-execution signal that resolution refuses on). */ matchExecSeen: boolean; /** The config files that were actually read, walk order (cycle detection is done by the walk itself). */ files: string[]; @@ -281,7 +281,7 @@ export function walkSshConfig(rootFile: string, homeDir: string, budget: SshWalk return; } if (active.has(real)) { - walk.includeCycle = true; // a file under its own includer: the §2 fact + walk.includeCycle = true; // a file under its own includer: the disclosure fact the discovery answer carries return; } if (seen.has(real)) return; // a second ordinary include: dedupe, not a cycle @@ -319,7 +319,7 @@ export function walkSshConfig(rootFile: string, homeDir: string, budget: SshWalk } } } else if (keyword === "match") { - // `Match exec …` runs a command locally (the §2 execution fact); the + // `Match exec …` runs a command locally (the execution fact resolution refuses on); the // criteria keyword pair is what matters, not the command. const criteria = arg.toLowerCase(); if (/(^|\s)exec(\s|$)/.test(criteria)) walk.matchExecSeen = true; 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..f0087d197 --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -0,0 +1,217 @@ +import { + parseSshConnectionSnapshot, + SSH_PATH_MAX_CHARS, + type SshConnectionSnapshotWire, + type SshHopWire, +} from "@internal/subshell-protocol"; + +/** + * The runtime renderer: an approved snapshot in, option argv + own config file + * out (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md §5.2: render a + * per-connection config from the resulting snapshot; the built `ssh` runs + * against the rendered snapshot config via `-F` only, never the connecting + * machine's live `~/.ssh/config`). + * + * 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. + * - **The argv** (`sshOptionTokens` + `sshDestinationToken`) carries only the + * DESTINATION-scoped facts (`-F`, 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. This is the M1 + * interactive-terminal policy (spec 2026-10-07 §5.2/§9): every hop stays + * confined (forwarding, control sockets, local commands, the escape menu all + * die here because ssh spawns ProxyJump children that re-read THIS file), + * while the session itself stays a terminal: auth methods are the server's to + * offer and a changed host key is OpenSSH's own hard block. Each entry is + * that sentence made concrete; the comments name which. + */ +const MANDATORY_POLICY: readonly [string, string][] = [ + // first connect records the key in the machine's own known_hosts; a CHANGED + // key is refused by OpenSSH itself (spec 2026-10-07 §9: accept-new is the M1 posture) + ["StrictHostKeyChecking", "accept-new"], + // no agent or X11 forwarding (spec 2026-10-07 §5.2: forwards refused by name, never reappearing) + ["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 (spec 2026-10-07 §5.2: remote/local commands refused by name) + ["PermitLocalCommand", "no"], + // the command contract owns the remote side; no imported remote command / env (spec 2026-10-07 §5.2: env and command sends refused) + ["RemoteCommand", "none"], + // no escape menu; `~` is a local shell (spec 2026-10-07 §5.2: escape sends refused) + ["EscapeChar", "none"], + // no ambient control sockets or multiplexing (spec 2026-10-07 §5.2: the render carries only approved facts) + ["ControlMaster", "no"], + ["ControlPath", "none"], + ["GSSAPIAuthentication", "no"], + // trust is host-key trust only: DNS- and fetch-based checks are not + // host-key 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 + * rendering is byte-stable for a given snapshot, 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 the snapshot names; absent names render nothing. */ +function knownHostsLines(files: string[]): string[] { + // absent renders nothing: ssh's own default `~/.ssh/known_hosts` is the M1 + // trust store (spec 2026-10-07 §9) - the tier's product wrote /dev/null because its + // BatchMode posture made silence fail closed; here silence IS the policy. + if (files.length === 0) return []; + 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 (spec 2026-10-07 §5.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"); +} + +/** + * Build ssh's option argv for one snapshot: EVERYTHING after the binary + * EXCEPT the trailing `-- host` tail ({@link sshDestinationToken} carries + * that one entry). `-F ` first (the caller composed the path with + * {@link buildSshConfigPath} and owns the write), then `-p` ALWAYS (the + * port is a resolved fact, never ssh's guess), `-l` only when the snapshot + * names a user, and `HostKeyAlias`/`ProxyJump` only when set. No `-tt`: this + * pane allocates its PTY through tmux and carries no remote command, so + * OpenSSH auto-requests the remote tty. + * + * 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. Policy options deliberately DO NOT ride here: they live in the + * `-F` file the jump children re-read. + */ +export function sshOptionTokens(snapshot: SshConnectionSnapshotWire, configPath: string): string[] { + assertRenderable(snapshot); + if (configPath.length === 0 || !configPath.startsWith("/") || configPath.length > SSH_PATH_MAX_CHARS) { + throw new Error("config path must be absolute POSIX"); + } + const argv = ["-F", configPath]; + 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(",")}`); + } + return argv; +} + +/** + * The destination as the single argv entry that follows the `--` terminator: + * ssh's bare host, never a `user@`/`:port` decoration (user and port ride as + * their own option tokens). Refuses the same way {@link sshOptionTokens} + * does: the two halves of the argv share one renderability verdict. + */ +export function sshDestinationToken(snapshot: SshConnectionSnapshotWire): string { + assertRenderable(snapshot); + return snapshot.host; +} + +/** + * The derived per-pane config path is composed here so plane and node agree + * byte-for-byte from the two facts each already holds (dataDir, subshellId). + * The path DOES ride the launch frame (the ssh member's `configPath`), but + * as a CLAIM the receiving end never trusts: the agent's `commands/launch.ts` + * re-derives it from its own dataDir and the pane id and refuses a + * byte-mismatch (the LocalLauncher runs the same check in-process), so a + * hostile plane naming any path still cannot point the config write outside + * this machine's derivation. The refusals are impossible-state guards at + * composition sites (matching the codebase's path-composition doctrine): a + * relative dataDir or an id outside `/^[a-zA-Z0-9_-]{1,64}$/` THROWS rather + * than composing a path that escapes the directory. + */ +export function buildSshConfigPath(dataDir: string, subshellId: string): string { + if (!dataDir.startsWith("/")) throw new Error("ssh config path: dataDir must be absolute POSIX"); + if (!/^[a-zA-Z0-9_-]{1,64}$/.test(subshellId)) throw new Error("ssh config path: invalid subshellId"); + return `${dataDir}/ssh/${subshellId}/config`; +} diff --git a/packages/pane-runtime/src/ssh/ssh-resolve.ts b/packages/pane-runtime/src/ssh/ssh-resolve.ts index e9cd5d390..58c36f3e8 100644 --- a/packages/pane-runtime/src/ssh/ssh-resolve.ts +++ b/packages/pane-runtime/src/ssh/ssh-resolve.ts @@ -108,7 +108,7 @@ function setWhereAny(g: GLine[], keywords: readonly string[], unconfigured: read * 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. + * the silent-omission defect (spec 2026-10-07 §5.2). */ const FORWARD_LOCAL_KEYWORDS = ["localforward", "locallyforward"]; const FORWARD_REMOTE_KEYWORDS = ["remoteforward", "remotelyforward"]; @@ -159,8 +159,8 @@ function blockMatchesAlias(block: SshHostBlock, alias: string): boolean { /** * 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 + * naming WHY (ssh-config's refusal-by-name rule: "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. * @@ -254,7 +254,7 @@ export async function resolveSshAliasConfig( // 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 + // would never catch) — the grammar's config_ambiguous code. 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 diff --git a/packages/pane-runtime/src/ssh/ssh-spawn.ts b/packages/pane-runtime/src/ssh/ssh-spawn.ts index 44baaeebf..b2e394dcd 100644 --- a/packages/pane-runtime/src/ssh/ssh-spawn.ts +++ b/packages/pane-runtime/src/ssh/ssh-spawn.ts @@ -38,7 +38,7 @@ export interface SshProcessResult { * * @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). + * consults PATH to pick an executable (the explicit-argv rule of this engine's contract). * @param env - the COMPLETE child environment; no inheritance, by design * @param timeoutMs - hard deadline; the child is terminated at it */ diff --git a/packages/plugins/ssh/.hash-runnerrc.json b/packages/plugins/ssh/.hash-runnerrc.json new file mode 100644 index 000000000..25618ba4c --- /dev/null +++ b/packages/plugins/ssh/.hash-runnerrc.json @@ -0,0 +1,7 @@ +{ + "inputs": { + "includes": ["src/**", "package.json"] + }, + "execOnChange": "bun run build", + "hashFile": ".hashes.json" +} diff --git a/packages/plugins/ssh/LICENSE b/packages/plugins/ssh/LICENSE new file mode 100644 index 000000000..2382e4e9e --- /dev/null +++ b/packages/plugins/ssh/LICENSE @@ -0,0 +1,203 @@ +Copyright 2026 Disaresta, LLC + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/plugins/ssh/icon.svg b/packages/plugins/ssh/icon.svg new file mode 100644 index 000000000..b8776ee80 --- /dev/null +++ b/packages/plugins/ssh/icon.svg @@ -0,0 +1 @@ +SSH diff --git a/packages/plugins/ssh/package.json b/packages/plugins/ssh/package.json new file mode 100644 index 000000000..5894f782d --- /dev/null +++ b/packages/plugins/ssh/package.json @@ -0,0 +1,60 @@ +{ + "name": "@subshell-ai/plugin-ssh", + "description": "Interactive SSH plugin for Subshell", + "version": "1.0.0", + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "types": "dist/index.d.ts", + "homepage": "https://subshell.sh", + "repository": { + "type": "git", + "url": "git+https://github.com/subshell-ai/subshell.git", + "directory": "packages/plugins/ssh" + }, + "bugs": { + "url": "https://github.com/subshell-ai/subshell/issues" + }, + "scripts": { + "build": "tsdown", + "build:dev": "hash-runner", + "clean": "rm -rf .turbo node_modules dist .hashes.json", + "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", + "lint:check": "biome check --no-errors-on-unmatched src", + "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", + "verify-types": "tsc --project tsconfig.json --noEmit" + }, + "devDependencies": { + "@internal/tsconfig": "workspace:*", + "@subshell-ai/plugin-api": "workspace:*", + "@types/bun": "1.3.14", + "hash-runner": "4.0.0", + "tsdown": "0.22.14", + "typescript": "7.0.2" + }, + "files": ["dist", "icon.svg"], + "publishConfig": { + "access": "public" + }, + "subshell": { + "apiVersion": 2, + "id": "ssh", + "type": "terminal", + "name": "SSH", + "description": "An interactive SSH session to any sshd host.", + "icon": "icon.svg", + "entry": "dist/index.js", + "detect": { + "binaryName": "ssh", + "envOverride": "SUBSHELL_SSH_PATH", + "knownPaths": ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh", "/opt/homebrew/bin/ssh"] + } + } +} diff --git a/packages/plugins/ssh/src/__tests__/ssh.test.ts b/packages/plugins/ssh/src/__tests__/ssh.test.ts new file mode 100644 index 000000000..3d96c298f --- /dev/null +++ b/packages/plugins/ssh/src/__tests__/ssh.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import type { BuildCommandInput, PresetDefinition } from "@subshell-ai/plugin-api"; +import createPlugin, { manifest } from "../index.js"; + +const BLANK: PresetDefinition = { + name: "Default", + description: null, + env: {}, + flags: [], + settings: null, + configIsolation: false, +}; + +function input(over: Partial = {}): BuildCommandInput { + return { binary: "/usr/bin/ssh", cwd: "/tmp/work", preset: BLANK, subshellName: "", ...over }; +} + +const plugin = () => createPlugin({} as never); + +describe("ssh manifest", () => { + it("is a terminal-type plugin", () => { + expect(manifest.id).toBe("ssh"); + expect(manifest.type).toBe("terminal"); + }); + + it("declares the ssh binary and the operator's pin seam", () => { + // `SUBSHELL_SSH_PATH` is the same override the agent's tier-1 ssh-shared + // ladder reads, so one operator setting names one binary everywhere. + expect(manifest.detect?.binaryName).toBe("ssh"); + expect(manifest.detect?.envOverride).toBe("SUBSHELL_SSH_PATH"); + }); + + it("names the stock install locations as ABSOLUTE paths", () => { + // Rung 3 of binary-lookup.ts uses a leading-`/` entry verbatim (it is a + // LOCATION, not a HOME-relative hint), so these are the actual system + // locations: Linux, the macOS base install, and Homebrew. + expect(manifest.detect?.knownPaths).toEqual([ + "/usr/bin/ssh", + "/bin/ssh", + "/usr/local/bin/ssh", + "/opt/homebrew/bin/ssh", + ]); + }); +}); + +describe("ssh plugin", () => { + it("declares no capabilities", () => { + // The remote shell hosts no local process that could speak MCP, resume a + // conversation, or edit settings. Capabilities are validated at load, so + // claiming one it does not implement would be a launch-time failure. + expect(plugin().capabilities()).toEqual([]); + }); + + it("runs the resolved binary with the preset flags", () => { + const cmd = plugin().buildCommand( + input({ preset: { ...BLANK, flags: ["-F", "/var/lib/subshell/ssh/sess/config", "example.com"] } }), + ); + expect(cmd).toEqual(["/usr/bin/ssh", "-F", "/var/lib/subshell/ssh/sess/config", "example.com"]); + }); + + it("appends preset flags, then extra flags, in that order", () => { + const cmd = plugin().buildCommand( + input({ preset: { ...BLANK, flags: ["-J", "bastion"] }, extraFlags: ["-A", "host.example"] }), + ); + expect(cmd).toEqual(["/usr/bin/ssh", "-J", "bastion", "-A", "host.example"]); + }); + + it("runs bare when nothing is configured", () => { + expect(plugin().buildCommand(input())).toEqual(["/usr/bin/ssh"]); + }); + + it("ignores the subshell name, having no flag for it", () => { + expect(plugin().buildCommand(input({ subshellName: "prod box" }))).toEqual(["/usr/bin/ssh"]); + }); + + it("accepts a blank preset", () => { + expect(plugin().validatePreset(BLANK).valid).toBe(true); + }); +}); diff --git a/packages/plugins/ssh/src/index.ts b/packages/plugins/ssh/src/index.ts new file mode 100644 index 000000000..bbe12d452 --- /dev/null +++ b/packages/plugins/ssh/src/index.ts @@ -0,0 +1,77 @@ +import { + type BuildCommandInput, + type HarnessPluginFactory, + type PluginCapability, + type PluginHost, + type PresetDefinition, + type PresetValidationResult, + type SubshellPlugin, + validateGenericPreset, +} from "@subshell-ai/plugin-api"; + +/** + * Built-in: an interactive SSH session. + * + * Launch shape: the resolved ssh binary, plus the preset's option tokens. + * The pane's command IS the ssh process, so tmux supplies the PTY and the + * ssh env is the pane env; the connection itself is fully described by the + * option tokens (`-F` a rendered config, `-p`, `-l`, jump flags), which is + * why `buildCommand` is the terminal plugin's line verbatim. The pane is of + * TYPE `terminal` for the same reason: it is a plain interactive process + * with no agent protocol beside it. + * + * **Why this plugin declares a `detect` block at all.** The terminal plugin's + * answer was indirect (a shell happens to be a binary); here it is direct: + * `ssh` IS a binary the same way every agent harness is one, and a null + * resolved path makes a plugin both invisible in inventories and unlaunchable + * (`subshell-manager.service.ts` refuses to launch one). The rule travels to + * every node as data (the `launch.ssh` rung re-resolves it against the + * node's own machine); `SUBSHELL_SSH_PATH` is the operator's pin, the same + * seam the tier-1 `ssh-shared.ts` ladder reads. The absolute `knownPaths` are + * used verbatim by rung 3 of the lookup (a leading `/` is a location, not a + * HOME-relative hint), which is where a stock OpenSSH install lives on Linux, + * macOS and Homebrew. + * + * Identity, detection and install guidance live in this package's + * package.json `subshell` block, NOT here: the host reads them without + * importing or executing a line of this file. + * + * `host` carries what this module cannot import. See `@subshell-ai/plugin-api`. + */ +const createPlugin: HarnessPluginFactory = (_host: PluginHost): SubshellPlugin => ({ + // Deliberately empty. An ssh session has no MCP dialect to speak (the + // remote shell hosts no local process that could use it), no conversation + // to resume, no attention signal to parse and no settings to edit. The + // host validates this list at load, so naming a capability here that the + // plugin does not implement produces a launch that fails rather than a + // feature. + capabilities: (): PluginCapability[] => [], + + // Terminal-equivalence ruling (whole-branch review, accepted): a + // preset-composed ssh launch WITHOUT the ssh column (plain create route + + // crafted preset flags) is reachable on a gate-off node deliberately, + // because it is exactly terminal-equivalent: `nodeCanLaunchOn` already + // grants arbitrary argv via preset flags to anyone who may launch there, + // so the gate-off machine offers nothing this path can reach that an + // ordinary terminal pane does not. What the SSH gate reserves is the + // SUBSYSTEM's privileges: the rendered `-F` config, the exported + // `SSH_AUTH_SOCK`, resolve/discovery, and the owner-only-input kind, none + // of which a preset-flag launch carries. Do not "fix" this: closing it + // would mean taking preset flags off the terminal path too. + buildCommand(input: BuildCommandInput): string[] { + const { binary, preset, extraFlags } = input; + // No name flag: ssh has no session-title notion, so the pane title is + // whatever the remote shell sets and the reconcile sweep adopts it like + // it adopts any terminal's. + // Each stored flag is one complete argv token, as in every other plugin; + // `shellQuote` at every launch site wraps them, as it does everywhere. + return [binary, ...preset.flags, ...(extraFlags ?? [])]; + }, + + validatePreset(preset: PresetDefinition): PresetValidationResult { + return validateGenericPreset(preset); + }, +}); + +export default createPlugin; +export { manifest } from "./manifest.js"; diff --git a/packages/plugins/ssh/src/manifest.ts b/packages/plugins/ssh/src/manifest.ts new file mode 100644 index 000000000..880cbd2df --- /dev/null +++ b/packages/plugins/ssh/src/manifest.ts @@ -0,0 +1,15 @@ +import { parseManifest, type SubshellManifest } from "@subshell-ai/plugin-api"; +import pkg from "../package.json" with { type: "json" }; + +/** + * This package's own `subshell` block, read from its package.json so the + * manifest has ONE source of truth. + * + * A host that installs this plugin at runtime reads package.json off disk; a + * host that ships it built in imports this. Both must describe the same + * plugin, and the only way to guarantee that is for them to be the same bytes. + */ +const parsed = parseManifest(pkg); +if ("error" in parsed) throw new Error(`@subshell-ai/plugin-ssh has an invalid manifest: ${parsed.error}`); + +export const manifest: SubshellManifest = parsed; diff --git a/packages/plugins/ssh/tsconfig.json b/packages/plugins/ssh/tsconfig.json new file mode 100644 index 000000000..df0c1ed5a --- /dev/null +++ b/packages/plugins/ssh/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@internal/tsconfig/tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "types": ["bun"] + } +} diff --git a/packages/plugins/ssh/tsdown.config.ts b/packages/plugins/ssh/tsdown.config.ts new file mode 100644 index 000000000..69bd13a9a --- /dev/null +++ b/packages/plugins/ssh/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + format: ["esm"], + sourcemap: false, + target: ["es2024"], + nodeProtocol: true, + fixedExtension: false, + dts: true, + // A plugin is loaded from disk by a compiled binary and CANNOT resolve a + // bare specifier, so nothing of ours may survive as an import in the output. + // plugin-api is types plus pure helpers, which is exactly what makes it safe + // to inline here. + noExternal: ["@subshell-ai/plugin-api"], +}); diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 474a10261..6e3f3e1b0 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -24,6 +24,7 @@ import { parseNodeRuntimeReport, partPathOf, } from "../node-frames.js"; +import { SSH_CONFIG_FILE_MAX_BYTES } from "../ssh-limits.js"; import { MIN_NODE_VERSION } from "../versions.js"; const launchCmd = { @@ -317,7 +318,11 @@ describe("parseNodeCommandBody", () => { // bump is the load-bearing part: a pre-16 agent is wire-indistinguishable // from a 16 agent whose mirror says no, and a capability gate cannot live // with that ambiguity. - expect(NODE_PROTOCOL_VERSION).toBe(16); + // 17 is the ssh launcher tier (spec 2026-10-07 §4.3/§5): the launch frame's + // `ssh` block and the `ssh_discover_aliases` / `ssh_resolve_config` arms. + // A lagging agent ignores the launch block and spawns a bare `ssh` with no + // `-F` config, so it is HELD (update-only) until crossed, not approximated. + expect(NODE_PROTOCOL_VERSION).toBe(17); }); it("accepts set_allowed_dirs and rejects a missing or non-array dirs", () => { @@ -906,6 +911,41 @@ describe("ssh gate (arrived at protocol 16)", () => { }); }); +describe("ssh command arms and the launch ssh block", () => { + it("ssh discovery/resolve arms delegate to the ssh grammar", () => { + expect(parseNodeCommandBody({ type: "ssh_discover_aliases" })).toEqual({ type: "ssh_discover_aliases" }); + expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "box-a" })).toEqual({ + type: "ssh_resolve_config", + alias: "box-a", + }); + // alias hygiene lives in ssh-frames' parser (port-verified): option-like and whitespace are refused there + expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "-x" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "a b" })).toBeNull(); + }); + + it("launch accepts an ssh block and refuses malformed ones", () => { + const good = { ...launchCmd, ssh: { configPath: "/d/ssh/s1/config", fileContent: "Host *\n" } }; + expect(parseNodeCommandBody(good)).toMatchObject({ + type: "launch", + ssh: { configPath: "/d/ssh/s1/config", fileContent: "Host *\n" }, + }); + expect(parseNodeCommandBody({ ...launchCmd, ssh: { configPath: "relative", fileContent: "Host *\n" } })).toBeNull(); + expect( + parseNodeCommandBody({ + ...launchCmd, + ssh: { configPath: "/d/ssh/s1/config", fileContent: "x".repeat(SSH_CONFIG_FILE_MAX_BYTES + 1) }, + }), + ).toBeNull(); + expect( + parseNodeCommandBody({ + ...launchCmd, + ssh: { configPath: "/d/ssh/s1/config", fileContent: "x".repeat(SSH_CONFIG_FILE_MAX_BYTES) }, + }), + ).not.toBeNull(); + expect(parseNodeCommandBody({ ...launchCmd, ssh: "x" })).toBeNull(); + }); +}); + describe("update command (protocol 10, spec 2026-09-15 §5.1)", () => { it("pins the wire shape as a LITERAL, because this one is frozen across protocol bumps", () => { // Every other command travels between two ends that agreed on diff --git a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts new file mode 100644 index 000000000..b4a013a1f --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { parseNodeSshAliasList, parseNodeSshResolveOutcome } from "../node-results.js"; +import { makeAliasList, makeResolveOk, makeResolveRefused } from "./fixtures/ssh-fixtures.js"; + +describe("parseNodeSshAliasList", () => { + test("narrows a well-formed answer", () => { + expect(parseNodeSshAliasList(makeAliasList({ aliases: ["box-a", "box-b"] }))).toEqual({ + aliases: ["box-a", "box-b"], + includeCycle: false, + truncated: false, + }); + }); + test("refuses a past-cap list whole (the truncated flag depends on the cap)", () => { + expect( + parseNodeSshAliasList(makeAliasList({ aliases: Array.from({ length: 501 }, (_, i) => `h${i}`) })), + ).toBeNull(); + }); + test("refuses non-boolean flags and non-string members", () => { + expect(parseNodeSshAliasList({ aliases: ["x"], includeCycle: "no", truncated: false })).toBeNull(); + expect(parseNodeSshAliasList({ aliases: [1], includeCycle: false, truncated: false })).toBeNull(); + }); +}); + +describe("parseNodeSshResolveOutcome", () => { + test("accepted arm runs the FULL snapshot validator (no unvalidated snapshot enters the plane)", () => { + const ok = makeResolveOk(); + expect(parseNodeSshResolveOutcome(ok)).not.toBeNull(); + const tampered = structuredClone(ok); + (tampered as { snapshot: { host: string } }).snapshot.host = "-oProxyCommand=evil"; + expect(parseNodeSshResolveOutcome(tampered)).toBeNull(); + }); + test("connectingAccount is optional text, never another type", () => { + expect(parseNodeSshResolveOutcome(makeResolveOk())?.accepted === true).toBe(true); + const withAccount = structuredClone(makeResolveOk()) as { connectingAccount?: unknown }; + withAccount.connectingAccount = 7; + expect(parseNodeSshResolveOutcome(withAccount)).toBeNull(); + }); + test("refused arm names a known code and string settings", () => { + expect(parseNodeSshResolveOutcome(makeResolveRefused())).toEqual({ + accepted: false, + code: "unsupported_setting", + settings: ["ProxyCommand"], + }); + expect(parseNodeSshResolveOutcome({ accepted: false, code: "not_a_code", settings: [] })).toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 8a2002a56..31bf6ab96 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -144,6 +144,8 @@ export { parseNodePathExistsResult, parseNodeProbeEntries, parseNodePromptDeliver, + parseNodeSshAliasList, + parseNodeSshResolveOutcome, parseNodeStatDirResult, parseNodeTreeManifestPage, parseNodeWriteFileResult, @@ -241,9 +243,18 @@ export { type SshErrorCode, } from "./ssh-errors.js"; export { + parseSshNodeCommandBody, + SSH_COMMAND_TYPES, + type SshDiscoverAliasesCommand, + type SshNodeCommandBody, + type SshResolveConfigCommand, +} from "./ssh-frames.js"; +export { + SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_DISCOVERED_ALIASES, SSH_MAX_PROXY_HOPS, SSH_NAME_MAX_CHARS, + SSH_PATH_MAX_CHARS, SSH_PROBE_DEADLINE_MS, } from "./ssh-limits.js"; export type { diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 855ca6c03..f1d2c2031 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1,5 +1,7 @@ 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 { SSH_CONFIG_FILE_MAX_BYTES, SSH_PATH_MAX_CHARS } from "./ssh-limits.js"; /** * Node ↔ control-plane wire contract (spec 2026-08-31 §3). @@ -147,8 +149,16 @@ import type { JsonValue } from "./json.js"; * `sshEnabled`, which on the wire is indistinguishable from a 16 agent whose * mirror simply says no. A capability gate cannot live with that ambiguity * (spec §4.3), so the number is what refuses the old agent, not the frame. + * + * **16 -> 17 (this tier):** the ssh launch block and the two discovery/resolve + * commands. A lagging agent is HELD (update-only) until crossed, per the + * capability-gate doctrine in docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md + * §4.3; the desktop update path is the crossing. The ambiguity the bump + * refuses: a tier-1 agent that ignores the launch frame's `ssh` member would + * spawn a bare `ssh` pane with no `-F` config (§4.3's gate doctrine refuses to + * live with that). */ -export const NODE_PROTOCOL_VERSION = 16; +export const NODE_PROTOCOL_VERSION = 17; /** * The FIRST protocol whose agents verify the publisher signature on an @@ -672,6 +682,16 @@ export type NodeCommandBody = * node-supplied: it is self-knowledge, not plugin knowledge. */ mcp?: { path: string; fileContent: string; args?: string[]; env?: Record }; + /** + * The ssh pane's rendered config: the content the plane composed from + * the approved snapshot, plus the absolute node-side path the agent + * writes it to. The file-placement contract is the `mcp` block's: 0600 + * in a 0700 dir, derived from the node's own `dataDir`, written before + * spawn, and the exit watch deletes the derived path beside the pane's + * other artifacts. The pane's argv names `-F` at this path; present + * only for ssh launches, absent for every harness pane. + */ + ssh?: { configPath: string; fileContent: string }; /** Resume pin for harnesses that support it */ harnessSession?: HarnessSessionWire; /** tmux subshell name (the subshell id) */ @@ -1103,7 +1123,12 @@ export type NodeCommandBody = * sizing round trips is the caller that knows the frame cap. */ maxBytes: number; - }; + } + // The SSH discovery/resolve reads: two commands folded in as one union + // member so the SSH grammar lives in `ssh-frames.ts` beside the commands it + // narrows; their answers are validated by the two ssh result parsers + // appended to `node-results.ts` in the same tier. + | SshNodeCommandBody; /** * One node's maintenance state, as it travels in either direction. @@ -1447,6 +1472,20 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { if ("args" in m && !isStrArray(m.args)) return null; if ("env" in m && !isStringMap(m.env)) return null; } + if ("ssh" in value) { + const ssh = value.ssh as unknown; + if ( + typeof ssh !== "object" || + ssh === null || + typeof (ssh as { configPath?: unknown }).configPath !== "string" || + !(ssh as { configPath: string }).configPath.startsWith("/") || + (ssh as { configPath: string }).configPath.length > SSH_PATH_MAX_CHARS || + typeof (ssh as { fileContent?: unknown }).fileContent !== "string" || + (ssh as { fileContent: string }).fileContent.length > SSH_CONFIG_FILE_MAX_BYTES + ) { + return null; + } + } if ("harnessSession" in value) { const h = value.harnessSession; if (!isRecord(h) || !isStr(h.id) || (h.mode !== "start" && h.mode !== "resume")) return null; @@ -1698,6 +1737,11 @@ 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); default: return null; } diff --git a/packages/subshell-protocol/src/node-results.ts b/packages/subshell-protocol/src/node-results.ts index 2d68968c7..de2d1ea29 100644 --- a/packages/subshell-protocol/src/node-results.ts +++ b/packages/subshell-protocol/src/node-results.ts @@ -19,8 +19,12 @@ * 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"; function isNonEmptyStr(value: unknown): value is string { return isStr(value) && value.length > 0; @@ -537,3 +541,46 @@ export function parseNodeAgentLogSlice(data: unknown): NodeAgentLogSlice | null if (nextByte < 0 || size < 0) return null; return { text, nextByte, size, truncated }; } + +/* ------------------------------------------------------------------ */ +/* ssh discovery/resolution (the tier-1 wire types, dispatched from */ +/* here on) */ +/* ------------------------------------------------------------------ */ + +/** + * 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[])] }; +} diff --git a/packages/subshell-protocol/src/ssh-config.ts b/packages/subshell-protocol/src/ssh-config.ts index 1fe88695d..4ce8a511b 100644 --- a/packages/subshell-protocol/src/ssh-config.ts +++ b/packages/subshell-protocol/src/ssh-config.ts @@ -7,10 +7,11 @@ * 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 (of the - * destination product's four, only the resolve command survives; its node-side - * dispatch is not wired on this tier). 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. + * destination product's four, only the resolve command survives, and the + * launcher tier wires its node-side dispatch in + * `apps/node/agent/src/commands/ssh-aliases.ts`). 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 diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index e856d4834..da22d140a 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -7,9 +7,11 @@ * * The rest of the family the earlier command 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`. + * launch`, `ssh_input_control`) retired with that destination-execution + * product, which never shipped; their `type` arms are therefore deleted, not + * refused. The brokered-session commands are NOT defined on this branch — they + * arrive with the sealed agent-relay milestone (M2); no file names them here yet + * (`ssh-limits.ts` carries their limits ahead of the frames, as it declares). * * The commands are TRANSPORT-AGNOSTIC plain objects: the server-hosted * `local` node executes the same runtime in-process against these exact @@ -17,9 +19,9 @@ * the transport's, and they live outside the body. * * Hand-rolled in the `node-frames.ts` style; imports no `node:` builtin, so - * it COULD join the Metro-safe barrel, but does not yet: on this tier the - * grammar fixtures' type imports are its only consumer, and Plan 2 exports - * and dispatches it with the ssh RPC verbs. + * it rides the Metro-safe barrel with the rest of the grammar. The wiring + * landed with the launcher tier: `parseNodeCommandBody` dispatches these two + * arms here, and `node-results.ts` validates their answers. */ import { isRecord, isStr } from "./guards.js"; @@ -68,10 +70,9 @@ function isAliasName(value: unknown): value is string { } /** - * Validates and narrows any `ssh_*` command body. Plan 2 routes - * `parseNodeCommandBody`'s two `type` arms here, so the SSH grammar lives in - * ONE file beside the commands it narrows; until that wiring lands no - * `ssh_*` frame is dispatched on this branch. The contract this upholds is + * 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 this upholds is * the same as the node commands' today: a NON-null return is safe to switch * on by `type`. * diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts index bad12756c..20803a982 100644 --- a/packages/subshell-protocol/src/ssh-limits.ts +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -68,6 +68,9 @@ 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; +/** Largest ssh_config the wire carries in one `launch.ssh.fileContent` member (a rendered snapshot config is hundreds of bytes; this bounds a hostile plane). */ +export const SSH_CONFIG_FILE_MAX_BYTES = 65_536; + /** * Grace period a SIGTERM gets before the group is SIGKILLed. Born with the * structured runs, kept because the session supervisor kills the brokered diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts index 120afa595..0a246172f 100644 --- a/packages/subshell-protocol/src/ssh-results.ts +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -5,9 +5,9 @@ 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). Their `parse*` validators join - * `node-results.ts` beside every other result validator when Plan 2 wires the - * ssh RPC verbs; none of them exists on this branch yet. + * destination product (design 2026-10-05 §7). Their `parse*` validators + * (`parseNodeSshAliasList`, `parseNodeSshResolveOutcome`) live in + * `node-results.ts` beside every other result validator. * * Imports no `node:` builtin; lives in the Metro-safe barrel. */ diff --git a/scripts/__tests__/package-releases.test.ts b/scripts/__tests__/package-releases.test.ts index 32a9209b2..1bf70bfa6 100644 --- a/scripts/__tests__/package-releases.test.ts +++ b/scripts/__tests__/package-releases.test.ts @@ -15,6 +15,7 @@ describe("publishedPackages", () => { "@subshell-ai/plugin-netbird", "@subshell-ai/plugin-opencode", "@subshell-ai/plugin-pi", + "@subshell-ai/plugin-ssh", "@subshell-ai/plugin-tailscale", "@subshell-ai/plugin-terminal", ]);