Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
170 commits
Select commit Hold shift + click to select a range
d9faea3
docs(ssh-anywhere): design for SSH to any machine, from anywhere
theogravity Oct 7, 2026
3dca25f
docs(ssh-anywhere): destination-first UI; #330 UX not ported
theogravity Oct 7, 2026
b3748a7
docs(ssh-anywhere): default-off per-node SSH capability gate (owner-e…
theogravity Oct 7, 2026
894070e
docs(ssh-anywhere): Plan 1 foundations (ssh_enabled gate + ported pri…
theogravity Oct 7, 2026
94af16f
docs(ssh-anywhere): strip em dashes from Plan 1 (lint:prose)
theogravity Oct 7, 2026
7e98924
docs(ssh-anywhere): spec review round 1 - fix anti-plane-compromise c…
theogravity Oct 7, 2026
a049d79
docs(ssh-anywhere): spec review round 2 - seal gives confidentiality …
theogravity Oct 7, 2026
95968e5
docs(ssh-anywhere): spec review round 3 - local-admin gate exception …
theogravity Oct 7, 2026
01b2083
docs(ssh-anywhere): spec+plan review round 4 - enroll already registe…
theogravity Oct 7, 2026
3bb572a
docs(ssh-anywhere): spec+plan review round 5 - nodes already carry a …
theogravity Oct 7, 2026
ec94aa0
docs(ssh-anywhere): spec+plan review round 6 - worktree cd on Task 5'…
theogravity Oct 7, 2026
3f9da84
feat(ssh): port OpenSSH grammar, budgets, command frames, and named-r…
theogravity Oct 7, 2026
7d93d4b
feat(ssh): port OpenSSH config discovery and ssh -G resolve into pane…
theogravity Oct 7, 2026
4bd1559
test(e2e): port the isolated sshd fixture
theogravity Oct 7, 2026
fa0ce9b
feat(nodes): add ssh_enabled capability column (default off) + migration
theogravity Oct 7, 2026
5743721
feat(nodes): nodeCanSsh predicate (owner-only, gated by ssh_enabled)
theogravity Oct 7, 2026
b6afb00
feat(nodes): ssh_enabled plane write + view + service + PUT /:id/ssh-…
theogravity Oct 7, 2026
68c26c0
feat(nodes): ssh gate loop - node mirror file, set_ssh_enabled push, …
theogravity Oct 7, 2026
4f67f24
chore(changeset): node ssh capability gate (server + node minor)
theogravity Oct 7, 2026
0d7df38
fix(backups): advance the restore migration ceiling to 0047 and rebui…
theogravity Oct 7, 2026
bb8d705
docs(ssh-anywhere): final-review wave - changeset states the protocol…
theogravity Oct 7, 2026
bcfd312
docs(ssh-anywhere): final-review round 2 - the stale frozen-spec bann…
theogravity Oct 7, 2026
74e5490
docs(ssh-anywhere): final-review round 3 - the Match-exec disclosure …
theogravity Oct 7, 2026
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
e745303
docs(plans): Plan 3 - the destination-first UI (access downgrade + Co…
theogravity Oct 8, 2026
d92f23b
feat(api): ssh panes read as view-only in subshell views for everyone…
theogravity Oct 8, 2026
80ef59f
feat(web): ssh surface plumbing - types, hooks, and the Connect entry
theogravity Oct 8, 2026
c43cb77
fix(node-admin): ApiError keeps the parsed failure body beside the sl…
theogravity Oct 8, 2026
0d695f1
feat(web): the destination-first Connect page
theogravity Oct 8, 2026
d45df99
fix(web): reviewer round 1 - the machine field's explanation lines ar…
theogravity Oct 8, 2026
db78242
feat(web): ssh panes label themselves and tell viewers why typing is off
theogravity Oct 8, 2026
8da407e
test(e2e): connect flow end to end - destination-first launch and the…
theogravity Oct 8, 2026
20edf35
chore(changeset): the destination-first UI (Milestone 1 complete)
theogravity Oct 8, 2026
50ef752
fix(web): reviewer round 1 - maintenance names itself, Connect cannot…
theogravity Oct 8, 2026
02c644a
test(e2e): the gate-off pin follows the kind-aware remedy copy
theogravity Oct 8, 2026
db86b7f
fix(web): reviewer round 2 - the local gate remedy points at the mach…
theogravity Oct 8, 2026
74a7c4a
test(web): raise the per-test budget knob 30s to 45s
theogravity Oct 8, 2026
feb17da
test(web): reset the global fetch to the delegator after every test
theogravity Oct 8, 2026
fe0197b
test(web): reviewer - attribute the budget history and the fetch leak…
theogravity Oct 8, 2026
389dff3
test(ssh): align fixtures with current main
theogravity Oct 11, 2026
bfef7c8
docs(ssh-relay): Milestone 2 sealed agent relay design spec
theogravity Oct 8, 2026
62e4d7a
docs(ssh-relay): M2 spec review round 1 - security honesty, protocol …
theogravity Oct 8, 2026
da80ea3
docs(ssh-relay): M2 spec review round 2 - signing scope, replay seq, …
theogravity Oct 8, 2026
9c8269a
docs(ssh-relay): M2 spec review round 3 - encryption-key pinning, out…
theogravity Oct 8, 2026
a480cac
docs(ssh-relay): M2 spec review round 4 - roster enumeration, grant-s…
theogravity Oct 8, 2026
c5193f1
docs(ssh-relay): M2 spec review round 5 - detection-not-prevention ho…
theogravity Oct 8, 2026
84a329c
docs(ssh-relay): M2 spec review round 6 - cap enforcement, mirror gat…
theogravity Oct 8, 2026
60e2de9
docs(ssh-relay): M2 spec review round 7 - rotation is not an identity…
theogravity Oct 8, 2026
a07ea69
docs(ssh-relay): M2 spec review round 8 - symmetric pre-first-pin window
theogravity Oct 8, 2026
971d680
docs(ssh-relay): M2 spec review round 9 - name the three rotation stores
theogravity Oct 8, 2026
439447d
docs(ssh-relay): M2 spec review round 10 - 'concerns' three stores (r…
theogravity Oct 8, 2026
924da82
docs(ssh-relay): M2 implementation plan (16 tasks, subagent-driven)
theogravity Oct 8, 2026
8751acc
docs(ssh-relay): plan pre-flight - correct agent request codes to 13/…
theogravity Oct 8, 2026
7d40691
feat(ssh-relay): per-machine ES256 signing keypair, registered at enroll
theogravity Oct 8, 2026
0429c4a
test+docs(ssh-relay): address Task 1 review (identities nullability t…
theogravity Oct 8, 2026
ad6f1fb
feat(ssh-relay): always-strict node machine pin store (both keys, no …
theogravity Oct 8, 2026
9b85642
fix(ssh-relay): Task 2 review - base64url round-trip, prototype-safe …
theogravity Oct 8, 2026
ab95cd9
feat(ssh-relay): ssh_register_identity delivers signing keys for pre-…
theogravity Oct 8, 2026
80658cd
test(ssh-relay): cover the CAS-loser branch; fix dead JSDoc link (Tas…
theogravity Oct 8, 2026
c134a33
feat(ssh-relay): protocol 18, relay open/close commands + blind relay…
theogravity Oct 8, 2026
84def3d
fix(ssh-relay): Task 4 review - signing-key shape gate, observable ov…
theogravity Oct 8, 2026
b03e732
feat(ssh-relay): sealed envelope codec with signed canonical payload …
theogravity Oct 8, 2026
1059f82
fix(ssh-relay): Task 5 review - structural nonce binding, required ex…
theogravity Oct 8, 2026
d86bc56
feat(ssh-relay): B-side ssh-agent proxy socket over sealed relay frames
theogravity Oct 8, 2026
7d8b2b2
docs(ssh-relay): plan Task 7 agent codes corrected to 13/15 (real age…
theogravity Oct 8, 2026
083ad86
fix(ssh-relay): Task 6 review - burn slot on conn death, deliver-or-f…
theogravity Oct 8, 2026
57d1aa4
docs(ssh-relay): plan T7 - OpenSSH agent-proto.h numbering (13/15), n…
theogravity Oct 8, 2026
6beb4f6
feat(ssh-relay): A-side responder enforces origin, method allow-list,…
theogravity Oct 8, 2026
757a895
docs(ssh-relay): agent numbering is probed, not baked (measured OpenS…
theogravity Oct 8, 2026
a83f91f
feat(ssh-relay): probe A's agent numbering; scope sign before forward
theogravity Oct 8, 2026
27ee9c9
fix(ssh-relay): strictly parse SIGN_RESPONSE body; drop chimeric prox…
theogravity Oct 8, 2026
8d2f182
docs(ssh-relay): record the shipped Task 7 seam shape in the plan
theogravity Oct 8, 2026
af7adf1
feat(ssh-relay): paneId in the relay-open grammar, shared agent-socke…
theogravity Oct 8, 2026
96317bd
feat(ssh-relay): plane relay broker with lifetime, quota, blind routi…
theogravity Oct 8, 2026
a1f43c3
feat(ssh-relay): route the handler seam and every pane death through …
theogravity Oct 8, 2026
ad4b52f
feat(ssh-relay): node relay executors, deliver-or-throw pump, dispatc…
theogravity Oct 8, 2026
182d23f
feat(ssh-relay): bound the node relay registry to SSH_RELAY_MAX_PER_NODE
theogravity Oct 8, 2026
f579b6a
fix(ssh-relay): release proxy/responder on cap-throw; tombstone close…
theogravity Oct 8, 2026
6d10cfb
test(ssh-relay): typecheck seam for the agent relay sources the integ…
theogravity Oct 8, 2026
ad0b53c
test(ssh-relay): relay integration matrix, replay inertness, reverse …
theogravity Oct 8, 2026
29c0a6f
test(ssh-relay): pin openability so relay refusals cannot pass vacuously
theogravity Oct 8, 2026
6f1e4ba
feat(ssh-grants): migration 0050 - grants, first-use queue, host pins
theogravity Oct 8, 2026
dd39d40
feat(ssh-grants): grant repository, five named refusal codes, grant_a…
theogravity Oct 8, 2026
c4904d7
feat(ssh-grants): the grant layer service - first use, approval, matc…
theogravity Oct 8, 2026
507aa96
feat(ssh-grants): grants + approvals routes, the relay launch leg, th…
theogravity Oct 8, 2026
6394a54
docs(security): SS10 gains the grant + relay audit families
theogravity Oct 8, 2026
8121676
docs(ssh-relay): approve audit names the chosen fingerprints (spec 9/…
theogravity Oct 8, 2026
2a0a8af
fix(ssh-relay): approve audit names the chosen fingerprints (spec 9/1…
theogravity Oct 8, 2026
0f664bc
feat(ssh-relay): arm ssh_agent_identities in the node-link grammar wi…
theogravity Oct 8, 2026
db89b04
feat(ssh-relay): ssh_agent_identities enumerates A's public roster fo…
theogravity Oct 8, 2026
1810b4b
feat(ssh-grants): the approvals roster fetch - ssh_agent_identities o…
theogravity Oct 8, 2026
998eacc
test(ssh-relay): flip matrix case 10 - ssh_agent_identities answered …
theogravity Oct 8, 2026
51e1042
fix(node): spell the package.json import with { type: json }
theogravity Oct 8, 2026
9df2412
feat(ssh-protocol): the ssh_host_key capture arm and the required rel…
theogravity Oct 8, 2026
cb0b4bf
feat(pane-runtime): relay-mode ssh config render pins the trust sourc…
theogravity Oct 8, 2026
b3b8eb7
feat(node): ssh_host_key capture arm and the B-side pinned known_host…
theogravity Oct 8, 2026
451b53d
feat(ssh-host-pins): the TOFU store - capture from A, explicit supply…
theogravity Oct 8, 2026
59ef1e3
feat(ssh-relay): every relay leg carries the destination pin
theogravity Oct 8, 2026
69264e6
test(node): narrow the ssh_host_key answer and pin the JWK-refusal fi…
theogravity Oct 8, 2026
bcbab66
fix(ssh-relay): neutralize GlobalKnownHostsFile in relay mode; idempo…
theogravity Oct 9, 2026
7ec2e82
feat(ssh-relay): ssh-fingerprint block in the runtime report, hand-ro…
theogravity Oct 9, 2026
5f352fc
feat(ssh-relay): agent carries the §4.6 trust block in ready and on i…
theogravity Oct 9, 2026
6a56de1
feat(ssh-relay): mirror the trust block at ready, serialize it under …
theogravity Oct 9, 2026
b6008a8
feat(ssh-relay): machine trust card on the node page, owner/edit only…
theogravity Oct 9, 2026
32b3666
feat(ssh-relay): render the trust card on the node's own dashboard; c…
theogravity Oct 9, 2026
c9c27a2
fix(install.sh): the 404 advice branch prints --key <setup key>, neve…
theogravity Oct 9, 2026
e3692b8
feat(ssh-relay): non-interactive 'Set up Subshell here' enrollment ov…
theogravity Oct 9, 2026
a90c696
feat(ssh-setup-here): the destination upgrade service - mint, exec, r…
theogravity Oct 9, 2026
0626927
feat(ssh-setup-here): POST /api/ssh/setup-here, the destination-upgra…
theogravity Oct 9, 2026
a94138d
feat(web): 'Set up Subshell here' action on the SSH pane
theogravity Oct 9, 2026
67059f1
test(ssh-setup-here): keep the new suites clean under verify-types
theogravity Oct 9, 2026
a081310
fix(ssh-relay): classify dark-destination curl failure as the named e…
theogravity Oct 9, 2026
f0b52d5
feat(ssh-relay): client shapes and hooks for grants, approvals, and h…
theogravity Oct 9, 2026
84dba14
feat(ssh-relay): grants screen with the instant-both-ways revoke
theogravity Oct 9, 2026
169a4bd
feat(ssh-relay): first-use approval queue naming the pane, machine, d…
theogravity Oct 9, 2026
9891936
feat(ssh-relay): destination trust screen with delete and the explici…
theogravity Oct 9, 2026
1d03040
feat(ssh-relay): plane trust section, display-only line, owner-only r…
theogravity Oct 9, 2026
d0d8868
feat(ssh-relay): SSH settings page and its rail entry
theogravity Oct 9, 2026
f3b84b5
fix(ssh-relay): grants edit, visible stale-roster handling, localized…
theogravity Oct 9, 2026
aa8dba5
feat(ssh-relay): §4.5 machine trust re-pair end to end (T17)
theogravity Oct 9, 2026
7c96ca1
feat(ssh-relay): manual grant create with a roster-by-node read
theogravity Oct 9, 2026
2acf2b0
test(ssh-relay): pin the grant create cap as the sole submit gate; co…
theogravity Oct 9, 2026
952a675
test(ssh-relay): crown-jewel end-to-end relay enrollment over a real …
theogravity Oct 9, 2026
f482eea
chore: changeset for the ssh relay milestone (major)
theogravity Oct 9, 2026
9d60fa7
docs(ssh-relay): record the shipped teardown deviation (5.6 grace rep…
theogravity Oct 9, 2026
d794a35
fix(notify): grant_approval pushes land on settings, not a phantom pa…
theogravity Oct 9, 2026
c73834f
fix(node): machine pin refuses a second pin; roster read refuses an o…
theogravity Oct 9, 2026
c0d3c85
docs(ssh-relay): close-reason coverage, fingerprint spelling, selecto…
theogravity Oct 9, 2026
530589c
docs(ssh-grants): correct the IPv6 limitation wording (wildcards stil…
theogravity Oct 9, 2026
575c32b
fix(ssh-grants): lowercase the first-use selector; the IPv6 limitatio…
theogravity Oct 9, 2026
8a9cd52
chore(mobile): spell PR 338 without the hash in comments (design chec…
theogravity Oct 9, 2026
6b42126
test(ssh): align fixtures with current main
theogravity Oct 11, 2026
29e557a
fix(ssh): complete relay launch and setup recovery UX
theogravity Oct 9, 2026
18ecb11
fix(ssh): address stack review findings and enrollment recovery
theogravity Oct 9, 2026
adf0a5b
fix(ssh): integrate SSH terminals into subshell launch flows
theogravity Oct 9, 2026
7c1f397
fix(ssh): distinguish selected subshell type from keyboard focus
theogravity Oct 9, 2026
bf1bb7a
fix(ui): make segmented selections clear throughout the web app
theogravity Oct 9, 2026
8627e41
fix(ui): keep original segment icons without selection checkmarks
theogravity Oct 9, 2026
3e7133b
fix(web): require an available machine before SSH creation
theogravity Oct 9, 2026
36a06f7
fix(web): use buttons for SSH setup actions
theogravity Oct 9, 2026
f0ab89b
fix(web): omit server row from SSH setup guidance
theogravity Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 5 additions & 0 deletions .changeset/clear-segmented-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@internal/server": patch
---

Make selected tabs and segmented choices clearly visible throughout the web interface, including Terminal / Desktop App. Choices retain their original icons with the stronger selected highlight.
6 changes: 6 additions & 0 deletions .changeset/node-ssh-capability-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@internal/server": minor
"@internal/node": minor
---

Per-node SSH capability gate: a new ssh_enabled flag (migration 0047) defaults off on every machine including the control-plane host, owners flip it at PUT /api/nodes/:id/ssh-enabled (admins on the server row) audited as node.ssh_enabled.update, the node caches the value in a mirror file it reads only to refuse, and the node protocol bumps to 16: update the server before the nodes, because the link gate is exact-match and a lagging agent is held (offline for every purpose but update) until it crosses the bump on the update command. The ported foundations (OpenSSH config discovery, ssh -G snapshot resolution, the isolated sshd e2e fixture) land with it; nothing launches SSH yet.
18 changes: 18 additions & 0 deletions .changeset/ssh-destination-first-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@internal/server": minor
"@internal/server-web": minor
---

The Connect page: one question first - where do you want to work? Pick a
destination from your remembered hosts, your recent ones, or the connecting
machine's SSH config aliases, or type any host; one button opens the pane.
The connecting machine defaults to your chosen one, machines whose SSH is off
stay named with the remedy rather than silently skipped, and a config that
needs settings Subshell does not run shows which ones blocked. SSH-terminal
panes now read as view-only to everyone but their owner everywhere the pane
is shown (the server's view says so, so the terminal is honestly read-only),
and the pane's header labels itself SSH and tells a viewer why typing is off.

Milestone 1 of the SSH-anywhere design is complete with this: an operator on
an enabled machine opens an interactive session at any sshd-only host, and no
private key or plaintext challenge ever crosses the plane.
5 changes: 5 additions & 0 deletions .changeset/ssh-in-subshells.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@internal/server": patch
---

Open SSH terminals from New subshell, workspace additions, and splits. Explain the destination and connecting machine, move alternate keys under advanced options, and approve keys inside the launch dialog without losing workspace context. Existing Connect links open the shared subshell launcher. The selected subshell type has a distinct highlight, with initial focus following the selected type.
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.
5 changes: 5 additions & 0 deletions .changeset/ssh-machine-availability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@internal/server": patch
---

Block SSH creation when no connecting machine is ready, with setup guidance and links to machine settings. Reject stale machine choices and explain loading failures separately.
5 changes: 5 additions & 0 deletions .changeset/ssh-relay-browser-journey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@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.
17 changes: 17 additions & 0 deletions .changeset/ssh-relay-milestone-2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@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.

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.

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.
10 changes: 10 additions & 0 deletions .changeset/ssh-stack-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@internal/server": patch
"@internal/node": patch
"@internal/desktop-client": patch
"@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.

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.
4 changes: 3 additions & 1 deletion .claude/rules/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ fields. Name-length limits and historical specs aren't exceptions. Follow
the [content guide](../../apps/docs/STYLE.md#product-copy-and-confirmations)
for action labels, consequences, and examples.
**A required input takes a gold `*` at rest and a gold caption once the caret
leaves it empty; a hard error (a refused value, a server refusal) stays
leaves it empty; a required control with no caret-leave moment (a picker whose
nothing-picked state disables the submit) wears the gold caption at rest once
its options settle; a hard error (a refused value, a server refusal) stays
`text-destructive` red** (ruling 2026-09-30: gold says "nothing typed yet",
red says "what you typed is wrong"; `fieldErrorToned` reads the split).
**There is no 12px**: `detail` (13) is the floor, and `text-xs` /
Expand Down
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|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|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.

## 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
47 changes: 47 additions & 0 deletions apps/client/mobile/src/__tests__/push-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "bun:test";
import { decidePushAction, SETTINGS_HREF } from "../lib/push-routing";

/**
* The tap/action decision behind a push (PR #338 review, Important 1): a
* `grant_approval` push carries the grant REQUEST uuid as `sid`, so the old
* blind push to `/subshell/<sid>` was a 404 dead-end and its Silence bell
* PATCHed a nonexistent subshell. `push-bridge` keeps the side effects; this
* pure helper owns the choice, which is all the regression needs pinning.
*/
describe("decidePushAction", () => {
it("grant_approval tap routes to the settings tab, never a phantom pane", () => {
const a = decidePushAction({ sid: "req-uuid", kind: "grant_approval", origin: "https://a" }, undefined);
expect(a).toEqual({ action: "route", href: SETTINGS_HREF });
expect(JSON.stringify(a)).not.toContain("/subshell/");
// The settings tab must be the spelled group route the app itself uses.
expect(SETTINGS_HREF).toBe("/(tabs)/settings");
});

it("grant_approval silence is ignored: no PATCH intent against a request id", () => {
const a = decidePushAction({ sid: "req-uuid", kind: "grant_approval", origin: "https://a" }, "silence");
expect(a).toEqual({ action: "ignore" });
});

it("pane kinds tap to /subshell/<sid>, encoded, unchanged", () => {
const a = decidePushAction({ sid: "sess-1", kind: "turn_complete", origin: "https://a" }, undefined);
expect(a).toEqual({ action: "route", href: "/subshell/sess-1" });
expect(decidePushAction({ sid: "a b", kind: "needs_attention" }, "open")).toEqual({
action: "route",
href: "/subshell/a%20b",
});
});

it("pane kinds silence asks for the bell PATCH with the sid", () => {
expect(decidePushAction({ sid: "sess-1", kind: "needs_attention" }, "silence")).toEqual({
action: "silence",
sid: "sess-1",
});
});

it("a pane payload with no sid is ignored (the pre-existing guard)", () => {
expect(decidePushAction({ kind: "turn_complete", origin: "https://a" }, undefined)).toEqual({
action: "ignore",
});
expect(decidePushAction({ kind: "exited" }, "silence")).toEqual({ action: "ignore" });
});
});
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
Loading
Loading