diff --git a/.changeset/node-ssh-capability-gate.md b/.changeset/node-ssh-capability-gate.md new file mode 100644 index 000000000..134cd7202 --- /dev/null +++ b/.changeset/node-ssh-capability-gate.md @@ -0,0 +1,6 @@ +--- +"@internal/server": minor +"@internal/node": minor +--- + +Per-node SSH capability gate: a new ssh_enabled flag (migration 0047) defaults off on every machine including the control-plane host, owners flip it at PUT /api/nodes/:id/ssh-enabled (admins on the server row) audited as node.ssh_enabled.update, the node caches the value in a mirror file it reads only to refuse, and the node protocol bumps to 16: update the server before the nodes, because the link gate is exact-match and a lagging agent is held (offline for every purpose but update) until it crosses the bump on the update command. The ported foundations (OpenSSH config discovery, ssh -G snapshot resolution, the isolated sshd e2e fixture) land with it; nothing launches SSH yet. diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 2e7f5236a..a037fd698 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|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` · 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/apps/node/agent/src/__tests__/ssh-enabled.test.ts b/apps/node/agent/src/__tests__/ssh-enabled.test.ts new file mode 100644 index 000000000..2b164bf2b --- /dev/null +++ b/apps/node/agent/src/__tests__/ssh-enabled.test.ts @@ -0,0 +1,290 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { NodeCommandBody, NodeEvent } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { maybeReportSshEnabled, seedSshEnabledMemo } from "../commands/report.js"; +import type { NodeConfig } from "../config.js"; +import { readSshEnabled, reportableSshEnabled, sshAllowed, sshEnabledPath, writeSshEnabled } from "../ssh-enabled.js"; +import { SubshellMetaStore } from "../subshell-meta.js"; +import { captureLogs } from "./helpers/capture-logs.js"; + +/** + * The SSH-capability mirror (spec 2026-10-07 §4.3): persistence, the + * file-mode doctrine borrowed from `maintenance.ts`, and the FAIL-CLOSED read + * — but with the default INVERTED. Maintenance refuses when it reads `on`; + * this refuses unless it reads `on`, because the plane is the only writer and + * "never told" is not "allowed" for a widening of egress and keys. + * + * The wiring half goes through `dispatchCommand`, so the frame parser and the + * switch are exercised too (the `commands-maintenance.test.ts` pattern). + */ + +const STAMP = "2026-10-07T10:00:00.000Z"; +const made: string[] = []; + +function freshDataDir(): string { + // realpath'd like the commands-maintenance harness: macOS hides /private + // behind the temp dir, and an assertion about the file path should test the + // path the daemon itself would write. + const dir = realpathSync(mkdtempSync(join(tmpdir(), "subshell-ssh-on-"))); + made.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** The mirror's own mtime, as the unreadable branch stamps it. */ +function mtimeOf(dataDir: string): string { + return statSync(sshEnabledPath(dataDir)).mtime.toISOString(); +} + +/** A context whose tmux THROWS on anything unstubbed (the maintenance harness). */ +function makeCtx(dataDir: string, events: NodeEvent[] = []): CommandContext { + const config: NodeConfig = { + serverUrl: "http://localhost:1", + nodeId: "node-1", + nodeKey: "k", + controlPublicKey: "{}", + dataDir, + name: "test-node", + }; + const tmux = new Proxy( + {}, + { + get: () => () => { + throw new Error("tmux must not be reached"); + }, + }, + ); + return { + config, + tmux: tmux as unknown as CommandContext["tmux"], + meta: new SubshellMetaStore(dataDir), + nowMs: () => 1_700_000_000_000, + ws: { send: (ev) => events.push(ev) }, + watchers: new Map(), + tails: new Map(), + uploads: new Map(), + runtime: null, + requestRestart: () => {}, + }; +} + +describe("persistence", () => { + it("an empty dir reads `absent`: nothing has said yes, so SSH is refused", () => { + expect(readSshEnabled(freshDataDir())).toEqual({ kind: "absent" }); + }); + + it("round-trips the exact stamp it was given", () => { + const dataDir = freshDataDir(); + expect(writeSshEnabled(dataDir, { on: true, changedAt: STAMP })).toEqual({ on: true, changedAt: STAMP }); + expect(readSshEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP }); + }); + + it("stores the whole wire verbatim — `ready.sshEnabled` is buildable from what was written", () => { + const dataDir = freshDataDir(); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(JSON.parse(readFileSync(sshEnabledPath(dataDir), "utf8"))).toEqual({ on: true, changedAt: STAMP }); + }); + + it("stores `on: false` as a value too, but the mirror's ANSWER for it is refusal", () => { + // The plane stamps the file when it turns SSH off, and the bytes stay on + // disk; what the reader answers is the gate's answer, and an off file and + // no file give the same one. There is no node-side writer, so the off + // stamp is not something anyone later reconciles against — the plane's + // own row is the record, and it agrees. + const dataDir = freshDataDir(); + const state = { on: false, changedAt: "2026-10-07T11:00:00.000Z" }; + writeSshEnabled(dataDir, state); + expect(JSON.parse(readFileSync(sshEnabledPath(dataDir), "utf8"))).toEqual(state); + expect(readSshEnabled(dataDir)).toEqual({ kind: "absent" }); + }); + + it("writes the file 0600, and creates a missing data dir rather than throwing", () => { + const dataDir = freshDataDir(); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(statSync(sshEnabledPath(dataDir)).mode & 0o777).toBe(0o600); + const nested = join(freshDataDir(), "nested", "data"); + writeSshEnabled(nested, { on: true, changedAt: STAMP }); + expect(exists(sshEnabledPath(nested))).toBe(true); + }); + + it("leaves no temp file behind, and the file it leaves is whole", () => { + // Atomic because a half-written file parses as corrupt, and corrupt means + // REFUSED here — a torn write would cut SSH for the duration of the write. + const dataDir = freshDataDir(); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + writeSshEnabled(dataDir, { on: false, changedAt: "2026-10-07T12:00:00.000Z" }); + expect(readdirSync(dataDir)).toEqual(["ssh-enabled.json"]); + }); + + function exists(file: string): boolean { + try { + statSync(file); + return true; + } catch { + return false; + } + } +}); + +describe("reading is fail-CLOSED", () => { + it("answers `unreadable` for corrupt JSON, carries the file's mtime, and says so once", () => { + const dataDir = freshDataDir(); + writeFileSync(sshEnabledPath(dataDir), "{not json"); + const cap = captureLogs(); + try { + expect(readSshEnabled(dataDir)).toEqual({ kind: "unreadable", changedAt: mtimeOf(dataDir) }); + } finally { + cap.restore(); + } + expect(cap.lines.filter((l) => l.includes("ssh-enabled"))).toHaveLength(1); + }); + + it("answers `unreadable` for a well-formed file missing a field, and for a non-boolean on", () => { + // Same classification maintenance gives garbage, and the same reason: both + // halves or nothing. The brief's unreadable fixtures are content fixtures + // (a corrupt file) rather than chmod-000 — an EACCES read fails into the + // same branch, so the three spellings of "cannot read" must not answer + // differently. + const dataDir = freshDataDir(); + writeFileSync(sshEnabledPath(dataDir), JSON.stringify({ on: true })); + const cap = captureLogs(); + try { + expect(readSshEnabled(dataDir)).toEqual({ kind: "unreadable", changedAt: mtimeOf(dataDir) }); + writeFileSync(sshEnabledPath(dataDir), JSON.stringify({ on: "yes", changedAt: STAMP })); + expect(readSshEnabled(dataDir).kind).toBe("unreadable"); + } finally { + cap.restore(); + } + }); +}); + +describe("the gate classifier", () => { + it("allows ONLY a parsed on:true mirror; absent and unreadable both refuse", () => { + // The whole point of the flag: a node that cannot read its own setting + // refuses — the opposite of the directory allowlist's deliberate fail-open + // (spec 2026-10-07 §4.3). + expect(sshAllowed({ kind: "on", changedAt: STAMP })).toBe(true); + expect(sshAllowed({ kind: "absent" })).toBe(false); + expect(sshAllowed({ kind: "unreadable", changedAt: STAMP })).toBe(false); + expect(sshAllowed({ kind: "unreadable" })).toBe(false); + }); +}); + +describe("what a read is worth announcing", () => { + it("announces `on` verbatim; an unreadable mirror as the REFUSAL it produces", () => { + // Maintenance reports an unreadable mirror as `on` because that is what + // it refuses with; here the refusal is spelled `on: false`. Either way + // the plane hears the answer the machine is actually acting on, and a row + // that disagrees earns the push that rewrites the broken file. + expect(reportableSshEnabled({ kind: "on", changedAt: STAMP })).toEqual({ on: true, changedAt: STAMP }); + expect(reportableSshEnabled({ kind: "unreadable", changedAt: STAMP })).toEqual({ on: false, changedAt: STAMP }); + }); + + it("sends nothing for absent, and nothing an unstamped unreadable", () => { + expect(reportableSshEnabled({ kind: "absent" })).toBeUndefined(); + expect(reportableSshEnabled({ kind: "unreadable" })).toBeUndefined(); + }); +}); + +describe("set_ssh_enabled (the plane's half)", () => { + it("persists the plane's exact bytes and memoizes them — no echo, no re-stamp", async () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + const cmd = { type: "set_ssh_enabled", on: true, changedAt: STAMP } satisfies NodeCommandBody; + expect(await dispatchCommand(ctx, cmd)).toEqual({ ok: true }); + expect(readSshEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP }); + expect(ctx.lastReportedSshEnabled).toEqual({ on: true, changedAt: STAMP }); + expect(events).toEqual([]); // the command answers with a result; it announces nothing + }); + + it("an off flip also lands verbatim, and the machine says nothing about it", async () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + await dispatchCommand(ctx, { type: "set_ssh_enabled", on: false, changedAt: "2026-10-07T13:00:00.000Z" }); + maybeReportSshEnabled(ctx); + expect(events).toEqual([]); + }); +}); + +describe("the heartbeat belt", () => { + it("reports a mirror that moved outside the plane's own push, once", () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); // as a plane push would, memo unmoved + + maybeReportSshEnabled(ctx); + maybeReportSshEnabled(ctx); + + expect(events).toEqual([{ type: "ssh_enabled", on: true, changedAt: STAMP }]); + expect(ctx.lastReportedSshEnabled).toEqual({ on: true, changedAt: STAMP }); + }); + + it("announces an unreadable mirror once, as the refusal, and repairs nothing itself", () => { + // The repair is the plane's push (its row wins); a node overwriting the + // corrupt file would destroy the bytes an operator may want to look at, + // and inventing an `off` here would mask the disagreement the report + // exists to raise. + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + writeFileSync(sshEnabledPath(dataDir), "{not json"); + const stamp = mtimeOf(dataDir); + const ctx = makeCtx(dataDir, events); + const cap = captureLogs(); + try { + maybeReportSshEnabled(ctx); + maybeReportSshEnabled(ctx); + } finally { + cap.restore(); + } + expect(events).toEqual([{ type: "ssh_enabled", on: false, changedAt: stamp }]); + expect(readSshEnabled(dataDir).kind).toBe("unreadable"); + }); + + it("a mirror deleted under a live connection is NOT restored — the plane is the only writer", () => { + // Maintenance re-writes a vanished mirror from its memo because a CLI at + // the keyboard can delete it legitimately. There is no such writer here: + // hand-deleting this file is not an interface, and the next `ready` makes + // the plane push the row value back. + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + seedSshEnabledMemo(ctx); + unlinkSync(sshEnabledPath(dataDir)); + + maybeReportSshEnabled(ctx); + + expect(events).toEqual([]); + expect(readSshEnabled(dataDir)).toEqual({ kind: "absent" }); + }); + + it("seed returns the value ready carries and silences the first heartbeat for it", () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(seedSshEnabledMemo(ctx)).toEqual({ on: true, changedAt: STAMP }); + maybeReportSshEnabled(ctx); + expect(events).toEqual([]); + expect(seedSshEnabledMemo(makeCtx(freshDataDir()))).toBeUndefined(); + }); +}); diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index 201aa481f..a3de26868 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -5,6 +5,7 @@ import type { NodeEvent, NodeMaintenanceWire, NodeRuntimeReport, + NodeSshEnabledWire, } from "@internal/subshell-protocol"; import type { NodeConfig } from "../config.js"; import type { ServiceDeps } from "../service.js"; @@ -155,6 +156,21 @@ export interface CommandContext { * from echoing the plane's own write back at it one tick later. */ lastReportedMaintenance?: NodeMaintenanceWire; + /** + * The `{ on, changedAt }` this connection last told the plane about its SSH + * capability, or undefined when it has said nothing (spec 2026-10-07 §4.3). + * + * The same echo-suppression the maintenance memo exists for — but with a + * simpler job here, because the plane is this flag's ONLY writer: the memo + * exists so the heartbeat belt reports a mirror that moved by accident + * (corruption, mostly) and never the plane's own push come back one tick + * later. Seeded at every connect from the same read that built `ready`. + * + * There is deliberately no `mirrorRestoreLogged` partner: SSH has no + * restore-from-memo path (no node-side writer whose deletion deserves + * repair), so there is no failing restore to say anything about. + */ + lastReportedSshEnabled?: NodeSshEnabledWire; /** * Whether this connection has already reported a mirror it could not * rewrite (see `restoreMirrorFromMemo`). diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 8ff435dca..447fde0ae 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -27,6 +27,7 @@ import { execService } from "./service.js"; import { execSetLogLevel } from "./set-log-level.js"; import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; +import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; import { execTransferWrite } from "./transfer-write.js"; import { execTreeManifest } from "./tree-manifest.js"; @@ -47,7 +48,9 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * the one command whose wire shape is frozen, because the plane sends it to * agents whose protocol it does not share), and the five archive-transfer * commands `archive_create`, `file_read`, `transfer_write`, `archive_extract` - * and `tree_manifest` (spec 2026-10-01 §4, protocol 15). Any + * and `tree_manifest` (spec 2026-10-01 §4, protocol 15), and + * `set_ssh_enabled` (spec 2026-10-07 §4.3, protocol 16 — the SSH gate's plane- + * to-machine write). Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. * @@ -122,6 +125,8 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return execSetLogLevel(ctx, cmd); case "set_maintenance": return execSetMaintenance(ctx, cmd); + case "set_ssh_enabled": + return execSetSshEnabled(ctx, cmd); case "set_server_url": return await execSetServerUrl(ctx, cmd); case "update": diff --git a/apps/node/agent/src/commands/report.ts b/apps/node/agent/src/commands/report.ts index a681c4429..0b8c37d14 100644 --- a/apps/node/agent/src/commands/report.ts +++ b/apps/node/agent/src/commands/report.ts @@ -1,6 +1,7 @@ -import type { NodeEvent, NodeMaintenanceWire } from "@internal/subshell-protocol"; +import type { NodeEvent, NodeMaintenanceWire, NodeSshEnabledWire } from "@internal/subshell-protocol"; import { log } from "../log.js"; import { readMaintenance, reportableMaintenance, writeMaintenance } from "../maintenance.js"; +import { readSshEnabled, reportableSshEnabled } from "../ssh-enabled.js"; import type { CommandContext, WatcherRegistration } from "./context.js"; /** @@ -145,6 +146,65 @@ export function seedMaintenanceMemo(ctx: CommandContext): NodeMaintenanceWire | return ctx.lastReportedMaintenance; } +/** + * Announce one SSH-capability mirror state to the plane and remember having + * done so (spec 2026-10-07 §4.3). + * + * The memo update is not bookkeeping: it is what keeps the next heartbeat tick + * silent about a value the plane already holds — and what keeps a + * `set_ssh_enabled` the plane just pushed from coming back as an event. + * + * @param ctx - the daemon's command context (owns the socket seam and the memo) + * @param state - the mirror's answer, sent verbatim (never re-stamped here) + */ +export function reportSshEnabled(ctx: CommandContext, state: NodeSshEnabledWire): void { + ctx.ws.send({ type: "ssh_enabled", on: state.on, changedAt: state.changedAt }); + ctx.lastReportedSshEnabled = state; +} + +/** + * Report the SSH mirror IF it has moved since this connection last spoke + * about it — the heartbeat belt, and by far the quieter one. + * + * With no node-side writer, the common steady state says nothing: an + * off-or-absent mirror has nothing to announce (the plane's own row is + * authoritative, and silence reads as refusal on both ends), and an on mirror + * only speaks up once. What this loop exists to catch is the file changing + * UNDER the connection — corruption flipping a machine from allowed to + * refused mid-session — so the plane learns it now and pushes its row (the + * repair) rather than discovering the disagreement at the next reconnect. + * + * There is no absent-restore half (the maintenance path above has one for its + * keyboard writer): hand-deleting this file is not an interface, and the next + * `ready` reconciles it in the plane's direction. + * + * @param ctx - the daemon's command context + */ +export function maybeReportSshEnabled(ctx: CommandContext): void { + const state = reportableSshEnabled(readSshEnabled(ctx.config.dataDir)); + if (!state) return; + const last = ctx.lastReportedSshEnabled; + if (last?.on === state.on && last.changedAt === state.changedAt) return; + reportSshEnabled(ctx, state); +} + +/** + * Seed this connection's SSH memo from the mirror on disk. + * + * Called once per connect, immediately before `ready` — which carries the same + * value, so the first heartbeat must not repeat it — and again on every + * reconnect, because a new socket has told the plane nothing. The one read of + * the file builds both, so the frame and the memo cannot disagree. + * + * @param ctx - the daemon's command context + * @returns the value `ready` should carry, or undefined when there is nothing + * truthful to report + */ +export function seedSshEnabledMemo(ctx: CommandContext): NodeSshEnabledWire | undefined { + ctx.lastReportedSshEnabled = reportableSshEnabled(readSshEnabled(ctx.config.dataDir)); + return ctx.lastReportedSshEnabled; +} + /** Stop the shared tick if one is running (idempotent). */ function stopLoop(ctx: CommandContext): void { if (ctx.watchTick !== undefined) { diff --git a/apps/node/agent/src/commands/set-ssh-enabled.ts b/apps/node/agent/src/commands/set-ssh-enabled.ts new file mode 100644 index 000000000..4e3e75553 --- /dev/null +++ b/apps/node/agent/src/commands/set-ssh-enabled.ts @@ -0,0 +1,30 @@ +import { writeSshEnabled } from "../ssh-enabled.js"; +import type { Cmd, CommandContext, CommandResult } from "./context.js"; + +/** + * `set_ssh_enabled` (spec 2026-10-07 §4.3): the plane's half of the SSH gate. + * + * It writes the file and NOTHING else. In particular it kills no panes and + * revokes nothing: the gate is checked when an SSH act is asked for, so + * turning it off takes effect on the next one, and turning it on grants no + * pane anything that was not already reachable by other means. + * + * The plane's own `changedAt` is stored verbatim, so both copies end + * byte-identical; re-stamping would leave the next `ready` reporting a + * disagreement the link latency invented. + * + * A failing write propagates: `dispatchCommand` turns the throw into + * `{ ok: false }`, and the plane learns its push did not land rather than + * recording a mirror that does not exist. + * + * @param ctx - the per-daemon execution context (data dir + the report memo) + * @param cmd - the verified `set_ssh_enabled` command + * @returns `{ ok: true }` once the file is on disk + */ +export function execSetSshEnabled(ctx: CommandContext, cmd: Cmd<"set_ssh_enabled">): CommandResult { + // Memoized as REPORTED, not merely written: the plane is the source of this + // value — the ONLY source it will ever have — so sending it back on the next + // heartbeat tick would be an echo the plane then reconciles against itself. + ctx.lastReportedSshEnabled = writeSshEnabled(ctx.config.dataDir, { on: cmd.on, changedAt: cmd.changedAt }); + return { ok: true }; +} diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 3cc3beb5d..bfd1cfc11 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -10,13 +10,20 @@ import { type NodeEvent, type NodeMaintenanceWire, type NodeRuntimeReport, + type NodeSshEnabledWire, SeqTracker, verifyCommand, } from "@internal/subshell-protocol"; import { backoffDelay } from "./backoff.js"; import type { CommandContext, CommandResult, CommandWs } from "./commands/context.js"; import { dispatchCommand } from "./commands/index.js"; -import { buildSubshellsReport, maybeReportMaintenance, seedMaintenanceMemo } from "./commands/report.js"; +import { + buildSubshellsReport, + maybeReportMaintenance, + maybeReportSshEnabled, + seedMaintenanceMemo, + seedSshEnabledMemo, +} from "./commands/report.js"; import { stopAllTails } from "./commands/tail.js"; import { sweepStaleTransfers } from "./commands/transfer-sweep.js"; import { cleanupStaleUploads } from "./commands/write-file.js"; @@ -334,6 +341,7 @@ function readyEvent( config: NodeConfig, runtime: NodeRuntimeReport | null, maintenance: NodeMaintenanceWire | undefined, + sshEnabled: NodeSshEnabledWire | undefined, ): Extract { return { type: "ready", @@ -380,6 +388,13 @@ function readyEvent( // every launch here, and a plane told nothing about that has no row to // reconcile and no reason to push the clean file that repairs it. ...(maintenance ? { maintenance } : {}), + // This machine's SSH-capability mirror (spec 2026-10-07 §4.3), carried + // exactly as maintenance is: OMITTED when absent (an off file or no file — + // silence means REFUSED here, and a row that says on answers it with the + // push), and an UNREADABLE file carried as the refusal it acts on under + // its own mtime, so the plane can repair the file instead of holding a + // silent disagreement until the next reconnect. + ...(sshEnabled ? { sshEnabled } : {}), }; } @@ -983,7 +998,10 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // The mirror is read SYNCHRONOUSLY too, and by the seeder itself: the // value `ready` carries and the memo that stops the first heartbeat // repeating it are one read, so they cannot disagree. - send(ws, readyEvent(config, runtime, seedMaintenanceMemo(ctx))); + // The SSH mirror rides the same synchronous read + seed as the + // maintenance one (spec 2026-10-07 §4.3): the value `ready` carries and + // the memo that stops the first heartbeat repeating it are one read. + send(ws, readyEvent(config, runtime, seedMaintenanceMemo(ctx), seedSshEnabledMemo(ctx))); // Connect-time `subshells_report` (spec §3.3): re-projects the panes // that survived an agent restart so the control plane heals its rows. // Fire-and-forget with catch-log — a scan failure (junk meta, tmux @@ -1025,6 +1043,11 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // when the machine had no panes running — the commonest case for // `subshell maintenance off`, where there is nothing to die at all. maybeReportMaintenance(ctx); + // The SSH gate's belt (spec 2026-10-07 §4.3): quieter by construction + // (no writer but the plane, so the steady state announces nothing), + // and this tick is its ONLY mid-session path — there are no deaths + // for it to ride ahead of, because an SSH flip kills no panes. + maybeReportSshEnabled(ctx); }, heartbeatMs); // Periodic `inventory` push — the "every 5 min" leg of spec §7 // (P3-T8c). The timer's lifecycle mirrors the heartbeat's — armed diff --git a/apps/node/agent/src/ssh-enabled.ts b/apps/node/agent/src/ssh-enabled.ts new file mode 100644 index 000000000..84f825596 --- /dev/null +++ b/apps/node/agent/src/ssh-enabled.ts @@ -0,0 +1,174 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { type NodeSshEnabledWire, parseNodeSshEnabled } from "@internal/subshell-protocol"; +import { logger } from "./log.js"; + +/** + * This node's MIRROR of the SSH capability flag (spec 2026-10-07 §4.3): one + * word meaning "this machine may take part in Subshell SSH at all". + * + * It is the maintenance flag's twin in mechanics and its inversion in default. + * Same file-permission doctrine (0600, temp+rename, its own file beside + * `maintenance.json` and for the same two reasons — `config.json` is + * snapshotted at boot and it is the node key's only home). Same fail-CLOSED + * read: here the risk is what ENABLING lets Subshell do (egress over SSH, this + * machine's keys and agent), so a setting this process cannot read is not a + * permission. Absent refuses too — that is the inversion: maintenance's + * absent meant "behave as before" and launches flowed; SSH has no before. + * + * **The plane is the sole writer.** There is no `subshell ssh-enabled` verb, + * because turning a machine's egress on from a keyboard at that machine is + * exactly the decision this gate exists to make someone make in a browser, as + * the owner, with an audit row. So the mirror has only two authors — a + * `set_ssh_enabled` push and a repair — and its only reader is the SSH gate + * (this process, fail-closed) and the `ready` report that lets the plane + * notice when the two copies disagree. + * + * Because no one stamps this file locally, nothing compares stamps across the + * two ends: reconciliation never runs newer-wins here, the plane pushes its + * row, and the node's copy converges by obeying rather than by bargaining. + */ + +/** File name inside the agent data dir. */ +const FILE = "ssh-enabled.json"; + +/** Absolute path of the SSH-capability mirror for a data dir. */ +export function sshEnabledPath(dataDir: string): string { + return join(dataDir, FILE); +} + +/** + * What the mirror says, as the three answers a caller must tell apart — the + * gate's answers, not the file's biography. + * + * `on` is the only kind that permits, and it carries the stamp because + * `ready.sshEnabled` must be buildable from what was written. A file that + * parses to `{ on: false, changedAt }` and a file that does not exist give the + * SAME answer to every reader (refuse; and the plane's row already says off, + * so there is nothing to report), and no node-side writer exists whose stamp + * anyone would later reconcile — so the off file reads as `absent` while its + * bytes stay on disk. `unreadable` refuses too, but unlike the other two it + * says so: the answer it produces may match a quiet row, and when the row says + * on it is a disagreement the plane can only repair if it hears about it. + */ +export type SshEnabledRead = + /** Nothing permits here: no file, or a file that says off. */ + | { kind: "absent" } + /** The mirror parsed and says yes: SSH may run here. */ + | { + kind: "on"; + /** The stored stamp, verbatim — `ready.sshEnabled` is built from it. */ + changedAt: string; + } + /** The file exists but is not a state: REFUSED (fail-closed), reported under the file's mtime. */ + | { + kind: "unreadable"; + /** The file's mtime, ISO — absent only when the stat failed too, leaving nothing truthful to send. */ + changedAt?: string; + }; + +/** + * The gate's own answer, spelled once: ONLY a parsed `on` permits. + * + * Exported so the classifier the spec names ("absent and unreadable refuse, + * only on:true allows") lives in one place for every reader — the SSH launch + * path and any later status verb — rather than as `kind === "on"` typed out at + * each call site and drifting into an `|| absent` someday. + */ +export function sshAllowed(read: SshEnabledRead): boolean { + return read.kind === "on"; +} + +/** + * Reads the mirror. + * + * Total — every failure answers `unreadable` (one warn line) rather than + * throwing, because every caller is on a path where a throw would cost more + * than the wrong answer: the SSH gate, a heartbeat tick, and the `ready` + * builder. + * + * The parse is the WIRE parser, not a private one: the file holds exactly what + * travels, and a second validator here would be a second opinion about what + * `{ on, changedAt }` means. + */ +export function readSshEnabled(dataDir: string): SshEnabledRead { + const file = sshEnabledPath(dataDir); + if (!existsSync(file)) return { kind: "absent" }; + try { + const state = parseNodeSshEnabled(JSON.parse(readFileSync(file, "utf8"))); + if (!state) throw new SyntaxError("not an ssh-enabled state"); + return state.on ? { kind: "on", changedAt: state.changedAt } : { kind: "absent" }; + } catch (err) { + logger.withError(err).warn(`ssh-enabled: ${file} unreadable; treating this node as SSH-DISABLED`); + // The mtime, never `now`: this answer refuses, so the plane has to hear + // about it (a row that says on would push the clean file that repairs it), + // and the one timestamp available that nobody invented is when the file + // last changed. A fresh `now` per read would defeat the report memo and + // put one frame on the wire per heartbeat, forever. A stat that fails in + // turn leaves the stamp off: the refusal stands, and there is nothing to + // send. + const changedAt = mtimeOf(file); + return changedAt ? { kind: "unreadable", changedAt } : { kind: "unreadable" }; + } +} + +/** + * A file's mtime as an ISO stamp. TOTAL — every caller is on a path where the + * answer is optional and a throw would cost the whole read. + */ +function mtimeOf(file: string): string | undefined { + try { + return statSync(file).mtime.toISOString(); + } catch { + return undefined; + } +} + +/** + * The wire value a read is worth announcing, or `undefined` when there is + * nothing truthful to say. + * + * Every reporting path goes through this so the answers cannot be classified + * differently in three places: `ready`, the heartbeat memo check and (from + * Plan 2) the SSH gate all owe the plane the same value for the same file. An + * unreadable mirror announces the REFUSAL it actually produces — the inverse + * of maintenance's unreadable, which announces the `on` IT produces. + * + * @param read - what {@link readSshEnabled} answered + * @returns the `{ on, changedAt }` to send, or `undefined` for absent (the + * plane's row needs no echo from a node agreeing off) and for an unstamped + * unreadable + */ +export function reportableSshEnabled(read: SshEnabledRead): NodeSshEnabledWire | undefined { + if (read.kind === "on") return { on: true, changedAt: read.changedAt }; + if (read.kind === "unreadable" && read.changedAt) return { on: false, changedAt: read.changedAt }; + return undefined; +} + +/** + * Persists the mirror, 0600, via temp + rename. + * + * Atomic for the maintenance reason and sharper: a gate may read this at any + * instant, and a half-written file parses as corrupt — which under the + * fail-closed rule REFUSES every SSH act for as long as the write takes, + * mid-connect rather than merely wide of a flag. + * + * The `state` is stored VERBATIM. The plane sent it; re-stamping here would + * make the two copies differ by exactly the link delay, and the next `ready` + * would report a disagreement this process invented — which the plane would + * "repair" by pushing the same value again, forever. + * + * @returns the state as stored + */ +export function writeSshEnabled(dataDir: string, state: NodeSshEnabledWire): NodeSshEnabledWire { + const file = sshEnabledPath(dataDir); + const body: NodeSshEnabledWire = { on: state.on, changedAt: state.changedAt }; + // 0700, matching the data dir `identity.ts` creates. mkdir's mode is + // umask-clamped, so it is a floor; the file's own 0600 below is set at + // creation and is the one that matters. + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(body, null, 2), { mode: 0o600 }); + renameSync(tmp, file); + return body; +} diff --git a/apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts b/apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts new file mode 100644 index 000000000..7c59fb915 --- /dev/null +++ b/apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { nodesRoutes } from "@/api/nodes/index.js"; +import { db } from "@/db/index.js"; +import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +/** + * `PUT /api/nodes/:id/ssh-enabled` (spec 4.3) — the per-node SSH capability + * gate, opt-in and off by default. + * + * OWNER-only (`gate.canManage`, with the seeded-`local` admin exception), the + * same gate as maintenance, delete and re-share — deliberately NOT the + * `nodeCanConfigure` gate an `edit` grantee holds. The act is what decides it: + * flipping this on lets the plane dial out of and serve keys from an OS + * account this machine owns; a grantee may restart the agent or read its log, + * but widening what SSH can do from someone else's machine is not theirs. + * + * The route performs no write and records no audit itself — `setNodeSshEnabled` + * is the one change-site (row + audit, and from Task 7 the push), so this file + * asserts the ROUTE wired the acting human into that row. + */ +describe("node ssh-enabled route", () => { + const pw = "ssh-enabled-1"; + const ownerEmail = `sh-owner-${crypto.randomUUID()}@subshell.local`; + const editorEmail = `sh-editor-${crypto.randomUUID()}@subshell.local`; + const otherEmail = `sh-other-${crypto.randomUUID()}@subshell.local`; + const adminEmail = `sh-admin-${crypto.randomUUID()}@subshell.local`; + let ownerId: string; + let editorId: string; + let ownerCookie: string; + let editorCookie: string; + let otherCookie: string; + let adminCookie: string; + const NODE = `n-sh-${crypto.randomUUID()}`; + + async function mkUser(email: string, role: "user" | "admin" = "user"): Promise { + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role, + }); + } + + /** Every `node.ssh_enabled.update` row this file's node has collected, id-keyed. */ + async function sshAudit(): Promise> { + const rows = await db + .selectFrom("auditEvents") + .select(["id", "actorUserId", "metadataJson"]) + .where("targetId", "=", NODE) + .where("action", "=", "node.ssh_enabled.update") + .execute(); + return new Map(rows.map((r) => [r.id, { actorUserId: r.actorUserId, metadataJson: r.metadataJson }])); + } + + function put(nodeId: string, cookie: string, on: boolean) { + return nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${nodeId}/ssh-enabled`, { + method: "PUT", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${cookie}` }, + body: JSON.stringify({ on }), + }), + ); + } + + beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(ownerEmail); + editorId = await mkUser(editorEmail); + await mkUser(otherEmail); + await mkUser(adminEmail, "admin"); + ownerCookie = await signIn(ownerEmail, pw); + editorCookie = await signIn(editorEmail, pw); + otherCookie = await signIn(otherEmail, pw); + adminCookie = await signIn(adminEmail, pw); + await ensureLocalNode(db); + + const now = new Date().toISOString(); + await db + .insertInto("nodes") + .values({ + id: NODE, + ownerUserId: ownerId, + name: NODE, + kind: "agent", + status: "offline", + createdAt: now, + updatedAt: now, + } as never) + .execute(); + await new NodeSharesRepository(db).replaceForNode(NODE, [{ granteeUserId: editorId, permission: "edit" }], ownerId); + }); + + afterAll(async () => { + await db.deleteFrom("nodes").where("id", "=", NODE).execute(); + // Leave the shared `local` row exactly as found: every file in this + // package gets its own database, but a flipped host gate still reads as + // the fleet default if a later test in THIS file's process re-reads it. + await db + .updateTable("nodes") + .set({ sshEnabled: 0, sshEnabledAt: null } as never) + .where("id", "=", LOCAL_NODE_ID) + .execute(); + for (const email of [ownerEmail, editorEmail, otherEmail, adminEmail]) await deleteUserByEmailOrId(email); + }); + + it("the owner turns it on, and the answer is the node view carrying the flag", async () => { + const res = await put(NODE, ownerCookie, true); + expect(res.status).toBe(200); + const view = (await res.json()) as { sshEnabled: boolean }; + expect(view.sshEnabled).toBe(true); + }); + + it("names the acting human and carries ONLY {on} in the audit row", async () => { + // The ROUTE is the only place `actorUserId: user.id` is wired in — the + // service takes it as an argument, so a regression to a null actor passes + // every test of the service alone. + await put(NODE, ownerCookie, false); + const before = await sshAudit(); + + expect((await put(NODE, ownerCookie, true)).status).toBe(200); + // Keyed on which row is NEW rather than on "the latest": rows share a + // millisecond stamp and their ids are unordered, so a newest-first read + // cannot name this act's row. + const added = [...(await sshAudit())].filter(([id]) => !before.has(id)); + expect(added).toHaveLength(1); + expect(added[0][1].actorUserId).toBe(ownerId); + // The metadata is the STRING `"{\"on\":true}"`: the flag and nothing else + // — no key material, no config values (spec 4.3; audit never carries secrets). + expect(added[0][1].metadataJson).toBe('{"on":true}'); + }); + + it("turning it off lands on the row and the view", async () => { + const res = await put(NODE, ownerCookie, false); + expect(res.status).toBe(200); + expect(((await res.json()) as { sshEnabled: boolean }).sshEnabled).toBe(false); + }); + + it("an edit grantee is refused — it is not their machine to widen", async () => { + // Deliberately NOT `nodeCanConfigure`: a grantee may re-check and read + // logs; enabling SSH through someone else's OS account is a different act. + expect((await put(NODE, editorCookie, true)).status).toBe(403); + }); + + it("a stranger gets 404, never 403 — an invisible node stays invisible", async () => { + expect((await put(NODE, otherCookie, true)).status).toBe(404); + }); + + it("answers 404 for an unknown node", async () => { + expect((await put(`n-missing-${crypto.randomUUID()}`, ownerCookie, true)).status).toBe(404); + }); + + it("an admin is refused on a node they do not own, and allowed on `local`", async () => { + // The admin boost is instance-wide EDIT, never ownership: `canManage` is + // the real owner, plus admins on the seeded host alone. + expect((await put(NODE, adminCookie, true)).status).toBe(403); + // A member sees `local` through the seeded Everyone/edit grant but holds + // no management there — refused, not invisible (a node on the page must + // not answer "not found"). + expect((await put(LOCAL_NODE_ID, otherCookie, true)).status).toBe(403); + // Unlike maintenance, this flip stops nothing, so ON is safe to test on + // the shared host row; `afterAll` restores it. + const res = await put(LOCAL_NODE_ID, adminCookie, true); + expect(res.status).toBe(200); + expect(((await res.json()) as { sshEnabled: boolean }).sshEnabled).toBe(true); + }); +}); diff --git a/apps/server/api/src/api/nodes/index.ts b/apps/server/api/src/api/nodes/index.ts index 177879980..8d51a6280 100644 --- a/apps/server/api/src/api/nodes/index.ts +++ b/apps/server/api/src/api/nodes/index.ts @@ -18,6 +18,7 @@ import { setNodeAllowedDirsRoute } from "@/api/nodes/set-node-allowed-dirs.route import { setNodeMaintenanceRoute } from "@/api/nodes/set-node-maintenance.route.js"; import { setNodeServerUrlRoute } from "@/api/nodes/set-node-server-url.route.js"; import { setNodeSharesRoute } from "@/api/nodes/set-node-shares.route.js"; +import { setNodeSshEnabledRoute } from "@/api/nodes/set-node-ssh-enabled.route.js"; import { updateNodeRoute } from "@/api/nodes/update-node.route.js"; /** @@ -46,6 +47,7 @@ export const nodesRoutes = new Elysia({ prefix: "/api/nodes" }) .use(setNodeSharesRoute) .use(setNodeAllowedDirsRoute) .use(setNodeMaintenanceRoute) + .use(setNodeSshEnabledRoute) .use(rotateNodeKeyRoute) .use(reregisterNodeRoute) .use(recheckNodeRoute) diff --git a/apps/server/api/src/api/nodes/node-view.ts b/apps/server/api/src/api/nodes/node-view.ts index 0dfdac980..b199f2ad0 100644 --- a/apps/server/api/src/api/nodes/node-view.ts +++ b/apps/server/api/src/api/nodes/node-view.ts @@ -155,6 +155,10 @@ export const NodeViewSchema = t.Object({ "Which end declared the current value (a browser or the machine's own `subshell maintenance` verb), so a person reading the page learns whether someone at the keyboard did this; null when it was never set", }, ), + sshEnabled: t.Boolean({ + description: + "Whether this machine may be used for Subshell SSH (outbound ssh and serving its keys). Opt-in per node, off by default on every row including the control-plane host; the launch gate refuses SSH here while false, whoever asks", + }), held: t.Nullable( t.Object({ reason: t.Union([t.Literal("below-floor"), t.Literal("protocol-mismatch"), t.Literal("encryption-required")], { @@ -368,6 +372,7 @@ function nodeViewBase( maintenance: row.maintenance === 1, maintenanceAt: row.maintenanceAt, maintenanceSource: row.maintenanceSource, + sshEnabled: row.sshEnabled === 1, // Read from the LIVE registry rather than the row, for the same reason // `runtime` is: being held is a fact about a socket that exists right now, // and a column would go stale the moment the process ends. It is NOT diff --git a/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts b/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts new file mode 100644 index 000000000..3e23d5435 --- /dev/null +++ b/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts @@ -0,0 +1,82 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { authGuard, ForbiddenError, requireCookieActor } from "@/api/auth-guard.js"; +import { loadNodeGate } from "@/api/nodes/node-gate.js"; +import { NodeViewSchema, toNodeView } from "@/api/nodes/node-view.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { apiErrorBody } from "@/lib/api-error.js"; +import { apiModels } from "@/schema/index.js"; +import { setNodeSshEnabled } from "@/services/nodes/ssh-enabled.js"; + +const SshEnabledBodySchema = t.Object({ + on: t.Boolean({ + description: + "Allow this machine to be used for Subshell SSH (outbound ssh and serving its keys). Off by default. Owner only; admin on the control-plane host.", + }), +}); + +/** + * `PUT /api/nodes/:id/ssh-enabled` `{on}` — open or close Subshell SSH on one + * machine (spec 4.3). Cookie-only. + * + * **OWNER-only** (`gate.canManage`, with the seeded-`local` admin exception), + * the same gate as maintenance, delete and re-share — deliberately NOT the + * `nodeCanConfigure` gate an `edit` grantee holds. Enabling SSH widens what + * can be done THROUGH this machine's OS account (dial out with its keys, + * serve them to peers); restarting the agent or reading its log is + * administration, deciding who may SSH from it is a different act. + * + * The route writes nothing and audits nothing itself: `setNodeSshEnabled` is + * the one change-site (row + audit, and from Task 7 the best-effort push to + * the machine), so no caller — including the node-side paths Task 7 adds — + * can perform half the act. + * + * It answers on an OFFLINE node rather than refusing: the flag is the plane's + * record, and the machine learns the value at its next `ready` (Task 7's + * reconcile). Refusing would make the flag settable only while the machine is + * reachable, which is backwards for a gate that exists to be closed. + */ +export const setNodeSshEnabledRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .put( + "/:id/ssh-enabled", + async ({ params, body, user, actor, status }) => { + requireCookieActor(actor, "Node SSH enablement is restricted to browser sessions"); + const gate = await loadNodeGate(user.id, params.id); + if (!gate) { + return status(404, apiErrorBody({ code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Node not found" })); + } + if (!gate.canManage) throw new ForbiddenError(); + + await setNodeSshEnabled({ + nodeId: gate.row.id, + on: body.on, + changedAt: new Date().toISOString(), + actorUserId: user.id, + }); + // Re-read rather than patching the gate's snapshot: the service wrote + // the row, and the view must render what is stored, not what this + // handler believes it asked for. + const row = (await new NodesRepository(db).findById(gate.row.id)) ?? gate.row; + return await toNodeView(row, gate.access, gate.isAdmin, gate.granted); + }, + { + params: t.Object({ id: t.String({ description: "Node id" }) }), + body: SshEnabledBodySchema, + response: { + 200: NodeViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "setNodeSshEnabled", + tags: ["nodes"], + description: + "Allow or refuse Subshell SSH on a node (owner only; admin on the control-plane host). Off by default; enabling lets the plane dial out of and serve keys from this machine.", + }, + }, + ); diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 9f1bb6dae..c5c3ce828 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -388,18 +388,24 @@ test("real entry: available older upgrade snapshot restores and migrates before mkdirSync(backupDir, { mode: 0o700 }); const snapshot = join(backupDir, "subshell-v1.7.0-20260101-000000.db"); const db = new Database(instance.databasePath); - const migration = db.query("SELECT * FROM kysely_migration WHERE name='0046-backup-recovery'").get() as { - name: string; - timestamp: string; - }; + // "Older" must be a valid LEDGER PREFIX: the newest registered migrations + // 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. + const migrationRows = db + .query( + "SELECT * FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled') ORDER BY name", + ) + .all() as { name: string; timestamp: string }[]; db.exec( - "DROP TABLE backup_recovery; DELETE FROM kysely_migration WHERE name='0046-backup-recovery'; CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "DROP TABLE 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');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "CREATE TABLE backup_recovery(user_id TEXT PRIMARY KEY REFERENCES user(id)); 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; UPDATE migration_restore_probe SET value='newer destination';", ); - db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(migration.name, migration.timestamp); + for (const row of migrationRows) + db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); db.close(); const catalog = ok(await cli(["backup", "--list", "--json"], instance.configDir)); expect(catalog.backups).toHaveLength(1); @@ -407,17 +413,30 @@ test("real entry: available older upgrade snapshot restores and migrates before const restored = ok(await cli(["restore", snapshot, "--yes", "--no-start", "--json"], instance.configDir)); expect(restored).toMatchObject({ status: "pending-boot", legacyDatabaseOnly: true }); const offline = new Database(instance.databasePath, { readonly: true }); - expect(offline.query("SELECT name FROM kysely_migration WHERE name='0046-backup-recovery'").get()).toBeNull(); + expect( + offline + .query("SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled')") + .all(), + ).toEqual([]); offline.close(); const running = await boot(instance.configDir, port); const migrated = new Database(instance.databasePath, { readonly: true }); - expect(migrated.query("SELECT name FROM kysely_migration WHERE name='0046-backup-recovery'").get()).toMatchObject({ - name: "0046-backup-recovery", - }); + expect( + migrated + .query( + "SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled') ORDER BY name", + ) + .all(), + ).toEqual([{ name: "0046-backup-recovery" }, { name: "0047-node-ssh-enabled" }]); 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", }); + expect( + migrated + .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" }]); migrated.close(); expect(readFileSync(join(instance.configDir, "config.env"), "utf8")).toBe(originalConfig); const deadline = Date.now() + 15_000; diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 9d68dc0b0..25a4a654e 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -46,6 +46,7 @@ import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cros import * as oauthCallbackBaseMigration from "@/db/migrations/0044-oauth-callback-base.js"; import * as nodeReregistrationMigration from "@/db/migrations/0045-node-reregistration.js"; import * as backupRecoveryMigration from "@/db/migrations/0046-backup-recovery.js"; +import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; /** * Runs all pending Kysely migrations against the app database. @@ -132,6 +133,9 @@ export async function runMigrations(): Promise { "0044-oauth-callback-base": oauthCallbackBaseMigration, "0045-node-reregistration": nodeReregistrationMigration, "0046-backup-recovery": backupRecoveryMigration, + // 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, }; }, }, diff --git a/apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts b/apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts new file mode 100644 index 000000000..f52bf23b9 --- /dev/null +++ b/apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts @@ -0,0 +1,18 @@ +import type { Kysely } from "kysely"; + +// Adds the per-node SSH capability gate. Default 0 keeps the whole fleet (and +// the control-plane host) off after an upgrade: SSH egress and key use are +// opt-in per machine (spec 4.3; the maintenance default-0 "upgrade keeps the +// fleet as it was" doctrine). +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("nodes") + .addColumn("ssh_enabled", "integer", (col) => col.notNull().defaultTo(0)) + .execute(); + await db.schema.alterTable("nodes").addColumn("ssh_enabled_at", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("ssh_enabled_at").execute(); + await db.schema.alterTable("nodes").dropColumn("ssh_enabled").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts b/apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts new file mode 100644 index 000000000..bd1e322ba --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts @@ -0,0 +1,99 @@ +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 sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + nodes: Record; +} + +/** + * The SSH capability gate columns. Default 0 is the whole upgrade story: a + * node enrolled before this migration (and the seeded local row) reads OFF, + * because SSH egress and key use are opt-in per machine. + */ +describe("migration 0047-node-ssh-enabled", () => { + let dbFile: string; + let db: Kysely; + + /** Insert a node row the way the pre-0047 repository did - no ssh fields at all. */ + const legacyNode = (id: string) => + db + .insertInto("nodes") + .values({ + id, + owner_user_id: "u1", + name: id, + kind: "agent", + created_at: "2026-10-01T00:00:00.000Z", + updated_at: "2026-10-01T00:00:00.000Z", + }) + .execute(); + + beforeAll(async () => { + dbFile = `/tmp/subshell-0047-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + // Written BEFORE the columns exist - the upgrade case this test is about. + await legacyNode("pre-existing"); + await sshEnabledMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds the two columns", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((c) => c.name); + expect(names).toContain("ssh_enabled"); + expect(names).toContain("ssh_enabled_at"); + }); + + it("leaves every pre-existing node with SSH off (default-off doctrine)", async () => { + const r = await sql<{ + ssh_enabled: number; + ssh_enabled_at: string | null; + }>`SELECT ssh_enabled, ssh_enabled_at FROM nodes WHERE id = 'pre-existing'`.execute(db); + expect(r.rows[0]).toEqual({ ssh_enabled: 0, ssh_enabled_at: null }); + }); + + it("defaults a row written without the columns to 0", async () => { + await legacyNode("fresh"); + const r = await sql<{ ssh_enabled: number }>`SELECT ssh_enabled FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0].ssh_enabled).toBe(0); + }); + + it("stores the flag with its stamp", async () => { + await db + .updateTable("nodes") + .set({ ssh_enabled: 1, ssh_enabled_at: "2026-10-07T10:00:00.000Z" }) + .where("id", "=", "fresh") + .execute(); + const r = await sql<{ + ssh_enabled: number; + ssh_enabled_at: string; + }>`SELECT ssh_enabled, ssh_enabled_at FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0]).toEqual({ ssh_enabled: 1, ssh_enabled_at: "2026-10-07T10:00:00.000Z" }); + }); + + it("down removes both columns and leaves the rows", async () => { + await sshEnabledMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((c) => c.name); + expect(names).not.toContain("ssh_enabled"); + expect(names).not.toContain("ssh_enabled_at"); + const left = await sql<{ id: string }>`SELECT id FROM nodes ORDER BY id`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["fresh", "pre-existing"]); + await sshEnabledMigration.up(db); // leave the DB on the current shape + }); +}); diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts index f54455f90..14045c278 100644 --- a/apps/server/api/src/db/repositories/nodes.repository.ts +++ b/apps/server/api/src/db/repositories/nodes.repository.ts @@ -56,6 +56,10 @@ export class NodesRepository extends BaseRepository { maintenance: input.maintenance ?? 0, maintenanceAt: input.maintenanceAt ?? null, maintenanceSource: input.maintenanceSource ?? null, + // A new node has SSH off: `0` is the column default (migration 0047) + // and the gate is opt-in per machine (spec 4.3); mirror it the same way. + sshEnabled: input.sshEnabled ?? 0, + sshEnabledAt: input.sshEnabledAt ?? null, createdAt: input.createdAt ?? now, updatedAt: now, }) @@ -222,6 +226,35 @@ export class NodesRepository extends BaseRepository { .executeTakeFirst(); } + /** + * Write the SSH capability flag and its stamp (spec 4.3). + * + * Same shape as {@link setMaintenance} minus the `source` column: both + * fields move together, and the CALLER supplies `changedAt` — the stamp is + * the reconciliation protocol between the plane's row and the node's mirror + * file (Task 7), so a relay must not re-stamp the fact it is carrying. + */ + async setSshEnabled( + id: string, + state: { + /** True = this machine may be used for Subshell SSH. */ + on: boolean; + /** ISO 8601 of the write that produced `on` — never re-stamped on relay. */ + changedAt: string; + }, + ): Promise { + return await this.db + .updateTable("nodes") + .set({ + sshEnabled: state.on ? 1 : 0, + sshEnabledAt: state.changedAt, + updatedAt: new Date().toISOString(), + }) + .where("id", "=", id) + .returningAll() + .executeTakeFirst(); + } + /** * The unfinished subshells on one node, as FULL rows. * diff --git a/apps/server/api/src/db/types/nodes.db-types.ts b/apps/server/api/src/db/types/nodes.db-types.ts index 98c3f6794..80c215583 100644 --- a/apps/server/api/src/db/types/nodes.db-types.ts +++ b/apps/server/api/src/db/types/nodes.db-types.ts @@ -87,6 +87,20 @@ export interface NodeTable { maintenanceAt: string | null; /** Which end wrote the standing value ({@link MaintenanceSource}); null when none has. */ maintenanceSource: MaintenanceSource | null; + /** + * 1 = SSH egress and key use are enabled on this machine (spec 4.3). + * Opt-in per node, never a fleet default: 0 for every row that predates + * the column, so an upgrade leaves the whole fleet (and the control-plane + * host) with SSH off until someone flips it. + */ + sshEnabled: number; + /** + * ISO 8601 of the write that last changed {@link sshEnabled} (the wire's + * `changedAt`), or null when the flag was never written. The stamp rides + * the flag on every push, the shape {@link maintenanceAt} has for + * maintenance. + */ + sshEnabledAt: string | null; /** ISO 8601 creation time */ createdAt: string; /** ISO 8601 last update time */ diff --git a/apps/server/api/src/index.ts b/apps/server/api/src/index.ts index 6c12bc0f0..ec5c826ba 100644 --- a/apps/server/api/src/index.ts +++ b/apps/server/api/src/index.ts @@ -50,6 +50,7 @@ import { startServer } from "@/server.js"; import { loadAndApplyDebugLogging } from "@/services/logging-preference.js"; import { startInventoryRefresh } from "@/services/nodes/inventory-refresh.js"; import { reconcileMaintenance } from "@/services/nodes/maintenance.js"; +import { reconcileSshEnabled } from "@/services/nodes/ssh-enabled.js"; import { setNodeLifecycleHooks } from "@/services/nodes/node-events.js"; import { listOnline } from "@/services/nodes/node-registry.js"; import { prepareNetworkGuards, prepareNetworkProcesses } from "@/services/network/prepare.js"; @@ -323,6 +324,9 @@ async function bootServer(): Promise { // not route through the manager: the hook hands the machine's own copy to // the one module that reconciles the two (spec 2026-09-14 §5.2). onMaintenance: (nodeId, reported) => reconcileMaintenance(nodeId, reported), + // The SSH mirror's sibling (spec 2026-10-07 §4.3): same door, opposite + // direction — the row is the record, so this reconcile only pushes. + onSshEnabled: (nodeId, reported) => reconcileSshEnabled(nodeId, reported), }); // Restore alive/exit state at boot: a backend restart mid-subshell must not // leave stale alive=1 rows (tmux subshells died with the old process). diff --git a/apps/server/api/src/lib/__tests__/node-access.test.ts b/apps/server/api/src/lib/__tests__/node-access.test.ts index d69333662..570f37569 100644 --- a/apps/server/api/src/lib/__tests__/node-access.test.ts +++ b/apps/server/api/src/lib/__tests__/node-access.test.ts @@ -13,6 +13,7 @@ import { nodeCanManage, nodeCanManageFor, nodeCanRetire, + nodeCanSsh, resolveNodeAccess, } from "@/lib/node-access.js"; @@ -322,3 +323,44 @@ describe("nodeCanLaunchOn", () => { }); }); }); + +describe("nodeCanSsh", () => { + it("refuses when the node's SSH gate is off, whoever the actor is", () => { + expect( + nodeCanSsh({ kind: "agent", access: "owner", isAdmin: false, serverAccountEnabled: true, sshEnabled: false }), + ).toBe(false); + }); + it("refuses a non-owner even when enabled and shared edit", () => { + expect( + nodeCanSsh({ kind: "agent", access: "edit", isAdmin: false, serverAccountEnabled: true, sshEnabled: true }), + ).toBe(false); + }); + it("refuses an admin on someone else's node: the boost confers management, never SSH use", () => { + expect( + nodeCanSsh({ kind: "agent", access: "edit", isAdmin: true, serverAccountEnabled: true, sshEnabled: true }), + ).toBe(false); + }); + it("allows an owner of an enabled agent node", () => { + expect( + nodeCanSsh({ kind: "agent", access: "owner", isAdmin: false, serverAccountEnabled: true, sshEnabled: true }), + ).toBe(true); + }); + it("allows an admin on the enabled local server (admins resolve to edit, never owner)", () => { + expect( + nodeCanSsh({ kind: "local", access: "edit", isAdmin: true, serverAccountEnabled: true, sshEnabled: true }), + ).toBe(true); + }); + it("the seeded Everyone edit grant on local does not confer SSH use", () => { + expect( + nodeCanSsh({ kind: "local", access: "edit", isAdmin: false, serverAccountEnabled: true, sshEnabled: true }), + ).toBe(false); + }); + it("local honors the server-as-node switch", () => { + expect( + nodeCanSsh({ kind: "local", access: "owner", isAdmin: false, serverAccountEnabled: true, sshEnabled: true }), + ).toBe(true); + expect( + nodeCanSsh({ kind: "local", access: "edit", isAdmin: true, serverAccountEnabled: false, sshEnabled: true }), + ).toBe(false); + }); +}); diff --git a/apps/server/api/src/lib/node-access.ts b/apps/server/api/src/lib/node-access.ts index 8630e1a6e..6e30d45e2 100644 --- a/apps/server/api/src/lib/node-access.ts +++ b/apps/server/api/src/lib/node-access.ts @@ -97,6 +97,30 @@ export function nodeCanLaunchOn( return kind === "local" ? nodeCanLaunch(granted) : nodeCanLaunch(access); } +/** Whether this node may be used for Subshell SSH at all (dial out or serve keys). + * Owner-only, matching who may broker an OS account's credentials, with the same + * local-node admin exception `nodeCanManageFor` applies (the resolver ranks admins + * at `edit`, never `owner`, so without `isAdmin` an admin could never USE the + * server gate they are allowed to enable). Deliberately NOT shaped like + * `nodeCanLaunchOn`: local's seeded Everyone grant confers launch, never SSH use, + * so there is no `granted` parameter here to widen it. Gated by the row's + * ssh_enabled (fail-closed) and, for `local`, the server-as-node switch. Answers + * yes/no only; naming WHY (gate off vs not owner vs maintenance vs no-launch) is + * the caller's copy, per spec section 12. */ +export function nodeCanSsh(opts: { + kind: NodeKind; + access: NodeAccess; + isAdmin: boolean; + serverAccountEnabled: boolean; + sshEnabled: boolean; +}): boolean { + if (!opts.sshEnabled) return false; + if (opts.kind === "local") { + return (opts.access === "owner" || opts.isAdmin) && opts.serverAccountEnabled; + } + return opts.access === "owner"; +} + /** * TRUE when ANY share level grants launch — deliberately NOT the subshell rule, * where launch sits at edit. Product decision (spec 2026-08-31 §2): a `view` diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 2d0ac1be5..b897e6afb 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0046-backup-recovery"; +export const LATEST_BACKUP_MIGRATION = "0047-node-ssh-enabled"; /** 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/nodes/__tests__/node-events.test.ts b/apps/server/api/src/services/nodes/__tests__/node-events.test.ts index 88804099a..2e901edef 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-events.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-events.test.ts @@ -255,6 +255,7 @@ describe("exit / subshells_report → lifecycle hooks (spec §3.3)", () => { }, onSubshellsReport: () => {}, onMaintenance: () => {}, + onSshEnabled: () => {}, }); const h = makeHarness(); const ws = fakeSocket("n1"); @@ -280,6 +281,7 @@ describe("exit / subshells_report → lifecycle hooks (spec §3.3)", () => { setNodeLifecycleHooks({ onExit: () => {}, onMaintenance: () => {}, + onSshEnabled: () => {}, onSubshellsReport: (nodeId, report) => { seen.push({ nodeId, report }); }, diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index 4a7e2eaaa..ff41e1bfa 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -694,6 +694,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => onMaintenance: (nodeId, reported) => { seen.push([nodeId, reported]); }, + onSshEnabled: () => {}, }); try { const ws = fakeSocket("n1"); @@ -750,6 +751,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => onMaintenance: (nodeId, reported) => { seen.push([nodeId, reported]); }, + onSshEnabled: () => {}, }); return { seen, off: () => resetNodeEventsForTests() }; } @@ -813,6 +815,87 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => }); }); + /** + * The SSH mirror (spec 2026-10-07 §4.3) walks the same two frames into the + * same hook as maintenance — one reconciler for connect-time state and a + * mid-session report — and the tests below pin that the DIRECTION is not + * shared: what the hook does with what it hears lives in + * `ssh-enabled.test.ts`, where "never adopt" is asserted. Here it is only + * the door: both carriers, the socket's nodeId, the lenient field. + */ + describe("ssh_enabled → the lifecycle hook (spec 2026-10-07 §4.3)", () => { + /** Install a recording SSH hook and return both the log and the uninstaller. */ + function recordSshEnabled(): { seen: [string, unknown][]; off: () => void } { + const seen: [string, unknown][] = []; + setNodeLifecycleHooks({ + onExit: () => {}, + onSubshellsReport: () => {}, + onMaintenance: () => {}, + onSshEnabled: (nodeId, reported) => { + seen.push([nodeId, reported]); + }, + }); + return { seen, off: () => resetNodeEventsForTests() }; + } + + it("a ready CARRYING sshEnabled reconciles it", async () => { + const { seen, off } = recordSshEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ sshEnabled: { on: true, changedAt: "2026-10-07T10:00:00.000Z" } })), + ); + expect(seen).toEqual([["n1", { on: true, changedAt: "2026-10-07T10:00:00.000Z" }]]); + } finally { + off(); + } + }); + + it("a ready WITHOUT it still reconciles — silence is the fail-closed answer, not a skipped beat", async () => { + // A node with no mirror file reads REFUSED, which is precisely when the + // plane's row (say, ON from a flip during the outage) must push. + const { seen, off } = recordSshEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame())); + expect(seen).toEqual([["n1", undefined]]); + } finally { + off(); + } + }); + + it("the ssh_enabled EVENT reaches the same hook, under the SOCKET's nodeId", async () => { + const { seen, off } = recordSshEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "ssh_enabled", on: false, changedAt: "2026-10-07T11:00:00.000Z" }), + ); + expect(seen).toEqual([["n1", { on: false, changedAt: "2026-10-07T11:00:00.000Z" }]]); + } finally { + off(); + } + }); + + it("a ready whose sshEnabled field is malformed keeps the ready and drops the field", async () => { + // Lenient exactly like `maintenance`: the plane learns the drop as + // silence, and silence reconciles toward the row. + const { seen, off } = recordSshEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame({ sshEnabled: { on: "yes" } }))); + expect(h.ready).toHaveLength(1); + expect(seen).toEqual([["n1", undefined]]); + } finally { + off(); + } + }); + }); + it("ready stashes homeDir and selfInvoke on the live facts; env is NOT a ready field", async () => { // Spec 2026-09-10 §5 as amended by the final review: `ready` reports the // home (resume defaults hang off it) and the agent's `selfInvoke` 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 cf2083078..ed438d787 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 @@ -173,6 +173,8 @@ function nodeRow(over: Partial = {}): NodeTable { maintenance: 0, maintenanceAt: null, maintenanceSource: null, + sshEnabled: 0, + sshEnabledAt: null, createdAt: "2026-09-01T00:00:00.000Z", updatedAt: "2026-09-01T00:00:00.000Z", ...over, diff --git a/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts b/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts new file mode 100644 index 000000000..e395f127d --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts @@ -0,0 +1,293 @@ +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 { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { + decideSshEnabled, + pushSetSshEnabled, + reconcileSshEnabled, + setNodeSshEnabled, +} from "@/services/nodes/ssh-enabled.js"; +import { attachScriptedNode, ok } from "@/test-helpers/scripted-node.js"; + +/** + * The SSH gate's plane half (spec 2026-10-07 §4.3): the decide table — pure, + * no DB, no socket (the `maintenance-decide.test.ts` pattern) — and the hook + * body with one real row and one scripted node (the `maintenance-apply.test.ts` + * pattern, and its seam: no RPC mocks, the push travels the real + * `sendCommand` chain and lands in the scripted wire, which is what proves the + * `set_ssh_enabled` frame parses against the shipped parser). + * + * The property this suite exists to pin is the DIRECTION: the plane is the + * sole writer, so a reconcile NEVER adopts. Every disagreement ends with the + * row's value pushed down and the row itself unmoved. + */ + +const nodes = new NodesRepository(db); +const nodeIds: string[] = []; +const emails: string[] = []; +let ownerId: string; + +async function mkUser(): Promise { + const email = `ssh-on-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("ssh-pass-1"), + role: "user", + }); +} + +/** An enrolled agent node with no live socket — every command on it fails offline. */ +async function mkNode(): Promise { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ id, ownerUserId: ownerId, name: `ssh-${id.slice(0, 8)}`, kind: "agent", status: "online" }); + return id; +} + +/** Audit rows for one target, newest last. */ +async function auditFor(targetId: string): Promise<{ actorUserId: string | null; meta: unknown }[]> { + const rows = await db + .selectFrom("auditEvents") + .select(["actorUserId", "metadataJson"]) + .where("targetId", "=", targetId) + .where("action", "=", "node.ssh_enabled.update") + .orderBy("createdAt", "asc") + .execute(); + return rows.map((r) => ({ actorUserId: r.actorUserId, meta: JSON.parse(r.metadataJson ?? "null") })); +} + +/** Let the void-fired pushes land (the maintenance-apply posture). */ +const settle = () => new Promise((r) => setTimeout(r, 25)); + +beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(); +}); + +afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of nodeIds) await nodes.deleteById(id); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +/* ------------------ the pure table -------------------------- */ + +describe("decideSshEnabled — the plane is authoritative, so stamps are never read", () => { + const row = (sshEnabled: 0 | 1, sshEnabledAt: string | null) => ({ sshEnabled, sshEnabledAt }); + + it("agreement is a noop — reported matches the row, either value", () => { + expect( + decideSshEnabled(row(1, "2026-10-07T10:00:00.000Z"), { on: true, changedAt: "2026-10-07T10:00:00.000Z" }), + ).toBe("noop"); + expect( + decideSshEnabled(row(0, "2026-10-07T10:00:00.000Z"), { on: false, changedAt: "2026-10-07T09:00:00.000Z" }), + ).toBe("noop"); + // Differing stamps under an agreeing value are a relayed fact, not a + // fight — maintenance's tie-breaker has no job when nothing writes from + // the node. + expect( + decideSshEnabled(row(1, "2026-10-07T12:00:00.000Z"), { on: true, changedAt: "2026-10-07T09:00:00.000Z" }), + ).toBe("noop"); + }); + + it("silence means OFF here: row-off agrees (noop), row-on pushes", () => { + // An absent mirror reads REFUSED on the machine, so a never-written or + // off row and a silent node already answer alike; the row saying ON faces + // a machine that refuses, and the row wins — that push is the repair. + expect(decideSshEnabled(row(0, null), undefined)).toBe("noop"); + expect(decideSshEnabled(row(0, "2026-10-07T10:00:00.000Z"), undefined)).toBe("noop"); + expect(decideSshEnabled(row(1, "2026-10-07T10:00:00.000Z"), undefined)).toBe("push-plane"); + }); + + it("disagreement pushes the row, in both directions — and never adopts, not even a NEWER report", () => { + // maintenance would adopt a newer node stamp; there is no node stamp here + // that anyone wrote, so even a future-dated report is just bytes the row + // overwrites. + expect( + decideSshEnabled(row(0, "2026-10-07T10:00:00.000Z"), { on: true, changedAt: "2099-01-01T00:00:00.000Z" }), + ).toBe("push-plane"); + expect( + decideSshEnabled(row(1, "2026-10-07T10:00:00.000Z"), { on: false, changedAt: "2026-10-07T09:00:00.000Z" }), + ).toBe("push-plane"); + }); + + it("the one disagreement with nothing to send is a noop, not an invented stamp", () => { + // Row never written + a node mirror saying ON (data dir carried across + // from another plane, say): the row IS off, every plane-side gate reads + // the row, so the disagreement is inert — and a push would have to mint a + // `changedAt` for a write that never happened. + expect(decideSshEnabled(row(0, null), { on: true, changedAt: "2026-10-07T09:00:00.000Z" })).toBe("noop"); + }); +}); + +/* ------------------ the act, on a real row -------------------------- */ + +describe("setNodeSshEnabled — write, audit, push, one function", () => { + it("writes the row, audits EXACTLY ONE row naming the actor, and pushes the stamped value", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + const changedAt = "2026-10-07T10:00:00.000Z"; + try { + await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); + const row = await nodes.findById(nodeId); + expect(row?.sshEnabled).toBe(1); + expect(row?.sshEnabledAt).toBe(changedAt); + await settle(); + // Exactly one audit row — the push adds nothing, and a second writer of + // this act is precisely what the one-function shape exists to prevent. + const rows = await auditFor(nodeId); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBe(ownerId); + expect(rows[0].meta).toEqual({ on: true }); + // The push is the row's OWN bytes — the wire frame the shipped parser + // accepted, not a re-stamped copy. + expect(scripted.cmdsOf("set_ssh_enabled")).toEqual([{ type: "set_ssh_enabled", on: true, changedAt }]); + } finally { + scripted.detach(); + } + }); + + it("an OFF flip lands too — the mirror is written as a value, not deleted", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + try { + await setNodeSshEnabled({ nodeId, on: false, changedAt: "2026-10-07T11:00:00.000Z", actorUserId: ownerId }); + const row = await nodes.findById(nodeId); + expect(row?.sshEnabled).toBe(0); + expect(row?.sshEnabledAt).toBe("2026-10-07T11:00:00.000Z"); + await settle(); + expect(scripted.cmdsOf("set_ssh_enabled")).toEqual([ + { type: "set_ssh_enabled", on: false, changedAt: "2026-10-07T11:00:00.000Z" }, + ]); + } finally { + scripted.detach(); + } + }); + + it("survives an offline node — the push is best-effort; the row already refuses", async () => { + const nodeId = await mkNode(); + await expect( + setNodeSshEnabled({ nodeId, on: true, changedAt: "2026-10-07T12:00:00.000Z", actorUserId: ownerId }), + ).resolves.toBeUndefined(); + expect((await nodes.findById(nodeId))?.sshEnabled).toBe(1); + }); +}); + +/* ------------------ the hook body -------------------------- */ + +describe("reconcileSshEnabled — decide, push the row, write nothing", () => { + it("agreement pushes NOTHING — the steady state must not echo a command per beat", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + const changedAt = "2026-10-07T10:00:00.000Z"; + await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); + await settle(); + try { + // The `ready` that follows the push carries exactly what was written. + await reconcileSshEnabled(nodeId, { on: true, changedAt }); + await settle(); + expect(scripted.countOf("set_ssh_enabled")).toBe(1); // only the flip's own push + } finally { + scripted.detach(); + } + }); + + it("disagreement pushes the ROW's value verbatim, and the row never moves", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + const changedAt = "2026-10-07T10:00:00.000Z"; + await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); + await settle(); + try { + // The machine reports REFUSED (its mirror corrupted, say) under an ON + // row: the answer is the row pushed down — never the report pulled up. + await reconcileSshEnabled(nodeId, { on: false, changedAt: "2026-10-07T13:00:00.000Z" }); + await settle(); + const pushes = scripted.cmdsOf("set_ssh_enabled"); + expect(pushes).toHaveLength(2); + expect(pushes[1]).toEqual({ type: "set_ssh_enabled", on: true, changedAt }); // the row's bytes, stamp included + expect((await nodes.findById(nodeId))?.sshEnabledAt).toBe(changedAt); // unmoved: never adopted, never re-stamped + } finally { + scripted.detach(); + } + }); + + it("row ON + node silent → push; and the reconcile itself writes no row and no audit", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + const changedAt = "2026-10-07T10:00:00.000Z"; + await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); + await settle(); + const auditsBefore = (await auditFor(nodeId)).length; + try { + // A wiped data dir: no mirror, no stamp to offer, the row's value is + // re-armed by the push (the maintenance-decide table's same cell). + await reconcileSshEnabled(nodeId, undefined); + await settle(); + expect(scripted.cmdsOf("set_ssh_enabled")).toEqual([ + { type: "set_ssh_enabled", on: true, changedAt }, + { type: "set_ssh_enabled", on: true, changedAt }, + ]); + expect((await auditFor(nodeId)).length).toBe(auditsBefore); // pushes are not acts; only flips audit + } finally { + scripted.detach(); + } + }); + + it("a future-dated report does not outrank anything — there is nothing to outrank", async () => { + // The maintenance suite's clamp test has no SSH twin because SSH has + // nothing to clamp: an adopted stamp is the failure mode clamps guard + // against, and SSH never adopts one to begin with. + const nodeId = await mkNode(); + await nodes.setSshEnabled(nodeId, { on: false, changedAt: "2026-10-07T10:00:00.000Z" }); + await reconcileSshEnabled(nodeId, { on: true, changedAt: "2099-01-01T00:00:00.000Z" }); + await settle(); + const row = await nodes.findById(nodeId); + expect(row?.sshEnabled).toBe(0); + expect(row?.sshEnabledAt).toBe("2026-10-07T10:00:00.000Z"); + expect(await auditFor(nodeId)).toHaveLength(0); // reconcile records nothing, ever + }); + + it("does nothing for a node that is not enrolled here", async () => { + await expect(reconcileSshEnabled("not-a-node", { on: true, changedAt: "2026-10-07T10:00:00.000Z" })).resolves.toBe( + undefined, + ); + }); +}); + +/* ------------------ `local` -------------------------- */ + +describe("`local` has no mirror to push to", () => { + it("pushSetSshEnabled is a NO-OP for local — resolves without a socket, where an agent would fail offline", async () => { + // The proof is the contrast: `sendCommand` to a node with no live socket + // rejects (the offline case above swallows exactly that). Local must + // RESOLVE — its answer to the gate is the DB row, and there is no second + // copy to tell. + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); + await expect( + pushSetSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T10:00:00.000Z" }), + ).resolves.toBeUndefined(); + }); + + it("setNodeSshEnabled works on local: row + audit, and the flip completes", async () => { + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); + const changedAt = "2026-10-07T10:00:00.000Z"; + await setNodeSshEnabled({ nodeId: LOCAL_NODE_ID, on: true, changedAt, actorUserId: ownerId }); + expect((await nodes.findById(LOCAL_NODE_ID))?.sshEnabled).toBe(1); + // Restore whatever the shared test DB had, so no later suite inherits a + // server account with SSH open (spec §4.3: local ships OFF). The restore + // goes through the service, so this test leaves two node.ssh_enabled.update + // audit rows on LOCAL_NODE_ID in THIS file's DB - harmless by design: each + // test file gets its own database, and audit-counting tests filter by + // targetId. + await setNodeSshEnabled({ nodeId: LOCAL_NODE_ID, on: false, changedAt, actorUserId: ownerId }); + expect((await nodes.findById(LOCAL_NODE_ID))?.sshEnabled).toBe(0); + }); +}); diff --git a/apps/server/api/src/services/nodes/node-events.ts b/apps/server/api/src/services/nodes/node-events.ts index dae255544..45aae4d33 100644 --- a/apps/server/api/src/services/nodes/node-events.ts +++ b/apps/server/api/src/services/nodes/node-events.ts @@ -1,4 +1,4 @@ -import type { NodeEvent, NodeMaintenanceWire } from "@internal/subshell-protocol"; +import type { NodeEvent, NodeMaintenanceWire, NodeSshEnabledWire } from "@internal/subshell-protocol"; /** * The backend event plane for `/ws/node` (spec 2026-08-31 §3.3, phase 2). @@ -97,6 +97,21 @@ export interface NodeLifecycleHooks { * (no file there, or a malformed `ready` field the lenient parser dropped) */ onMaintenance(nodeId: string, reported: NodeMaintenanceWire | undefined): Promise | void; + /** + * The machine's own copy of its SSH capability (spec 2026-10-07 §4.3) — + * from `ready.sshEnabled` at connect, or from the `ssh_enabled` event when + * the mirror moved underneath the connection (in practice: corruption). + * + * The same hook-not-repository shape as {@link onMaintenance}, and the same + * one reconciler for both frames; what differs is the DIRECTION — the plane + * is this flag's only writer, so reconciliation never adopts, it pushes. + * + * @param nodeId - the SOCKET's authenticated identity, never a frame's claim + * @param reported - the machine's mirror answer, or undefined when it + * reported none (no file, a file that says off, or a malformed `ready` + * field the lenient parser dropped — all of which mean REFUSED there) + */ + onSshEnabled(nodeId: string, reported: NodeSshEnabledWire | undefined): Promise | void; } let lifecycleHooks: NodeLifecycleHooks | undefined; diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index 45da89174..0c4b31dc9 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -973,6 +973,13 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: const hooks = getNodeLifecycleHooks(); if (hooks) await hooks.onMaintenance(nodeId, event.maintenance); else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile maintenance`); + // The SSH mirror travels the same door (spec 2026-10-07 §4.3) with the + // simpler decision behind it: the row is the record and never adopts, + // so the hook's whole cost is one read and at most one fire-and-forget + // push of the row's own value. Awaited only because it is trivially + // cheap; everything slow lives in the push's own best-effort swallow. + if (hooks) await hooks.onSshEnabled(nodeId, event.sshEnabled); + else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile ssh_enabled`); return; } case "heartbeat": @@ -1031,6 +1038,19 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: else logger.warn(`node ws: maintenance from ${nodeId} with no lifecycle hook`); return; } + case "ssh_enabled": { + const hooks = getNodeLifecycleHooks(); + // The machine's SSH mirror moved mid-session. With no node-side writer + // there is no keyboard flip to carry — what arrives here is a file that + // changed by other means (corruption, chiefly: it flips the node to + // REFUSED silently, and this frame is how the plane learns to push the + // repair now rather than at the next reconnect). Same hook as `ready`: + // a mid-session report and a connect-time state are the same + // disagreement, and one reconciler keeps them answering alike. + if (hooks) await hooks.onSshEnabled(nodeId, { on: event.on, changedAt: event.changedAt }); + else logger.warn(`node ws: ssh_enabled from ${nodeId} with no lifecycle hook`); + return; + } case "result": { // Connection-scoped settle: the frame can only resolve pendings on the // socket it arrived on (same record the close path drains). Fall back to diff --git a/apps/server/api/src/services/nodes/ssh-enabled.ts b/apps/server/api/src/services/nodes/ssh-enabled.ts new file mode 100644 index 000000000..d7582b336 --- /dev/null +++ b/apps/server/api/src/services/nodes/ssh-enabled.ts @@ -0,0 +1,155 @@ +import type { NodeSshEnabledWire } from "@internal/subshell-protocol"; +import { LOCAL_NODE_ID, type NodeTable } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { audit } from "@/services/audit.js"; +import { sendCommand } from "@/services/nodes/node-rpc.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The node SSH capability flag (spec 2026-10-07 §4.3), written in exactly one + * place — the posture `maintenance.ts` established and for the same reason: + * two implementations of the change act is two implementations that drift, + * and the half that drifts is whichever one nobody exercised. + * + * **The differences from maintenance are all in one fact: the plane is the + * SOLE writer.** There is no `subshell ssh-enabled` verb, so there is no + * second stamp to race — reconciliation never compares dates, never adopts, + * and answers every disagreement the same way: push the row. That is also + * why {@link SshEnabledWrite} has no `source` field and its `actorUserId` + * cannot be null: the only caller that reaches the row is a human's request, + * and the reconcile path performs no write at all. + */ + +export interface SshEnabledWrite { + nodeId: string; + on: boolean; + changedAt: string; + actorUserId: string; +} + +/** Flip one node's SSH capability: row, audit, and the best-effort push — + * all in this one function, so no caller can perform half the act. */ +export async function setNodeSshEnabled(write: SshEnabledWrite): Promise { + const { repos } = getRequestlessContext(); + await repos.nodes.setSshEnabled(write.nodeId, { on: write.on, changedAt: write.changedAt }); + await audit({ + actorUserId: write.actorUserId, + action: "node.ssh_enabled.update", + targetType: "node", + targetId: write.nodeId, + metadataJson: JSON.stringify({ on: write.on }), + }); + // The row is written and audited BEFORE the push, so a node that answers the + // command cannot be looking at a mirror the record has not caught up to — + // and a push that fails (the node is offline) costs nothing: the value + // re-lands at the node's next `ready` through {@link reconcileSshEnabled}. + pushSetSshEnabledBestEffort(write.nodeId, { on: write.on, changedAt: write.changedAt }); +} + +/** The node-row fields {@link decideSshEnabled} reads — nothing else is relevant. */ +type SshRow = Pick; + +/** What reconciliation decided about one node's two copies of the flag. */ +export type SshDecision = "push-plane" | "noop"; + +/** + * Decide what to do when the plane's row and the node's report disagree. + * PURE — it reads two values and answers; the caller performs. + * + * The table, and why each row is what it is (spec §4.3 — no node writer, so + * this is maintenance's `decideMaintenance` with the stamp comparison struck + * out; there is no "node wrote newer" case to price): + * + * | node reports | plane row | → | + * |---|---|---| + * | nothing | off / never written | `noop` — silence means refusal HERE, and the row already refuses | + * | nothing | on | `push-plane` — the node has no file (or a lost one); give it ours | + * | the row's value | — | `noop` — agreement, the steady state; a push here would echo forever | + * | the other value | with a stamp | `push-plane` — the row is the record; the machine is told, never asked | + * | the other value | never written | `noop` — nothing to send (no stamp exists), and the disagreement is INERT: the plane gates every SSH act on the row, so a stale mirror-on permits nothing the row permits | + * + * Stamps are never compared: with no node-side writer, a differing + * `changedAt` under an agreeing `on` is just a value relayed through the + * link — pushing on it would be a command per heartbeat for a difference that + * is only latency. And NOTHING adopts: an old mirror, a corrupted mirror + * reporting the refusal it produces, a future one — none of them move the + * row. That is the direction this whole task exists to pin. + * + * @param row - the plane's node row (the two SSH fields only) + * @param reported - what the machine says its mirror answers, or undefined + * when it reported nothing (no file, a file that says off, or a malformed + * `ready` field the lenient parser dropped) + */ +export function decideSshEnabled(row: SshRow, reported: NodeSshEnabledWire | undefined): SshDecision { + const rowOn = row.sshEnabled === 1; + // Silence reads as OFF — the node's own fail-closed default — which is the + // only reading under which "node lost its file while the row says off" is + // the quiet agreement it is. + if ((reported?.on ?? false) === rowOn) return "noop"; + // The one disagreement with nothing to send (rows: never-written row, node + // mirror on). `decideMaintenance` had an `adopt-node` for this cell; here + // the row is the record and an unstamped push would invent a write time. + if (!row.sshEnabledAt) return "noop"; + return "push-plane"; +} + +/** + * Send one node the SSH state the plane holds, so both copies end + * byte-identical. + * + * `local` is skipped: the control-plane host has no agent socket and no + * mirror file — its answer to the gate is the DB row itself, which is exactly + * why the server account's SSH runs check the row and nothing else. + * + * @param nodeId - the node to tell; `local` is a no-op + * @param state - the STORED value, relayed verbatim; never re-stamped here, + * or the relay would outrank the decision it is carrying + */ +export async function pushSetSshEnabled(nodeId: string, state: NodeSshEnabledWire): Promise { + if (nodeId === LOCAL_NODE_ID) return; + await sendCommand(nodeId, { type: "set_ssh_enabled", on: state.on, changedAt: state.changedAt }); +} + +/** + * {@link pushSetSshEnabled}, fire-and-forget with the failure logged — the + * shape `pushSetMaintenanceBestEffort` established, for the same reasons. + * + * An offline node simply misses it: the flag is already on the plane's row, + * which is what refuses every SSH act plane-side, and the node re-learns the + * value on its next `ready` through {@link reconcileSshEnabled}. There is + * nothing a caller could usefully await or retry. + */ +export function pushSetSshEnabledBestEffort(nodeId: string, state: NodeSshEnabledWire): void { + void pushSetSshEnabled(nodeId, state).catch((err: unknown) => { + logger + .withError(err) + .warn(`ssh-enabled push failed for node ${nodeId}; its mirror stays stale until it reconnects`); + }); +} + +/** + * Reconcile one node's report against the plane's row — the body of the + * `onSshEnabled` lifecycle hook, fed by `ready.sshEnabled` and by the + * `ssh_enabled` event a machine sends when its mirror moved underneath it. + * + * The whole hook is one read and at most one fire-and-forget push: there is + * nothing to stop (an SSH flip kills no panes), nothing to write (the row + * already stands), and nothing to await (a slow node must not hold the + * socket's frame queue on a flag the row is already enforcing). A row that + * disagrees with the machine pushes; a machine that disagrees with nothing + * says nothing further. Never the other direction — that rule is the point. + * + * @param nodeId - the SOCKET's authenticated identity, never a frame's claim + * @param reported - the machine's mirror answer, or undefined when it + * reported none + */ +export async function reconcileSshEnabled(nodeId: string, reported: NodeSshEnabledWire | undefined): Promise { + const { repos } = getRequestlessContext(); + const row = await repos.nodes.findById(nodeId); + if (!row) return; + const stamp = row.sshEnabledAt; + if (decideSshEnabled(row, reported) !== "push-plane" || stamp === null) return; + // (`decideSshEnabled` only answers "push-plane" when a stamp exists; the + // null check is what shows the type what the decide already proved.) + pushSetSshEnabledBestEffort(nodeId, { on: row.sshEnabled === 1, changedAt: stamp }); +} diff --git a/docs/security.md b/docs/security.md index bcfdff107..95bfb308f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2276,7 +2276,8 @@ Audit events are written, grouped by family: records acts, and a pre-gate 403 is not one. - **Nodes**: `node.enroll`, `node.delete`, `node.rename`, `node.key_rotate`, `node.reregister_key`, `node.reregister`, `node.allowed_dirs.update`, `node.config.update`, - `node.maintenance.update`, `node.logging.update`, `node.update`, + `node.maintenance.update`, `node.ssh_enabled.update`, + `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). diff --git a/e2e/fixtures/sshd.ts b/e2e/fixtures/sshd.ts new file mode 100644 index 000000000..f9c00e87b --- /dev/null +++ b/e2e/fixtures/sshd.ts @@ -0,0 +1,298 @@ +/** + * The e2e sshd fixture (specs 22 and 23): a REAL isolated sshd, the port of + * `packages/pane-runtime/src/ssh/__tests__/sshd-fixture.test.ts`'s recipe + * from Bun APIs to Node ones (the Playwright runner is Node). It owns and + * cleans only its own pieces: generated host + client keys, one sshd on a + * loopback ephemeral port (own pid tracked), and an `sshConfigHome` the node + * agent runs as its HOME. The developer's `~/.ssh` is never touched — every + * byte lives under the caller's temp root. + * + * Trust choice (one pick, documented per the brief): a PRE-TRUSTED + * `known_hosts` at `/.ssh/known_hosts` — exactly where the + * node's `ssh -G` resolves the default `UserKnownHostsFile` (the child HOME + * is this dir), so the approved snapshot carries the fixture's trusted file + * and the runtime's mandatory `StrictHostKeyChecking yes` passes on the + * pinned key. No `accept-new` first-contact pinning: the feature requires + * existing verified trust, and the fixture enters that contract already + * trusted rather than exercising a mode production never uses. + */ +import { spawn, spawnSync } from "node:child_process"; +import { accessSync, chmodSync, closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { createServer } from "node:net"; +import { userInfo } from "node:os"; +import path from "node:path"; + +/** The alias every consumer of this fixture references (`~/.ssh/config`). */ +export const SSH_FIXTURE_ALIAS = "e2edest"; + +/** sshd's known install locations beyond PATH (probe fallbacks, the ladder's shape). */ +const SSHD_KNOWN_PATHS = ["/usr/sbin/sshd"]; + +/** PATH probe (the Node stand-in for `Bun.which`); sshd also checks the known install paths and honors `E2E_SSHD_BIN`. */ +function which(bin: string, extraPaths: readonly string[] = []): string | null { + const override = bin === "sshd" ? process.env["E2E_SSHD_BIN"] : undefined; + if (override !== undefined && override !== "") return override; + const dirs = (process.env.PATH ?? "").split(path.delimiter).filter((d) => d !== ""); + for (const dir of [...dirs, ...extraPaths]) { + try { + accessSync(path.join(dir, bin)); + return path.join(dir, bin); + } catch { + // not here + } + } + return null; +} + +/** The missing pieces, empty when the host can run the real-daemon suite. */ +export function missingSshBins(): string[] { + const missing: string[] = []; + if (which("ssh") === null) missing.push("ssh"); + if (which("ssh-keygen") === null) missing.push("ssh-keygen"); + if (which("sshd", SSHD_KNOWN_PATHS) === null) missing.push("sshd"); + return missing; +} + +/** A loopback ephemeral port, freed the instant it is named (the Node stand-in for `Bun.listen`). */ +async function freePort(): Promise { + const srv = createServer(); + await new Promise((resolve, reject) => { + srv.once("error", reject); + srv.listen(0, "127.0.0.1", () => resolve()); + }); + const { port } = srv.address() as AddressInfo; + await new Promise((resolve) => srv.close(() => resolve())); + return port; +} + +/** The probe asks "is anything listening"; StrictHostKeyChecking=no is the probe's own trust, never the feature's (the real trust is the pre-trusted known_hosts). `-F /dev/null` seals the ambient `~/.ssh/config` out of a probe meant to be ambient-free. */ +const PROBE_OPTS = [ + "BatchMode=yes", + "ConnectTimeout=1", + "StrictHostKeyChecking=no", + "UserKnownHostsFile=/dev/null", +].flatMap((o) => ["-o", o]); + +/** sshd's readiness, probed through ssh itself: "not refused" means the daemon answers (the bun recipe's rule). */ +async function waitListening(sshBin: string, port: number, logPath: string): Promise { + for (let i = 0; i < 100; i++) { + const probe = spawnSync(sshBin, ["-F", "/dev/null", "-p", String(port), ...PROBE_OPTS, "127.0.0.1", "true"], { + stdio: ["ignore", "ignore", "pipe"], + }); + const err = probe.stderr?.toString() ?? ""; + if (!/Connection refused|connect to host 127\.0\.0\.1 port|Connection timed out/i.test(err)) return; + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`sshd did not come up on 127.0.0.1:${port}\n${readFileSync(logPath, "utf8").slice(-2000)}`); +} + +export interface SshFixture { + /** The loopback port the fixture sshd answers on. */ + port: number; + /** A loopback port THIS fixture holds with an accept-then-destroy listener: the dead-run target answers with an immediate connection reset (a freed port could be silently rebound between free and dial, turning the honest 255 into a ssh-connect timeout). */ + deadPort: number; + /** The fixture user — the host's own account (sshd refuses passwords; keys only). */ + user: string; + /** The directory to hand the node agent as HOME (its `.ssh/` carries config + known_hosts). */ + sshConfigHome: string; + /** Absolute path of the passphrase-less client key sshd authorizes. */ + trustedKeyPath: string; + /** The daemon's own stderr log — a real "Accepted publickey" line is a connect truth no client-side fact gives. */ + logPath: string; + /** SIGTERM the daemon this fixture started (and only that one). */ + stop(): Promise; +} + +/** Live fixture daemons, killed on process exit so a crashed worker never strands an sshd. */ +const live = new Set(); +process.on("exit", () => { + for (const pid of live) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // already gone + } + } +}); + +/** + * Start the fixture under `root` (a caller-mkdtemp'd dir; the caller owns the + * bytes, this function owns the daemon). Generates ed25519 host + client keys, + * writes the account config alias `e2edest`, the pre-trusted `known_hosts`, + * and sshd's own config (loopback-only, key-only auth, no PAM). + * + * `envPath` (spec 23) adds one `SetEnv PATH=` line. sshd resets a session's + * PATH to the platform login default regardless of the daemon's own + * environment (measured: a PATH prepended before spawn never reaches the + * remote command), so a destination that must find a scratch binary on PATH + * needs the daemon to hand it out. Absent, the fixture is exactly the + * default-PATH world it has always been. + */ +export async function startSshFixture(root: string, options: { envPath?: string } = {}): Promise { + const sshBin = which("ssh"); + const keygenBin = which("ssh-keygen"); + const sshdBin = which("sshd", SSHD_KNOWN_PATHS); + if (sshBin === null || keygenBin === null || sshdBin === null) { + throw new Error(`startSshFixture called on a host without ssh/ssh-keygen/sshd (${missingSshBins().join(", ")})`); + } + // The connecting account NAME, deterministically. `os.userInfo().username` + // is env-driven on bun (the Playwright runner IS bun) and passwd-driven on + // node: in a container with no USER/LOGNAME env bun answers "unknown" while + // sshd, matching the uid not the env, logs the real account ("root"). + // uid 0 is root on every supported OS; otherwise env, then the passwd + // lookup. The specs hand the agent `USER`/`LOGNAME` (see 22-ssh-runtime) so + // the daemon's own `connectingAccount` says the same thing everywhere. + const user = process.getuid?.() === 0 ? "root" : (process.env.USER ?? process.env.LOGNAME ?? userInfo().username); + const home = path.join(root, "home"); + mkdirSync(home, { recursive: true, mode: 0o700 }); + mkdirSync(path.join(home, ".ssh"), { mode: 0o700 }); + chmodSync(home, 0o700); + + const hostKey = path.join(root, "hostkey"); + const clientKey = path.join(root, "clientkey"); + for (const f of [hostKey, clientKey]) { + const kg = spawnSync(keygenBin, ["-q", "-t", "ed25519", "-N", "", "-f", f], { stdio: "ignore" }); + if (kg.status !== 0) throw new Error(`ssh-keygen failed for ${f}: exit ${kg.status}`); + } + + const port = await freePort(); + // The dead-run target, HELD for the fixture's life (review M2): we bind the + // named-free port ourselves and destroy every inbound socket, so a dial is + // refused fast (ssh: "Connection closed", exit 255 - exactly what + // sshTransportFailure corroborates) and NO third party can re-bind the name + // between our freePort and the dead run's connect. + const deadPort = await freePort(); + const refuseHold = createServer((sock) => sock.destroy()); + await new Promise((resolve, reject) => { + refuseHold.once("error", reject); + refuseHold.listen(deadPort, "127.0.0.1", () => resolve()); + }); + + // Pre-trusted known_hosts, named ABSOLUTE in the config below. ssh resolves + // the DEFAULT `userknownhostsfile` against the passwd home, not the injected + // $HOME, so a bare `~/.ssh/known_hosts` default would make the approved + // snapshot point at the host user's real file and the mandatory + // `StrictHostKeyChecking yes` probe fail host_key_unknown. The bracketed + // `[host]:port` authority form is what the non-22 port matches. + const knownHosts = path.join(home, ".ssh", "known_hosts"); + const keyTypeAndBlob = readFileSync(`${hostKey}.pub`, "utf8").trim().split(" ").slice(0, 2).join(" "); + writeFileSync(knownHosts, `[127.0.0.1]:${port} ${keyTypeAndBlob}\n`); + + // The account's config: the alias every consumer references. `IdentityAgent + // none` keeps an ambient ssh-agent out of resolution (its keys could crowd + // the fixture's single trusted key against sshd's MaxAuthTries). + writeFileSync( + path.join(home, ".ssh", "config"), + [ + `Host ${SSH_FIXTURE_ALIAS}`, + " HostName 127.0.0.1", + ` Port ${port}`, + ` User ${user}`, + ` IdentityFile ${clientKey}`, + " IdentitiesOnly yes", + " IdentityAgent none", + ` UserKnownHostsFile ${knownHosts}`, + "", + ].join("\n"), + { mode: 0o600 }, + ); + + // sshd trusts exactly the fixture's client key; the host's own `~/.ssh` is + // never consulted (StrictModes off lets authorized_keys live at the root). + const authorizedKeys = path.join(root, "authorized_keys"); + writeFileSync(authorizedKeys, `${readFileSync(`${clientKey}.pub`, "utf8").trim()}\n`); + + const sshdDir = path.join(root, "sshd"); + mkdirSync(sshdDir, { recursive: true }); + const sshdConfig = path.join(sshdDir, "sshd_config"); + const sshdLog = path.join(sshdDir, "sshd.log"); + writeFileSync( + sshdConfig, + [ + `Port ${port}`, + "ListenAddress 127.0.0.1", + "AddressFamily inet", + `HostKey ${hostKey}`, + `PidFile ${path.join(sshdDir, "sshd.pid")}`, + `AuthorizedKeysFile ${authorizedKeys}`, + "StrictModes no", + "UsePAM no", + "UseDNS no", + "PasswordAuthentication no", + "KbdInteractiveAuthentication no", + "PubkeyAuthentication yes", + "PermitUserEnvironment no", + "PrintMotd no", + "LogLevel VERBOSE", + // The spec-23 seam: a session PATH the daemon hands out (the default + // login PATH would otherwise hide any scratch binary). One token, no + // spaces: sshd's SetEnv value grammar. + ...(options.envPath !== undefined && options.envPath !== "" ? [`SetEnv PATH=${options.envPath}`] : []), + ].join("\n"), + ); + // sshd's privilege-separation directory is compiled in (/run/sshd; no + // sshd_config keyword overrides it) and the daemon refuses to start without + // it. Measured: the CI builder image's openssh-server postinst does NOT + // create it in a non-systemd container (job run 37274033881 died exactly + // there), while dev hosts usually have one because sshd is installed and + // running. Ensure it opportunistically: an existing dir or a non-writable + // /run surfaces through sshd's own stderr in the readiness failure below. + try { + mkdirSync("/run/sshd", { mode: 0o755 }); + } catch { + // already there, or not ours to make - sshd's log will say which + } + // sshd -e means "log to stderr" and takes NO file argument: the fixture + // redirects the child's stderr into its own file (the bun recipe's rule). + const logFd = openSync(sshdLog, "w"); + const child = spawn(sshdBin, ["-D", "-f", sshdConfig, "-e"], { + stdio: ["ignore", "ignore", logFd], + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + }); + closeSync(logFd); + const pid = child.pid; + if (pid === undefined) throw new Error("sshd spawn returned no pid"); + live.add(pid); + child.once("exit", () => live.delete(pid)); + // A spawn-adjacent failure is async on a long-lived child: hold the message + // for the readiness failure text rather than letting it throw unhandled. + let spawnError: string | null = null; + child.once("error", (err) => { + spawnError = String(err); + }); + try { + await waitListening(sshBin, port, sshdLog); + } catch (err) { + throw new Error(`${String(err)}${spawnError === null ? "" : `\nspawn error: ${spawnError}`}`); + } + + return { + port, + deadPort, + user, + sshConfigHome: home, + trustedKeyPath: clientKey, + logPath: sshdLog, + async stop(): Promise { + await new Promise((resolve) => refuseHold.close(() => resolve())); + live.delete(pid); + for (const sig of ["SIGTERM", "SIGKILL"] as const) { + try { + process.kill(pid, sig); + } catch { + return; + } + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch { + return; + } + await new Promise((r) => setTimeout(r, 100)); + } + } + }, + }; +} diff --git a/packages/pane-runtime/src/index.ts b/packages/pane-runtime/src/index.ts index 2a585070d..919629169 100644 --- a/packages/pane-runtime/src/index.ts +++ b/packages/pane-runtime/src/index.ts @@ -139,6 +139,22 @@ export { runBounded, } from "./run-bounded.js"; export { shellQuote } from "./shell.js"; +export { + defaultSshConfigPath, + discoverSshAliases, + isDiscoverableAlias, + type SshAliasDiscovery, + type SshConfigWalk, + type SshHostBlock, + type SshWalkBudget, + walkSshConfig, +} from "./ssh/ssh-discover.js"; +export { + parseProxyHop, + resolveSshAliasConfig, + type SshResolutionDeps, +} from "./ssh/ssh-resolve.js"; +export { runSshProcess, type SshProcessResult, sshChildPath } from "./ssh/ssh-spawn.js"; export { safeTransferPath } from "./tar-blocks.js"; export { type ExtractResult, extractTarGz } from "./tar-extractor.js"; export { extractTgz, type TarEntry, type TgzLimits } from "./tar-vendor.js"; diff --git a/packages/pane-runtime/src/ssh/__tests__/helpers.ts b/packages/pane-runtime/src/ssh/__tests__/helpers.ts new file mode 100644 index 000000000..528bcd0a5 --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/helpers.ts @@ -0,0 +1,149 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; + +/** + * SSH test fixtures. NOTHING here may touch a developer's `~/.ssh` or the + * live instance (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md): every config the tests read is a file + * under a fresh temp dir, every "ssh" the tests run is a shell script this + * file bakes with absolute paths (the runtime's child env is an allowlist, so + * a knob delivered by env would be a knob the PRODUCTION child could never + * see — baked constants are the honest test seam), and the isolated-sshd + * suite owns its temp HOME end to end. + */ + +/** Fresh temp root (realpath'd: macOS `/var` symlink noise breaks prefix asserts). */ +export function tempRoot(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +export function cleanup(root: string): void { + rmSync(root, { recursive: true, force: true }); +} + +/** A grammatically valid approved snapshot; override any field. */ +export function makeSnapshot(overrides: Partial = {}): SshConnectionSnapshotWire { + return { + alias: "deploy-app02", + host: "app-02.example.com", + user: "deploy", + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: ["/home/deploy/.ssh/known_hosts"], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...overrides, + }; +} + +/** A sha256-looking request digest. */ +export function makeDigest(seed: string): string { + let h = 0; + for (const ch of seed) h = (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0; + return h.toString(16).padStart(8, "0").repeat(8); +} + +/** Valid plane-shaped run id (hex + hyphen, ≤ 64). */ +export function makeRunId(n: number): string { + return `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`; +} + +/** Read every `ARGS:` line a recording shim appended, in order (one joined argv string per spawn). */ +export function shimArgs(logPath: string): string[] { + try { + return readFileSync(logPath, "utf8") + .split("\n") + .filter((l) => l.startsWith("ARGS:")) + .map((l) => l.slice("ARGS:".length)); + } catch { + return []; + } +} + +/** All recorded lines from a shim log. */ +export function shimLog(logPath: string): string { + try { + return readFileSync(logPath, "utf8"); + } catch { + return ""; + } +} + +/** + * Write the recording ssh shim into `dir/ssh`. Knobs are BAKED CONSTANTS: + * + * - `opts.dashG`: when set, an invocation with `-G` anywhere cats this text + * and exits 0 (the resolution evaluator's canned answer). + * - `opts.stdout` / `opts.stdoutFile` / `opts.stderr` / `opts.stderrFile`: + * bytes to emit (inline or from a file this function seeds). + * - `opts.sleep`: seconds to `exec sleep` after emitting (the long-running + * run the cancel/deadline paths stop). + * - `opts.exitCode`: the exit status. + * + * The script appends `ARGS:` plus `ENV:` probe lines naming the + * auth-agent, askpass, DISPLAY, TERM, and the full key list of its + * environment, and `CFG:` when an `-F` is present. + * The `-G` branch runs BEFORE the ARGS recording only for -G invocations so + * resolution tests can assert on the emitted config; every other invocation + * records first. + */ +export function writeSshShim( + dir: string, + opts: { + dashG?: string; + stdout?: string; + stdoutFile?: string; + stderr?: string; + stderrFile?: string; + sleep?: number; + exitCode?: number; + } = {}, +): { bin: string; log: string } { + mkdirSync(dir, { recursive: true }); + const log = join(dir, "shim.log"); + const parts: string[] = ["#!/bin/sh"]; + parts.push(`LOG='${log}'`); + if (opts.stdoutFile) parts.push(`OUTF='${opts.stdoutFile}'`); + if (opts.stderrFile) parts.push(`ERRF='${opts.stderrFile}'`); + parts.push(`printf 'ARGS:%s\\n' "$*" >> "$LOG"`); + parts.push( + `printf 'ENV:auth=%s ask=%s disp=%s term=%s\\n' "\${SSH_AUTH_SOCK-}" "\${SSH_ASKPASS-}" "\${DISPLAY-}" "\${TERM-}" >> "$LOG"`, + ); + parts.push(`printf 'ENVKEYS:%s\\n' "$(env | cut -d= -f1 | sort | tr '\\n' ' ')" >> "$LOG"`); + parts.push( + `prev=""; for a in "$@"; do if [ "$prev" = "-F" ]; then printf 'CFG:<<\\n'; cat "$a"; printf '>>\\n' >> "$LOG"; fi; prev="$a"; done >> "$LOG" 2>/dev/null`, + ); + if (opts.dashG !== undefined) { + parts.push(`for a in "$@"; do if [ "$a" = "-G" ]; then printf 'GARGS:%s\\n' "$*" >> "$LOG"; cat <<'GEOF'`); + parts.push(opts.dashG); + parts.push("GEOF\nexit 0\nfi; done"); + } + if (opts.stdout !== undefined) parts.push(`printf '%s' ${shellSingleQuote(opts.stdout)}`); + if (opts.stdoutFile) parts.push(`[ -n "\${OUTF-}" ] && cat "$OUTF"`); + if (opts.stderr !== undefined) parts.push(`printf '%s' ${shellSingleQuote(opts.stderr)} 1>&2`); + if (opts.stderrFile) parts.push(`[ -n "\${ERRF-}" ] && cat "$ERRF" 1>&2`); + if (opts.sleep !== undefined) parts.push(`exec sleep ${opts.sleep}`); + parts.push(`exit ${opts.exitCode ?? 0}`); + const bin = join(dir, "ssh"); + writeFileSync(bin, `${parts.join("\n")}\n`, { mode: 0o755 }); + chmodSync(bin, 0o755); + return { bin, log }; +} + +function shellSingleQuote(v: string): string { + return `'${v.replaceAll("'", `'\\''`)}'`; +} + +/** `~`-less POSIX single-quote for fixture config text (paths here are literals). */ +export { shellSingleQuote as sq }; diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts new file mode 100644 index 000000000..db45fab8c --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts @@ -0,0 +1,158 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { defaultSshConfigPath, discoverSshAliases, walkSshConfig } from "../ssh-discover.js"; +import { cleanup, tempRoot } from "./helpers.js"; + +/** + * Discovery is a NAME-HUNT with budgets (ssh-discover.ts module doc): what it + * must get right is which names appear, which are excluded, and what the + * two "the list may be incomplete" facts report. Config CONTENT never + * escapes the walk — the answers carry no directive values at all. + */ + +let root: string; + +beforeAll(() => { + root = tempRoot("subshell-ssh-discover-"); +}); + +afterAll(() => { + cleanup(root); +}); + +function fixtureConfig(tag: string, body: string): string { + const dir = join(root, tag); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "config"); + writeFileSync(path, body); + return path; +} + +describe("discoverSshAliases", () => { + it("reports plain Host names, sorted and deduplicated", () => { + const config = fixtureConfig( + "plain", + [ + "# a comment", + "Host beta", + " HostName beta.example.com", + "Host alpha gamma", + " User deploy", + "Host beta", // duplicate name across blocks + "", + ].join("\n"), + ); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + expect(found.aliases).toEqual(["alpha", "beta", "gamma"]); + expect(found.includeCycle).toBe(false); + expect(found.truncated).toBe(false); + }); + + it("omits wildcard-only patterns and negations, keeps plain siblings", () => { + const config = fixtureConfig( + "wildcards", + [ + "Host *.example.com !secret.example.com", // nothing plain on this line + " User x", + "Host prod-*", // pattern prefix, not an alias + "Host qa-server", + " User y", + "Host q?", // single-char pattern + ].join("\n"), + ); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + expect(found.aliases).toEqual(["qa-server"]); + }); + + it("follows includes and detects an include cycle", () => { + const dir = join(root, "cycle"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "a.conf"), ["Include b.conf", "Host alias-a", " User x"].join("\n")); + writeFileSync( + join(dir, "b.conf"), + ["Include a.conf", "Host alias-b"].join("\n"), // back into a's ancestry + ); + const walk = walkSshConfig(join(dir, "a.conf"), root); + expect(walk.includeCycle).toBe(true); + expect(walk.files.length).toBe(2); // a then b; the re-include stopped at the cycle, did not spin + }); + + it("dedupes a diamond include WITHOUT reporting a cycle", () => { + const dir = join(root, "diamond"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "common.conf"), "Host shared"); + writeFileSync(join(dir, "top.conf"), ["Include common.conf", "Include common.conf", "Host other"].join("\n")); + const found = discoverSshAliases({ homeDir: root, configPath: join(dir, "top.conf") }); + expect(found.aliases).toEqual(["other", "shared"]); + expect(found.includeCycle).toBe(false); + }); + + it("expands glob includes with ~", () => { + const dir = join(root, "glob"); + const inc = join(dir, "conf.d"); + mkdirSync(inc, { recursive: true }); + writeFileSync(join(inc, "one.conf"), "Host g-one"); + writeFileSync(join(inc, "two.conf"), "Host g-two"); + writeFileSync(join(inc, "skip.txt"), "Host g-not-included"); + // ~ expansion: homeDir is the fixture root. + const found = discoverSshAliases({ + homeDir: root, + configPath: fixtureConfig("glob-root", "Include ~/glob/conf.d/*.conf"), + }); + expect(found.aliases).toEqual(["g-one", "g-two"]); + }); + + it("flags truncated when the file budget is exhausted", () => { + const dir = join(root, "budget"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "b.conf"), "Host from-b"); + writeFileSync(join(dir, "c.conf"), "Host from-c"); + const configPath = join(dir, "config"); + writeFileSync(configPath, ["Include b.conf", "Include c.conf", "Host from-root"].join("\n")); + const found = discoverSshAliases({ homeDir: dir, configPath, budget: { maxFiles: 2 } }); // root + b; c is never read + expect(found.truncated).toBe(true); + expect(found.aliases).toContain("from-b"); + expect(found.aliases).not.toContain("from-c"); + }); + + it("flags truncated when the alias cap is reached", () => { + const lines: string[] = []; + for (let i = 0; i < 501; i++) lines.push(`Host alias-${String(i).padStart(4, "0")}`); + const config = fixtureConfig("cap", lines.join("\n")); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + expect(found.aliases.length).toBe(500); + expect(found.truncated).toBe(true); + }); + + it("sees Match exec in the walk (the local-execution signal §2 refuses on)", () => { + const config = fixtureConfig("matchexec", ["Match exec echo hi", " User x", "Host m-alias"].join("\n")); + const walk = walkSshConfig(config, root); + expect(walk.matchExecSeen).toBe(true); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + expect(found.aliases).toEqual(["m-alias"]); // Match blocks carry no names + }); + + it("missing config file yields an empty honest list, never a throw", () => { + const found = discoverSshAliases({ homeDir: root, configPath: join(root, "nope", "config") }); + expect(found).toEqual({ aliases: [], includeCycle: false, truncated: false }); + }); + + it("the default path is the home config", () => { + expect(defaultSshConfigPath("/home/zed")).toBe("/home/zed/.ssh/config"); + }); + + it("answers names only: no directive value text appears anywhere in the result", () => { + const config = fixtureConfig( + "namesonly", + ["Host top-secret-host", " IdentityFile /home/deploy/.ssh/private_key_material", " User supersecret"].join( + "\n", + ), + ); + const found = discoverSshAliases({ homeDir: root, configPath: config }); + const blob = JSON.stringify(found); + expect(blob).toContain("top-secret-host"); // the NAME is the answer + expect(blob).not.toContain("private_key_material"); + expect(blob).not.toContain("supersecret"); + }); +}); diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts new file mode 100644 index 000000000..0a708603d --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts @@ -0,0 +1,435 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseProxyHop, resolveSshAliasConfig } from "../ssh-resolve.js"; +import { cleanup, tempRoot, writeSshShim } from "./helpers.js"; + +/** + * Resolution tests run pane-runtime's resolver against a SHIM ssh that emits + * canned `ssh -G` output — the shape of the answer is what the filter must + * decide on, and the shim keeps the decision hermetic. (The real sshd suite + * in `ssh-loopback-sshd.test.ts` covers the same paths against the actual + * OpenSSH evaluator; here we test the filter, not OpenSSH.) + * + * The canned fixtures mirror the REAL grammar measured on OpenSSH 10.2p1 + * (review finding C1: the first fixtures omitted `-G`'s default + * `tunnel false` / `permitremoteopen any` lines and the tilde-form default + * identityfiles, so a filter bug hiding under those spellings was invisible + * to every fixture). The suite ends with the pin the fixture mirror cannot + * be trusted to be honest about: `resolveSshAliasConfig` driven by the REAL + * host `ssh -G`, loud-skipped only where ssh is absent. + * + * The shim's `-F` handling is deliberately bypassed for the `-G` branch + * except to confirm the resolver names the fixture config file: the canned + * output IS the fake OpenSSH answer. + */ + +let root: string; + +beforeAll(() => { + root = tempRoot("subshell-ssh-resolve-"); +}); + +afterAll(() => cleanup(root)); + +function gConfig(body: string, _aliasLines = true): { homeDir: string; configPath: string } { + const dir = tempRoot("subshell-ssh-resolve-cfg-"); + const configPath = join(dir, "config"); + writeFileSync(configPath, body); + return { homeDir: dir, configPath }; +} + +/** Run the resolver with a shim whose -G output is `gOutput`; also hands back the fixture home (tilde-expansion asserts). */ +async function resolveWith( + alias: string, + gOutput: string, + opts: { + configBody?: string; + env?: Record; + account?: string; + } = {}, +): Promise<{ out: Awaited>; homeDir: string }> { + const dir = tempRoot("subshell-ssh-resolve-run-"); + const shim = writeSshShim(join(dir, "bin"), { dashG: gOutput }); + const cfg = gConfig(opts.configBody ?? `Host ${alias}\n HostName resolved.example\n`); + const out = await resolveSshAliasConfig(alias, { + sshBin: shim.bin, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin", HOME: cfg.homeDir, ...opts.env }, + connectingAccount: opts.account ?? "opsuser", + timeoutMs: 5_000, + }); + return { out, homeDir: cfg.homeDir }; +} + +/** + * The filter's own clean answer, spelled like a real 10.x `-G`: the default + * `tunnel false` / `permitremoteopen any` / `permitlocalcommand no` lines are + * PRESENT (an unconfigured option is echoed with its default spelling, and + * the filter must know that spelling — a fixture that omitted them let the + * every-alias-refused bug through). + */ +const CLEAN_G = [ + "host resolved.example", + "hostname resolved.example", + "user deploy", + "port 2222", + "identityfile /home/ops/.ssh/id_ed25519", + "certificatefile /home/ops/.ssh/id_ed25519-cert.pub", + "userknownhostsfile /home/ops/.ssh/known_hosts", + "hostkeyalias app02-web", + "forwardagent no", + "gssapiauthentication no", + "tunnel false", + "tunneldevice any:any", + "permitremoteopen any", + "permitlocalcommand no", + "addkeystoagent false", + "controlmaster false", +].join("\n"); + +/** + * A plain-alias answer straight out of OpenSSH 10.x defaults: the identity + * files arrive in `~/.ssh/...` TILDE FORM (the `-G` default spelling), the + * known-hosts file is already absolute, and there is no explicit + * IdentityAgent line. This is the dump that turned every alias without an + * explicit IdentityFile into `config_ambiguous` before the tilde expanded. + */ +const REAL10X_G = [ + "host plainhost", + "hostname plainhost", + "user opsuser", + "port 22", + "addressfamily any", + "batchmode no", + "checkhostip no", + "compression no", + "controlmaster false", + "forwardx11 no", + "gssapiauthentication no", + "hashknownhosts no", + "identitiesonly no", + "kbdinteractiveauthentication yes", + "passwordauthentication yes", + "permitlocalcommand no", + "pubkeyauthentication true", + "requesttty auto", + "stricthostkeychecking ask", + "tcpkeepalive yes", + "tunnel false", + "verifyhostkeydns false", + "visualhostkey no", + "canonicalizemaxdots 1", + "connectionattempts 1", + "serveralivecountmax 3", + "serveraliveinterval 0", + "requiredrsasize 1024", + "ciphers chacha20-poly1305@openssh.com,aes128-gcm@openssh.com", + "loglevel INFO", + "identityfile ~/.ssh/id_rsa", + "identityfile ~/.ssh/id_ecdsa", + "identityfile ~/.ssh/id_ecdsa_sk", + "identityfile ~/.ssh/id_ed25519", + "identityfile ~/.ssh/id_ed25519_sk", + "globalknownhostsfile /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2", + "userknownhostsfile /home/opsuser/.ssh/known_hosts /home/opsuser/.ssh/known_hosts2", + "logverbose none", + "channeltimeout none", + "permitremoteopen any", + "addkeystoagent false", + "forwardagent no", + "connecttimeout none", + "tunneldevice any:any", +].join("\n"); + +describe("resolveSshAliasConfig", () => { + it("normalizes a clean answer into the approved snapshot", async () => { + const { out } = await resolveWith("app02", CLEAN_G); + expect(out.accepted).toBe(true); + if (out.accepted) { + expect(out.snapshot.host).toBe("resolved.example"); + expect(out.snapshot.user).toBe("deploy"); + expect(out.snapshot.port).toBe(2222); + expect(out.snapshot.identityFiles).toEqual(["/home/ops/.ssh/id_ed25519"]); + expect(out.snapshot.certificateFiles).toEqual(["/home/ops/.ssh/id_ed25519-cert.pub"]); + expect(out.snapshot.knownHostsFiles).toEqual(["/home/ops/.ssh/known_hosts"]); + expect(out.snapshot.hostKeyAlias).toBe("app02-web"); + expect(out.snapshot.alias).toBe("app02"); + expect(out.connectingAccount).toBe("opsuser"); + // forbidden members exist as null, by grammar + expect(out.snapshot.proxyCommand).toBeNull(); + } + }); + + it("accepts a whole-dump of real OpenSSH 10.x defaults, expanding tilde identity refs", async () => { + const { out, homeDir } = await resolveWith("plainhost", REAL10X_G, { + configBody: "Host something-else\n HostName elsewhere\n", + }); + expect(out.accepted).toBe(true); + if (out.accepted) { + // The tilde defaults expanded against the fixture home; nothing on the + // snapshot may stay tilde-form (the parser refuses it, and refusing a + // DEFAULT config is the C1 defect). + expect(out.snapshot.identityFiles).toEqual([ + join(homeDir, ".ssh/id_rsa"), + join(homeDir, ".ssh/id_ecdsa"), + join(homeDir, ".ssh/id_ecdsa_sk"), + join(homeDir, ".ssh/id_ed25519"), + join(homeDir, ".ssh/id_ed25519_sk"), + ]); + expect(out.snapshot.identityFiles.every((p) => p.startsWith("/"))).toBe(true); + expect(out.snapshot.knownHostsFiles).toEqual([ + "/home/opsuser/.ssh/known_hosts", + "/home/opsuser/.ssh/known_hosts2", + ]); + expect(out.snapshot.certificateFiles).toEqual([]); + expect(out.snapshot.hostKeyAlias).toBeNull(); + expect(out.snapshot.user).toBeNull(); // -G echoed the connecting account's own default + } + }); + + it("blocks a CONFIGURED tunnel or permitremoteopen while the default spellings pass", async () => { + const defaults = await resolveWith("app02", CLEAN_G); + expect(defaults.out.accepted).toBe(true); + + const tunnel = await resolveWith("app02", `${CLEAN_G.replace("tunnel false", "tunnel yes")}`); + expect(tunnel.out).toEqual({ accepted: false, code: "unsupported_setting", settings: ["Tunnel"] }); + + const remote = await resolveWith("app02", CLEAN_G.replace("permitremoteopen any", "permitremoteopen host:1080")); + expect(remote.out).toEqual({ accepted: false, code: "unsupported_setting", settings: ["PermitRemoteOpen"] }); + }); + + it("reads one HostName plus one Port as ONE route, not a conflict (real configs set both)", async () => { + const { out } = await resolveWith("app02", CLEAN_G, { + configBody: ["Host app02", " HostName resolved.example", " Port 2222"].join("\n"), + }); + expect(out.accepted).toBe(true); + }); + + it("maps a config ProxyCommand to a named refusal naming the keyword", async () => { + const { out } = await resolveWith("app02", `${CLEAN_G}\nproxycommand nc -X proxy 443 %h %p`); + expect(out).toEqual({ accepted: false, code: "unsupported_setting", settings: ["ProxyCommand"] }); + }); + + it("refuses each unrepresentable family by name, together when several are set", async () => { + const { out } = await resolveWith( + "app02", + [ + CLEAN_G, + "locallyforward 8080 localhost:80", + "permitremoteopen yes", + "remotecommand /bin/sh", + "sendenv FOO", + "localcommand /bin/true", + ].join("\n"), + ); + expect(out.accepted).toBe(false); + if (!out.accepted) { + expect(out.code).toBe("unsupported_setting"); + expect(out.settings).toEqual(["LocalForward", "PermitRemoteOpen", "LocalCommand", "RemoteCommand", "SendEnv"]); + } + }); + + it("refuses a Match exec config before the evaluator even decides", async () => { + const { out } = await resolveWith("app02", CLEAN_G, { + configBody: "Match exec curl -s trust.example\n User x\nHost app02\n HostName resolved.example\n", + }); + expect(out).toEqual({ accepted: false, code: "unsupported_setting", settings: ["Match exec"] }); + }); + + it("refuses a hop chain longer than the frozen cap", async () => { + const hops = ["a.example", "b.example", "c.example", "d.example", "e.example"].join(","); + const { out } = await resolveWith("app02", `${CLEAN_G}\nproxyjump ${hops}`); + expect(out).toEqual({ accepted: false, code: "proxy_chain_too_long", settings: [] }); + }); + + it("normalizes a bounded ProxyJump chain into approved hops", async () => { + const { out } = await resolveWith("app02", `${CLEAN_G}\nproxyjump ops@jump.example.com:2222,bastion`); + expect(out.accepted).toBe(true); + if (out.accepted) { + expect(out.snapshot.proxyJumps).toEqual([ + { host: "jump.example.com", user: "ops", port: 2222 }, + { host: "bastion", user: "deploy", port: 22 }, + ]); + } + }); + + it("treats a token that is not in the config as a manual destination only when -G echoes it as the host", async () => { + const manual = await resolveWith( + "fresh.host.example", + "host fresh.host.example\nhostname fresh.host.example\nuser opsuser\nport 22", + { + configBody: "Host something-else\n HostName elsewhere\n", + }, + ); + expect(manual.out.accepted).toBe(true); + if (manual.out.accepted) expect(manual.out.snapshot.host).toBe("fresh.host.example"); + + const rewritten = await resolveWith( + "ghost", + "host not-the-alias.example\nhostname not-the-alias.example\nuser opsuser\nport 22", + { + configBody: "Host unrelated\n User x\n", + }, + ); + expect(rewritten.out).toEqual({ accepted: false, code: "config_missing", settings: [] }); + }); + + it("resolves a conflicting-Hostname alias to config_ambiguous", async () => { + const { out } = await resolveWith("app02", CLEAN_G, { + configBody: ["Host app02", " Hostname one.example", "Host app02", " Hostname two.example"].join("\n"), + }); + expect(out).toEqual({ accepted: false, code: "config_ambiguous", settings: [] }); + }); + + it("resolves a conflicting-Port alias (same host, two ports) to config_ambiguous", async () => { + // The ports arm ALONE must refuse: no HostName difference is present to + // trip the hostNames set, so this pins the `ports.size > 1` branch. + const { out } = await resolveWith("app02", CLEAN_G, { + configBody: ["Host app02", " Port 22", "Host app02", " Port 2222"].join("\n"), + }); + expect(out).toEqual({ accepted: false, code: "config_ambiguous", settings: [] }); + }); + + it("names the agent socket from the account env, and an explicit IdentityAgent path", async () => { + const viaEnv = await resolveWith("app02", `${CLEAN_G}\nidentityagent ssh-agent`, { + env: { SSH_AUTH_SOCK: "/run/user/1000/ssh.sock" }, + }); + expect(viaEnv.out.accepted && viaEnv.out.snapshot.authAgentSocket).toBe("/run/user/1000/ssh.sock"); + + const explicit = await resolveWith("app02", `${CLEAN_G}\nidentityagent /home/ops/.ssh/custom-agent.sock`, { + env: { SSH_AUTH_SOCK: "/run/user/1000/ssh.sock" }, + }); + expect(explicit.out.accepted && explicit.out.snapshot.authAgentSocket).toBe("/home/ops/.ssh/custom-agent.sock"); + + const none = await resolveWith("app02", `${CLEAN_G}\nidentityagent none`, { + env: { SSH_AUTH_SOCK: "/run/user/1000/ssh.sock" }, + }); + expect(none.out.accepted && none.out.snapshot.authAgentSocket).toBeNull(); + }); + + it("stores user null when -G reports the connecting account's own default", async () => { + const out = await resolveWith("app02", CLEAN_G.replace("user deploy", "user opsuser"), { account: "opsuser" }); + expect(out.out.accepted && out.out.snapshot.user).toBeNull(); + }); + + it("refuses option-like and empty aliases before spawning anything", async () => { + const dir = tempRoot("subshell-ssh-resolve-bad-"); + const shim = writeSshShim(join(dir, "bin"), { dashG: CLEAN_G }); + const cfg = gConfig("Host x"); + for (const bad of ["-oProxyCommand=evil", "with space", ""]) { + const out = await resolveSshAliasConfig(bad, { + sshBin: shim.bin, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin" }, + connectingAccount: "opsuser", + timeoutMs: 5_000, + }); + expect(out.accepted).toBe(false); + } + }); + + it("a failing evaluator answers config_ambiguous, never a crash", async () => { + const dir = tempRoot("subshell-ssh-resolve-fail-"); + const shim = writeSshShim(join(dir, "bin"), { exitCode: 255, stderr: "bad configuration line" }); + // no dashG: the shim exits 255 for the -G call too. + const cfg = gConfig("Host app02\n HostName resolved.example\n"); + const out = await resolveSshAliasConfig("app02", { + sshBin: shim.bin, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin" }, + connectingAccount: "opsuser", + timeoutMs: 5_000, + }); + expect(out.accepted).toBe(false); + if (!out.accepted) expect(out.code).toBe("config_ambiguous"); + }); + + // THE C1 pin: the fixture mirror can only be as honest as the hand that + // wrote it, so the filter is finally run against the REAL host OpenSSH. + // The loud-skip pattern is `ssh-loopback-sshd.test.ts`'s: no ssh binary, + // loud warn, every case skipped, the shim suites above still run. + const HAVE_SSH = Bun.which("ssh") !== null; + if (!HAVE_SSH) { + console.warn( + "[ssh-resolve] ssh not present: REAL `ssh -G` GRAMMAR PIN SKIPPED (loud skip; shim fixtures above mirror 10.x output)", + ); + } + + describe("against the real host ssh -G", () => { + it.skipIf(!HAVE_SSH)( + "accepts an alias with NO explicit IdentityFile (tilde defaults + default tunnel spellings)", + async () => { + const cfg = gConfig("Host realg\n HostName 127.0.0.1\n Port 2222\n User targetuser\n"); + const out = await resolveSshAliasConfig("realg", { + sshBin: Bun.which("ssh") as string, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin" }, + connectingAccount: "opsuser", + timeoutMs: 10_000, + }); + expect(out.accepted).toBe(true); + if (out.accepted) { + expect(out.snapshot.host).toBe("127.0.0.1"); + expect(out.snapshot.port).toBe(2222); + expect(out.snapshot.user).toBe("targetuser"); + // The whole C1 chain in one assert: real defaults printed, no + // unsupported_setting (the tunnel/permitremoteopen arms), and no + // config_ambiguous (the tilde identityfiles expanded). + expect(out.snapshot.identityFiles.length).toBeGreaterThan(0); + expect(out.snapshot.identityFiles.every((p) => p.startsWith(`${cfg.homeDir}/.ssh/`))).toBe(true); + } + }, + 20_000, + ); + + it.skipIf(!HAVE_SSH)( + "still refuses, by name, what a real config sets", + async () => { + const cfg = gConfig( + "Host realbad\n HostName 127.0.0.1\n PermitRemoteOpen host:1080\n LocalCommand touch /tmp/nope\n", + ); + const out = await resolveSshAliasConfig("realbad", { + sshBin: Bun.which("ssh") as string, + homeDir: cfg.homeDir, + configPath: cfg.configPath, + env: { PATH: "/usr/bin" }, + connectingAccount: "opsuser", + timeoutMs: 10_000, + }); + expect(out.accepted).toBe(false); + if (!out.accepted) { + expect(out.code).toBe("unsupported_setting"); + expect(out.settings).toContain("PermitRemoteOpen"); + expect(out.settings).toContain("LocalCommand"); // the M1 typo, on the real evaluator's own echo + } + }, + 20_000, + ); + }); +}); + +describe("parseProxyHop", () => { + it("parses the whole token grammar", () => { + expect(parseProxyHop("jump.example.com", null)).toEqual({ host: "jump.example.com", user: null, port: 22 }); + expect(parseProxyHop("ops@jump.example.com:2222", null)).toEqual({ + host: "jump.example.com", + user: "ops", + port: 2222, + }); + expect(parseProxyHop("[2001:db8::1]", null)).toEqual({ host: "[2001:db8::1]", user: null, port: 22 }); + expect(parseProxyHop("ops@[2001:db8::1]:2222", null)).toEqual({ + host: "[2001:db8::1]", + user: "ops", + port: 2222, + }); + expect(parseProxyHop("@jump.example.com", null)).toBeNull(); + expect(parseProxyHop("host:notaport", null)).toBeNull(); + expect(parseProxyHop("host:99999", null)).toBeNull(); + expect(parseProxyHop("host:0", null)).toBeNull(); + }); +}); diff --git a/packages/pane-runtime/src/ssh/ssh-discover.ts b/packages/pane-runtime/src/ssh/ssh-discover.ts new file mode 100644 index 000000000..42cb47a4b --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-discover.ts @@ -0,0 +1,364 @@ +import { closeSync, constants, lstatSync, openSync, readdirSync, readSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { SSH_MAX_DISCOVERED_ALIASES, SSH_NAME_MAX_CHARS } from "@internal/subshell-protocol"; + +/** + * Bounded discovery of SSH alias NAMES (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md): + * discover aliases by bounded parsing of the account's config and includes; + * omit wildcard-only entries, detect include cycles. + * + * This parser answers NAMES, never config contents, and it is deliberately a + * NAME-HUNT and not an implementation of ssh_config semantics: the authoritative + * resolution of what an alias means is `ssh -G` (`ssh-resolve.ts`), which is + * OpenSSH's own evaluator. What must not be outsourced is the BOUNDS: reading + * the account's config is the one step an alias-rich `Include` chain can turn + * into arbitrary filesystem traversal and unbounded work, so the walk carries + * hard budgets (file count, total bytes, include-expansion width) and a cycle + * detector over its own ancestor chain. A cycle is a FACT the human reviewing + * the list needs (`includeCycle`), never a silent stop. + * + * A few ssh_config facts this parser must still get right, because they decide + * which names are reported: `Host` takes a whitespace-separated pattern list + * (wildcard patterns and `!`-negations are not aliases), `Include` takes + * whitespace-separated globs with `~` expansion, `Match` blocks carry no names, + * and `#` comments run from unquoted whitespace-preceded hashes. Anything + * subtler than that is `ssh -G`'s problem by design. + */ + +/** The alias list the command answers; mirrors the frozen `NodeSshAliasListResult` shape. */ +export interface SshAliasDiscovery { + /** Alias names, deduplicated, sorted; wildcard-only and negated patterns excluded. */ + aliases: string[]; + /** One config file appeared in another's include ANCESTRY chain; the list is what parsed before the repeat. */ + includeCycle: boolean; + /** A budget was exhausted or the name cap was reached; more may exist than were returned. */ + truncated: boolean; +} + +/** One parsed `Host` block: its pattern tokens and the directives set under it. */ +export interface SshHostBlock { + /** The pattern tokens after `Host` (verbatim, including wildcards/negations). */ + tokens: string[]; + /** Directives under this block, in file order; keyword lowercased, value raw. */ + directives: { keyword: string; value: string }[]; +} + +/** What one bounded walk produced, for discovery AND resolution to share. */ +export interface SshConfigWalk { + /** Every `Host` block in include order, plus the pre-first-Host global directives. */ + hostBlocks: SshHostBlock[]; + /** Directives seen before any `Host` line (the implicit-global context). */ + globalDirectives: { keyword: string; value: string }[]; + /** A `Match exec` line was seen anywhere in the walk (a local-execution signal §2 refuses on). */ + matchExecSeen: boolean; + /** The config files that were actually read, walk order (cycle detection is done by the walk itself). */ + files: string[]; + includeCycle: boolean; + truncated: boolean; +} + +/** Budgets for one walk; defaults are the shipped numbers, tests pass smaller. */ +export interface SshWalkBudget { + /** Max config files read in one walk (includes count). */ + maxFiles?: number; + /** Max bytes read across all files. */ + maxTotalBytes?: number; + /** Max globs expanded per `Include` directive. */ + maxIncludeMatches?: number; + /** Longest single line kept (openbsd caps config lines too; longer lines are dropped). */ + maxLineBytes?: number; +} + +const DEFAULT_BUDGET = { + maxFiles: 64, + maxTotalBytes: 2 * 1024 * 1024, + maxIncludeMatches: 256, + maxLineBytes: 4096, +}; + +/** Default location of the connecting account's SSH client config. */ +export function defaultSshConfigPath(homeDir: string): string { + return join(homeDir, ".ssh", "config"); +} + +/** + * An alias name worth reporting: pattern syntax (`*`, `?`), negation (`!`), + * option-like leading `-`, whitespace, and control characters are all excluded, + * matching the hygiene the wire grammar applies to aliases + * (`parseSshNodeCommandBody`'s `isAliasName`) so a name discovery reports is a + * name resolution can be asked for. + */ +export function isDiscoverableAlias(token: string): boolean { + return ( + token.length > 0 && + token.length <= SSH_NAME_MAX_CHARS && + !token.includes("*") && + !token.includes("?") && + !token.startsWith("!") && + !token.startsWith("-") && + !/\s|[\p{Cc}]/u.test(token) + ); +} + +/** Keyword/value split: `keyword value`, `keyword=value`, case-insensitive keyword. */ +function splitKeywordArg(line: string): { keyword: string; arg: string } | null { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) return null; + // Strip comments: an unquoted `#` preceded by whitespace ends the meaningful + // text. Quoted values (rare, and only meaningful for Match/exec arguments + // this parser never evaluates) are left alone rather than half-parsed. + const comment = findCommentStart(trimmed); + const body = comment === -1 ? trimmed : trimmed.slice(0, comment).trimEnd(); + if (body === "") return null; + const eq = body.indexOf("="); + const ws = body.search(/\s/); + const cut = eq === -1 ? ws : ws === -1 ? eq : Math.min(eq, ws); + if (cut === -1) return { keyword: body.toLowerCase(), arg: "" }; + const keyword = body.slice(0, cut).toLowerCase(); + let arg = body.slice(cut); + if (arg.startsWith("=")) arg = arg.slice(1); + return { keyword, arg: arg.trim() }; +} + +/** Index of the comment start: a `#` at position 0 or preceded by whitespace (and not inside quotes). */ +function findCommentStart(line: string): number { + let inSingle = false; + let inDouble = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (ch === "#" && !inSingle && !inDouble && (i === 0 || /\s/.test(line[i - 1]))) return i; + } + return -1; +} + +/** Expand a leading `~` / `~/` against the home dir; anything else passes through. Exported for {@link resolveSshAliasConfig}: `ssh -G` prints default identity paths in tilde form, and the snapshot grammar takes only absolutes. */ +export function expandTilde(raw: string, homeDir: string): string { + if (raw === "~") return homeDir; + if (raw.startsWith("~/")) return join(homeDir, raw.slice(2)); + return raw; +} + +/** + * Expand one Include glob to concrete file paths. The pattern is anchored at + * the longest non-globbing directory prefix; the remainder is matched + * segment-by-segment against `readdirSync`, capped by `maxMatches`. This is a + * deliberately small glob engine (`*` and `?` within a segment, no `**`, no + * bracket classes): ssh_config Include globs that this cannot expand yield + * fewer aliases, never an unbounded walk. + */ +function expandIncludeGlob(pattern: string, includingFile: string, homeDir: string, maxMatches: number): string[] { + let expanded = expandTilde(pattern, homeDir); + if (!isAbsolute(expanded)) expanded = resolve(dirname(includingFile), expanded); + const segments = expanded.split("/").filter((s) => s !== ""); + const out: string[] = ["/"]; + for (const segment of segments) { + const next: string[] = []; + const globbing = segment.includes("*") || segment.includes("?"); + if (!globbing) { + for (const base of out) next.push(join(base, segment)); + } else { + const re = globToRe(segment); + for (const base of out) { + let names: string[]; + try { + names = readdirSync(base); + } catch { + continue; // unreadable directory: OpenSSH would skip it too + } + for (const name of names) { + if (re.test(name)) next.push(join(base, name)); + // Cap hit mid-walk: `next` already holds `maxMatches` real matches, + // so return it (the old `atRoot ? [] : next` was dead - `atRoot` is + // false at every point this line runs, review M7). + if (next.length >= maxMatches) return next; + } + } + } + out.length = 0; + out.push(...next); + } + const files: string[] = []; + for (const p of out) { + try { + if (lstatSync(p).isFile()) files.push(p); + } catch { + // missing/unreachable: not an error, an empty match + } + if (files.length >= maxMatches) break; + } + return files; +} + +function globToRe(segment: string): RegExp { + const escaped = segment + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replaceAll("*", ".*") + .replaceAll("?", "."); + return new RegExp(`^${escaped}$`); +} + +/** Read one config file within the budgets; returns its lines and mutates the byte total. */ +function readCapped(file: string, bytesLeft: number, maxLineBytes: number): { lines: string[]; used: number } { + let fd: number; + try { + // O_NOFOLLOW: a planted symlink at an Include target is refused, not + // chased — the walk reads the account's config, not whatever a symlink + // on its path was pointed at. + fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch { + return { lines: [], used: 0 }; + } + try { + const cap = Math.max(0, Math.min(bytesLeft, DEFAULT_BUDGET.maxTotalBytes)); + if (cap === 0) return { lines: [], used: 0 }; + const buf = Buffer.allocUnsafe(cap + 1); + const n = readSync(fd, buf, 0, cap + 1, null); + const used = Math.min(n, cap); + const text = buf.subarray(0, used).toString("utf8"); + const lines = text.split("\n"); + if (n > cap) lines.pop(); // a cut tail line is a partial line, not data + return { + lines: lines.map((l) => (l.length > maxLineBytes ? l.slice(0, maxLineBytes) : l)), + used: n, // over-budget reads still count what was attempted + }; + } finally { + closeSync(fd); + } +} + +/** + * A quoted Include token: OpenSSH accepts `"…"` around a pattern containing + * spaces; peel them so the space-split token list stays correct. + */ +function peelQuotes(token: string): string { + if (token.length >= 2 && token.startsWith('"') && token.endsWith('"')) return token.slice(1, -1); + return token; +} + +/** + * Walk one account's ssh_config: read the root file, follow `Include` globs + * with budgets, and collect `Host` block structure + `Match exec` presence. + * Cycle detection is over the walk's own ACTIVE ancestor chain (a file under + * itself), so a diamond (two includes of one shared file) dedupes silently + * while a true cycle raises `includeCycle`. NEVER throws: every fs surprise + * ends the walk of THAT file, not the discovery. + * + * @param rootFile - absolute path of the account's config (may not exist) + * @param homeDir - the connecting account's home, for `~` expansion + * @param budget - walk budgets (see {@link SshWalkBudget}) + */ +export function walkSshConfig(rootFile: string, homeDir: string, budget: SshWalkBudget = {}): SshConfigWalk { + const caps = { ...DEFAULT_BUDGET, ...budget }; + const walk: SshConfigWalk = { + hostBlocks: [], + globalDirectives: [], + matchExecSeen: false, + files: [], + includeCycle: false, + truncated: false, + }; + let filesRead = 0; + let bytesUsed = 0; + let currentBlock: SshHostBlock | null = null; + const seen = new Set(); // every file already parsed (dedupe) + const active = new Set(); // the ancestor chain (cycle detector) + + const openBlock = (): SshHostBlock => { + const block: SshHostBlock = { tokens: [], directives: [] }; + walk.hostBlocks.push(block); + currentBlock = block; + return block; + }; + + const visit = (file: string): void => { + let real: string; + try { + real = realpathSync(file); + } catch { + return; + } + if (active.has(real)) { + walk.includeCycle = true; // a file under its own includer: the §2 fact + return; + } + if (seen.has(real)) return; // a second ordinary include: dedupe, not a cycle + if (filesRead >= caps.maxFiles || bytesUsed >= caps.maxTotalBytes) { + walk.truncated = true; + return; + } + active.add(real); + seen.add(real); + filesRead += 1; + walk.files.push(real); + const budgetLeft = caps.maxTotalBytes - bytesUsed; + const { lines, used } = readCapped(real, budgetLeft, caps.maxLineBytes); + bytesUsed += used; + if (used > budgetLeft) walk.truncated = true; // the file was cut by the byte budget + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ""); + const parsed = splitKeywordArg(line); + if (!parsed) continue; + const { keyword, arg } = parsed; + if (keyword === "host") { + const block = openBlock(); + block.tokens = arg + .split(/\s+/) + .filter((t) => t !== "") + .map(peelQuotes); + } else if (keyword === "include") { + const tokens = arg + .split(/\s+/) + .filter((t) => t !== "") + .map(peelQuotes); + for (const token of tokens) { + for (const included of expandIncludeGlob(token, real, homeDir, caps.maxIncludeMatches)) { + visit(included); + } + } + } else if (keyword === "match") { + // `Match exec …` runs a command locally (the §2 execution fact); the + // criteria keyword pair is what matters, not the command. + const criteria = arg.toLowerCase(); + if (/(^|\s)exec(\s|$)/.test(criteria)) walk.matchExecSeen = true; + } else if (keyword !== "") { + (currentBlock ? currentBlock.directives : walk.globalDirectives).push({ keyword, value: arg }); + } + } + active.delete(real); + }; + + visit(rootFile); + return walk; +} + +/** + * The `ssh_discover_aliases` engine: walk the account's config and answer the + * deduplicated, sorted alias names under the wire cap. Returns NAMES only — + * the frozen result envelope carries no config content, and neither does this. + * + * @param opts.homeDir - the connecting account's home (default: this process's) + * @param opts.configPath - the account's config file (default: `/.ssh/config`) + * @param opts.budget - walk budgets + */ +export function discoverSshAliases( + opts: { homeDir?: string; configPath?: string; budget?: SshWalkBudget } = {}, +): SshAliasDiscovery { + const homeDir = opts.homeDir ?? homedir(); + const walk = walkSshConfig(opts.configPath ?? defaultSshConfigPath(homeDir), homeDir, opts.budget); + const names = new Set(); + for (const block of walk.hostBlocks) { + for (const token of block.tokens) { + if (isDiscoverableAlias(token)) names.add(token); + } + } + let truncated = walk.truncated; + let aliases = [...names].sort(); + if (aliases.length > SSH_MAX_DISCOVERED_ALIASES) { + aliases = aliases.slice(0, SSH_MAX_DISCOVERED_ALIASES); + truncated = true; + } + return { aliases, includeCycle: walk.includeCycle, truncated }; +} diff --git a/packages/pane-runtime/src/ssh/ssh-resolve.ts b/packages/pane-runtime/src/ssh/ssh-resolve.ts new file mode 100644 index 000000000..e9cd5d390 --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-resolve.ts @@ -0,0 +1,366 @@ +import { existsSync } from "node:fs"; +import { userInfo } from "node:os"; +import { + type NodeSshResolveOutcomeWire, + parseSshConnectionSnapshot, + SSH_MAX_PROXY_HOPS, + SSH_PROBE_DEADLINE_MS, + type SshConnectionSnapshotWire, + type SshErrorCode, + type SshHopWire, +} from "@internal/subshell-protocol"; +import { + defaultSshConfigPath, + expandTilde, + type SshHostBlock, + type SshWalkBudget, + walkSshConfig, +} from "./ssh-discover.js"; +import { runSshProcess, sshChildPath } from "./ssh-spawn.js"; + +/** + * Resolution: alias in, approved snapshot out (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, + * section 5.2): human-only resolution followed by an approved normalized + * configuration. + * + * The evaluator is OpenSSH itself — `ssh -G ` prints the effective + * config for a destination without connecting, which is the only answer that + * honors include precedence, defaults, and `Match` the way the real client + * would apply them. What this module adds is the FILTER: every resolved + * setting is either represented in the frozen snapshot grammar or refused by + * name, and the finished snapshot is re-validated through + * {@link parseSshConnectionSnapshot} before it is ever returned, so the + * resolver cannot emit a shape the wire would later reject. + * + * **Disclosure this step owes the human:** `ssh -G` EVALUATES the account's + * config, which means a trusted `Match exec` can run a LOCAL command during + * resolution. The structural walk runs BEFORE the eval and refuses every + * `Match exec` it sees, so a seen one never reaches `-G` here at all. The + * disclosure covers what the walk could not see: its byte, + * file and glob-match caps mean a `Match exec` past a cap is invisible to the + * walk but not to OpenSSH, and `-G` runs it. Plan 2's wire JSDoc and UI copy + * must say so before the button. The doctrine (ssh-config.ts: "Configuration + * here is executable, not passive data") is this engine's Gate A contract, + * carried verbatim; the governing design is + * docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md section 5.2. + */ + +/** Inputs {@link resolveSshAliasConfig} needs; every one is injectable so tests never touch a developer's `~/.ssh`. */ +export interface SshResolutionDeps { + /** Absolute path of the ssh(1) binary (the agent resolves the ladder; nothing here re-searches PATH to pick the executable). */ + sshBin: string; + /** The connecting account's home directory (HOME for the child, `~` expansion). */ + homeDir: string; + /** The account's config file (default `/.ssh/config`). */ + configPath?: string; + /** Process env whose `SSH_AUTH_SOCK` participates in agent resolution (default: this process's). */ + env?: Record; + /** The connecting OS account name (display fact); default `os.userInfo().username` when obtainable. */ + connectingAccount?: string; + /** Overall deadline for the `ssh -G` evaluation. */ + timeoutMs?: number; + /** Config-walk budgets (tests pass small ones). */ + walkBudget?: SshWalkBudget; +} + +/** One line of `ssh -G` output, lowercased keyword + raw argument string. */ +type GLine = { keyword: string; value: string }; + +/** Refusal with no config keywords to name (the code itself is the whole diagnosis). */ +function refuse(code: SshErrorCode, settings: string[] = []): NodeSshResolveOutcomeWire { + return { accepted: false, code, settings }; +} + +/** Parse `-G` stdout: one `keyword value...` line each; blank/continuation-free by OpenSSH's own format. */ +function parseDashG(stdout: string): GLine[] { + const lines: GLine[] = []; + for (const raw of stdout.split("\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + const cut = line.search(/\s/); + if (cut === -1) lines.push({ keyword: line.toLowerCase(), value: "" }); + else lines.push({ keyword: line.slice(0, cut).toLowerCase(), value: line.slice(cut + 1).trim() }); + } + return lines; +} + +/** All values of any of the accepted spellings, whitespace-split (the list options' own delimiter). */ +function valuesOf(g: GLine[], ...keywords: string[]): string[] { + const out: string[] = []; + for (const line of g) { + if (keywords.includes(line.keyword)) out.push(...line.value.split(/\s+/).filter((v) => v !== "")); + } + return out; +} + +/** True when any value of the keyword is "set" (not none/off/no/0 — the option's unconfigured spelling). */ +function setWhere(g: GLine[], keyword: string, unconfigured: readonly string[] = ["none"]): boolean { + return setWhereAny(g, [keyword], unconfigured); +} + +function setWhereAny(g: GLine[], keywords: readonly string[], unconfigured: readonly string[] = ["none"]): boolean { + return valuesOf(g, ...keywords).some((v) => !unconfigured.includes(v.toLowerCase())); +} + +/** + * The forward keyword, BOTH ways. OpenSSH's `ssh -G` echoes the option + * keyword, and the historical/config spellings of these three differ across + * versions (`LocalForward` vs the `locallyforward` echo, likewise remote) — + * accepting both spellings means a version drift can only ever make us + * REFUSE on a present directive, never miss one. Missing a forward would be + * the silent-omission defect §2 names. + */ +const FORWARD_LOCAL_KEYWORDS = ["localforward", "locallyforward"]; +const FORWARD_REMOTE_KEYWORDS = ["remoteforward", "remotelyforward"]; +const FORWARD_DYNAMIC_KEYWORDS = ["dynamicforward"]; + +/** Parse one ProxyJump token `[user@]host[:port]` into the frozen hop shape; bracketed IPv6 keeps its brackets (the snapshot grammar wants them). */ +export function parseProxyHop(token: string, defaultUser: string | null): SshHopWire | null { + if (token === "") return null; + let rest = token; + let user: string | null = null; + const at = rest.lastIndexOf("@"); + if (at > 0) { + user = rest.slice(0, at); + rest = rest.slice(at + 1); + if (user === "") user = null; + } else if (at === 0) { + return null; // "@host" — an empty user is not a route fact to store + } + let port: number | null = null; + let host = rest; + if (rest.startsWith("[")) { + const close = rest.indexOf("]"); + if (close === -1) return null; + host = rest.slice(0, close + 1); + const tail = rest.slice(close + 1); + if (tail.startsWith(":")) { + port = Number(tail.slice(1)); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + } else if (tail !== "") { + return null; + } + } else { + const colon = rest.lastIndexOf(":"); + if (colon !== -1) { + host = rest.slice(0, colon); + port = Number(rest.slice(colon + 1)); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + } + } + if (host === "") return null; + return { host, user: user ?? defaultUser, port: port ?? 22 }; +} + +/** Case-insensitive alias membership among a Host block's pattern tokens (negations excluded — `!x` never makes a block match x). */ +function blockMatchesAlias(block: SshHostBlock, alias: string): boolean { + return block.tokens.some((t) => !t.startsWith("!") && !/[?*]/.test(t) && t.toLowerCase() === alias.toLowerCase()); +} + +/** + * Resolve one alias on this machine into the approved snapshot, or a refusal + * naming WHY (§2: "refuse with a named limitation rather than silently + * changing connection semantics"). A refusal is an ACCEPTED command answer + * (it rides `accepted:false`, in-the-data) so the human can read which + * setting blocked and go edit the config. + * + * @param alias - the config token the human chose (also a manual destination + * when the config has no block for it and the token is itself a hostname) + * @param deps - binary path, home, config path, env, budgets (all injectable) + */ +export async function resolveSshAliasConfig( + alias: string, + deps: SshResolutionDeps, +): Promise { + // Shape gate FIRST (defense-in-depth beside the wire parser): this string + // reaches `ssh -G` as an argv tail — an option-like or control-bearing + // token must die here, not at getopt. + if (alias.length === 0 || alias.length > 253 || alias.startsWith("-") || /\s|\p{Cc}/u.test(alias)) { + return refuse("config_missing"); + } + const configPath = deps.configPath ?? defaultSshConfigPath(deps.homeDir); + const env = deps.env ?? (process.env as Record); + const connectingAccount = deps.connectingAccount ?? safeUsername(); + + // The structural walk happens before the evaluation: `Match exec` is a + // config-shape fact the walk sees without running anything, and alias + // membership (manual alias vs config alias) comes from the same walk the + // discovery command answers with — one parser, one truth about "in config". + const walk = walkSshConfig(configPath, deps.homeDir, deps.walkBudget); + if (walk.matchExecSeen) return refuse("unsupported_setting", ["Match exec"]); + + const argv = [deps.sshBin, "-G"]; + if (existsSync(configPath)) argv.push("-F", configPath); + argv.push(alias); + const childEnv: Record = { + PATH: await sshChildPath(), + HOME: deps.homeDir, + ...(env.LANG ? { LANG: env.LANG } : {}), + ...(env.TMPDIR ? { TMPDIR: env.TMPDIR } : {}), + ...(env.SSH_AUTH_SOCK ? { SSH_AUTH_SOCK: env.SSH_AUTH_SOCK } : {}), + }; + for (const [k, v] of Object.entries(env)) if (k.startsWith("LC_") && v) childEnv[k] = v; + const run = await runSshProcess(argv, childEnv, deps.timeoutMs ?? SSH_PROBE_DEADLINE_MS); + if (run.timedOut || run.spawnError || run.code !== 0) { + // The evaluator could not be brought to answer: the destination cannot be + // reduced to one reviewable route. `config_ambiguous` is the frozen code + // for "cannot be uniquely resolved"; settings carries ssh's own last line + // so the human sees a hint that is not a diagnosis (never a config blob). + const last = run.stderr.trim().split("\n").pop() ?? ""; + return refuse("config_ambiguous", last === "" ? [] : [`ssh -G failed: ${last.slice(0, 200)}`]); + } + const g = parseDashG(run.stdout); + + // --- forbidden settings: any of these present at RESOLUTION fails setup --- + const blocked: string[] = []; + if (setWhere(g, "proxycommand")) blocked.push("ProxyCommand"); + if (setWhereAny(g, FORWARD_LOCAL_KEYWORDS)) blocked.push("LocalForward"); + if (setWhereAny(g, FORWARD_REMOTE_KEYWORDS)) blocked.push("RemoteForward"); + if (setWhereAny(g, FORWARD_DYNAMIC_KEYWORDS)) blocked.push("DynamicForward"); + // The unconfigured spellings are the ones OpenSSH's `-G` PRINTS for an + // unconfigured option, measured on 10.2p1: `tunnel false` and + // `permitremoteopen any`. A config-set value is whatever the config chose + // (`tunnel yes`, `permitremoteopen hosts:socks:1080`), which lands outside + // these lists and blocks. The historical `no` spelling stays accepted: + // older `-G` output and hand-fed fixtures use it. + if (setWhere(g, "tunnel", ["no", "false"])) blocked.push("Tunnel"); + if (setWhere(g, "permitremoteopen", ["none", "any"])) blocked.push("PermitRemoteOpen"); + if (setWhere(g, "permitlocalcommand", ["no"])) blocked.push("PermitLocalCommand"); + if (setWhere(g, "localcommand")) blocked.push("LocalCommand"); + if (setWhere(g, "remotecommand")) blocked.push("RemoteCommand"); + if (g.some((l) => l.keyword === "sendenv" && l.value !== "")) blocked.push("SendEnv"); + if (g.some((l) => l.keyword === "setenv" && l.value !== "")) blocked.push("SetEnv"); + if (setWhere(g, "knownhostscommand")) blocked.push("KnownHostsCommand"); + if (blocked.length > 0) return refuse("unsupported_setting", blocked); + // NOTE on what is NOT refused here: ForwardAgent/ForwardX11/ControlPath/ + // EscapeChar/GSSAPI found in config are killed by the MANDATORY runtime + // policy (ssh-render.ts applies it to every hop regardless), so they change + // nothing the human approved and stay representable. A ProxyCommand has no + // kill switch — it IS the route — so it fails setup instead. That asymmetry + // is the grammar's, not an accident. + + // --- destination facts --- + const hostValues = valuesOf(g, "hostname"); + const hostLine = valuesOf(g, "host")[0] ?? alias; + const host = hostValues[0] ?? hostLine; + const matchedBlocks = walk.hostBlocks.filter((b) => blockMatchesAlias(b, alias)); + const inConfig = matchedBlocks.length > 0; + if (!inConfig) { + // Manual-alias path: only legitimate when OpenSSH itself treated the + // token as the hostname (no config rewrite happened). Anything else means + // the token is neither an alias nor a destination the account can reach. + if (host.toLowerCase() !== alias.toLowerCase()) return refuse("config_missing"); + } else { + // Two blocks setting CONFLICTING destination facts cannot be reduced to + // one route a human reviewed (first-obtained-wins makes -G deterministic, + // but the ORDER that decided it is config-invisible noise the reviewer + // would never catch) — §2's config_ambiguous. One HostName plus one Port + // is NOT a conflict (measured on 10.2p1: the shared-set spelling refused + // every `HostName x` + `Port n` block, the ordinary config shape) — each + // family is counted on its own, and only a family with two different + // values is the ambiguity this check names. + const hostNames = new Set(); + const ports = new Set(); + for (const b of [...walk.globalDirectives, ...matchedBlocks.flatMap((m) => m.directives)]) { + if (b.keyword === "hostname" && b.value !== "") hostNames.add(b.value.toLowerCase()); + if (b.keyword === "port" && b.value !== "") ports.add(b.value); + } + if (hostNames.size > 1 || ports.size > 1) return refuse("config_ambiguous"); + } + + const portRaw = Number(valuesOf(g, "port")[0] ?? "22"); + const port = Number.isInteger(portRaw) ? portRaw : NaN; + const userRaw = valuesOf(g, "user")[0] ?? null; + // -G always prints SOME user (the connecting account's own default); null + // means exactly that default, so the snapshot stores null for it and a real + // value only when the config chose a different account. + const user = userRaw !== null && userRaw.toLowerCase() !== (connectingAccount ?? "").toLowerCase() ? userRaw : null; + + // --- trust + identity refs (paths on THIS machine; the snapshot carries refs, never contents) --- + // `-G` prints the DEFAULT identity files in tilde form (`identityfile + // ~/.ssh/id_rsa`); the snapshot grammar takes only absolute paths, so the + // tilde expands against the same home ssh itself would expand it against. + // `~user/...` does not expand: the parser refuses the ref whole, which is + // fail-closed, not a silent route change. + const refPaths = (keyword: string): string[] => + valuesOf(g, keyword) + .filter((v) => v !== "" && v !== "none") + .map((v) => expandTilde(v, deps.homeDir)); + const identityFiles = refPaths("identityfile"); + const certificateFiles = refPaths("certificatefile"); + const knownHostsFiles = refPaths("userknownhostsfile"); + const hostKeyAliasRaw = valuesOf(g, "hostkeyalias")[0] ?? "none"; + const hostKeyAlias = hostKeyAliasRaw !== "none" && hostKeyAliasRaw !== "" ? hostKeyAliasRaw : null; + + // --- agent: ONLY an absolute socket from the account's trusted setup (an + // explicit IdentityAgent path, else the account's own SSH_AUTH_SOCK) --- + const identityAgent = valuesOf(g, "identityagent")[0] ?? null; + let authAgentSocket: string | null = null; + if (identityAgent?.startsWith("/")) { + authAgentSocket = identityAgent; + } else if (identityAgent !== "none" && env.SSH_AUTH_SOCK?.startsWith("/")) { + // Everything that is not a path and not `none` — the explicit `ssh-agent` + // spelling, any future one, and the keyword's absence — means "use the + // account's agent", whose socket is exactly the account's env + // `SSH_AUTH_SOCK`. `none` above is the only way to say "no agent"; the + // env sock is trusted because it is the connecting account's own setup, + // and the child env carries it only when the snapshot names a socket. + authAgentSocket = env.SSH_AUTH_SOCK; + } + + // --- jump chain: comma-separated per directive, all directives concatenated in config order --- + const hops: SshHopWire[] = []; + for (const line of g.filter((l) => l.keyword === "proxyjump")) { + for (const token of line.value + .split(",") + .map((t) => t.trim()) + .filter((t) => t !== "")) { + const hop = parseProxyHop(token, user); + if (!hop) return refuse("config_ambiguous", ["ProxyJump"]); + hops.push(hop); + } + } + if (hops.length > SSH_MAX_PROXY_HOPS) return refuse("proxy_chain_too_long"); + + const candidate = { + alias, + host, + user, + port, + identityFiles, + certificateFiles, + authAgentSocket, + knownHostsFiles, + hostKeyAlias, + proxyJumps: hops, + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + } satisfies SshConnectionSnapshotWire; + + const approved = parseSshConnectionSnapshot(candidate); + if (approved === null) { + // The normalized facts do not fit the grammar (a hostname with spaces in + // config, an out-of-range port, a relative identity path from a broken + // build): unrepresentable, and refused whole. + return refuse("config_ambiguous"); + } + return { + accepted: true, + snapshot: approved, + ...(connectingAccount !== undefined ? { connectingAccount } : {}), + }; +} + +function safeUsername(): string | undefined { + try { + return userInfo().username; + } catch { + return undefined; + } +} diff --git a/packages/pane-runtime/src/ssh/ssh-spawn.ts b/packages/pane-runtime/src/ssh/ssh-spawn.ts new file mode 100644 index 000000000..44baaeebf --- /dev/null +++ b/packages/pane-runtime/src/ssh/ssh-spawn.ts @@ -0,0 +1,133 @@ +/** + * One ssh child, run to completion, captured, with a deadline that holds. + * + * This is the short-RPC sibling of the brokered-session supervisor: + * `ssh -G` resolution and the session probe both finish or die inside a + * bounded window and their stdout/stderr + * ARE the answer, so they need capture, not a streaming output store. The + * deadline posture is `run-bounded.ts`'s (kill AND cancel the readers, then + * escalate to SIGKILL — killing the child does not close pipes its children + * hold), simplified to a captured two-stream read because nothing here + * streams lines; a chatty ssh cannot matter because `-G` and a bounded probe + * produce a fixed small shape, and the capture caps keep even a pathological + * child inside the RPC envelope. + */ + +import { loginPathEntries } from "../login-path.js"; + +/** Captured-output cap per stream for the short ssh RPCs (config dumps are small; a runaway is capped, not followed). */ +const CAPTURE_CAP_BYTES = 256 * 1024; + +/** Escalation gap between SIGTERM and SIGKILL on the deadline path. */ +const KILL_GRACE_MS = 2_000; + +/** The outcome of one short ssh process. */ +export interface SshProcessResult { + /** Exit code, or null when the process was killed by a signal or never spawned. */ + code: number | null; + stdout: string; + stderr: string; + /** The deadline ended the run. */ + timedOut: boolean; + /** The child could not be spawned at all (no such binary); the message is on stderr. */ + spawnError: boolean; +} + +/** + * Run `argv` to completion or deadline, capturing both streams separately. + * + * @param argv - the command; argv[0] must be an absolute path — the callers + * resolve `ssh` through the binary ladder BEFORE calling, and nothing here + * consults PATH to pick an executable (the §2 "explicit argv" rule). + * @param env - the COMPLETE child environment; no inheritance, by design + * @param timeoutMs - hard deadline; the child is terminated at it + */ +export async function runSshProcess( + argv: readonly string[], + env: Record, + timeoutMs: number, +): Promise { + let proc: Bun.Subprocess<"ignore", "pipe", "pipe">; + try { + proc = Bun.spawn([...argv], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env, + }) as Bun.Subprocess<"ignore", "pipe", "pipe">; + } catch (err) { + return { + code: null, + stdout: "", + stderr: err instanceof Error ? err.message : String(err), + timedOut: false, + spawnError: true, + }; + } + const outReader: CappedReader = proc.stdout.getReader(); + const errReader: CappedReader = proc.stderr.getReader(); + let timedOut = false; + let escalate: ReturnType | undefined; + const stop = (): void => { + timedOut = true; + proc.kill(); + void outReader.cancel().catch(() => {}); + void errReader.cancel().catch(() => {}); + escalate ??= setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch { + // already reaped + } + }, KILL_GRACE_MS); + escalate.unref?.(); + }; + const timer = setTimeout(stop, timeoutMs); + timer.unref?.(); + try { + const [stdout, stderr] = await Promise.all([drainCapped(outReader), drainCapped(errReader)]); + const code = await proc.exited; + return { code: timedOut ? null : code, stdout, stderr, timedOut, spawnError: false }; + } catch { + return { code: null, stdout: "", stderr: "", timedOut, spawnError: false }; + } finally { + clearTimeout(timer); + if (escalate !== undefined) clearTimeout(escalate); + } +} + +/** The minimal reader shape the drain needs (structural, like `run-bounded.ts`'s — the exact reader class varies by stream generic). */ +interface CappedReader { + read(): Promise<{ done?: boolean; value?: Uint8Array }>; + cancel(): Promise; +} + +/** Read a stream up to the cap; past the cap KEEP DRAINING (a child blocked on a full, unread pipe never exits). */ +async function drainCapped(reader: CappedReader): Promise { + const chunks: Uint8Array[] = []; + let kept = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value !== undefined && kept < CAPTURE_CAP_BYTES) { + chunks.push(value); + kept += value.byteLength; + } + } + } catch { + // cancelled by the deadline: return what arrived + } + return new TextDecoder().decode(Buffer.concat(chunks)); +} + +/** + * The PATH value an ssh child gets: this process's PATH plus the login + * shell's entries (a systemd-launched daemon's PATH is baked at install time + * and cannot see a Homebrew /usr/local/bin ssh installed later). Exposed so + * the supervised run's spawn and the short RPCs state the PATH rule once. + */ +export async function sshChildPath(): Promise { + const entries = [...(process.env.PATH ?? "").split(":"), ...(await loginPathEntries())].filter(Boolean); + return [...new Set(entries)].join(":"); +} diff --git a/packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts b/packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts new file mode 100644 index 000000000..90e798278 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts @@ -0,0 +1,120 @@ +/** + * Shared SSH fixtures (Gate A, pruned with the destination product by + * Workstream C: the run/terminal/test/control factories deleted with their + * wire arms). Workstreams B, C, D and G build their suites + * from THESE factories rather than hand-rolling frames: a fixture that stops + * parsing is a contract break caught in the protocol package, not four silent + * drifts downstream. Every factory returns the WIRE type (plain data), every + * "boundary" export is the exact legal extreme, and `invalidSnapshotVariants` + * is the adversarial set the node-side validator must refuse - one member per + * absent-forbidden field plus the hygiene refusals. + * + * Pure data + spread overrides; imports nothing from `bun:test` so production + * code could (but should not) share it. + */ + +import type { SshConnectionSnapshotWire, SshHopWire } from "../../ssh-config.js"; +import type { SshErrorCode } from "../../ssh-errors.js"; +import type { SshDiscoverAliasesCommand, SshResolveConfigCommand } from "../../ssh-frames.js"; +import { SSH_MAX_IDENTITY_REFS, SSH_MAX_PROXY_HOPS } from "../../ssh-limits.js"; +import type { NodeSshAliasListResult, NodeSshResolveOutcomeWire } from "../../ssh-results.js"; + +/** A well-formed hop (fixture arg for chains). */ +export function makeHop(n = 0): SshHopWire { + return { host: `jump-${n}.example.net`, user: n % 2 === 0 ? `jumpuser${n}` : null, port: 22 }; +} + +/** The canonical approved snapshot: two identities, an agent, two known-hosts files, one hop. */ +export function makeSnapshot(overrides: Partial = {}): SshConnectionSnapshotWire { + return { + alias: "app02", + host: "app-02.example.net", + user: "deploy", + port: 22, + identityFiles: ["/home/deploy/.ssh/id_ed25519", "/home/deploy/.ssh/deploy_key"], + certificateFiles: ["/home/deploy/.ssh/id_ed25519-cert.pub"], + authAgentSocket: "/run/user/1000/ssh-agent.sock", + knownHostsFiles: ["/home/deploy/.ssh/known_hosts", "/etc/ssh/ssh_known_hosts"], + hostKeyAlias: null, + proxyJumps: [makeHop(0)], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...overrides, + }; +} + +/** + * An invalid snapshot per forbidden field, with a realistic offending value. + * The node validator must refuse EVERY entry here, and the resolve/UI story + * must be able to name which field blocked. + */ +export function invalidSnapshotVariants(): { field: string; snapshot: Record }[] { + const base = makeSnapshot(); + return [ + { field: "proxyCommand", snapshot: { ...base, proxyCommand: "nc proxy 8443" } }, + { field: "forwards", snapshot: { ...base, forwards: "LocalForward 9000 socks" } }, + { field: "tunnels", snapshot: { ...base, tunnels: "yes" } }, + { field: "localCommands", snapshot: { ...base, localCommands: "notify-send up" } }, + { field: "remoteCommand", snapshot: { ...base, remoteCommand: "sh -i" } }, + { field: "sendEnv", snapshot: { ...base, sendEnv: "LANG LC_*" } }, + { field: "setEnv", snapshot: { ...base, setEnv: "FOO=bar" } }, + { field: "escapes", snapshot: { ...base, escapes: "~C" } }, + ]; +} + +/** Hygiene violations the snapshot parser must refuse (beyond the forbidden members). */ +export function invalidSnapshotHygieneVariants(): { field: string; snapshot: Record }[] { + const base = makeSnapshot(); + return [ + { field: "optionLikeHost", snapshot: { ...base, host: "-oProxyCommand=touch_pwned" } }, + { field: "controlCharUser", snapshot: { ...base, user: "deploy\x00" } }, + { field: "relativeIdentity", snapshot: { ...base, identityFiles: [".ssh/id_ed25519"] } }, + { field: "zeroPort", snapshot: { ...base, port: 0 } }, + { + field: "overMaxHops", + snapshot: { ...base, proxyJumps: Array.from({ length: SSH_MAX_PROXY_HOPS + 1 }, (_, i) => makeHop(i)) }, + }, + { + field: "identityListTooLong", + snapshot: { + ...base, + identityFiles: Array.from({ length: SSH_MAX_IDENTITY_REFS + 1 }, (_, i) => `/home/deploy/.ssh/id_${i}`), + }, + }, + { field: "missingUserKey", snapshot: Object.fromEntries(Object.entries(base).filter(([k]) => k !== "user")) }, + ]; +} + +/* ------------------------------------------------------------------ */ +/* commands */ +/* ------------------------------------------------------------------ */ + +/** The one input-free command, spelled for census loops. */ +export function makeDiscoverAliases(): SshDiscoverAliasesCommand { + return { type: "ssh_discover_aliases" }; +} + +export function makeResolveConfig(overrides: Partial = {}): SshResolveConfigCommand { + return { type: "ssh_resolve_config", alias: "app02", ...overrides }; +} + +export function makeAliasList(overrides: Partial = {}): NodeSshAliasListResult { + return { aliases: ["app01", "app02", "staging"], includeCycle: false, truncated: false, ...overrides }; +} + +export function makeResolveOk(snapshot = makeSnapshot()): NodeSshResolveOutcomeWire { + return { accepted: true, snapshot }; +} + +export function makeResolveRefused( + code: SshErrorCode = "unsupported_setting", + settings = ["ProxyCommand"], +): NodeSshResolveOutcomeWire { + return { accepted: false, code, settings }; +} diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 6894a0f12..474a10261 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -310,7 +310,14 @@ describe("parseNodeCommandBody", () => { // 15 is the archive-transfer surface (spec 2026-10-01 §2): five commands // (archive_create, file_read, transfer_write, archive_extract, // tree_manifest) plus the window/entry constants both ends must agree on. - expect(NODE_PROTOCOL_VERSION).toBe(15); + // 16 is the node SSH capability gate (spec 2026-10-07 §4.3): one + // owner-controlled flag per machine, off by default and fail-closed — + // `set_ssh_enabled` carries the plane's value, `ready.sshEnabled` states + // the node's mirror, the `ssh_enabled` event reports a moved file. The + // 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); }); it("accepts set_allowed_dirs and rejects a missing or non-array dirs", () => { @@ -844,6 +851,61 @@ describe("maintenance (arrived at protocol 8)", () => { }); }); +// The maintenance frames' sibling set (spec 2026-10-07 §4.3): same three +// carriers, one writer (the plane) instead of two, and the fail-closed +// default inverted — an absent mirror refuses. +describe("ssh gate (arrived at protocol 16)", () => { + const base = { + type: "ready", + agentVersion: MIN_NODE_VERSION, + protocolVersion: NODE_PROTOCOL_VERSION, + os: "linux", + arch: "x64", + hostname: "h", + dataDir: "/d", + capabilities: [], + }; + const state = { on: true, changedAt: "2026-10-07T10:00:00.000Z" }; + + it("carries the mirror on ready, and drops a malformed one without refusing the ready", () => { + // Lenient exactly like `maintenance`: the rest of the frame brings the + // node online, and the plane's answer to silence is its own row — a row + // that says on pushes the value down, so a dropped field strands nobody. + expect(parseNodeEvent({ ...base, sshEnabled: state })).toMatchObject({ type: "ready", sshEnabled: state }); + expect(parseNodeEvent(base)).toMatchObject({ type: "ready" }); + const bad = parseNodeEvent({ ...base, sshEnabled: { on: "yes" } }); + expect(bad?.type).toBe("ready"); + expect(bad && "sshEnabled" in bad ? bad.sshEnabled : undefined).toBeUndefined(); + }); + + it("parses the standalone event strictly — it is the mid-session carrier", () => { + expect(parseNodeEvent({ type: "ssh_enabled", ...state })).toEqual({ type: "ssh_enabled", ...state }); + expect(parseNodeEvent({ type: "ssh_enabled", on: false, changedAt: state.changedAt })).toEqual({ + type: "ssh_enabled", + on: false, + changedAt: state.changedAt, + }); + expect(parseNodeEvent({ type: "ssh_enabled", on: true })).toBeNull(); + expect(parseNodeEvent({ type: "ssh_enabled", changedAt: state.changedAt })).toBeNull(); + expect(parseNodeEvent({ type: "ssh_enabled", on: "yes", changedAt: state.changedAt })).toBeNull(); + }); + + it("parses the set_ssh_enabled command and refuses a partial one", () => { + expect(parseNodeCommandBody({ type: "set_ssh_enabled", ...state })).toEqual({ + type: "set_ssh_enabled", + ...state, + }); + expect(parseNodeCommandBody({ type: "set_ssh_enabled", on: false, changedAt: state.changedAt })).toEqual({ + type: "set_ssh_enabled", + on: false, + changedAt: state.changedAt, + }); + expect(parseNodeCommandBody({ type: "set_ssh_enabled", on: true })).toBeNull(); + expect(parseNodeCommandBody({ type: "set_ssh_enabled", changedAt: state.changedAt })).toBeNull(); + expect(parseNodeCommandBody({ type: "set_ssh_enabled", on: 1, changedAt: state.changedAt })).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__/ssh-config.test.ts b/packages/subshell-protocol/src/__tests__/ssh-config.test.ts new file mode 100644 index 000000000..67aab9922 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-config.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { parseSshConnectionSnapshot, SSH_FORBIDDEN_SNAPSHOT_FIELDS } from "../ssh-config.js"; +import { SSH_MAX_PROXY_HOPS } from "../ssh-limits.js"; +import { + invalidSnapshotHygieneVariants, + invalidSnapshotVariants, + makeHop, + makeSnapshot, +} from "./fixtures/ssh-fixtures.js"; + +/** + * The Gate A snapshot grammar: what the node re-checks on every command, so + * these refusals ARE the security story of §2's "configuration is executable" + * rule at the boundary. A green suite here means a `ProxyCommand` cannot + * reach a resolver's argv through a snapshot, whatever the plane stored. + */ +describe("parseSshConnectionSnapshot", () => { + it("round-trips the canonical snapshot with every field intact", () => { + const snapshot = makeSnapshot(); + expect(parseSshConnectionSnapshot(structuredClone(snapshot))).toEqual(snapshot); + }); + + it("accepts an all-null minimal snapshot (no user, no agent, no hops)", () => { + const minimal = makeSnapshot({ + user: null, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + proxyJumps: [], + }); + expect(parseSshConnectionSnapshot(minimal)).toEqual(minimal); + }); + + it("rebuilt output is not the input object (stray keys die with the copy)", () => { + const input = { ...makeSnapshot(), proxyUseExec: true } as unknown as Record; + const out = parseSshConnectionSnapshot(input); + expect(out).not.toBeNull(); + expect("proxyUseExec" in (out as unknown as Record)).toBe(false); + expect(out).not.toBe(input); + }); + + it("refuses every absent-forbidden member carrying a value", () => { + for (const { field, snapshot } of invalidSnapshotVariants()) { + expect(parseSshConnectionSnapshot(structuredClone(snapshot))).toBeNull(); + // The list is the documented eight; a fixture drift (a variant whose + // field is not in the constant) fails right here, not in a downstream + // matcher that forgot to check one. + expect((SSH_FORBIDDEN_SNAPSHOT_FIELDS as readonly string[]).includes(field)).toBe(true); + } + }); + + it("refuses explicit non-null spellings too (true, 0, empty string: only null is absent)", () => { + for (const value of [true, false, 0, "", [], {}] as unknown[]) { + expect(parseSshConnectionSnapshot({ ...makeSnapshot(), forwards: value })).toBeNull(); + } + }); + + it("hygiene refusals: option-like hosts, control characters, relative paths, bad ports, over-cap lists, missing keys", () => { + for (const { field, snapshot } of invalidSnapshotHygieneVariants()) { + expect(parseSshConnectionSnapshot(structuredClone(snapshot))).toBeNull(); + expect(field).not.toEqual(""); + } + }); + + it("a hop chain exactly at the cap parses; one past it does not", () => { + const hops = Array.from({ length: SSH_MAX_PROXY_HOPS }, (_, i) => makeHop(i)); + expect(parseSshConnectionSnapshot(makeSnapshot({ proxyJumps: hops }))).not.toBeNull(); + expect(parseSshConnectionSnapshot(makeSnapshot({ proxyJumps: [...hops, makeHop(99)] }))).toBeNull(); + }); + + it("an IPv6 literal host and a bracketed alias survive (colons are legal there)", () => { + expect(parseSshConnectionSnapshot(makeSnapshot({ host: "[2001:db8::2]:22" }))).toBeNull(); // a port baked into host is NOT the shape; port is its own field + expect(parseSshConnectionSnapshot(makeSnapshot({ host: "[2001:db8::2]" }))).not.toBeNull(); + }); + + it("rejects null and non-objects whole", () => { + expect(parseSshConnectionSnapshot(null)).toBeNull(); + expect(parseSshConnectionSnapshot("app02")).toBeNull(); + expect(parseSshConnectionSnapshot([])).toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 005d4852e..8a2002a56 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -103,12 +103,14 @@ export { type NodeMaintenanceWire, type NodeRuntimeReport, type NodeServiceVerb, + type NodeSshEnabledWire, type PluginReportWire, type PresetDefinitionWire, parseNodeCommandBody, parseNodeEvent, parseNodeMaintenance, parseNodeRuntimeReport, + parseNodeSshEnabled, partPathOf, } from "./node-frames.js"; export { @@ -226,5 +228,27 @@ export { type SizingPolicy, type ViewerCapacity, } from "./shared-geometry.js"; +export { + parseSshConnectionSnapshot, + SSH_FORBIDDEN_SNAPSHOT_FIELDS, + type SshConnectionSnapshotWire, + type SshHopWire, +} from "./ssh-config.js"; +export { + isSshErrorCode, + SSH_ERROR_CODES, + SSH_ERROR_DESCRIPTIONS, + type SshErrorCode, +} from "./ssh-errors.js"; +export { + SSH_MAX_DISCOVERED_ALIASES, + SSH_MAX_PROXY_HOPS, + SSH_NAME_MAX_CHARS, + SSH_PROBE_DEADLINE_MS, +} from "./ssh-limits.js"; +export type { + NodeSshAliasListResult, + NodeSshResolveOutcomeWire, +} from "./ssh-results.js"; export { MAX_UPLOAD_BYTES } from "./uploads.js"; export { MIN_NODE_VERSION, nodeVersionSupported, semverLt } from "./versions.js"; diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 270ea7108..855ca6c03 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -133,8 +133,22 @@ import type { JsonValue } from "./json.js"; * log-confined beside the generalized `file_read`, and `write_file`'s upload * roots stay exactly as narrow as they are while `transfer_write` arrives as * its own policy-gated sibling. + * + * **15 → 16 is the node SSH capability gate (spec 2026-10-07 §4.3).** One + * owner-controlled flag per machine saying "this machine may take part in + * Subshell SSH at all", off by default and fail-closed: `set_ssh_enabled` + * carries the plane's value to the node's mirror file, `ready.sshEnabled` + * states that mirror at connect, and the `ssh_enabled` event reports a mirror + * that moved underneath the connection. Unlike maintenance there is no + * node-side writer — the plane is the sole author and every reconcile pushes + * the row, never the other way. Additive, and breaking anyway, because the + * gate is exact-match — and breaking is the POINT here rather than a formality + * to shrug through: a pre-16 agent never sees `set_ssh_enabled` and reports no + * `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. */ -export const NODE_PROTOCOL_VERSION = 15; +export const NODE_PROTOCOL_VERSION = 16; /** * The FIRST protocol whose agents verify the publisher signature on an @@ -906,6 +920,28 @@ export type NodeCommandBody = /** The plane's stamp for this value; stored verbatim. */ changedAt: string; } + | { + /** + * Write this node's SSH-capability mirror file (spec 2026-10-07 §4.3). + * + * The plane's ONLY writing copy of the flag — unlike maintenance there + * is no keyboard verb to race, so this is the only frame that ever + * changes the file apart from a repair of a corrupt one. It writes the + * file and nothing else: turning SSH off kills no panes (SSH acts are + * short-lived per-command work, and turning it ON widens nothing this + * machine was already refusing mid-flight). + * + * Carries the plane's own `changedAt` rather than letting the node stamp + * its own: both copies must end byte-identical, so a later `ready` + * report reads as agreement rather than a disagreement this process + * invented. + */ + type: "set_ssh_enabled"; + /** True = this machine may be used for Subshell SSH. */ + on: boolean; + /** The plane's stamp for this value; stored verbatim. */ + changedAt: string; + } | { /** * Replace this agent's own binary and restart into it (spec 2026-09-15 §5.2). @@ -1092,6 +1128,31 @@ export function parseNodeMaintenance(value: unknown): NodeMaintenanceWire | null return { on: value.on, changedAt: value.changedAt }; } +/** + * One node's SSH capability, as it travels in either direction (spec + * 2026-10-07 §4.3) — the same `{ on, changedAt }` shape as maintenance, with + * the default and the reconcile direction inverted: `on` means this machine + * MAY take part in Subshell SSH, and only the plane writes it. + * + * `changedAt` is the stamp of the write that produced `on`, carried verbatim + * in both directions so the two copies stay byte-identical and neither side + * can mistake a relay for a new decision. Nothing here compares stamps: the + * plane is authoritative and every disagreement ends with the plane's value + * pushed down. + */ +export interface NodeSshEnabledWire { + /** True = this machine may be used for Subshell SSH. */ + on: boolean; + /** ISO 8601 stamp of the write that produced this value. */ + changedAt: string; +} + +/** A {@link NodeSshEnabledWire}, or null when the value is not one. */ +export function parseNodeSshEnabled(value: unknown): NodeSshEnabledWire | null { + if (!isRecord(value) || !isBool(value.on) || !isStr(value.changedAt)) return null; + return { on: value.on, changedAt: value.changedAt }; +} + /** Agent → control events, unsigned (socket-authed; spec §3.3). */ /** * One field of a plugin's preset-editor schema, as it travels. @@ -1243,6 +1304,16 @@ export type NodeEvent = * own record, which is the only way that file gets repaired. */ maintenance?: NodeMaintenanceWire; + /** + * This machine's SSH-capability mirror, as its file reads at connect + * (spec 2026-10-07 §4.3). Absent means the node has no file — which for + * SSH reads as REFUSED (fail-closed), not as agreeing with the row. + * + * Dropped rather than fatal when malformed, exactly like `maintenance`: + * the refusal is the safe direction, and the plane reconciles the + * disagreement from its own row on the very same `ready`. + */ + sshEnabled?: NodeSshEnabledWire; } | { /** @@ -1259,6 +1330,24 @@ export type NodeEvent = /** ISO 8601 stamp of the write on the machine. */ changedAt: string; } + | { + /** + * This machine's SSH mirror moved underneath the connection (spec + * 2026-10-07 §4.3) — the heartbeat belt's carrier. + * + * With no node-side writer there is no keyboard flip to report (that is + * what `set_maintenance`'s sibling event exists for); what this carries + * is a file that changed by some other means — chiefly CORRUPTION, which + * flips a machine from "allowed" to "refused" silently and would + * otherwise stay a secret until the next reconnect. The plane's answer + * to any disagreement is a push of its row, which is also the repair. + */ + type: "ssh_enabled"; + /** The refusal/allowance this machine is acting on, as its mirror reads. */ + on: boolean; + /** ISO 8601 stamp from the file (its own mtime when the file is unreadable). */ + changedAt: string; + } | { type: "inventory"; harnesses: { @@ -1507,6 +1596,10 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { const state = parseNodeMaintenance(value); return state ? { type: "set_maintenance", ...state } : null; } + case "set_ssh_enabled": { + const state = parseNodeSshEnabled(value); + return state ? { type: "set_ssh_enabled", ...state } : null; + } case "update": { // Shape only, like `set_server_url` above: WHICH url is acceptable is // the plane's to decide, and the digest is checked against the bytes @@ -1644,19 +1737,26 @@ export function parseNodeEvent(raw: string | object): NodeEvent | null { ) { return null; } - // `runtime` and `maintenance` are additive: a malformed one is dropped - // so the connection still comes up without it, rather than refused. + // `runtime`, `maintenance` and `sshEnabled` are additive: a malformed + // one is dropped so the connection still comes up without it, rather + // than refused. For `sshEnabled` the drop is also SAFE in a way the + // others are not quite: the plane answers silence with its row (a row + // that says on pushes down), so a malformed field cannot strand a + // machine on the wrong side of the gate. const { runtime: rawRuntime, maintenance: rawMaintenance, + sshEnabled: rawSshEnabled, ...rest - } = value as Record & { runtime?: unknown; maintenance?: unknown }; + } = value as Record & { runtime?: unknown; maintenance?: unknown; sshEnabled?: unknown }; const runtime = rawRuntime === undefined ? null : parseNodeRuntimeReport(rawRuntime); const maintenance = rawMaintenance === undefined ? null : parseNodeMaintenance(rawMaintenance); + const sshEnabled = rawSshEnabled === undefined ? null : parseNodeSshEnabled(rawSshEnabled); return { ...(rest as unknown as Extract), ...(runtime ? { runtime } : {}), ...(maintenance ? { maintenance } : {}), + ...(sshEnabled ? { sshEnabled } : {}), }; } case "inventory": { @@ -1675,6 +1775,12 @@ export function parseNodeEvent(raw: string | object): NodeEvent | null { const state = parseNodeMaintenance(value); return state ? { type: "maintenance", ...state } : null; } + case "ssh_enabled": { + // Strict for the same reason: this frame is the machine reporting what + // it is acting on; half of it is not an answer. + const state = parseNodeSshEnabled(value); + return state ? { type: "ssh_enabled", ...state } : null; + } case "heartbeat": return isStr(value.ts) ? { type: "heartbeat", ts: value.ts } : null; case "result": diff --git a/packages/subshell-protocol/src/ssh-config.ts b/packages/subshell-protocol/src/ssh-config.ts new file mode 100644 index 000000000..1fe88695d --- /dev/null +++ b/packages/subshell-protocol/src/ssh-config.ts @@ -0,0 +1,248 @@ +/** + * The approved normalized connection snapshot (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, + * section 5.2). Configuration here is executable, not passive data. + * + * This is the ONLY connection shape the wire ever carries. OpenSSH config can + * start local helpers, forward ports and reuse ambient sockets, so nothing + * here is a passthrough: the human-facing resolution step produces this shape + * once, and the NODE re-validates it with {@link + * parseSshConnectionSnapshot} on EVERY command that embeds a snapshot (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. + * + * The eight absent-forbidden members (`proxyCommand`, `forwards`, `tunnels`, + * `localCommands`, `remoteCommand`, `sendEnv`, `setEnv`, `escapes`) are typed + * as literal `null` ON PURPOSE: they exist in the shape to be refused. A + * resolver that faithfully recorded a `ProxyCommand` produced a value this + * parser rejects, which is the "refuse with a named limitation rather than + * silently changing connection semantics" rule expressed in grammar. The + * runtime check is stricter than the type: any present non-null value fails, + * so hand-built JSON cannot smuggle one past TypeScript. + * + * Hand-rolled in the `node-frames.ts` validator style; imports no `node:` + * builtin; lives in the Metro-safe barrel. + */ + +import { isInt, isRecord, isStr } from "./guards.js"; +import { + SSH_MAX_CERT_REFS, + SSH_MAX_IDENTITY_REFS, + SSH_MAX_KNOWN_HOSTS_FILES, + SSH_MAX_PROXY_HOPS, + SSH_NAME_MAX_CHARS, + SSH_PATH_MAX_CHARS, +} from "./ssh-limits.js"; + +/** + * One ProxyJump hop as the approved snapshot carries it: a destination and + * nothing else. Hops are normalized "under the same restrictions" as the + * final destination (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md) - they never carry identity files or + * options; authentication for the whole chain comes from the snapshot's own + * refs, which is what makes the chain reviewable as a route. + * + * A `type` alias, not an interface: snapshots ride inside JSON command fields, + * and an interface has no implicit index signature. + */ +export type SshHopWire = { + /** Hop hostname (same hygiene as the destination host) */ + host: string; + /** Hop user; null = the connecting account's default */ + user: string | null; + /** Hop port, already resolved (an absent `Port` means 22) */ + port: number; +}; + +/** + * A resolved, approved SSH destination, normalized to the settings the approved + * grammar supports and nothing more. Persisted server-side per connection revision and + * embedded verbatim in every node command that connects. + */ +export interface SshConnectionSnapshotWire { + /** + * The config token the human chose (display and review context only - + * routing uses `host`, and a manual alias with no config behind it still + * carries the destination it resolved to). + */ + alias: string; + /** Resolved destination hostname. Never an option-like string: the validator refuses a leading `-`. */ + host: string; + /** Destination user; null means the connecting account's own default */ + user: string | null; + /** Destination port, resolved (1..65535; a default-until-resolved `Port` never reaches the wire as 0) */ + port: number; + /** + * Identity file references: ABSOLUTE POSIX paths on the CONNECTING NODE, + * never key contents. Credential material stays where the OS put it. + */ + identityFiles: string[]; + /** Certificate file references (OpenSSH allows several `CertificateFile` lines), same path-only rule. */ + certificateFiles: string[]; + /** + * The authentication-agent socket to use: an absolute path from the + * connecting account's trusted setup, or null to run with no agent. An + * already-unlocked agent MAY be used (the key home's agent, per + * docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md); this field is + * the only channel through which one is named. + */ + authAgentSocket: string | null; + /** Known-hosts files to consult. Revocation/CA semantics inside them are the runtime's to preserve; the refs are the snapshot's to carry. */ + knownHostsFiles: string[]; + /** OpenSSH `HostKeyAlias` when the config sets one; the name strict checking looks up. */ + hostKeyAlias: string | null; + /** Bounded ProxyJump chain, outermost first, at most {@link SSH_MAX_PROXY_HOPS} hops. */ + proxyJumps: SshHopWire[]; + /** Literal null: a resolved `ProxyCommand` makes this snapshot REFUSABLE, never renderable. */ + proxyCommand: null; + /** Literal null: `LocalForward`/`RemoteForward`/`DynamicForward` found at resolution fail setup. */ + forwards: null; + /** Literal null: `Tunnel`/`PermitRemoteOpen` found at resolution fail setup. */ + tunnels: null; + /** Literal null: `LocalCommand`, `PermitLocalCommand`, or a `Match exec` found at resolution fail setup. */ + localCommands: null; + /** Literal null: `RemoteCommand` found at resolution fails setup; it would replace the command contract. */ + remoteCommand: null; + /** Literal null: `SendEnv` found at resolution fails setup; no environment rides the link. */ + sendEnv: null; + /** Literal null: `SetEnv` found at resolution fails setup; same reason, remote side. */ + setEnv: null; + /** Literal null: live escape characters fail setup; the ~ menu is an interactive local shell. */ + escapes: null; +} + +/** The absent-forbidden members, in the order the parser refuses them; the resolve `settings` list names these strings back. */ +export const SSH_FORBIDDEN_SNAPSHOT_FIELDS = [ + "proxyCommand", + "forwards", + "tunnels", + "localCommands", + "remoteCommand", + "sendEnv", + "setEnv", + "escapes", +] as const; + +/** One member of {@link SSH_FORBIDDEN_SNAPSHOT_FIELDS}. */ +export type SshForbiddenSnapshotField = (typeof SSH_FORBIDDEN_SNAPSHOT_FIELDS)[number]; + +/** + * Control characters and space: nothing a host, user, or alias may contain on + * any sane SSH stack. Spelled as the Unicode category (C0+C1 controls + * including DEL) because biome refuses the raw `\x00` spellings, and the + * category is the rule stated as its own name anyway. + */ +const NAME_UNSAFE_RE = /\s|\p{Cc}/u; + +/** + * A hostname: a DNS-ish label run (this grammar admits IPv4 literals and + * underscores, which occur in real deployments) OR a bracketed IPv6 literal. + * A port baked onto the host (`[::1]:2222`) is refused on purpose: `port` is + * its own field, and accepting the composed spelling would let a host string + * carry a second authority the reviewer never saw. + */ +const HOST_RE = /^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])$/; + +/** A POSIX-ish username: the characters ssh's `user@host` split and PAM actually meet. */ +const USER_RE = /^[A-Za-z0-9._-]+$/; + +/** + * A wire name (config alias, `HostKeyAlias`): length-capped, no whitespace or + * control characters, and NOT option-like (a leading `-` is how a value + * becomes a flag when argv is assembled - the wire-name injection rule, checked where + * the value is born rather than trusting every later composer). + */ +function isWireName(value: unknown): value is string { + return ( + isStr(value) && + value.length > 0 && + value.length <= SSH_NAME_MAX_CHARS && + !value.startsWith("-") && + !NAME_UNSAFE_RE.test(value) + ); +} + +/** A destination hostname (or bracketed IPv6), by grammar not by hygiene: see {@link HOST_RE}. */ +function isHostName(value: unknown): value is string { + return isStr(value) && value.length <= SSH_NAME_MAX_CHARS && HOST_RE.test(value); +} + +/** A username, by grammar: see {@link USER_RE}. Length is bounded by the same cap. */ +function isUserName(value: unknown): value is string { + return isStr(value) && value.length <= SSH_NAME_MAX_CHARS && USER_RE.test(value); +} + +/** An absolute POSIX path: leading `/`, length-capped, no control characters. Existence and ownership are the node's to check; shape is ours. */ +function isAbsPosixPath(value: unknown): value is string { + return isStr(value) && value.startsWith("/") && value.length <= SSH_PATH_MAX_CHARS && !/\p{Cc}/u.test(value); +} + +/** A resolved port: a positive integer inside the legal range. */ +function isPort(value: unknown): value is number { + return isInt(value) && (value as number) >= 1 && (value as number) <= 65535; +} + +/** A path list within `cap`, or empty. Every ref list shares this grammar. */ +function isPathListWithin(value: unknown, cap: number): value is string[] { + return Array.isArray(value) && value.length <= cap && value.every(isAbsPosixPath); +} + +function isHop(value: unknown): value is SshHopWire { + if (!isRecord(value) || !isHostName(value.host) || !isPort(value.port)) return false; + if (!("user" in value)) return false; + return value.user === null || isUserName(value.user); +} + +/** + * Validates and narrows an arbitrary value into an approved connection + * snapshot. Both sides call it: the plane before it stores or dispatches one, + * the node before it renders a command line from one. A snapshot that fails + * ANY check is refused whole - there is no partial approval, and the + * forbidden-member refusal is deliberately a PARSE failure rather than a + * field drop (dropping a `ProxyCommand` silently is the exact defect the refusal-by-name rule names). + * + * @param value - candidate snapshot (parsed JSON; unknown provenance) + * @returns the narrowed snapshot, or null when malformed or non-approvable + */ +export function parseSshConnectionSnapshot(value: unknown): SshConnectionSnapshotWire | null { + if (!isRecord(value)) return null; + // Forbidden members first: any present non-null value refuses the whole + // snapshot, checked before the shape of everything else so a resolver bug + // surfaces as "this is not approvable", never as "incomplete input". + for (const field of SSH_FORBIDDEN_SNAPSHOT_FIELDS) { + if (value[field] != null) return null; + } + if (!isWireName(value.alias) || !isHostName(value.host) || !isPort(value.port)) return null; + if (!("user" in value) || !(value.user === null || isUserName(value.user))) return null; + if (!isPathListWithin(value.identityFiles, SSH_MAX_IDENTITY_REFS)) return null; + if (!isPathListWithin(value.certificateFiles, SSH_MAX_CERT_REFS)) return null; + if (!isPathListWithin(value.knownHostsFiles, SSH_MAX_KNOWN_HOSTS_FILES)) return null; + if (!("authAgentSocket" in value) || !(value.authAgentSocket === null || isAbsPosixPath(value.authAgentSocket))) + return null; + if (!("hostKeyAlias" in value) || !(value.hostKeyAlias === null || isWireName(value.hostKeyAlias))) return null; + if (!Array.isArray(value.proxyJumps) || value.proxyJumps.length > SSH_MAX_PROXY_HOPS) return null; + for (const hop of value.proxyJumps) if (!isHop(hop)) return null; + // Return a REBUILT object, never the input cast: the eight forbidden fields + // are written as literal null here, so what the caller holds is provably a + // snapshot the grammar can express - stray keys on the input die with it. + return { + alias: value.alias, + host: value.host, + user: value.user === undefined ? null : (value.user as string | null), + port: value.port, + identityFiles: [...(value.identityFiles as string[])], + certificateFiles: [...(value.certificateFiles as string[])], + authAgentSocket: value.authAgentSocket === undefined ? null : (value.authAgentSocket as string | null), + knownHostsFiles: [...(value.knownHostsFiles as string[])], + hostKeyAlias: value.hostKeyAlias === undefined ? null : (value.hostKeyAlias as string | null), + proxyJumps: (value.proxyJumps as SshHopWire[]).map((h) => ({ host: h.host, user: h.user, port: h.port })), + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + }; +} diff --git a/packages/subshell-protocol/src/ssh-errors.ts b/packages/subshell-protocol/src/ssh-errors.ts new file mode 100644 index 000000000..97ee34442 --- /dev/null +++ b/packages/subshell-protocol/src/ssh-errors.ts @@ -0,0 +1,148 @@ +/** + * The named SSH refusal/limit codes, frozen (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md): + * a refused connection names its limitation rather than silently changing + * connection semantics. One set for the whole feature: the node answers a refused + * command with the BARE code as its `result{error}` string when the plane must + * map it (the exact posture of the `NODE_RESULT_*` constants - the plane + * compares `NodeRpcError.detail` by EQUALITY, never a substring, so a refusal + * that wants a typed remedy is this string and nothing else); the resolution + * and test result envelopes carry the same codes in their `code` fields; and + * the server's policy refusals and API error metadata surface them verbatim. + * + * The set was FROZEN after Gate A and is carried verbatim from the reference + * lineage (design 2026-10-05 §7); that lineage's retirement of the destination + * execution product pruned the three quota/storage codes only ITS runtimes + * emitted (`quota_runs`, `quota_terminals`, `storage_full`), a deletion that + * was legal precisely because that lineage's protocol bump never released. + * What this product line answers with today is resolution refusals; the + * session-runtime sentences ride along because the set is carried frozen, not + * because anything on this branch maps them. Adding a code is a coordinated + * change; renaming or deleting one is a wire change like any other. + * + * Imports no `node:` builtin; this module is in the Metro-safe barrel. + */ + +/** The refusal set as a runtime list. Exported beside the type because the parsers, the routes and the SPA all iterate it. */ +export const SSH_ERROR_CODES = [ + /** Resolution found a setting the approved normalization cannot represent (`ProxyCommand`, forwards, tunnels, local commands, `RemoteCommand`, `SendEnv`, `SetEnv`, escapes, …). The resolve answer's `settings` list names which. */ + "unsupported_setting", + /** The alias does not appear in the account's config (or an include) the discovery parsed. */ + "config_missing", + /** The alias matches conflicting `Host` blocks the normalizer cannot uniquely order; a human edits the config or enters the destination differently. */ + "config_ambiguous", + /** Strict host checking found no trust entry for the destination (or a hop). There is no accept endpoint in v1: the human trusts the key on the connecting account outside Subshell. */ + "host_key_unknown", + /** The destination's key does not match the trusted entry. Fail closed; never an automatic re-trust. */ + "host_key_changed", + /** The trusted entry is a revocation marker (`@revoked`) or the certificate chains to a revoked CA. */ + "host_key_revoked", + /** A named identity file is unreadable or needs a passphrase no in-band path can answer (key unlocking lives outside Subshell). */ + "key_unavailable", + /** The destination demands password, MFA or keyboard-interactive auth. v1 is key/certificate auth only. */ + "auth_mode_unsupported", + /** The resolved `ProxyJump` chain exceeds {@link SSH_MAX_PROXY_HOPS}. */ + "proxy_chain_too_long", + /** + * A signed command arrived past its freshness window after a reconnect. + * Rejecting it is the no-automatic-replay rule; the caller re-decides, the + * node never does. RESERVED, emitted nowhere (review M8): the freshness + * window the JWS signature carries is what actually refuses a stale start + * (the signing layer rejects the expired command before any handler runs), + * so no dispatch site names this code. It stays in the frozen set on purpose + * - the wire grammar and every reader's equality table are protocol-frozen + * and a bump cannot reclaim a removed name; the note is what keeps a future + * reader from "fixing" the apparent dead code by emitting it. + */ + "stale_command", + /** A start carried an ID the node already accepted for a DIFFERENT request digest. Durable dedup refused it; nothing was spawned twice. The session-open dedup kept the spelling (design §3). */ + "run_conflict", + /** The node never accepted this ID (or its record expired). A surviving equality code: the session arms answer it for an unknown session ref. */ + "run_unknown", + /** The pre-spawn connect probe failed for no more specific reason - the session open's reachability probe kept this spelling too. The one code that must never be dressed up as a diagnosis. */ + "connection_failed", + /** + * The session-runtime probe found no runtime binary on the destination + * (design 2026-10-05 §3: the `command -v` probe before the child spawns; + * the design names this refusal `SSH_RUNTIME_MISSING`). The remedy is the + * binary install ONLY - never enrollment, never `subshell setup` - and + * `SSH_ERROR_DESCRIPTIONS` keeps it to exactly that sentence. + */ + "runtime_missing", + /** Opening a session was refused at the per-node active-session quota (design §3, sibling of `quota_terminals`). */ + "session_quota", + /** The brokered child spoke something whose first frame is not a runtime hello (design §2: malformed leading bytes fail the open; the broker names it, the plane relays it). */ + "session_protocol", + /** + * The destination already carries a LIVE session: the second serve died at + * its callback-door bind (review m2 - the door refuses a live listener, and + * the bind site is the ONE enforcement point, since a plane-side + * second-session gate would break §6's reopen-and-adopt journey). The + * remedy is the other session - close it, do not touch the binary. + */ + "session_in_use", +] as const; + +/** One named refusal from {@link SSH_ERROR_CODES}. */ +export type SshErrorCode = (typeof SSH_ERROR_CODES)[number]; + +/** Whether `value` is one of the frozen codes. */ +export function isSshErrorCode(value: unknown): value is SshErrorCode { + return typeof value === "string" && (SSH_ERROR_CODES as readonly string[]).includes(value); +} + +/** + * One honest sentence per code, for any surface that must explain a refusal + * it cannot elaborate (MCP prose, SPA detail rows). Shipped strings: plain + * words, no markup, no secrets, and each one names the remedy's LOCATION + * (this account, this config, retry-later) because a code the human cannot + * act on is a bug report waiting to be filed. + */ +export const SSH_ERROR_DESCRIPTIONS: Record = { + unsupported_setting: + "The resolved SSH config needs a setting Subshell will not run (proxy commands, forwarding, tunnels, local commands, remote commands, or environment injection). Edit the config or choose another alias.", + config_missing: "That alias is not in the connecting account's SSH config. Check the spelling or add it there.", + config_ambiguous: + "The alias matches conflicting config blocks that cannot be reduced to one destination. Simplify the config or enter the alias differently.", + host_key_unknown: + "The destination has no trusted host key on the connecting account. Trust it there first; Subshell never accepts keys on your behalf.", + host_key_changed: + "The destination's host key no longer matches the trusted entry. Verify out of band and update the entry on the connecting account.", + host_key_revoked: + "The trusted host key or certificate chain is revoked. Restore a valid trust entry on the connecting account.", + key_unavailable: + "A referenced identity key cannot be used as-is (unreadable, or passphrase-protected). Unlock or reconfigure it on the connecting account.", + auth_mode_unsupported: + "The destination requires password or interactive authentication. Subshell supports key and certificate auth only.", + proxy_chain_too_long: "The jump-host chain is longer than Subshell will review and run. Shorten the ProxyJump path.", + stale_command: "The request expired before it reached the node. Nothing ran; send it again if it should.", + run_conflict: + "A run with this ID already exists with different contents. The earlier request stands; this one was refused.", + run_unknown: + "This node has no record of that run. Its result may never have existed or its history expired; never assume it completed.", + connection_failed: + "The connection test failed. Check the destination, the network, and the connecting account's SSH setup.", + runtime_missing: + "The destination has no Subshell binary. Install the `subshell` binary there (the same install as any node's agent, without enrollment); no account setup or daemon is needed.", + session_quota: "This machine already carries its share of open SSH sessions. Close one first.", + session_protocol: + "The destination answered the session open with something that is not the Subshell runtime's handshake. Check that the named program is the `subshell` binary.", + session_in_use: "This destination already has a live session. Close that session there first, then connect again.", +}; + +/** + * `result.error` from an input or prompt-delivery the node refused because its + * per-pane generation had moved on: a takeover or a revocation fenced it. + * + * THE single frozen wire spelling for a generation refusal. There is + * deliberately no matching member of `SSH_ERROR_CODES` (the Gate A review + * ruled one event, one name): the plane matches `NodeRpcError.detail` against + * THIS constant by equality, exactly like every other bare `NODE_RESULT_*`. + * Relocated here from the retired run-facts module (design 2026-10-05 §7). + * What keeps the spelling alive is the fence's SEAM, not its use: with the + * destination product retired, nothing records a generation, so every pane + * is an ordinary pane under the agent's deny-by-construction rule and this + * refusal cannot fire today. The wire field, the monotonic mirror, and this + * frozen answer are what the next control-transition feature adopts instead + * of reinventing (the honest header: apps/node/agent/src/input-generation.ts). + */ +export const NODE_RESULT_SSH_GENERATION_STALE = "stale input generation"; diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts new file mode 100644 index 000000000..e856d4834 --- /dev/null +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -0,0 +1,91 @@ +/** + * The two surviving SSH node commands (design 2026-10-05 §7): the bounded + * config DISCOVERY and the single-alias RESOLUTION that feed the wizard's + * review step. They ride the existing signed/encrypted command transport + * unchanged - signing proves WHO ordered it, this file freezes WHAT was + * ordered. + * + * The rest of the family the 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`. + * + * The commands are TRANSPORT-AGNOSTIC plain objects: the server-hosted + * `local` node executes the same runtime in-process against these exact + * bodies, so nothing may assume a socket, a jti, or a signature - those are + * the transport's, and they live outside the body. + * + * Hand-rolled in the `node-frames.ts` style; imports no `node:` builtin, 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. + */ + +import { isRecord, isStr } from "./guards.js"; +import { SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; + +/* ------------------------------------------------------------------ */ +/* command bodies */ +/* ------------------------------------------------------------------ */ + +/** + * Bounded parse of the connecting account's SSH config for alias NAMES. Takes + * no input beyond the command itself (which node answers it is the + * transport's, not the body's). Human-only at the API level; the node holds + * no concept of who asked - the signature already did that part. + */ +export interface SshDiscoverAliasesCommand { + type: "ssh_discover_aliases"; +} + +/** Resolve one alias into the approved normalized snapshot (or a named refusal). Human config step. */ +export interface SshResolveConfigCommand { + type: "ssh_resolve_config"; + /** The config token to resolve. Shape-checked here; whether it EXISTS in the account's config is what the command is for. */ + alias: string; +} + +/** Every SSH-family command body, as one union the {@link NodeCommandBody} union folds in. */ +export type SshNodeCommandBody = SshDiscoverAliasesCommand | SshResolveConfigCommand; + +/** Every SSH command `type`, for census tests and dispatch tables. */ +export const SSH_COMMAND_TYPES = ["ssh_discover_aliases", "ssh_resolve_config"] as const; + +/* ------------------------------------------------------------------ */ +/* arm validators (delegated from parseNodeCommandBody) */ +/* ------------------------------------------------------------------ */ + +/** Same hygiene the snapshot applies to names; a config alias is the same kind of string. */ +function isAliasName(value: unknown): value is string { + return ( + isStr(value) && + value.length > 0 && + value.length <= SSH_NAME_MAX_CHARS && + !value.startsWith("-") && + !/\s|\p{Cc}/u.test(value) + ); +} + +/** + * Validates and narrows any `ssh_*` command body. 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 + * the same as the node commands' today: a NON-null return is safe to switch + * on by `type`. + * + * @param value - candidate payload whose `type` starts with `ssh_` + * @returns the narrowed command, or null when malformed + */ +export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | null { + if (!isRecord(value) || !isStr(value.type)) return null; + switch (value.type) { + case "ssh_discover_aliases": + return { type: "ssh_discover_aliases" }; + case "ssh_resolve_config": + return isAliasName(value.alias) ? { type: "ssh_resolve_config", alias: value.alias } : null; + default: + return null; + } +} diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts new file mode 100644 index 000000000..bad12756c --- /dev/null +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -0,0 +1,140 @@ +/** + * The SSH resource limits. The NUMBERS were frozen at the Gate A + * implementation and carried verbatim from the reference lineage (design + * 2026-10-05 §7); the governing design for this product line is + * docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, which sets the + * policy for these budgets without restating the numerals. + * + * Everything here is PURE data: named frozen values plus the derived bounds + * the wire parsers enforce, so neither end of the link can drift. They are + * law, not local convenience - changing one is a coordinated edit - which is + * why they live here rather than in the packages that act on them. + * + * The destination execution product's limits (run deadlines, active-run and + * terminal quotas, output retention and aggregate storage, the read window + * and long-poll caps) deleted with it (design 2026-10-05 §7). Of what + * remains: the grammar bounds are enforced by this tier's parsers and the + * probe deadline by its engines, while the cancel grace and the + * session-runtime table describe the brokered-session subsystem this product + * line deliberately does not carry (spec section 15) - they ride along + * because the set is frozen as a whole, and nothing on this branch enforces + * them. + * + * Imports no `node:` builtin; this module is in the Metro-safe barrel. + */ + +/* ------------------------------------------------------------------ */ +/* frozen at the Gate A implementation (design 2026-10-05 lineage); governing policy: + docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md - one export each */ +/* ------------------------------------------------------------------ */ + +/** + * Connection/setup probe: a 30-second OVERALL deadline, and no automatic + * execution retry ever (a value frozen at the Gate A implementation and + * carried verbatim; the no-retry rule is the durable-dispatch contract, not a + * tuning knob). + */ +export const SSH_PROBE_DEADLINE_MS = 30 * 1000; + +/* ------------------------------------------------------------------ */ +/* derived contract bounds (chosen at the Gate A freeze, parser-enforced) */ +/* ------------------------------------------------------------------ */ + +/** + * Bounded ProxyJump chain (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, section 4.1). + * FOUR is a freeze decision, not a spec value: beyond four hops a normalized + * hop table stops being humanly reviewable, which is the whole point of + * reviewing the route before saving it. Resolution refuses longer chains by + * name (`proxy_chain_too_long`); the hop count a snapshot may carry is + * validated at every command that embeds one. + */ +export const SSH_MAX_PROXY_HOPS = 4; + +/** Identity-file references one approved snapshot may name. Absolute paths, never contents. */ +export const SSH_MAX_IDENTITY_REFS = 8; + +/** Certificate-file references one approved snapshot may name (OpenSSH allows several `CertificateFile` lines). */ +export const SSH_MAX_CERT_REFS = 8; + +/** Known-hosts files one approved snapshot may name (the trust refs the design preserves: docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md, section 9). */ +export const SSH_MAX_KNOWN_HOSTS_FILES = 8; + +/** Aliases one `ssh_discover_aliases` answer may carry; past it the answer sets `truncated`. */ +export const SSH_MAX_DISCOVERED_ALIASES = 500; + +/** Longest config alias / hostname / username the wire carries (253 is DNS's practical ceiling; the SSH token cannot legitimately exceed it). */ +export const SSH_NAME_MAX_CHARS = 253; + +/** Longest absolute POSIX path the wire carries (Linux's PATH_MAX is 4096; a path needing more is not ours to route). */ +export const SSH_PATH_MAX_CHARS = 4094; + +/** + * Grace period a SIGTERM gets before the group is SIGKILLed. Born with the + * structured runs, kept because the session supervisor kills the brokered + * child's GROUP with exactly this grace (design §3). Remote descendants are + * NEVER confirmed dead by any grace period - that honesty survived the run + * family. + */ +export const SSH_CANCEL_GRACE_MS = 5_000; + +/* ------------------------------------------------------------------ */ +/* session-runtime limits (design 2026-10-05 §2/§3) */ +/* ------------------------------------------------------------------ */ + +/** + * Inbound runtime-frame queue bound per session (design §2: 128 frames). + * Overflow CLOSES the session fail-closed - a plane that cannot drain its own + * session must stop the flow at the source rather than buffer without bound. + */ +export const SSH_SESSION_INBOUND_QUEUE_FRAMES = 128; + +/** + * Open deadline: spawn the child AND receive its parsed hello within this + * window, or the open fails - never a half-open session, never a retry (the + * no-automatic-execution-replay rule reads the probe's failure as final here + * too). + */ +export const SSH_SESSION_OPEN_DEADLINE_MS = 30_000; + +/** Active brokered sessions one connecting node may hold, across all owners. */ +export const SSH_SESSIONS_PER_NODE = 8; + +/** + * Longest callback correlation id (`rest_request`/`rest_response` `reqId`) + * the wire carries, printable characters only. The bound is load-bearing + * beyond tidiness: the plane echoes the id back inside a size-capped + * `rest_response` frame, and its answer-truncation budget is measured with + * the ACTUAL id, so the frame-cap arithmetic needs it finite and its escape + * cost knowable BEFORE the answer is sized. The runtime mints uuids (36 + * chars); 64 matches the session-ref ceiling, so no honest id is refused. + */ +export const SSH_REQ_ID_MAX_CHARS = 64; + +/** + * The ONE runtime-safe transfer window, in RAW bytes, for anything whose answer + * rides the session link as base64: a `log_read` window and a live-tail + * `output` chunk alike cap at this. The node link's own windows + * (`LOG_TAIL_BYTES` 256 KiB, `TAIL_CHUNK_BYTES` 192 KiB) are sized for the + * 1 MiB node frame and BREAK the session codec: base64 inflates 4/3, so 256 + * KiB raw is ~349,528 chars and even 192 KiB raw lands at 262,144 chars - + * exactly {@link SSH_SESSION_FRAME_MAX_BYTES} before the JSON envelope is + * glued on - and `encodeSshSessionFrame` throws past the cap. 128 KiB raw + * base64s to ~174,764 chars; the worst `result`/`output` envelope around it + * (uuid refs, numeric offsets, key quotes) costs well under 200 bytes, so a + * full window encodes at about 175 KiB against the 262,144-byte cap. The + * plane clamps its `log_read` asks to this and the runtime chunks its tail + * pump to this (`ctx.outputChunkCeilingBytes`); neither end splits a frame + * after composition, and the sizes that overflow are pinned refused by test. + */ +export const SSH_SESSION_LOG_WINDOW_BYTES = 128 * 1024; + +/** + * Largest raw stdout slice one brokered `session_frame` event may carry + * (design §3's pump, restated as a number: the node-frames `session_frame` + * doc always claimed "in ≤ 192 KiB pieces", and the broker's read can return + * more in one chunk). 192 KiB raw base64s to 262,144 chars, comfortably under + * the node link's own 1 MiB frame cap; the session's codec reassembles frames + * across pushes, so a chunk boundary is a chunk boundary and splitting costs + * the stream nothing but an event. + */ +export const SSH_SESSION_PUMP_CHUNK_BYTES = 192 * 1024; diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts new file mode 100644 index 000000000..120afa595 --- /dev/null +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -0,0 +1,60 @@ +import type { SshConnectionSnapshotWire } from "./ssh-config.js"; +import type { SshErrorCode } from "./ssh-errors.js"; + +/** + * The SSH result ENVELOPE types - what the two surviving `ssh_*` discovery + * commands' `result{data}` carries (the Gate A split from `ssh-frames.ts`; + * exported NAMES unchanged). The test/run/control envelopes retired with the + * destination product (design 2026-10-05 §7). 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. + * + * Imports no `node:` builtin; lives in the Metro-safe barrel. + */ + +/** + * `ssh_discover_aliases` answer: NAMES of usable aliases, never config file + * contents (docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md). Wildcard-only entries are already excluded + * and include cycles detected (the cycle is a FACT the human reviewing the + * list needs, never a silent stop). + */ +export interface NodeSshAliasListResult { + /** Alias names, sorted, deduplicated, at most {@link SSH_MAX_DISCOVERED_ALIASES}. */ + aliases: string[]; + /** An include cycle was detected during the bounded parse; the list is what parsed before it. */ + includeCycle: boolean; + /** The alias cap was hit; more exist. */ + truncated: boolean; +} + +/** + * `ssh_resolve_config` answer. The refusal is IN THE DATA, not an `ok:false` + * result: "this config needs a ProxyCommand" is a SUCCESSFUL resolution + * outcome the human must read (which setting blocked), not a transport error. + * `settings` names the blocked settings by the config keyword (a `ProxyCommand` + * row, a `LocalForward` row, …) so the human can edit the config and re-resolve. + */ +export type NodeSshResolveOutcomeWire = + | { + /** The alias normalized cleanly into an approved snapshot. */ + accepted: true; + /** The snapshot the human reviews, then saves. */ + snapshot: SshConnectionSnapshotWire; + /** + * The connecting account's OS user name, when the node could report it + * - the wizard's review of the resolved destination needs the connecting + * OS account named, which the snapshot itself never carries. Optional by + * design: an agent that cannot resolve `os.userInfo` answers without + * it and the review proceeds (the destination facts are the load- + * bearing half; this is display). + */ + connectingAccount?: string; + } + | { + /** Resolution refused: the config needs more than the approved normalization can run, or the destination is unusable. */ + accepted: false; + /** The named limitation ({@link SshErrorCode} subset: unsupported_setting, config_missing, config_ambiguous, proxy_chain_too_long, …). */ + code: SshErrorCode; + /** Config keywords that blocked acceptance; empty when the code names the whole cause. */ + settings: string[]; + };