Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f4ae6bb
docs(ssh): align security and release notes with launch access
theogravity Oct 9, 2026
c50bcbe
test(ssh): preserve broker operations in real relay capture
theogravity Oct 9, 2026
946c2b0
fix(ssh): refuse relay sockets beyond OpenSSH path limits
theogravity Oct 9, 2026
8a6a24a
test(ssh): detect optional OpenSSH host-key capture tool
theogravity Oct 9, 2026
bcf7122
test(web): keep the mobile agent picker inside the viewport
theogravity Oct 9, 2026
d116f6b
feat(ssh): guide setup and preserve launch context in one wizard
theogravity Oct 9, 2026
593942b
test(ssh): make browser fixtures portable across host environments
theogravity Oct 9, 2026
ecf3a59
fix(ssh): bind identity repair consent to verified fingerprints
theogravity Oct 9, 2026
2e3d20f
test(e2e): respect isolated temporary directories
theogravity Oct 9, 2026
1411ebb
docs(ssh): replace obsolete approval workflow guidance
theogravity Oct 9, 2026
e785171
test(e2e): settle remote directory defaults before filling
theogravity Oct 9, 2026
7322a6c
docs(ssh): align security summary with session authorization
theogravity Oct 9, 2026
12eb535
fix(ssh): preserve explicit key scope through machine changes
theogravity Oct 9, 2026
80891c7
test(ssh): align fixtures with current main
theogravity Oct 11, 2026
9886591
fix(ssh): clarify setup copy and connection errors
theogravity Oct 11, 2026
043df13
test(e2e): wait for the detail header before renaming
theogravity Oct 11, 2026
44f0567
test(ssh): wait for picker focus before searching
theogravity Oct 11, 2026
b262e3e
test(ssh): wait for loaded recovery and detail controls
theogravity Oct 11, 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
8 changes: 8 additions & 0 deletions .changeset/ssh-conversational-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@internal/server": patch
"@internal/node": patch
"@internal/desktop-server": patch
"@internal/desktop-client": patch
---

Clarify SSH setup, connection errors, key selection, and identity verification. Use conversational wording, keep trust warnings explicit, and remove outdated references to the old approval flow.
5 changes: 5 additions & 0 deletions .changeset/ssh-guided-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@internal/server": patch
---

Guide SSH setup inside the subshell launch dialog. Choose or enroll a machine, enable SSH, and explicitly select keys from another accessible machine without losing the destination or workspace context. Open the same wizard from SSH settings and machine settings, and show recovery steps when setup is incomplete.
2 changes: 1 addition & 1 deletion .changeset/ssh-relay-browser-journey.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@internal/server": patch
---

Choose another machine's SSH keys from Connect, return to the original connection after approval, and distinguish failed settings reads from empty lists. SSH destination setup now shows progress and can be closed and reopened without starting another installation.
Choose another machine's SSH keys within the connection flow, and distinguish failed settings reads from empty lists. SSH destination setup now shows progress and can be closed and reopened without starting another installation.
13 changes: 3 additions & 10 deletions .changeset/ssh-relay-milestone-2.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
---
"@internal/server": major
"@internal/node": major
"@internal/subshell-protocol": major
"@internal/pane-runtime": major
"@internal/server-web": major
"@internal/node-web": major
"@internal/node-admin": major
"@internal/backend-errors": major
"@internal/mobile": major
---

