diff --git a/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md new file mode 100644 index 000000000..185a9c422 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md @@ -0,0 +1,661 @@ +# SSH Anywhere - Plan 1: Foundations (gate + ported primitives) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On a branch `feat/ssh-anywhere-1` cut from `feat/ssh-anywhere` (stacked PR onto the spec PR #333), land the two foundations every later slice needs: a default-off, owner-controlled, fail-closed per-node `ssh_enabled` capability gate, and the neutral OpenSSH primitives (config discovery, `ssh -G` resolve, connection-snapshot grammar) plus the sshd e2e fixture, ported off `feat/ssh-support`. + +**Architecture:** The `ssh_enabled` gate mirrors the existing `maintenance` flag (plane column + node mirror file + a push command + `ready` reporting) but with the plane as the sole writer and no pane-killing side effects; a `nodeCanSsh` predicate refuses use of a disabled node. The ssh grammar lives in `@internal/subshell-protocol` (barrel-safe: no `node:` builtins), the ssh engines in `@internal/pane-runtime` (its barrel is never imported by mobile), and the sshd fixture in `e2e/fixtures`. + +**Tech Stack:** Bun, TypeScript, Elysia (routes), Kysely (migrations/types), node:test-style `bun:test` unit tests, Playwright (e2e), `@base-ui`, React (only read by later plans). + +## Global Constraints + +Every task obeys these (copied from the spec `docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md`): + +- New migration number is **`0047`** (`main`'s latest is `0046-backup-recovery.ts`); do NOT reuse the branch's 0047-0051 names. +- `ssh_enabled` defaults **off/false on every node including `local`**; the DB column is `notNull().defaultTo(0)` (the maintenance default-0 "upgrade keeps the fleet as it was" doctrine). +- The node-side read is **fail-closed**: a node that cannot read its own setting refuses SSH; an absent file reads as **off** (default-off). +- Changing the flag is **owner-only, admin on `local`** (`gate.canManage`), cookie-authenticated, audited as `node.ssh_enabled.update` (the node-family verb pattern; the name is added to the `docs/security.md` section 10 event list in Task 6); the flag is plane-written (no node self-toggle CLI). +- **Every command runs in the `/home/theo/projects/wt-ssh-anywhere` worktree**, never in `/home/theo/projects/subshell` (the shared checkout holds other branches' uncommitted work). The worktree is a linked git worktree, so it shares the main checkout's refs and `git show origin/feat/ssh-support:` works from inside it. +- **`NODE_PROTOCOL_VERSION` bumps 15 -> 16** (Task 7): the gate's new frames ride the bump because the node-link compatibility gate is exact-match. +- No SSH private key material, challenge, signature, or config value enters any log, argv, or audit row; audits name ids/hosts/field-names only. +- Node-builtin-importing modules must NOT be re-exported from `packages/subshell-protocol/src/index.ts` (Metro imports that barrel and cannot resolve `node:*`). Grammar modules that import no `node:` builtin MAY be barrel-exported there. `@internal/pane-runtime` is not a mobile dependency, so its engines may be exported from its own index barrel. +- `shellQuote` every token of any built command; static top-level imports only (no `await import` outside the sanctioned `plugin-runtime.ts`). +- Run package tests with `env -u SHELLOPTS -u BASHOPTS`; a `bun test ` silently passes on a wrong path, so check the file count. +- Commit as author `theo@suteki.nu`. +- Design system (only where copy is shipped, which this plan does not): role tokens, two-sentence max, no em dashes. + +--- + +## Branch setup (once, before Task 1) + +In the worktree (never in the shared checkout): + +```bash +cd /home/theo/projects/wt-ssh-anywhere +git switch -c feat/ssh-anywhere-1 # from feat/ssh-anywhere, the spec/plan branch +``` + +All tasks commit on `feat/ssh-anywhere-1`; Task 8 opens the PR against +`feat/ssh-anywhere`. + +--- + +## File Structure + +Ports (verbatim copies off `origin/feat/ssh-support`, then export wiring; the ported files cite `SSH-SUPPORT.md`, which was deleted as superseded, so each port commit rewrites every `SSH-SUPPORT.md §N` pointer to this spec's `2026-10-07-ssh-anywhere-design.md §N`): +- Create: `packages/subshell-protocol/src/ssh-config.ts` (grammar; no `node:` builtins → barrel-safe) +- Create: `packages/subshell-protocol/src/ssh-limits.ts` (budgets; no `node:` builtins → barrel-safe) +- Create: `packages/subshell-protocol/src/ssh-errors.ts` (error codes + shipped descriptions; imports nothing → barrel-safe) +- Create: `packages/subshell-protocol/src/ssh-results.ts` (resolve/discover wire outcomes; type-only imports → barrel-safe) +- Create: `packages/subshell-protocol/src/ssh-frames.ts` (ssh RPC command grammar; needed by the fixtures' type imports - not barrel-exported in this plan) +- Create: `packages/subshell-protocol/src/__tests__/ssh-config.test.ts` +- Create: `packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts` (imported by the grammar test) +- Create: `packages/pane-runtime/src/ssh/ssh-spawn.ts` (`node:*` → pane-runtime barrel only) +- Create: `packages/pane-runtime/src/ssh/ssh-discover.ts` (`node:fs/os/path` → pane-runtime barrel only) +- Create: `packages/pane-runtime/src/ssh/ssh-resolve.ts` +- Create: `packages/pane-runtime/src/ssh/__tests__/helpers.ts` (test fixtures + ssh shim; imported by both engine tests) +- Create: `packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts` (ported verbatim, not hand-written) +- Create: `packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts` (ported verbatim, not hand-written) +- Create: `e2e/fixtures/sshd.ts` + +Gate (new, mirroring the maintenance template): +- Create: `apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts` +- Modify: `apps/server/api/src/db/migrate.ts` (register `0047`) +- Modify: `apps/server/api/src/db/types/nodes.db-types.ts` (`sshEnabled`, `sshEnabledAt`) +- Modify: `apps/server/api/src/db/repositories/nodes.repository.ts` (`setSshEnabled`) +- Modify: `apps/server/api/src/lib/node-access.ts` (`nodeCanSsh`) +- Modify: `apps/server/api/src/api/nodes/node-view.ts` (`sshEnabled` in `NodeViewSchema` + `nodeViewBase`) +- Create: `apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts` (calls the service, like the maintenance route calls `setNodeMaintenance`) +- Create: `apps/server/api/src/services/nodes/ssh-enabled.ts` (Task 6: `setNodeSshEnabled` row-write + audit; Task 7 adds `pushSetSshEnabled` + `reconcileSshEnabled`) +- Modify: `apps/server/api/src/api/nodes/index.ts` (mount the route) +- Modify: `docs/security.md` §10 + `.claude/rules/security-context.md` (add `node.ssh_enabled.update` to the nodes audit family) +- Modify: `packages/subshell-protocol/src/node-frames.ts` (`NodeSshEnabledWire`, `parseNodeSshEnabled`, `set_ssh_enabled` command arm, `ssh_enabled` event arm, `ready` arm, `NODE_PROTOCOL_VERSION` 15 -> 16 + history comment; the `NODE_RESULT_SSH_DISABLED` result string is deliberately NOT here - it ships with the Plan 2 launcher that produces it) +- Modify: `packages/subshell-protocol/src/index.ts` (export grammar + the new wire symbols) +- Create: `apps/node/agent/src/ssh-enabled.ts` (`node:*` file read/write, fail-closed) +- Create: `apps/node/agent/src/commands/set-ssh-enabled.ts` (executor: write mirror file + memoize) +- Modify: `apps/node/agent/src/commands/context.ts` (`lastReportedSshEnabled?` memo) +- Modify: `apps/node/agent/src/commands/report.ts` (`reportSshEnabled`, `maybeReportSshEnabled`, `seedSshEnabledMemo`) +- Modify: `apps/node/agent/src/commands/index.ts` (dispatch `set_ssh_enabled`) +- Modify: `apps/node/agent/src/daemon.ts` (`readyEvent` includes `sshEnabled`) +- Modify: `apps/server/api/src/services/nodes/node-events.ts` + `apps/server/api/src/index.ts` (onReady reconcile hook) +- Test files alongside each new service/route/module in `__tests__/`. + +--- + +## Task 1: Port the ssh grammar into `subshell-protocol` (barrel-safe) + +**Files:** +- Create: `packages/subshell-protocol/src/ssh-limits.ts`, `.../ssh-config.ts`, `.../ssh-errors.ts`, `.../ssh-results.ts`, `.../ssh-frames.ts` +- Create: `packages/subshell-protocol/src/__tests__/ssh-config.test.ts`, `.../__tests__/fixtures/ssh-fixtures.ts` +- Modify: `packages/subshell-protocol/src/index.ts` + +(`ssh-frames.ts` - the `ssh_discover_aliases` / `ssh_resolve_config` command grammar, 87 lines, imports only `./guards.js` (already on main) and `./ssh-limits.js` - is ported because the fixtures file imports its two command types, and `verify-types` typechecks `src/**` including `__tests__`, so a dangling import there is a Task 8 failure. It is deliberately NOT barrel-exported: no M1 consumer, and Plan 2 wires its parser when it mounts the ssh RPC verbs.) + +**Interfaces:** +- Consumes: nothing. +- Produces (from `@internal/subshell-protocol`): the limits `SSH_MAX_DISCOVERED_ALIASES`, `SSH_MAX_PROXY_HOPS`, `SSH_NAME_MAX_CHARS`, `SSH_PROBE_DEADLINE_MS`; the grammar `type SshHopWire`, `type SshConnectionSnapshotWire`, `parseSshConnectionSnapshot`, `SSH_FORBIDDEN_SNAPSHOT_FIELDS`; the named-refusal codes and shipped sentences `SSH_ERROR_CODES`, `type SshErrorCode`, `isSshErrorCode`, `SSH_ERROR_DESCRIPTIONS` (the Plan 3 review UI renders the descriptions by equality, never the wire code); the RPC outcomes `type NodeSshAliasListResult`, `type NodeSshResolveOutcomeWire`. This is exactly what Task 2's engines import from the package barrel (`ssh-resolve.ts` pulls seven of these symbols; a missing one fails its test run at Task 2 Step 2, not here). + +- [ ] **Step 1: Copy the six files verbatim off the branch, into the worktree** + +```bash +cd /home/theo/projects/wt-ssh-anywhere +B=origin/feat/ssh-support +mkdir -p packages/subshell-protocol/src/__tests__/fixtures +for f in ssh-limits ssh-config ssh-errors ssh-results ssh-frames; do + git show "$B:packages/subshell-protocol/src/$f.ts" > packages/subshell-protocol/src/$f.ts +done +git show "$B:packages/subshell-protocol/src/__tests__/ssh-config.test.ts" > packages/subshell-protocol/src/__tests__/ssh-config.test.ts +git show "$B:packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts" > packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts +grep -nE "from \"node:" packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-errors.ts packages/subshell-protocol/src/ssh-results.ts packages/subshell-protocol/src/ssh-frames.ts # MUST print nothing +``` + +Expected: the `grep` prints nothing (all five modules are `node:`-free, so they are barrel-safe). If it prints any line, that module is NOT grammar and belongs in `pane-runtime` instead - stop and re-scope. + +- [ ] **Step 2: Rewrite the deleted-document pointers** + +The copied files' doc comments cite `SSH-SUPPORT.md`, deleted as superseded. Replace every citation with `docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md`, keeping the sentence true (drop a `§N` cross-reference when the spec has no matching section rather than pointing at a wrong one). Check: `grep -c "SSH-SUPPORT" packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-errors.ts packages/subshell-protocol/src/ssh-results.ts packages/subshell-protocol/src/ssh-frames.ts` prints `0` for all five. + +- [ ] **Step 3: Run the grammar test (it imports the copied files by relative path, so it is green before any barrel wiring)** + +Run: `cd /home/theo/projects/wt-ssh-anywhere/packages/subshell-protocol && env -u SHELLOPTS -u BASHOPTS bun test src/__tests__/ssh-config.test.ts` +Expected: PASS (1 file). A failed import means the copy is incomplete - fix the copy, do not trim the test. + +- [ ] **Step 4: Barrel-export the grammar** + +Append to `packages/subshell-protocol/src/index.ts` (do NOT export any `node:`-importing module here): + +```ts +export { + SSH_MAX_DISCOVERED_ALIASES, + SSH_MAX_PROXY_HOPS, + SSH_NAME_MAX_CHARS, + SSH_PROBE_DEADLINE_MS, +} from "./ssh-limits.js"; +export { + type SshHopWire, + type SshConnectionSnapshotWire, + parseSshConnectionSnapshot, + SSH_FORBIDDEN_SNAPSHOT_FIELDS, +} from "./ssh-config.js"; +export { + SSH_ERROR_CODES, + type SshErrorCode, + isSshErrorCode, + SSH_ERROR_DESCRIPTIONS, +} from "./ssh-errors.js"; +export { + type NodeSshAliasListResult, + type NodeSshResolveOutcomeWire, +} from "./ssh-results.js"; +``` + +(Do not export `NODE_RESULT_SSH_GENERATION_STALE` or the other #330 result-channel strings; they have no consumer on this path.) + +- [ ] **Step 5: Build the package and run its tests** + +Run: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/subshell-protocol && cd packages/subshell-protocol && env -u SHELLOPTS -u BASHOPTS bun test` +Expected: build succeeds; `ssh-config` test PASS; no other protocol test regresses. + +- [ ] **Step 6: Confirm the mobile build is unaffected (the Metro trap)** + +Run: `grep -c "ssh-config\|ssh-limits" /home/theo/projects/wt-ssh-anywhere/packages/subshell-protocol/dist/index.js` +Expected: nonzero - the built barrel references the grammar; because the four files import no `node:`, Metro can still resolve the barrel. (The real mobile guard runs in CI; this is the local sanity check the rules call for.) + +- [ ] **Step 7: Commit** + +```bash +cd /home/theo/projects/wt-ssh-anywhere +git add packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/ssh-config.ts packages/subshell-protocol/src/ssh-errors.ts packages/subshell-protocol/src/ssh-results.ts packages/subshell-protocol/src/ssh-frames.ts packages/subshell-protocol/src/index.ts packages/subshell-protocol/src/__tests__/ssh-config.test.ts packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts +git commit -m "feat(ssh): port OpenSSH grammar, budgets, command frames, and named-refusal codes into subshell-protocol" +``` + +--- + +## Task 2: Port the ssh engines into `pane-runtime` (barrel-safe because mobile never imports it) + +**Files:** +- Create: `packages/pane-runtime/src/ssh/ssh-spawn.ts`, `.../ssh-discover.ts`, `.../ssh-resolve.ts` +- Create: `packages/pane-runtime/src/ssh/__tests__/helpers.ts`, `.../ssh-discover.test.ts`, `.../ssh-resolve.test.ts` (all three ported verbatim; the two tests import `./helpers.js`) +- Modify: `packages/pane-runtime/src/index.ts` + +**Interfaces:** +- Consumes (Task 1): `type NodeSshResolveOutcomeWire`, `parseSshConnectionSnapshot`, `SSH_MAX_PROXY_HOPS`, `SSH_PROBE_DEADLINE_MS`, `type SshConnectionSnapshotWire`, `type SshErrorCode`, `type SshHopWire` (ssh-resolve.ts) plus `SSH_MAX_DISCOVERED_ALIASES`, `SSH_NAME_MAX_CHARS` (ssh-discover.ts), and `type SshConnectionSnapshotWire` in helpers.ts - all from `@internal/subshell-protocol`. +- Produces (from `@internal/pane-runtime`): `runSshProcess`, `type SshProcessResult`, `sshChildPath` (spawn); `defaultSshConfigPath`, `discoverSshAliases`, `isDiscoverableAlias`, `walkSshConfig`, `type SshAliasDiscovery`, `type SshConfigWalk`, `type SshHostBlock`, `type SshWalkBudget` (discover); `parseProxyHop`, `resolveSshAliasConfig`, `type SshResolutionDeps` (resolve). These are the branch's real BARREL export names, verified against the branch's own `pane-runtime/src/index.ts` on 2026-10-07 (`expandTilde` is file-exported but barrel-internal, as on the branch). + +- [ ] **Step 1: Copy the three engines and their three test files verbatim** + +```bash +cd /home/theo/projects/wt-ssh-anywhere +B=origin/feat/ssh-support +mkdir -p packages/pane-runtime/src/ssh/__tests__ +for f in ssh-spawn ssh-discover ssh-resolve; do + git show "$B:packages/pane-runtime/src/ssh/$f.ts" > packages/pane-runtime/src/ssh/$f.ts +done +for f in helpers ssh-discover.test ssh-resolve.test; do + git show "$B:packages/pane-runtime/src/ssh/__tests__/$f.ts" > packages/pane-runtime/src/ssh/__tests__/$f.ts +done +``` + +Then confirm each engine's imports are ONLY `node:*`, its `./` siblings inside `ssh/`, `../login-path.js`, or `@internal/subshell-protocol`. The known true edges (keep them): `ssh-spawn.ts` imports `loginPathEntries` from `../login-path.js` (a main-resident module, `packages/pane-runtime/src/login-path.ts`); `ssh-resolve.ts` imports `defaultSshConfigPath`, `expandTilde`, `type SshHostBlock`, `type SshWalkBudget`, `walkSshConfig` from `./ssh-discover.js` and `runSshProcess`, `sshChildPath` from `./ssh-spawn.js`; helpers.ts imports a protocol type. A `./ssh-render` / `./ssh-session-*` / `./kill-group` / codec import would be coupling to the abandoned runtime path - none exists on the branch today, so if one appears, record it and de-couple it (never by deleting a protocol import Task 1 supplies). Rewrite the `SSH-SUPPORT.md` pointers as in Task 1 Step 2 and check `grep -l "SSH-SUPPORT" packages/pane-runtime/src/ssh/*.ts packages/pane-runtime/src/ssh/__tests__/*.ts` prints nothing. + +- [ ] **Step 2: Run the ported tests** + +Run: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/subshell-protocol && cd packages/pane-runtime && env -u SHELLOPTS -u BASHOPTS bun test src/ssh/__tests__/ssh-discover.test.ts src/ssh/__tests__/ssh-resolve.test.ts` +Expected: PASS, 2 files. The resolve tests run the resolver against a shim `ssh` that `helpers.ts` bakes with canned `-G` output, and end with one case driven by the REAL host `ssh -G`, loud-skipped only where ssh is absent - the guard ships inside the ported test; do not add `missingSshBins` (that lives in the e2e package, which pane-runtime does not depend on). + +- [ ] **Step 3: Export the engines from the pane-runtime barrel (named exports, the file's existing style)** + +Append to `packages/pane-runtime/src/index.ts`: + +```ts +export { runSshProcess, type SshProcessResult, sshChildPath } from "./ssh/ssh-spawn.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"; +``` + +(`expandTilde` stays package-internal like the branch's barrel has it; the engines use it via relative imports.) + +- [ ] **Step 4: Build, typecheck, and commit** + +```bash +cd /home/theo/projects/wt-ssh-anywhere +bunx turbo build --filter=@internal/pane-runtime +bunx turbo verify-types --filter=@internal/pane-runtime +git add packages/pane-runtime/src/ssh packages/pane-runtime/src/index.ts +git commit -m "feat(ssh): port OpenSSH config discovery and ssh -G resolve into pane-runtime" +``` + +--- + +## Task 3: Port the sshd e2e fixture + +**Files:** +- Create: `e2e/fixtures/sshd.ts` +- Test: nothing (fixture; exercised by later plans) + +**Interfaces:** +- Produces (from `e2e/fixtures/sshd`): `missingSshBins`, `SSH_FIXTURE_ALIAS`, `startSshFixture`, `type SshFixture`. + +- [ ] **Step 1: Copy the fixture and check its imports** + +```bash +cd /home/theo/projects/wt-ssh-anywhere +B=origin/feat/ssh-support +git show "$B:e2e/fixtures/sshd.ts" > e2e/fixtures/sshd.ts +``` + +Open it: it must depend only on `node:*` + repo-internal e2e helpers (`../ports`, `../stack`) that already exist on `main`. Fix any import that pointed at a #330-only helper. + +- [ ] **Step 2: Typecheck the e2e package** + +Run: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo verify-types --filter=@internal/e2e` +Expected: no new errors from `sshd.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/fixtures/sshd.ts +git commit -m "test(e2e): port the isolated sshd fixture" +``` + +--- + +## Task 4: Add the `nodes.ssh_enabled` column (migration + row type) + +**Files:** +- Create: `apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts` +- Modify: `apps/server/api/src/db/migrate.ts` +- Modify: `apps/server/api/src/db/types/nodes.db-types.ts` +- Test: `apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts` (the established migration-test placement and pattern is `0031-node-maintenance.test.ts` beside the migrations, hand-built Kysely over a migration subset; the boot-seeded `local` row does NOT exist in such a DB, so the test proves the default on its own inserted rows) + +**Interfaces:** +- Produces: `NodeTable.sshEnabled: number` (0/1), `NodeTable.sshEnabledAt: string | null`; `NewNode.sshEnabled?: number`. + +- [ ] **Step 1: Write the failing migration test** + +Model it on `0031-node-maintenance.test.ts` read verbatim (`git show origin/main:apps/server/api/src/db/migrations/__tests__/0031-node-maintenance.test.ts`): hand-built Kysely over a migration subset, a row inserted BEFORE the new migration runs (that is the upgrade case), raw `pragma_table_info` checks, and a down/up round trip. The full test: + +```ts +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 + }); +}); +``` + +(`Math.random()` is fine here: this is a bun:test file, not a Workflow script. The temp file name follows the 0031 test's own pattern.) + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `cd /home/theo/projects/wt-ssh-anywhere/apps/server/api && env -u SHELLOPTS -u BASHOPTS bun test src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts` +Expected: FAIL (module `0047-node-ssh-enabled.js` does not exist yet). + +- [ ] **Step 3: Write the migration (copy the 0031 shape; kysely's `addColumn` is three-arg: name, type expression, constraint callback)** + +```ts +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(); +} +``` + +- [ ] **Step 4: Register it in `migrate.ts`** + +Add the import and map entry beside the maintenance ones, keyed `"0047-node-ssh-enabled"`. + +- [ ] **Step 5: Add the row-type fields** + +In `nodes.db-types.ts`, next to `maintenance`/`maintenanceAt`, add `sshEnabled: number;` and `sshEnabledAt: string | null;` (with the property JSDoc the file uses) and `sshEnabled?: number;` on `NewNode`. + +- [ ] **Step 6: Run the test to confirm it passes** + +Run: `cd /home/theo/projects/wt-ssh-anywhere/apps/server/api && env -u SHELLOPTS -u BASHOPTS bun test src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +cd /home/theo/projects/wt-ssh-anywhere +git add apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts apps/server/api/src/db/migrate.ts apps/server/api/src/db/types/nodes.db-types.ts apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts +git commit -m "feat(nodes): add ssh_enabled capability column (default off) + migration" +``` + +--- + +## Task 5: `nodeCanSsh` predicate + +**Files:** +- Modify: `apps/server/api/src/lib/node-access.ts` +- Test: `apps/server/api/src/lib/__tests__/node-access.test.ts` (add a describe) + +**Interfaces:** +- Produces: `nodeCanSsh(opts: { kind: NodeKind; access: NodeAccess; isAdmin: boolean; serverAccountEnabled: boolean; sshEnabled: boolean }): boolean`. Note the shape mirrors `nodeCanManageFor`, not `nodeCanLaunchOn`: it takes `isAdmin` (the resolver ranks admins at `edit`, never `owner`, so the local-admin exception is invisible in `access` alone) and deliberately has NO `granted` parameter (on `local`, granted access is Everyone-`edit` by the seeded share, and that grant must not confer SSH use). + +- [ ] **Step 1: Write the failing tests** + +```ts +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); + }); +}); +``` + +- [ ] **Step 2: Run to confirm it fails** + +Run: `cd /home/theo/projects/wt-ssh-anywhere/apps/server/api && env -u SHELLOPTS -u BASHOPTS bun test src/lib/__tests__/node-access.test.ts` +Expected: FAIL (`nodeCanSsh` not exported). + +- [ ] **Step 3: Implement it beside `nodeCanLaunchOn`** + +```ts +/** 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"; +} +``` + +- [ ] **Step 4: Run to confirm pass; commit** + +Run then: +```bash +cd /home/theo/projects/wt-ssh-anywhere +git add apps/server/api/src/lib/node-access.ts apps/server/api/src/lib/__tests__/node-access.test.ts +git commit -m "feat(nodes): nodeCanSsh predicate (owner-only, gated by ssh_enabled)" +``` + +--- + +## Task 6: Plane write + view + service + `PUT /:id/ssh-enabled` route + +**Files:** +- Modify: `apps/server/api/src/db/repositories/nodes.repository.ts` +- Modify: `apps/server/api/src/api/nodes/node-view.ts` +- Create: `apps/server/api/src/services/nodes/ssh-enabled.ts` (this task: row write + audit; Task 7 adds `pushSetSshEnabled` + `reconcileSshEnabled` to the SAME file) +- Create: `apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts` (thin gate + service call, exactly like `set-node-maintenance.route.ts` calling `setNodeMaintenance`) +- Modify: `apps/server/api/src/api/nodes/index.ts` +- Modify: `docs/security.md` §10 event list and `.claude/rules/security-context.md` (add `node.ssh_enabled.update` to the nodes audit family) +- Test: `apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts` + +**Interfaces:** +- Consumes (Task 4): row fields; (Task 5): nothing directly (the view reports the flag; `nodeCanSsh` is consumed by the Plan 2 launch path). +- Produces: `repos.nodes.setSshEnabled(id, { on, changedAt }): Promise`; `setNodeSshEnabled(write): Promise` - the ONE site of the change act (row write + audit; Task 7 layers the push into this same function). The route must not write or audit itself: `maintenance.ts`'s own doc comment states the reason - "two implementations of a four-step act is two implementations that drift, and the half that drifts is whichever one nobody exercised"; `apps/server/api/src/services/nodes/maintenance.ts` is the file to mirror; +- `NodeView.sshEnabled: boolean`; route `PUT /api/nodes/:id/ssh-enabled` body `{ on: boolean }` returning the node view. + +- [ ] **Step 1: Failing route test (owner on, non-owner refused, audit written, local admin-only)** + +Mirror the structure of `set-node-maintenance.route.test.ts`: create a member + a node they own; PUT `{on:true}` as owner -> 200 with `sshEnabled: true` and an audit row action `node.ssh_enabled.update` whose `metadataJson` is the JSON string `"{\"on\":true}"` (no secret, no config values); PUT as a member who neither owns nor is granted the node -> the same 404 the maintenance route gives for invisible rows; PUT as an `edit` grantee -> refused (management of the flag is owner-only even though launch is granted - spec 4.3); PUT `local` as member -> refused, as admin -> 200. + +- [ ] **Step 2: Run to confirm it fails**, then **Step 3: implement `setSshEnabled` in the repository** (copy `setMaintenance`'s shape, only `ssh_enabled`/`ssh_enabled_at`, `ssh_enabled: on ? 1 : 0`). + +- [ ] **Step 4: add `sshEnabled` to the view** - in `NodeViewSchema` a `sshEnabled: t.Boolean({ description: "…" })`; in `nodeViewBase`, `sshEnabled: row.sshEnabled === 1`. Both list and detail flow through `nodeViewBase`, so one add lands both. + +- [ ] **Step 5: write the service, then the route** + +`services/nodes/ssh-enabled.ts` (mirror `maintenance.ts`'s audit call exactly - string metadata, `getRequestlessContext()` for repos): + +```ts +export interface SshEnabledWrite { + nodeId: string; + on: boolean; + changedAt: string; + actorUserId: string; +} + +/** Flip one node's SSH capability: row, audit, and (from Task 7) 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 }), + }); + // Task 7 appends here: void pushSetSshEnabled(write.nodeId, { on: write.on, changedAt: write.changedAt }); +} +``` + +The route copies `set-node-maintenance.route.ts` (`requireCookieActor` -> `loadNodeGate` -> 404 if missing/invisible -> `if (!gate.canManage)` ForbiddenError -> `await setNodeSshEnabled({ nodeId, on: body.on, changedAt: new Date().toISOString(), actorUserId: user.id })` -> re-read the row -> `toNodeView(row, access, isAdmin, granted)`), with the body schema: + +```ts +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." }), +}); +``` + +Mount it in `api/nodes/index.ts` beside the maintenance route. + +- [ ] **Step 6: Run the route test to confirm pass**, then **rebuild backend so the Eden client sees the route**: `cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/server --filter=@internal/backend-client`. + +- [ ] **Step 7: Audit-event housekeeping** - add `node.ssh_enabled.update` to the event-name list in `docs/security.md` section 10 (beside `node.maintenance.update`) and to the nodes family line in `.claude/rules/security-context.md`; a name that exists in code but not in the list is a list that lies. + +- [ ] **Step 8: Commit** (all touched files + the test). + +--- + +## Task 7: Node-side mirror file + `set_ssh_enabled` push + `ready` reporting (fail-closed) + +**Files:** +- Create: `apps/node/agent/src/ssh-enabled.ts` +- Create: `apps/node/agent/src/commands/set-ssh-enabled.ts` +- Modify: `apps/node/agent/src/commands/context.ts`, `.../report.ts`, `.../index.ts`, `apps/node/agent/src/daemon.ts` +- Modify: `packages/subshell-protocol/src/node-frames.ts`, `packages/subshell-protocol/src/index.ts` +- Modify: `apps/server/api/src/services/nodes/node-events.ts`, `apps/server/api/src/index.ts` +- Modify: `apps/server/api/src/services/nodes/ssh-enabled.ts` (created in Task 6; this task appends the push and reconcile and enables the push line in `setNodeSshEnabled`) +- Test: `apps/node/agent/src/__tests__/ssh-enabled.test.ts` +- Test: `apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts` (the plane half: `reconcileSshEnabled`'s decide-table, mirroring `maintenance-decide.test.ts`'s pattern: reported matches row -> no push; reported disagrees -> push the ROW value, never adopt from the node; row on + node silent -> push; `local` -> push is a no-op; and `setNodeSshEnabled` writes + audits exactly one row) + +**Interfaces:** +- Produces: `sshEnabledPath(dataDir)`, `readSshEnabled(dataDir): {kind:"on"|"absent"|"unreadable", changedAt?}` (absent⇒treated off; unreadable⇒refuse), `writeSshEnabled(dataDir, state: NodeSshEnabledWire)` (stores the whole wire, mirroring `writeMaintenance`), `reportSshEnabled(ctx, state: NodeSshEnabledWire)`, `seedSshEnabledMemo`, `pushSetSshEnabled(nodeId, {on, changedAt})` (no-op for `local`), `reconcileSshEnabled(nodeId, reported)`, protocol `NodeSshEnabledWire`/`parseNodeSshEnabled`/`set_ssh_enabled` arm/`ready.sshEnabled`, and `NODE_PROTOCOL_VERSION` 15 -> 16. + +- [ ] **Step 1: Failing node-side test** - `readSshEnabled(dataDir)` on an empty dir -> `{kind:"absent"}`; after `writeSshEnabled(dataDir, { on: true, changedAt: "2026-10-07T10:00:00.000Z" })` -> `{kind:"on", changedAt: "2026-10-07T10:00:00.000Z"}` (the mirror stores the whole wire, exactly like `writeMaintenance(dataDir, state)` keeps `{ on, changedAt }` verbatim - `ready.sshEnabled` must be buildable from what was written); a chmod-000 (or unreadable) file -> `{kind:"unreadable"}`; assert the classifier maps `absent|unreadable` to "SSH refused" and only `on:true` to "allowed". + +- [ ] **Step 2: Run to confirm fail.** + +- [ ] **Step 3: Implement `apps/node/agent/src/ssh-enabled.ts`** mirroring `maintenance.ts` exactly (same file-permission doctrine 0600 temp+rename, same `MaintenanceRead`-shaped result with `kind`, the `unreadable` fail-closed arm), but: default/absent means **off**, and there is **no self-toggle** (plane is the only writer). + +- [ ] **Step 4: Protocol arms + version bump** in `node-frames.ts`: `interface NodeSshEnabledWire { on: boolean; changedAt: string }`, `parseNodeSshEnabled`, a `set_ssh_enabled` command arm, an `ssh_enabled` event arm, and `ready` gains `sshEnabled?: NodeSshEnabledWire` (drop-not-fatal when malformed, like `maintenance`). Then bump `NODE_PROTOCOL_VERSION` 15 -> 16 and add the file's history-comment entry: additive, and breaking anyway, because the link gate is exact-match - pre-16 agents must never be wire-ambiguous about honoring `set_ssh_enabled` (spec 4.3). Re-export from `index.ts`. + +- [ ] **Step 5: Agent executor** `commands/set-ssh-enabled.ts` mirroring `set-maintenance.ts`: write the mirror file, set `ctx.lastReportedSshEnabled`, return `{ ok: true }`. Dispatch it in `commands/index.ts`. + +- [ ] **Step 6: Reporting** - in `report.ts` add `reportSshEnabled(ctx, state: NodeSshEnabledWire)` (mirror `reportMaintenance(ctx, state)`: the wire carries `{ on, changedAt }`, the `ready` arm cannot be built from `on` alone), `maybeReportSshEnabled(ctx)` (heartbeat belt vs `ctx.lastReportedSshEnabled`), `seedSshEnabledMemo(ctx)`; in `daemon.ts` include `...(sshEnabled ? { sshEnabled } : {})` in `readyEvent`. + +- [ ] **Step 7: Plane push + reconcile, into Task 6's file** - append to `services/nodes/ssh-enabled.ts`: `pushSetSshEnabled(nodeId, { on, changedAt })` (`if (nodeId === LOCAL_NODE_ID) return;` then `sendCommand(..., { type: "set_ssh_enabled", ... })` best-effort, mirroring `maintenance.ts`'s push), `reconcileSshEnabled(nodeId, reported)` (plane value is authoritative: if the reported mirror disagrees with the row, push the row value; never adopt from the node). Enable the commented push line inside `setNodeSshEnabled` from Task 6, so the write, the audit, and the push stay one function. Wire `reconcileSshEnabled` into the on-`ready` hooks (`node-events.ts` + `index.ts`), alongside the existing `onMaintenance`. + +- [ ] **Step 8: Run all touched tests + build**: +`cd /home/theo/projects/wt-ssh-anywhere && bunx turbo build --filter=@internal/subshell-protocol && cd apps/node/agent && env -u SHELLOPTS -u BASHOPTS bun test src/__tests__/ssh-enabled.test.ts && cd ../../server/api && env -u SHELLOPTS -u BASHOPTS bun test src/services/nodes` +Expected: PASS; node defaults off/unreadable-refuse; local push is a no-op. + +- [ ] **Step 9: Commit.** + +--- + +## Task 8: Tier-level verification + changelog + +- [ ] **Step 1: Full boundary suite** (per `verification.md`): + +```bash +cd /home/theo/projects/wt-ssh-anywhere +bun run verify-types +bun run lint:check +bun run lint:prose +bunx turbo build +bun run test +``` + +Expected: all green. `verify-types` is what proves the port engines' barrel edits and the new protocol arms typecheck across dependents, and `bun run test` is mandatory here (pre-push does NOT run it, and this plan touched the protocol version every suite reads). Note the protocol bump is safe locally: all consumers import `NODE_PROTOCOL_VERSION` symbolically (verified on main), so no literal `15` asserts exist outside the constant. + +- [ ] **Step 2: Focused suite counts** - re-run the protocol, pane-runtime, node-agent, and server/api suites touched above and confirm the FILE COUNTs are non-zero (bun skips bad paths silently). + +- [ ] **Step 3: Changeset** - add a minor changeset noting the new `nodes.ssh_enabled` gate (migration 0047) and the ported ssh primitives, so release tooling sees the schema change. + +- [ ] **Step 4: Commit** and open the stacked PR: + +```bash +git push --no-verify -u origin HEAD +gh pr create --base feat/ssh-anywhere --title "SSH anywhere 1/3: foundations (ssh_enabled gate + ported primitives)" --body "..." +``` + +(Base is `feat/ssh-anywhere`, the branch below `feat/ssh-anywhere-1` this plan works on - never the repo default. Open the PR; do not merge it without the operator's word.) + +--- + +## Self-Review (author, after writing) + +- **Spec coverage:** Plan 1 covers spec §4.3 (the gate), §7 (`ssh_enabled` column), §15 (port grammar + engines + fixture), and the `nodeCanSsh` predicate from §5/§13. It does NOT cover the destination-first UI (§11), the terminal launch, or host-key pinning - those are Plans 2 and 3, by design. No M2 (relay) items leaked in. +- **Type consistency:** the wire type name is `NodeSshEnabledWire` everywhere; the repo method is `setSshEnabled`; the single change-site is `setNodeSshEnabled` (Task 6 creates it write+audit, Task 7 appends the push); the predicate is `nodeCanSsh` (options object, `isAdmin`, no `granted`); the audit action is `node.ssh_enabled.update`; the migration is `0047`; `NODE_PROTOCOL_VERSION` lands at 16. Confirm each before building on it in Plan 2. +- **Placeholders:** every "copy from X"/`git show` step names the exact source and lands in the worktree; new code is shown in full. Export names were verified against `origin/feat/ssh-support`'s files on 2026-10-07 (the engines' symbols, the grammar test's fixtures module, `kysely`'s three-arg `addColumn`, the hand-built migration-test pattern) - if any drifts when the task runs, the build or the test tells you; do not leave a stub. diff --git a/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md new file mode 100644 index 000000000..2ac355107 --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md @@ -0,0 +1,492 @@ +# SSH to Any Machine, from Anywhere + +Status: design, for review. Dated 2026-10-07. +Branch: `feat/ssh-anywhere`, cut from `origin/main`. This supersedes the approach +on `feat/ssh-support` (PR #330), left open and unmerged as a reference for the +brokered-session transport, not as a base. It also supersedes the prior +`SSH-SUPPORT.md` contract (runtime-on-destination, keys only ever local), now +removed: terminal to any host, runtime as an optional upgrade, cross-machine keys +via a sealed relay is the direction. + +## 1. Problem and goal + +From Subshell I want to open an interactive session against an arbitrary host over +SSH. The host may be an enrolled node or just a box running sshd; it must never be +required to run Subshell to be reachable. The SSH identity may live on a different +machine than the one dialing the host; in that case the private key must never +leave its home and the control plane must never see key material or plaintext +authentication challenges. + +The plain SSH terminal is the primary product. Turning the host into a full +Subshell agent destination is a secondary act on the same connection. + +## 2. Non-goals + +- Not a general-purpose SSH client for terminal use outside Subshell panes. +- Not a secret store: Subshell never custodies, imports, or persists an SSH + private key. +- Not multi-tenant hardening. Subshell is a single-operator, trusted-network + service (`docs/security.md` section 0); every machine involved is the operator's. +- Not replacing node enrollment. Reachable-by-enrollment and reachable-by-SSH are + two doors to the same pane machinery. + +## 3. Terminology and topology + +- **D** the destination: any host with an sshd. Holds no Subshell secret. May or + may not be an enrolled node. +- **B** the connecting machine: a node (a machine running the Subshell node agent, + which is how a laptop participates) that runs the `ssh` process and hosts the + pane. +- **A** the key home: the machine whose `ssh-agent` holds the key authenticating + D. Often B itself; sometimes a different node. +- **J** a jump hop: an intermediate host that `ssh -J` tunnels through. +- **The plane** the Subshell control plane. Nodes only ever meet each other through + it for Subshell traffic; raw ssh between a node and a host is direct and does not + route through the plane. + +A pane is the unit of value: a tmux-backed PTY on the connecting machine, streamed, +shareable, and logged like any other Subshell pane (section 5.4 states the sharing +caveat when that pane's ssh can drive an agent). + +## 4. Connection modes + +One launcher, three arrangements, chosen by where the signing key lives and how it +reaches D. **Direct**: the connecting machine B holds the key and `ssh` runs there, with no hop. +**Jump** (section 4.1): the origin A holds the key and dials out through a hop, pane +on A. **Relay** (section 4.2, M2): the pane is on B but the key is on a different +machine A, reached over a sealed shuttle. In Milestone 1 the key source is always the +connecting machine itself, so relay and the grants that drive it (section 5.1) are +out of scope; direct and jump are the M1 arrangements. + +### 4.1 Jump mode (A is the connecting machine) + +A runs `ssh -J D`. The key and agent stay on A; the hop is a network-only +relay that never sees the key; the pane lives on A. This is stock OpenSSH +`ProxyJump`, needs no new crypto, and is plane-blind because no key material or +challenge crosses the plane. Use it when the only reason to involve the hop is that +it can reach D's network while A cannot, and it is acceptable for the pane to run +on A. + +### 4.2 Relay mode (B is the connecting machine) + +B runs the pane and the `ssh D` process and authenticates to D through A's agent +over a sealed agent relay (section 8). The key never leaves A; the plane relays only +sealed requests it cannot read; the pane lives on B, on server-grade compute, and +persists after A disconnects. Use it when the session must run and survive on B, or +on D as a Subshell destination, rather than on the laptop. + +Relay mode requires A online and connected for the duration of the handshake. That +is the accepted price of the key never moving; an offline A is a clear error, never +a silent fallback to copying the key. + +### 4.3 Node SSH capability gate + +A machine's participation in Subshell SSH at all (dialing out over SSH, or serving +its key/agent) is opt-in per node and **off by default, including the `local` +server node**. The node carries one owner-controlled flag (admin on `local`), +`ssh_enabled`; a node with it off refuses every SSH role. Turning it on is a real +widening of that machine's egress and key exposure, so the gate fails **closed**: a +node that cannot read its own setting refuses, the opposite of the directory +allowlist's deliberate fail-open, because here the risk is what enabling lets +Subshell do. + +The flag is plane-authored; there is no node self-toggle CLI, so the node caches +the plane's value only to fail closed (no two-writer reconcile, unlike +maintenance, which the node can also set). Enabling is owner-only (admin on +`local`), audited. When a user needs a machine whose flag is off, the launch is +refused with the remedy named in copy: ask the owner of that machine to enable SSH +on it. The gate is per role: relay mode needs both the connecting machine (dials +out) and the key home (whose agent signs) enabled; jump mode needs only the origin +A (which dials out and serves its key) enabled; a jump hop `J` and the destination +`D` are dialed-to, not gated. Only the owner (admin on `local`) of an enabled machine may route SSH +through it (mirrors `nodeCanSsh`, which carries the same local-admin exception +`nodeCanManageFor` applies); an `edit` grantee of someone else's enabled node +is not thereby allowed to use that machine's egress or keys. The gate ships with a +`NODE_PROTOCOL_VERSION` bump because the node-link compatibility gate is exact-match: +a pre-bump agent that never sees `set_ssh_enabled` and reports no `sshEnabled` would +otherwise be wire-indistinguishable from a post-bump agent that honors both, and a +capability gate cannot live with that ambiguity (the file's own history bumped even +for additive frames for this reason). + +## 5. Architecture components + +### 5.1 Key grant (M2) + +An owner-scoped, plane-stored record (an M2 concept; M1 needs none because the key +source is always the connecting machine): to reach destinations matching a +selector, sign with the agent on machine A. It carries a name, the key-home node +id, and a destination selector (an alias, a concrete host, or a host glob matched +against the resolved destination hostname, as policy only, unrelated to the +wildcard patterns discovery drops). It stores no secret and no key bytes. Host-key +pins are NOT part of a grant: they are stored per resolved destination +`user@host:port` (section 9), so a selector naming many hosts has many pins, never +one. An "ask me each time" approval flag is deferred with the section 16 approval UX +and stored only once that design lands. Writes are audited as +`node.ssh_grant.create|update|delete` (new names to add to `docs/security.md` +section 10), naming ids and host only, never any credential. + +### 5.2 SSH launcher + +A single plane service that composes the `ssh` invocation toward the chosen +connecting machine and mode. It reuses the OpenSSH-based resolution already +specified: read concrete aliases from `~/.ssh/config`, resolve an alias with +`ssh -G`, and render a per-connection config from the resulting snapshot (section +15 states which are ported). Discovery and resolve run ON the connecting machine +via `sendCommand` (the signed, replay-fenced node command transport, present on +`main`); alias resolution is a user-initiated action, never run during discovery. +Every token in the built command is passed through `shellQuote`; the connection is +one shell string through tmux, so quoting is the load-bearing defense and is never +relaxed. The built `ssh` runs against the rendered snapshot config via `-F` only, +never the connecting machine's live `~/.ssh/config`: `~/.ssh/config` is +discovery/resolve input only, and the `-F` file is rendered solely from the approved +snapshot: settings refused by name at resolve (ProxyCommand, tunnels/forwards, +remote/local commands, env and escape sends) never reach the render, and settings +like `ForwardAgent` that are neither snapshotted nor named cannot reappear at +connect time because they are absent from the rendered file and ssh's own defaults +are off. + +### 5.3 Sealed agent relay (Milestone 2) + +The one genuinely new subsystem: encrypted channels carrying the SSH agent +protocol. On B, a local Unix socket speaks the `ssh-agent` wire and is exported as +`SSH_AUTH_SOCK` into the pane's `ssh`; requests arriving on it are sealed +end-to-end to A and shipped through the plane. On A, a responder accepts only +requests the plane brokered for a live grant, enforces the agent method allow-list +(section 8), forwards permitted requests to A's real agent, and seals the response +back. The plane routes by the recipient id in the envelope header without opening +anything. Detail in section 8. + +This is not "the primitives used as is." The roster half already exists: a node's +registration ECDH-ES identity (`apps/node/agent/src/identity.ts`, private half never +leaves the machine) is registered in the identities store as a `node:` principal +at enroll time (the enroll route writes it; node deletion cascades it), and +`GET /api/identities/:principalId` already serves any principal's public key behind +a `channels:read` gate. What is missing is the node end: the agent holds no REST +credential, so the peer's JWK must be delivered to it inside a signed command on the +existing node link; the agent has no machine pin store (a pane's channel use already +writes a `peers.json` into the agent's data dir, but those are `sess:` channel +pins kept by the pane-side MCP under the `SUBSHELL_CHANNEL_PIN` escape, and the +machine-relay store must be a separate one that never inherits that laxity); and +there is no operator-visible machine pin display or recovery flow. Building that +(link key delivery, a node-side machine pin store, first-use pinning, pin recovery) +is part of the M2 work, and the relay is +always pin-strict: `SUBSHELL_CHANNEL_PIN` is the channels' recovery escape and never +relaxes a machine-relay pin. It must also +mint and hold a per-machine ES256 signing keypair: `identity.ts` today holds only +the ECDH-ES encryption half, and the ES256 command keypair (`control-keys.ts`) is +plane-held, so the signing key the relay's origin proof (section 8) needs does not +exist per-machine yet. + +### 5.4 Terminal pane + +A launch of the `terminal` harness whose command is the launcher's `ssh` +invocation, on the connecting machine, under the tmux PTY. Streaming, input, +capture, logging, and sharing are the ordinary pane path. The far side needs only +sshd. + +To use the connecting machine's own agent, the launcher must pass `SSH_AUTH_SOCK` +into the ssh process: the pane env allowlist (`pane-runtime/src/launch.ts` +`curatedEnv`: `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `TMPDIR`, `LANG`, +`LC_ALL`, plus `CLAUDE_PATH`) deliberately omits it, so this is a scoped exception +applied to the `ssh` invocation only, not the whole pane env. In relay mode the +socket is B's proxy, not A's real agent. + +Sharing caveat: a pane whose ssh can drive an agent must not be handed to a viewer +who can type into it, since typing `ssh ` from that shell would use the +agent. So an SSH-terminal pane is **view-only to sharees** (input requires the +owner); an explicit "this pane can sign" affordance is a possible M2 refinement. + +### 5.5 Agent-destination upgrade + +A secondary act on an open session: "Set up Subshell here" boots the Subshell +runtime on D over an authenticated connection using the same launcher and key +source. This is where the brokered-session machinery from #330 becomes relevant, as +a consumer of the shared launcher rather than a separate product. Out of Milestone 1 +scope; revisited in the Milestone 2 spec. + +## 6. Phasing + +### 6.1 Milestone 1: terminal and jump (the mergeable first slice) + +Port the neutral primitives, then ship: SSH-terminal panes to any D regardless of +node status; alias discovery and `ssh -G` resolve; the destination-first launch +flow (section 11); destination host-key trust via the connecting machine's own +`~/.ssh/known_hosts` (stock OpenSSH `StrictHostKeyChecking`; the cross-box pinning +captured at grant creation (section 9) is M2); Jump mode end to end; the per-node SSH +capability gate (section 4.3, off by default including `local`, owner-enabled, +fail-closed, audited); and audits. No session-frame codec, supervisor, or +runtime-serve in this milestone. Delivers "SSH into any machine" for every case +that does not require the session to run on B with a key that lives only on A. + +### 6.2 Milestone 2: the sealed agent relay (own spec, own review) + +Relay mode, the grant/authorization machinery and machine-identity roster that +drive it, and the optional agent-destination upgrade. A focused security review +pass is expected here; it is the risky core and is deliberately isolated behind a +clean seam the M1 launcher exposes. + +## 7. Data model + +- `ssh_enabled` (default `false`, including `local`): the per-node capability gate + of section 4.3. Plane-authored row value, cached in a node-side mirror file the + node reads only to fail closed; owner to change (admin on `local`, section 4.3); + audited as `node.ssh_enabled.update`, the node-family verb pattern, added to the + `docs/security.md` section 10 event list. Not a new table (a column on `nodes`). +- `ssh_saved_hosts` (M1): per owner, keyed by the resolved canonical destination + `user@host:port` (display alias separate), with the connecting box used and + last-connect; recent hosts derive from connects. Also stores the user's default + connecting machine; with none set the UI prompts rather than silently choosing. +- `ssh_key_grants` (M2): id, owner, name, key-home node id, destination selector. + No secrets, no keys. +- `ssh_host_pins` (M2): keyed by resolved destination `user@host:port`, the pinned + public host key. One pin per resolved destination, independent of grants. +- Relay session state (M2, in-memory on the plane with a durable audit row): a + brokered pairing of grant, A, B, the pane/subshell id, an opaque routing ref, and + a lifetime. The agent payload (challenge, signature, and which of A's keys within + the filtered identity list; there is no destination field in an agent sign + request) lives only inside sealed envelopes, never in this state. +- Panes reuse the existing `subshells` rows; a connecting-machine-only SSH pane is + an ordinary row on B (or A in jump mode). Nothing about the key is written there. + +## 8. Relay protocol and security (M2) + +- Sealing reuses `packages/mcp-core/src/crypto.ts` (`seal`/`open`, + ECDH-ES+A256KW with A256GCM, plaintext recipient id header for blind routing); + pinning follows `packages/mcp-core/src/pin-store.ts`'s TOFU byte-equality rule in + a SEPARATE machine-pin store (section 5.3) - not the channels' `peers.json`, and + not the module-global `SUBSHELL_CHANNEL_PIN` settings its public entry honors. The + envelope the plane stores and routes is the same opaque blob the encrypted + channels already relay. +- The plane brokers a relay pairing when a grant is used: it tells A to expect + sealed requests for this pane, and B that its agent socket backs onto A. A routing + id rides in the envelope header for plane-side pairing, quota, and lifetime; the + agent payload rides inside the ciphertext. +- Agent method allow-list: the responder forwards only `REQUEST_IDENTITIES` and the + sign/authenticate methods OpenSSH needs. It refuses `ADD_IDENTITY`, + `REMOVE_IDENTITY`, `LOCK`/`UNLOCK`, and extension requests, so a granted handshake + window cannot mutate or lock A's agent against its other panes. + `REQUEST_IDENTITIES` is answered with A's keys filtered to the identity files the + resolved connection snapshot names for that destination (bounded by + `SSH_MAX_IDENTITY_REFS`), not A's whole list. +- Replay posture: because the plane stores envelopes, a byte-identical envelope + could be resent within the handshake window; a nonce/freshness inside the + ciphertext, bound to the relay session, makes such replays inert. +- Origin: sealing supplies confidentiality only. The reused primitive (`crypto.ts`) + is ephemeral-static ECDH-ES+A256KW: the sender contributes a per-message throwaway + key and only the recipient's static key is used, so a seal to A authenticates no + one. Anyone holding A's public key, including the plane that registered it, could + craft an envelope A opens. Origin therefore comes from a SIGNATURE: B signs the + inner payload with its machine key and A verifies against B's pinned key (the ES256 + JWS precedent from node command signing); the signature rides inside the sealed + envelope, which hides the payload from the plane. A's own TOFU pin of B's signing + key is the authorizer, not the plane that brokers the pairing. Signatures run both + ways (A signs its responses back, B verifies against its pin of A), and the + relay-session nonce is bound into both directions, so a captured envelope is inert + on replay. This rests on the + machine-identity roster and pin flow of section 5.3 and the pin-establishment + window of section 10. +- Scope of signing: SSH agent sign requests carry no destination, so a grant cannot + bind the agent to a host at the protocol layer; the residual risk (a compromised B + using the granted key against other hosts that key can reach) is accepted under + the single-operator posture and bounded by serving only the snapshot-named + identities plus instant revoke. +- No forwarding onward: B authenticates to D using A's agent but does not run + `ssh -A` toward D. A's agent is used for the handshake and is never exposed on D, + strictly safer than stock agent forwarding. +- Handshake-window lifetime: an SSH agent is needed only at connection + establishment (rekeying re-verifies the host key, not the client agent). The relay + proxy socket on B is created for the handshake, sealed traffic flows for seconds, + then the socket is unbound and the pairing torn down while the established pane + keeps running on its own transport. This is the "discard immediately after + connecting" intent, achieved without the key ever moving. A fresh re-authentication + re-opens the relay, requiring A online at that moment. + +## 9. Host-key trust + +Trust for D follows the key-holder, not the connecting box, and pins are stored per +resolved destination `user@host:port` (never one per grant). + +- M1: the connecting machine is the key home, so this is plain OpenSSH. The launcher + connects with `StrictHostKeyChecking=accept-new`: a first connect to D records its + key into the connecting machine's `~/.ssh/known_hosts`, and OpenSSH itself refuses a + later changed key (we run no separate byte-check in M1). +- M2 (relay): when a grant is created, A's trust in a destination's host key is + captured (TOFU from A's `~/.ssh/known_hosts`, or an explicit pin) and stored as + `ssh_host_pins` against that resolved destination. B's ssh then validates D + against a rendered `UserKnownHostsFile` containing the pin with + `StrictHostKeyChecking=yes`; B's own `known_hosts` is not the authority. + +A changed host key is a byte-equality hard block, matching the pin doctrine elsewhere +in the threat model. + +## 10. Trust model delta versus section 0 + +Gained: the plane never holds or sees an SSH private key or a plaintext sign +challenge; a plane compromised AFTER A first pinned B's signing key cannot forge a +request A will honor (origin is B's signature over the payload, verified against that +pin, not the seal); D never receives a forwarded agent. + +The word "after" is load-bearing. The TOFU pin between two machines is established +through the plane, so a plane compromised BEFORE A's first pin of B can substitute +its own key. That is exactly the first-use window `pin-store.ts` documents for +channels (a compromised server swapping a key into a roster slot, undetectable to +the sender) and the reason that module exists. Closing it needs an out-of-band pin or +a first-secure-contact story, which is M2 design, and `docs/security.md` section 0 +already lists control-plane compromise as not-defended. The relay reuses `pin-store`'s +byte-equality rule but not its `SUBSHELL_CHANNEL_PIN` escape: a machine-relay pin is +always strict, and recovery is an explicit re-pairing that replaces the pin +(section 5.3). + +Unchanged or accepted: the OS-user boundary on A and B is still the operator; a +machine you run the agent on is a machine you trust. A already-compromised B can +drive its granted relay during the handshake (bounded by the method allow-list and +key-scoping, section 8). The plane retains what it always had: denial of service, +and metadata (which machines talk, timing, sizes, and the plaintext routing ids on +sealed envelopes, which reveal no key material). + +## 11. UI + +The surface is designed fresh; #330's connect UX is intentionally not carried over. +That flow led with "where do you connect from," offered several overlapping ways to +pick a target at once (connect-from, connected hosts, saved locations, recent, a +manage link), and implied a Subshell runtime on the far side (folder, agent, and +preset pickers) even for what should be a plain shell. It read as confusing, so we +design around its failure modes rather than patch it. + +The job is one question, "where do you want to work?", so the flow leads with the +destination: + +1. A single searchable destination field. The connecting box defaults to the user's + chosen default connecting machine (the one used unless step 2 changes it), and + the field is fed by that box's `~/.ssh/config` (via `sendCommand`, section 5.2) + plus saved and recent hosts (the discovery idea, reused). Pick or type a host. +2. Choosing a different connecting machine, or a key source on another machine + ("sign with [machine]'s keys"), is a progressive follow-up shown only when the + default cannot do it, never a mandatory first step. The cross-machine key source + is the M2 relay; in M1 the key is always on the connecting box. The common case + just opens. +3. Opening yields the interactive terminal pane. "Set up Subshell here" is a + secondary act inside the pane, not a competing mode on the landing screen. + +Saved and recent hosts live on the plane, keyed by the resolved canonical +destination `user@host:port` (the alias is display metadata only), so an edited +alias can never silently re-point a saved destination or a pin. A machine whose SSH +gate is off (section 4.3) is never silently offered: when one is needed it is named +with the remedy to ask its owner to enable SSH there. Terminology stays to three +nouns: destination, connecting machine, key source. The destination field is a +`SearchableSelect` like the other launch pickers. Shipped copy follows the design +system: role tokens only, at most two sentences, no em dashes. + +## 12. Failure modes and UX + +- A offline in relay mode: refuse before the handshake, name the remedy (bring the + key machine online); never copy the key to B. +- No matching key in A's agent: say so and point at loading it; never fall back to + reading a passphrase-protected file. +- D host key changed since first recorded: the connect is refused (M1: OpenSSH's own + changed-key refusal against the connecting machine's `known_hosts`; M2: our pin's + byte-equality block), naming D. +- Grant names a connecting machine that cannot reach D: a distinct error from "no + key," so the two causes are not confused. +- Relay expired or A dropped mid-handshake: the connection either completed (pane + fine) or fails cleanly with a retry that re-opens the relay. +- Quota or policy refusal (by the plane or by A): a loud refusal naming the reason, + never a silent fallback (the convention this section follows). + +## 13. Security rules honored + +- No key material, challenge, or signature content in any log, argv, audit row, or + notification. Audit rows name ids, hosts, and field names only. +- The agent proxy socket on B is 0600 in a per-session directory; the rendered + connection config is 0600; transient files are removed with the pane. +- `shellQuote` on every token of the built command; the connection is one tmux shell + string, and that is never reasoned away. +- Node command transport is the existing authenticated, signed, replay-fenced + `sendCommand` path (present on `main`). The desktop ssh *broker* and its monotonic + replay fence are #330 code, not on this base; if M2 needs the laptop's own broker + it is re-ported then, not assumed here. +- Outbound SSH is gated per node, off by default including `local`, and fails closed + when the setting is unreadable (section 4.3); enabling is owner (admin on `local`) + only and audited (`node.ssh_enabled.update`). +- `SSH_AUTH_SOCK` is exported only into the ssh invocation, never the whole pane env, + and an agent-capable pane is view-only to sharees (section 5.4). +- Static imports only; no dynamic import outside the one sanctioned plugin site. +- Machine-to-machine sealed payloads reuse the channel crypto and the TOFU + byte-equality rule (in their own always-strict machine store, section 5.3), not a + new bespoke cipher. + +## 14. Testing + +- Unit, M1: alias discovery budgets and wildcard exclusion; `ssh -G` snapshot + parsing and the forbidden-settings refusal; ProxyJump argv construction and + quoting; the `ssh_enabled` gate default-off, fail-closed-on-unreadable, + owner/admin gating, and the `nodeCanSsh` boolean matrix (the predicate answers + yes/no; distinct refusal copy belongs to the launch route, section 12); the + scoped `SSH_AUTH_SOCK` + export (present on the ssh process, absent from the rest of the pane env); and the + view-only rule (a sharee typed into an SSH-terminal pane is refused input, + section 5.4). +- Unit, M2: connection-config render with the pinned `UserKnownHostsFile`; the + agent-proxy wire framing against a stub agent, including that non-allow-listed + agent methods are refused; grant validation; relay envelope seal/open (right + recipient opens, wrong id and tampering fail); envelope replay within a window is + inert. +- Integration, M2: two in-process machine identities as A and B with a fake plane + relay; a sign request reaches A's stub agent, the response returns sealed, the + relayed blob is opaque at the plane, an ungranted or wrong-origin request is + refused, and revoke stops signing mid-session. +- End to end: the sshd fixture reused as D. M1 direct drives a single-hop pane to D + on the connecting machine's own agent (the section 1 primary and section 11 common + case): it opens, streams, accepts owner input, and refuses a sharee's input. M1 + jump drives a real two-hop `ProxyJump` (bastion plus target) with the key on the + origin only. M2 relay is the crown-jewel test: a second + machine as A with the key in its agent, a B with no key, B authenticates to D + through the plane relay while the plane-side wire capture shows only opaque + envelopes plus plaintext routing ids, and the key never appears on B or the plane. +- CI regime per `verification.md`: focused files while iterating, the full trio + plus `lint:prose` at the boundary, e2e under the existing serial container regime, + `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never live `:3080`. + +## 15. Branching and reuse from main + +`origin/main` does not contain the ssh-runtime subsystem (discovery, resolve, the +connection-snapshot grammar, the brokered-session supervisor, the ssh-broker, or the +sshd fixture); those live only on `feat/ssh-support`. The E2EE primitives +(`mcp-core` seal/open and the TOFU pin store) are already on main. +`feat/ssh-anywhere` re-introduces only the neutral primitives: OpenSSH config +discovery, `ssh -G` resolve, and the connection-snapshot grammar, plus the sshd test +fixture, as a small first commit, and deliberately does not carry the session-frame +codec, supervisor, or runtime-serve. `feat/ssh-support` stays open and unmerged as a +reference for the brokered-session transport that M2 may revisit. Reuse is code and +ideas only: the UX is redesigned (section 11) rather than ported, and #330's connect +flow is a cautionary example, not a base. + +## 16. Deferred / open items for Milestone 2 + +- The node end of the machine-identity flow (section 5.3): link key delivery, the + node-side pin store, pin display and recovery, and how to narrow the first-use + window (section 10). The strictness question is decided: machine-relay pins are + always strict, no `SUBSHELL_CHANNEL_PIN` escape. +- Whether the agent-destination upgrade reuses #330's runtime-session protocol or a + lighter boot now that the terminal path exists. +- Per-use approval UX on A (notification or prompt to confirm a handshake), versus a + standing grant; and whether the approval flag on the grant ships with it. +- Whether to support A's identity by file (with an on-A passphrase prompt) at all, + versus requiring the key loaded in A's agent. +- Enrolling a relay-only destination as a first-class node automatically after a + successful "set up Subshell here." + +## 17. Success criteria + +Milestone 1 is done when, from the web app and the enrolled laptop, an operator +opens an interactive pane against an arbitrary sshd-only host on a machine whose +owner has enabled the (default-off) SSH gate, using that machine's own agent (via +the scoped `SSH_AUTH_SOCK` export) or a jump through a hop; it streams and accepts +the owner's input (sharees view-only, section 5.4), and no private key or plaintext +challenge ever crosses the plane. In M1 the plane is not on the ssh transport path: +ssh runs directly between the connecting machine and D, so no SSH protocol bytes, +keys, or challenges cross the plane; pane streaming and logging stay the ordinary +pane path (section 5.4), and D sees only a normal ssh handshake. Milestone 2 is done +when the same pane can run on a machine with no key at all, authenticating through a +machine that does, with a CI test proving the plane sees only opaque envelopes and +the key never leaves its home.