Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a8fbde6
docs(plans): Plan 2 - the launcher tier (M1 machine half: discovery/r…
theogravity Oct 8, 2026
4eeea5c
feat(protocol): ssh command arms delegate to the tier-1 grammar, ssh …
theogravity Oct 8, 2026
4e40b91
feat(pane-runtime): interactive ssh renderer - every-hop confinement,…
theogravity Oct 8, 2026
d6b25d9
feat(plugins): built-in ssh harness - the terminal launch, pointed at…
theogravity Oct 8, 2026
03529a8
feat(agent): gated ssh discovery/resolve; protocol 17 for the ssh lau…
theogravity Oct 8, 2026
0cada7c
fix(agent): reviewer round 1 - the shared refusal names the real cons…
theogravity Oct 8, 2026
8e09818
feat(launch): ssh config written by the spawning machine at the byte-…
theogravity Oct 8, 2026
9402722
feat(db): ssh_saved_hosts + the subshells.ssh snapshot column (migrat…
theogravity Oct 8, 2026
ca24274
feat(api): ssh launch plumbing - service, RPC wrappers, saved-host le…
theogravity Oct 8, 2026
55ab853
feat(api): the ssh launcher surface - gated discovery, resolve, launc…
theogravity Oct 8, 2026
4e0e839
feat(api): ssh panes take input from their owner alone (spec §5.4)
theogravity Oct 8, 2026
2fd8be4
fix(api): ssh relay input admission is required, not defaulted; chann…
theogravity Oct 8, 2026
324b2c7
fix(errors): stripAnsi ends an OSC at ST as well as BEL, so shell-int…
theogravity Oct 8, 2026
79e9fe2
test(e2e): ssh pane end to end - gated launch, owner input typed over…
theogravity Oct 8, 2026
4e07918
fix(api): local ssh panes are swept at terminate and delete, not only…
theogravity Oct 8, 2026
01aa890
fix(node): sweep orphan per-pane ssh dirs; sweep ssh dirs on maintena…
theogravity Oct 8, 2026
43b21f5
chore(changeset): the ssh launcher tier (protocol 17; server before n…
theogravity Oct 8, 2026
aa3e4ca
fix(ci): the End-to-end job installs OpenSSH so spec 22 runs, not skips
theogravity Oct 8, 2026
a5930dd
docs: tier-2 comments tell the truth about paths, teardown points, an…
theogravity Oct 8, 2026
fde9aca
fix(api): the ssh resolve/launch route descriptions carry the Match e…
theogravity Oct 8, 2026
5508ad5
docs(ssh): comments tell the truth; sshDirsDir goes file-private; pla…
theogravity Oct 8, 2026
19b81f2
docs(plans): Task 8's pointer records what actually shipped (no respo…
theogravity Oct 8, 2026
0e719f1
docs(ssh): the last bare §2 cites name their real sources; the canoni…
theogravity Oct 8, 2026
f568564
fix(ssh): a clean-machine launch defaults to Terminal, not the ssh pl…
theogravity Oct 8, 2026
5360ddf
docs(ssh): reviewer - the plugins param feeds the agent-harness TYPE …
theogravity Oct 8, 2026
faee0f3
fix(ssh): sweep the local ssh config dir in the shared death transition
theogravity Oct 8, 2026
a6dcbd4
fix(ssh): close the last two ssh-config leak paths and correct the sw…
theogravity Oct 8, 2026
91a3159
test(ssh): cover the reconcile no-socket sweep
theogravity Oct 8, 2026
41fd689
docs(protocol): retire two stale ssh-frame comments the launcher made…
theogravity Oct 8, 2026
f7379a3
test(backups): give the cancel-to-404 poll a wall deadline
theogravity Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/ssh-launcher-tier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@internal/server": minor
"@internal/node": minor
"@internal/subshell-protocol": minor
"@internal/pane-runtime": minor
"@internal/server-web": minor
"@internal/mobile": minor
---

SSH launcher tier: an enabled node can open interactive SSH-terminal panes at any
sshd host. `GET /api/ssh/aliases` and `POST /api/ssh/resolve` answer alias discovery
and `ssh -G` normalization on the connecting machine; `POST /api/ssh/launch` composes
the pane from the approved snapshot (rendered `-F` config, every-hop confinement
policy, host keys trusted `accept-new` in the connecting machine's own
`known_hosts`); saved/recent hosts and a default connecting machine live on the
plane keyed by the resolved destination. SSH-terminal panes accept input from their
owner alone (sharees view). The per-node `ssh_enabled` gate from the previous tier
refuses every one of these doors until the owner turns it on. A launch form with no
prior choice still defaults to the plain Terminal rather than to SSH, whose bare
launch has no destination to open; SSH stays pickable, and a preset names its own
agent.

Deployment: update the server before the nodes. The link gate is exact-match at
protocol 17, so a lagging agent is held (offline for every purpose but update)
until it crosses the bump on the update command. Nothing changes for anyone until
a node's gate is enabled.

Known limitation: keystrokes typed during the initial ssh handshake can be
swallowed before the remote shell reaches its prompt; the pane is fully usable
once connected.
2 changes: 1 addition & 1 deletion .claude/rules/security-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path,

Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row.

Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|logging.update|update|update.unknown|shares_set|local_share_changed|service` · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10.
Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10.

## Deliberate non-validation & accepted risks

Expand Down
47 changes: 40 additions & 7 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -927,16 +927,49 @@ jobs:
- name: Trust the bind-mounted workspace
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"

# The session-lifecycle spec spawns real tmux sessions (stub harness).
- name: Ensure tmux and jq
# Spec 12 (session lifecycle) spawns real tmux sessions (stub harness);
# spec 22 (ssh terminal) drives a REAL sshd plus an ssh client. Neither
# OpenSSH binary is in the builder image; installing here rather than
# in the image keeps the shared image lean. The container job runs as
# root, so apt-get needs no sudo. Tools map to PACKAGES, not names:
# "ssh" is not an apt package (openssh-client ships ssh + ssh-keygen;
# openssh-server ships sshd), so the missing binaries are translated
# before the deduped set is installed.
- name: Ensure tmux, jq, and OpenSSH
run: |
set -euo pipefail
missing=()
for tool in tmux jq; do command -v "$tool" >/dev/null || missing+=("$tool"); done
if [ ${#missing[@]} -gt 0 ]; then
apt-get update && apt-get install -y --no-install-recommends "${missing[@]}"
pkg_for() {
case "$1" in
tmux) echo tmux ;;
jq) echo jq ;;
ssh|ssh-keygen) echo openssh-client ;;
sshd) echo openssh-server ;;
esac
}
missing_pkgs=()
for tool in tmux jq ssh ssh-keygen sshd; do
command -v "$tool" >/dev/null && continue
pkg="$(pkg_for "$tool")"
for p in "${missing_pkgs[@]+"${missing_pkgs[@]}"}"; do
[ "$p" = "$pkg" ] && continue 2
done
missing_pkgs+=("$pkg")
done
if [ ${#missing_pkgs[@]} -gt 0 ]; then
apt-get update && apt-get install -y --no-install-recommends "${missing_pkgs[@]}"
fi
tmux -V && jq --version && ssh -V
# sshd -V prints usage and exits nonzero on some builds, so the
# presence proof is the binary: on PATH, else the package's
# documented location (the fixture probes /usr/sbin too).
command -v sshd || ls -l /usr/sbin/sshd
# Loud post-install assertion: an apt package rename would
# otherwise surface only as spec 22 skipping, i.e. the proof
# silently not running.
if ! command -v sshd >/dev/null && [ ! -x /usr/sbin/sshd ]; then
echo "::error::openssh-server present in no sshd location (PATH or /usr/sbin/sshd)"
exit 1
fi
tmux -V && jq --version

# tsdown/rolldown run under NODE, not bun — their bin shebang is
# `#!/usr/bin/env node` — so the repo has always had a Node requirement
Expand Down
13 changes: 13 additions & 0 deletions apps/client/mobile/src/lib/__tests__/agent-default.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ describe("defaultAgentId", () => {
expect(defaultAgentId([TERMINAL, CLAUDE, CODEX], ALL_INSTALLED, null)).toBe("claude-code");
});

it("a second terminal-type plugin does not steal the shell default", () => {
// Web mirror (CI e2e 2026-10-08): the catalog arrives id-sorted, so
// `ssh` (also type terminal, binary nearly always present) stands before
// `terminal`; the last resort must still land on the plain shell, which
// runs presetless, not on a bare `ssh` that exits without a host.
const SSH = plugin({ id: "ssh", name: "SSH", type: "terminal" });
expect(defaultAgentId([SSH, TERMINAL], ALL_INSTALLED, null)).toBe("terminal");
// A declared agent still outranks the shell tier.
expect(defaultAgentId([SSH, TERMINAL, CLAUDE], ALL_INSTALLED, null)).toBe("claude-code");
// The shell tier is a preference, not a filter: ssh alone still fills.
expect(defaultAgentId([SSH], ALL_INSTALLED, null)).toBe("ssh");
});

it("a missing type (older server) is still pickable, but no longer outranks a declared agent", () => {
// The middle tier reads `=== "agent-harness"` rather than
// `!== "terminal"` now, because a third type exists (network) and an
Expand Down
24 changes: 20 additions & 4 deletions apps/client/mobile/src/lib/agent-default.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { PluginView } from "@/types/plugin";
import type { SubshellView } from "@/types/subshell";

/** The built-in plain shell, by plugin id; the shell tier's favorite. Name
* matches the web mirror's constant of the same spelling. */
const TERMINAL_PLUGIN_ID = "terminal";

/**
* The New screen's default-agent rule (spec 2026-09-13 §5) — the mobile
* mirror of `defaultAgentId` in
* `apps/server/web/src/lib/subshell-compat.ts` (its call site there is
* `apps/server/web/src/lib/subshell-compat.tsx` (its call site there is
* `apps/server/web/src/components/subshell-picker/new-subshell-form.tsx`),
* kept in step by hand (the repo convention for this screen; change one,
* change both). Pure so the rule is testable without a device.
Expand All @@ -16,8 +20,8 @@ import type { SubshellView } from "@/types/subshell";
* stays the backstop for the race.
*
* The order: the harness of the user's most recent subshell while usable →
* the first usable plugin whose type IS `agent-harness` → anything usable →
* null.
* the first usable plugin whose type IS `agent-harness` → the plain shell
* (id `terminal`) → anything usable → null.
*
* That middle tier is a positive test, not the old `!== "terminal"`. The
* exclusion was right while two types existed and became wrong the moment a
Expand All @@ -26,6 +30,13 @@ import type { SubshellView } from "@/types/subshell";
* longer wins that tier; it still reaches the "anything usable" fallback, so
* nothing becomes unpickable.
*
* The shell tier is the ssh plugin's doing, mirrored from web (CI e2e
* 2026-10-08): `ssh` is also type terminal and its binary is nearly always
* present, and the catalog arrives id-sorted, so anything-usable would land
* on a bare `ssh` that exits without a host. The plain shell runs presetless,
* so it is the fallback; it stays a preference, not a filter, so an
* ssh-only catalog still fills.
*
* @param plugins - the instance catalog (`GET /api/plugins`)
* @param nodeHarnessInstalled - whether the SELECTED node reports this
* harness's binary installed (caller passes `true` when the node carries no
Expand All @@ -49,7 +60,12 @@ export function defaultAgentId(
if (recent && usable(recent)) return recent.id;
}
const usableAgents = plugins.filter(usable);
return usableAgents.find((p) => p.type === "agent-harness")?.id ?? usableAgents[0]?.id ?? null;
return (
usableAgents.find((p) => p.type === "agent-harness")?.id ??
usableAgents.find((p) => p.id === TERMINAL_PLUGIN_ID)?.id ??
usableAgents[0]?.id ??
null
);
}

/**
Expand Down
169 changes: 169 additions & 0 deletions apps/node/agent/src/__tests__/commands-ssh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type JsonValue, NODE_PROTOCOL_VERSION, type NodeCommandBody } from "@internal/subshell-protocol";
import type { CommandContext } from "../commands/context.js";
import { dispatchCommand } from "../commands/index.js";
import { writeSshEnabled } from "../ssh-enabled.js";

/**
* The two SSH read arms of the launcher tier (spec 2026-10-07 §4.3/§7), driven
* through `dispatchCommand` exactly as the daemon drives them (posture copied
* from commands-maintenance.test.ts: real temp dirs, the local mirror written
* with its own writer, every refusal asserted by `ok:false` + exact message).
*
* What THIS file owns that nothing else does:
* - the GATE ordering: a machine whose local mirror is not ON answers
* `ssh disabled on this node` BEFORE any binary lookup or spawn. The proof
* is a working recording shim that never runs (its log file is never even
* created) and a broken ladder whose message never surfaces.
* - discovery's names-only answer from a fixture HOME, through the frozen
* wire validator on the way out.
* - routing: a well-formed `ssh_resolve_config` reaches the HANDLER (it
* answers with the handler's own failure class, not dispatch's
* `unsupported`). Malformed aliases die at the frame parser before this
* point — the tier-1 parser tests own that refusal.
*
* The real `ssh -G` behavior against a fixture config is the tier-1 resolver
* suite's; the composed launch path is the e2e's. Do not re-test either here.
*/

const STAMP = "2026-10-07T10:00:00.000Z";
const GATE_REFUSAL = "ssh disabled on this node";

let base: string;
let savedSshPath: string | undefined;
let savedHome: string | undefined;

beforeAll(() => {
base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-ssh-")));
savedSshPath = process.env.SUBSHELL_SSH_PATH;
savedHome = process.env.HOME;
});

afterAll(() => {
if (savedSshPath === undefined) delete process.env.SUBSHELL_SSH_PATH;
else process.env.SUBSHELL_SSH_PATH = savedSshPath;
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
rmSync(base, { recursive: true, force: true });
});

/** Recording shim (mirrors the runtime test helper's shape, baked constants). */
function writeShim(dir: string, opts: { dashG?: string } = {}): { bin: string; log: string } {
mkdirSync(dir, { recursive: true });
const log = join(dir, "shim.log");
const lines = ["#!/bin/sh", `LOG='${log}'`, `printf 'ARGS:%s\\n' "$*" >> "$LOG"`];
if (opts.dashG !== undefined) {
lines.push(`for a in "$@"; do if [ "$a" = "-G" ]; then cat <<'GEOF'`);
lines.push(opts.dashG);
lines.push("GEOF\nexit 0\nfi; done");
}
lines.push("exit 0");
const bin = join(dir, "ssh");
writeFileSync(bin, `${lines.join("\n")}\n`, { mode: 0o755 });
return { bin, log };
}

/** A context whose only meaningful fact is the data dir its gate mirror lives in. */
function makeCtx(dataDir: string): CommandContext {
return { config: { dataDir } } as unknown as CommandContext;
}

/** Fresh data dir; `on: true` writes the mirror through its real writer. */
function dataDir(tag: string, gate: "absent" | "on" | "unreadable"): string {
const dir = join(base, tag, "data");
mkdirSync(dir, { recursive: true });
if (gate === "on") writeSshEnabled(dir, { on: true, changedAt: STAMP });
if (gate === "unreadable") writeFileSync(join(dir, "ssh-enabled.json"), "not json at all");
return dir;
}

/** A fixture HOME whose account config names two hosts. */
function homeWithConfig(tag: string): string {
const home = join(base, `${tag}-home`);
mkdirSync(join(home, ".ssh"), { recursive: true });
writeFileSync(join(home, ".ssh", "config"), "Host beta\n User x\nHost alpha\n HostName a.example\n");
return home;
}

describe("ssh gate: discovery and resolve refuse before anything runs", () => {
it("an absent mirror refuses BOTH arms by the exact name, whatever the ladder says", async () => {
const shim = writeShim(join(base, "shim-gate"), {
dashG: "host resolved.example\nhostname resolved.example\nuser deploy\nport 22\n",
});
process.env.SUBSHELL_SSH_PATH = shim.bin;
const dir = dataDir("gate-absent", "absent");

const discovery = await dispatchCommand(makeCtx(dir), { type: "ssh_discover_aliases" } satisfies NodeCommandBody);
expect(discovery).toEqual({ ok: false, error: GATE_REFUSAL });

const resolve = await dispatchCommand(makeCtx(dir), {
type: "ssh_resolve_config",
alias: "app02",
} satisfies NodeCommandBody);
expect(resolve).toEqual({ ok: false, error: GATE_REFUSAL });
// The gate check PRECEDES the ladder and the spawn: a perfectly good shim
// was never executed, and an answer computed after lookup would have said
// something else entirely.
expect(existsSync(shim.log)).toBe(false);
});

it("a broken ladder does not speak first: gate-off answers with the gate's own words", async () => {
process.env.SUBSHELL_SSH_PATH = "/nonexistent/ssh-binary";
const res = await dispatchCommand(makeCtx(dataDir("gate-vs-ladder", "absent")), {
type: "ssh_resolve_config",
alias: "app02",
} satisfies NodeCommandBody);
expect(res).toEqual({ ok: false, error: GATE_REFUSAL });
});

it("an unreadable mirror fails CLOSED (refuses) with the same words", async () => {
const res = await dispatchCommand(makeCtx(dataDir("gate-unreadable", "unreadable")), {
type: "ssh_discover_aliases",
} satisfies NodeCommandBody);
expect(res).toEqual({ ok: false, error: GATE_REFUSAL });
// The same classifier gates the resolve arm; pin the pairing, not just one side.
const res2 = await dispatchCommand(makeCtx(dataDir("gate-unreadable", "unreadable")), {
type: "ssh_resolve_config",
alias: "app02",
} satisfies NodeCommandBody);
expect(res2).toEqual({ ok: false, error: GATE_REFUSAL });
});
});

describe("ssh_discover_aliases / ssh_resolve_config arms (gate ON)", () => {
it("discovery answers the sorted NAMES through the validator from the account config", async () => {
const dir = dataDir("disc", "on");
process.env.HOME = homeWithConfig("disc");
const res = await dispatchCommand(makeCtx(dir), { type: "ssh_discover_aliases" } satisfies NodeCommandBody);
expect(res.ok).toBe(true);
const data = (res as { data?: JsonValue }).data as {
aliases: string[];
includeCycle: boolean;
truncated: boolean;
};
expect(data.aliases).toEqual(["alpha", "beta"]);
expect(data.includeCycle).toBe(false);
expect(data.truncated).toBe(false);
});

it("a well-formed resolve command REACHES the handler: its own failure class answers, not dispatch's `unsupported`", async () => {
// Gate ON + a missing binary: only the handler body can produce the
// binary-missing class. "unsupported" here would mean the dispatch arm is
// missing; the gate's words here would mean it ran before the ladder.
process.env.SUBSHELL_SSH_PATH = "/nonexistent/ssh-binary";
const res = await dispatchCommand(makeCtx(dataDir("reach", "on")), {
type: "ssh_resolve_config",
alias: "app02",
} satisfies NodeCommandBody);
expect(res.ok).toBe(false);
expect(String((res as { error?: string }).error)).toInclude("binary missing");
});
});

/** The bump this tier owns: literal-16 pins everywhere must now read 17. */
it("NODE_PROTOCOL_VERSION is 17 (the ssh launch block + the two read arms)", () => {
expect(NODE_PROTOCOL_VERSION).toBe(17);
});
Loading
Loading