SSH sealed agent relay (milestone 2): a machine that holds no keys can open an SSH-terminal pane anywhere its operator's key home can reach. The connecting machine's node binds a per-pane ssh-agent proxy socket (the pane's scoped `SSH_AUTH_SOCK`, unbound at teardown); every agent request is sealed machine-to-machine over the plane, which brokers the pairing and routes opaque envelopes by a routing ref and nothing else. The key home's responder probes the live agent's numbering (OpenSSH classic vs RFC 9987, learned per session, never assumed), forwards only `REQUEST_IDENTITIES` and `SIGN_REQUEST`, and scopes both to the grant's selected fingerprints. Origin rides an ES256 signature inside the sealed envelope; endpoint-contributed nonces and a monotonic per-direction sequence make every relayed byte replay-inert. Grants (`ssh_key_grants`) bind a key home to a destination selector; the first use of a new pair asks the key home's owner to approve (a durable `ssh_grant_requests` row plus an owner notification, a loud fail-fast refusal, never a hung pane), the approval records the chosen public fingerprints and captures the destination's host-key pin from the key home's own `known_hosts`, and revocation is instant both ways. Panes verify D against a per-session pinned `known_hosts` delivered on the signed relay-open (`StrictHostKeyChecking yes`, system-wide file disabled); a changed destination host key is a hard block and delete-plus-fresh-capture is the operator's TOFU recovery. Machine identity: every agent node carries an ES256 signing keypair reported at enroll, peers pin each other's signing AND encryption halves byte-equal in a store separate from channel pins, and the node's loopback dashboard and the machine trust card show the fingerprints for the out-of-band comparison. "Set up Subshell here" turns an open pane's destination into an enrolled node by driving the ordinary install one-liner over a separate non-interactive ssh exec, so the minted setup key never reaches the pane screen or its log; an installer line carrying a key is redacted twice over. The grants screen, the destination trust screen, and the launcher's key-home picker complete the surface.
Connect an SSH terminal using selected keys from another machine's SSH agent. Existing machine launch access is required on both machines; there are no separate SSH grants or approval requests. The relay encrypts agent requests between machines, limits identities and signing to the session's selected fingerprints, and never transfers private keys.

Deployment: update the server before the nodes. The link gate is exact-match at protocol 18, so an agent that has not crossed the bump is held and takes no relay traffic. Migrations 0049 through 0052 add the signing-key slots, the grants, the pending-approval rows, the host-key pins, and the pane's key-home column; never run them against a live database without a full archive. Nothing changes for anyone until a node's SSH gate is enabled and a launch names a key home.
Destination host keys and both machine identity keys remain pinned. Changed keys require explicit recovery. Relays close when launch access is removed, and "Set up Subshell here" preserves the original key selection while enrolling the destination without showing the setup key in the terminal.

Proof: e2e spec 23 (the crown jewel) drives the whole path against a real `ssh-agent`, a real `sshd`, real node daemons, and the real broker, with a plane-side capture asserting only opaque envelopes cross, and the 10.x numbering, the fingerprint scope, the revoke cut, the lifetime cap, the setup-key redaction, and the changed-host-key block plus TOFU recovery all asserted end to end.
Update the server before the nodes. This feature requires node protocol 19 and Subshell node 1.5.0 or later. Migration 0053 removes the obsolete SSH grant and approval tables while preserving saved destinations, host trust and audit history.
8 changes: 8 additions & 0 deletions .changeset/ssh-server-relay-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@internal/server": patch
"@internal/node": patch
"@internal/desktop-server": patch
"@internal/desktop-client": patch
---

Use the server or an enrolled node as either the connecting machine or the source of selected SSH agent keys. Preserve machine trust through explicit identity recovery, include server relay identity files in backups, and close relay sockets on shutdown. Block concurrent connections during identity repair and require verified restoration of quarantined trust files. Refuse relay socket paths that OpenSSH cannot use with guidance to shorten the connecting machine's data directory.
4 changes: 2 additions & 2 deletions .changeset/ssh-stack-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"@internal/desktop-server": patch
---

Fix SSH saved destination ports, jump-host alias resolution, identity paths containing spaces, and multiple known-hosts files. Host-key capture now selects keys for the exact destination port. Approval return links identify the exact request, and enrollment without a confirmed connection links to the enrolled node instead of offering another installation.
Fix SSH saved destination ports, jump-host alias resolution, identity paths containing spaces, and multiple known-hosts files. Host-key capture now selects keys for the exact destination port. Enrollment without a confirmed connection identifies the enrolled node instead of offering another installation.

Connections using another machine's keys now explain that ProxyJump and HostKeyAlias are unsupported before requesting approval. Direct connections refuse jump-specific authentication or trust settings that cannot be preserved.
Connections using another machine's keys now explain that ProxyJump and HostKeyAlias are unsupported before launch. Direct connections refuse jump-specific authentication or trust settings that cannot be preserved.
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|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_grant.request|ssh_grant.create (via first-use|manual; approve/create name the CHOSEN fingerprint VALUES + destination per spec §10 - the approve row is the only durable record of the operator's selection, and a fingerprint appears in NO log line or notification body)|ssh_grant.update|ssh_grant.delete (relaysClosed; the revoke also cuts live relays via closeForGrant "grant-revoked")|ssh_grant.approve|ssh_grant.deny (audit-only; grant-request expiry writes nothing)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|ssh_upgrade.run (the "Set up Subshell here" act, Task 14: pane/B/A/grant ids, canonical destination, exec id, outcome + service-chosen stage word; the minted setup key and ALL installer output appear in NO row and NO refusal copy)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, grantId on a relay launch}` (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.
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|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_machine_pin.repair (verified peer replacement; ids only)|ssh_identity.repair (admin-verified server disk identity; metadata null)|ssh_host_pin.create|ssh_host_pin.delete (canonical destination and public host fingerprint only)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|ssh_upgrade.run (the "Set up Subshell here" act, Task 14: pane/B/A ids and relay ref, canonical destination, exec id, outcome + service-chosen stage word; the minted setup key and ALL installer output appear in NO row and NO refusal copy)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, relayRef on a relay launch}` (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
36 changes: 35 additions & 1 deletion apps/node/agent/src/__tests__/relay-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { mkdtempSync, statSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
import { createConnection, type Socket } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand Down Expand Up @@ -692,3 +692,37 @@ test("RelaySessions dispatches by ref to the owning role: registered, unknown, d
relay.onInboundRelayFrame(frame("r-1")); // after closeAll nothing routes, nothing throws
expect(seenA.length).toBe(1);
});

test.each(["a".repeat(120), "é".repeat(45)])(
"refuses an oversized agent socket path before creating files (%s)",
async (segment) => {
const root = mkdtempSync(join(tmpdir(), "proxy-length-"));
const dataDir = join(root, segment);
const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]);
try {
await expect(
startAgentProxy({
dataDir,
paneId: "pane",
relayId: "relay",
ref: "ref",
peerNodeId: "a",
selfNodeId: "b",
peerSigningJwk: aKeys.signing.publicJwk,
peerEncryptionJwk: aKeys.encryption.publicJwk,
ownEncryptionPublicJwk: bKeys.encryption.publicJwk,
ownEncryptionPrivateJwk: bKeys.encryption.privateJwk,
ownSigningPrivateJwk: bKeys.signing.privateJwk,
seal: mcpSeal,
open: mcpOpen,
sendRelayFrame: () => {
throw new Error("must not send");
},
}),
).rejects.toThrow("Use a shorter Subshell data directory on the connecting machine.");
expect(existsSync(dataDir)).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe("POST /api/ssh/setup-here doors", () => {
const res = await post({ paneId }, { cookie: ownerCookie });
expect(res.status).toBe(409);
const body = (await res.json()) as { code?: string; message?: string };
expect(body.message).toContain("SSH-terminal pane");
expect(body.message).toContain("Open an SSH subshell");
});
});

Expand Down
48 changes: 27 additions & 21 deletions apps/server/api/src/services/__tests__/ssh-local-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,24 +52,30 @@ test("server setup execution runs in-process, validates its path, redacts secret
}
});

test("server host-key capture uses the service account's exact port-specific trust entry", async () => {
const directory = await mkdtemp(join(tmpdir(), "local-ssh-host-"));
const previous = process.env.SUBSHELL_SSH_KNOWN_HOSTS;
const previousBinary = process.env.SUBSHELL_SSH_KEYGEN_PATH;
const file = join(directory, "known_hosts");
// Capture delegates matching to OpenSSH; the public blob is opaque to -F.
await writeFile(file, "example.test ssh-ed25519 AAAA\n[example.test]:2222 ssh-ed25519 BBBB\n");
process.env.SUBSHELL_SSH_KNOWN_HOSTS = file;
process.env.SUBSHELL_SSH_KEYGEN_PATH = "/usr/bin/ssh-keygen";
try {
expect(await sshHostKey("local", { host: "example.test", port: 2222, user: null })).toEqual({
lines: ["[example.test]:2222 ssh-ed25519 BBBB"],
});
} finally {
if (previous === undefined) delete process.env.SUBSHELL_SSH_KNOWN_HOSTS;
else process.env.SUBSHELL_SSH_KNOWN_HOSTS = previous;
if (previousBinary === undefined) delete process.env.SUBSHELL_SSH_KEYGEN_PATH;
else process.env.SUBSHELL_SSH_KEYGEN_PATH = previousBinary;
await rm(directory, { recursive: true, force: true });
}
});
// OpenSSH is optional in the unit-test image; the SSH e2e image installs it.
const keygen = Bun.which("ssh-keygen");
test.skipIf(keygen === null)(
"server host-key capture uses the service account's exact port-specific trust entry",
async () => {
if (keygen === null) throw new Error("ssh-keygen is required for this integration check");
const directory = await mkdtemp(join(tmpdir(), "local-ssh-host-"));
const previous = process.env.SUBSHELL_SSH_KNOWN_HOSTS;
const previousBinary = process.env.SUBSHELL_SSH_KEYGEN_PATH;
const file = join(directory, "known_hosts");
// Capture delegates matching to OpenSSH; the public blob is opaque to -F.
await writeFile(file, "example.test ssh-ed25519 AAAA\n[example.test]:2222 ssh-ed25519 BBBB\n");
process.env.SUBSHELL_SSH_KNOWN_HOSTS = file;
process.env.SUBSHELL_SSH_KEYGEN_PATH = keygen;
try {
expect(await sshHostKey("local", { host: "example.test", port: 2222, user: null })).toEqual({
lines: ["[example.test]:2222 ssh-ed25519 BBBB"],
});
} finally {
if (previous === undefined) delete process.env.SUBSHELL_SSH_KNOWN_HOSTS;
else process.env.SUBSHELL_SSH_KNOWN_HOSTS = previous;
if (previousBinary === undefined) delete process.env.SUBSHELL_SSH_KEYGEN_PATH;
else process.env.SUBSHELL_SSH_KEYGEN_PATH = previousBinary;
await rm(directory, { recursive: true, force: true });
}
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ describe("setupHere door order (pane first, gates before any key)", () => {
const res = await setupHere({ viewerId: owner, paneId });
expect(res.ok).toBe(false);
if (res.ok) return;
expect(coded(res.refusal).message).toContain("SSH-terminal pane");
expect(coded(res.refusal).message).toContain("Open an SSH subshell");
});
});

Expand Down Expand Up @@ -442,8 +442,8 @@ describe("setupHere relay mode", () => {
const refusal = coded(res.refusal);
expect(refusal.status).toBe(409);
expect(refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS);
expect(refusal.message).toContain("could not reach");
expect(refusal.message).toContain("untouched");
expect(refusal.message).toContain("couldn't reach");
expect(refusal.message).toContain("Your SSH subshell keeps running.");
// The working pane: the row is unchanged, and B saw ONLY the exec arms
// - no input, no terminate, no launch, no touch of any kind.
const row = await new SubshellsRepository(db).findById(paneId);
Expand Down Expand Up @@ -614,7 +614,7 @@ describe("setupHere relay mode", () => {
if (res.ok) return;
const refusal = coded(res.refusal);
expect(refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_FAILED);
expect(refusal.message).toContain("without enrolling");
expect(refusal.message).toContain("no machine enrolled");
expect((await keys.listByUser(owner)).length).toBe(keysBefore);
} finally {
await clearUpgradeRows();
Expand Down Expand Up @@ -690,7 +690,7 @@ describe("setupHere direct mode (B's own keys, no relay)", () => {
if (res.ok) return;
const refusal = coded(res.refusal);
expect(refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS);
expect(refusal.message).toContain("could not reach");
expect(refusal.message).toContain("couldn't reach");
expect(refusal.message).toContain(APP_BASE_URL);
const audits = await upgradeRows();
expect(audits).toHaveLength(1);
Expand Down
Loading
Loading