From 164a9d9aeb3a22b75e09f68a74d34f5831efeb8e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Mon, 12 Oct 2026 14:52:19 -0700 Subject: [PATCH 01/59] feat(ssh): integrate terminal launcher and encrypted key relay --- .changeset/ssh-integration.md | 16 + .claude/rules/design-system.md | 4 +- .claude/rules/security-context.md | 2 +- .github/workflows/test.yml | 47 +- .../mobile/src/__tests__/push-routing.test.ts | 28 + .../src/lib/__tests__/agent-default.test.ts | 13 + apps/client/mobile/src/lib/agent-default.ts | 24 +- apps/client/mobile/src/lib/notif-data.ts | 6 +- apps/client/mobile/src/lib/push-routing.ts | 26 + .../mobile/src/providers/push-bridge.tsx | 10 +- .../src/__tests__/commands-ssh-exec.test.ts | 311 ++++ .../__tests__/commands-ssh-host-key.test.ts | 235 +++ .../__tests__/commands-ssh-identity.test.ts | 351 ++++ .../commands-ssh-machine-pin-repair.test.ts | 209 +++ .../src/__tests__/commands-ssh-relay.test.ts | 316 ++++ .../agent/src/__tests__/commands-ssh.test.ts | 177 ++ apps/node/agent/src/__tests__/daemon.test.ts | 126 ++ apps/node/agent/src/__tests__/enroll.test.ts | 22 +- .../node/agent/src/__tests__/identity.test.ts | 52 +- .../agent/src/__tests__/launch-ssh.test.ts | 363 ++++ .../src/__tests__/machine-pin-store.test.ts | 303 ++++ .../src/__tests__/relay-agent-scheme.test.ts | 325 ++++ .../src/__tests__/relay-agent-socket.test.ts | 129 ++ .../agent/src/__tests__/relay-proxy.test.ts | 728 ++++++++ .../src/__tests__/relay-responder.test.ts | 1500 +++++++++++++++++ .../agent/src/__tests__/relay-send.test.ts | 74 + .../src/__tests__/ssh-dir-retention.test.ts | 164 ++ .../agent/src/__tests__/ssh-enabled.test.ts | 290 ++++ .../__tests__/ssh-fingerprint-report.test.ts | 79 + apps/node/agent/src/commands/context.ts | 37 + apps/node/agent/src/commands/index.ts | 59 +- apps/node/agent/src/commands/launch.ts | 34 +- apps/node/agent/src/commands/report.ts | 92 +- .../agent/src/commands/set-ssh-enabled.ts | 30 + apps/node/agent/src/commands/ssh-aliases.ts | 73 + apps/node/agent/src/commands/ssh-exec.ts | 29 + apps/node/agent/src/commands/ssh-host-key.ts | 16 + apps/node/agent/src/commands/ssh-identity.ts | 46 + .../src/commands/ssh-machine-pin-repair.ts | 79 + .../node/agent/src/commands/ssh-relay-exec.ts | 135 ++ apps/node/agent/src/commands/ssh-relay.ts | 21 + apps/node/agent/src/commands/ssh-shared.ts | 10 + apps/node/agent/src/daemon.ts | 143 +- .../src/dashboard/__tests__/dashboard.test.ts | 44 +- apps/node/agent/src/dashboard/view.ts | 11 + apps/node/agent/src/enroll.ts | 3 + apps/node/agent/src/identity.ts | 100 +- apps/node/agent/src/index.ts | 2 +- apps/node/agent/src/machine-pin-store.ts | 1 + apps/node/agent/src/path-policy.ts | 69 +- apps/node/agent/src/relay-agent-scheme.ts | 15 + apps/node/agent/src/relay-agent-socket.ts | 1 + apps/node/agent/src/relay-proxy.ts | 6 + apps/node/agent/src/relay-responder.ts | 1 + apps/node/agent/src/relay-send.ts | 53 + apps/node/agent/src/ssh-dir-retention.ts | 135 ++ apps/node/agent/src/ssh-enabled.ts | 174 ++ apps/node/agent/src/ssh-fingerprint-report.ts | 52 + apps/node/agent/src/version.ts | 8 +- .../src/routes/__tests__/status-page.test.tsx | 122 ++ apps/node/web/src/routes/index.tsx | 6 +- .../src/api/__tests__/backups-route.test.ts | 12 +- .../__tests__/downloads-shelf-serve.test.ts | 20 +- .../src/api/__tests__/install-script.test.ts | 87 + .../__tests__/installed-plugin-launch.test.ts | 9 +- .../channels/__tests__/channels-route.test.ts | 104 ++ apps/server/api/src/api/identities.route.ts | 4 + apps/server/api/src/api/install-script.ts | 3 +- apps/server/api/src/api/models.ts | 3 + .../api/nodes/__tests__/enroll-route.test.ts | 94 +- .../nodes/__tests__/nodes-crud-route.test.ts | 60 + .../repair-node-machine-pin.route.test.ts | 361 ++++ .../nodes/__tests__/route-discovery.test.ts | 1 + .../__tests__/service-node.route.test.ts | 90 + .../set-node-ssh-enabled.route.test.ts | 171 ++ apps/server/api/src/api/nodes/enroll.route.ts | 52 +- .../api/src/api/nodes/get-node.route.ts | 14 +- apps/server/api/src/api/nodes/index.ts | 6 + .../src/api/nodes/local-ssh-identity.route.ts | 95 ++ apps/server/api/src/api/nodes/node-view.ts | 79 + .../nodes/repair-node-machine-pin.route.ts | 64 + .../src/api/nodes/rotate-node-key.route.ts | 25 +- .../src/api/nodes/set-node-shares.route.ts | 7 +- .../api/nodes/set-node-ssh-enabled.route.ts | 82 + apps/server/api/src/api/public-jwk.ts | 55 +- apps/server/api/src/api/routes.ts | 11 +- .../api/ssh/__tests__/host-pins-route.test.ts | 242 +++ .../ssh/__tests__/setup-here-route.test.ts | 147 ++ .../src/api/ssh/__tests__/ssh-routes.test.ts | 902 ++++++++++ apps/server/api/src/api/ssh/aliases.route.ts | 58 + .../api/src/api/ssh/identities.route.ts | 30 + apps/server/api/src/api/ssh/index.ts | 24 + apps/server/api/src/api/ssh/launch.route.ts | 92 + .../server/api/src/api/ssh/readiness.route.ts | 46 + apps/server/api/src/api/ssh/resolve.route.ts | 54 + .../api/src/api/ssh/saved-hosts.route.ts | 182 ++ .../api/src/api/ssh/setup-here.route.ts | 117 ++ apps/server/api/src/api/ssh/ssh-views.ts | 149 ++ apps/server/api/src/api/ssh/trust.route.ts | 167 ++ .../restart-ssh-refusal.route.test.ts | 106 ++ .../__tests__/subshell-exec-ssh.route.test.ts | 226 +++ .../subshell-input-ssh.route.test.ts | 205 +++ .../__tests__/subshell-view-ssh.test.ts | 161 ++ .../src/api/subshells/input-subshell.route.ts | 12 +- .../__tests__/backup-cli-subprocess.test.ts | 74 +- apps/server/api/src/db/migrate.ts | 29 + .../db/migrations/0047-node-ssh-enabled.ts | 18 + .../0048-ssh-launch-and-saved-hosts.ts | 46 + .../db/migrations/0049-ssh-relay-identity.ts | 26 + .../api/src/db/migrations/0050-ssh-grants.ts | 92 + .../0051-ssh-request-destination.ts | 20 + .../db/migrations/0052-subshell-key-home.ts | 22 + .../src/db/migrations/0053-drop-ssh-grants.ts | 12 + .../__tests__/0047-node-ssh-enabled.test.ts | 99 ++ .../0048-ssh-launch-and-saved-hosts.test.ts | 187 ++ .../__tests__/0049-ssh-relay-identity.test.ts | 140 ++ .../__tests__/0050-ssh-grants.test.ts | 238 +++ .../0051-ssh-request-destination.test.ts | 96 ++ .../__tests__/0052-subshell-key-home.test.ts | 100 ++ .../__tests__/0053-drop-ssh-grants.test.ts | 56 + .../__tests__/channels.repository.test.ts | 24 +- .../__tests__/repositories.test.ts | 2 + .../ssh-saved-hosts.repository.test.ts | 181 ++ .../__tests__/workspaces-repository.test.ts | 2 + .../db/repositories/identities.repository.ts | 53 +- .../src/db/repositories/nodes.repository.ts | 53 + .../repositories/ssh-host-pins.repository.ts | 78 + .../ssh-saved-hosts.repository.ts | 141 ++ .../db/repositories/subshells.repository.ts | 3 + .../api/src/db/types/identities.db-types.ts | 8 + apps/server/api/src/db/types/index.ts | 4 + .../server/api/src/db/types/nodes.db-types.ts | 22 + .../src/db/types/ssh-host-pins.db-types.ts | 31 + .../src/db/types/ssh-saved-hosts.db-types.ts | 80 + .../api/src/db/types/subshells.db-types.ts | 31 +- apps/server/api/src/index.ts | 49 +- .../src/lib/__tests__/ssh-pane-access.test.ts | 72 + apps/server/api/src/lib/ssh-pane-access.ts | 48 + .../helpers/agent-relay-globals.d.ts | 41 + .../services/__tests__/helpers/node-fakes.ts | 12 +- .../__tests__/helpers/ssh-relay-stack.ts | 621 +++++++ .../__tests__/ssh-host-pins.service.test.ts | 390 +++++ .../__tests__/ssh-launch.service.test.ts | 150 ++ .../__tests__/ssh-local-identity.test.ts | 75 + .../__tests__/ssh-local-operations.test.ts | 81 + .../__tests__/ssh-local-relay.test.ts | 143 ++ .../ssh-machine-pins.service.test.ts | 269 +++ .../__tests__/ssh-policy.service.test.ts | 85 + .../__tests__/ssh-relay.integration.test.ts | 663 ++++++++ .../__tests__/ssh-relay.service.test.ts | 794 +++++++++ .../__tests__/ssh-setup-here.service.test.ts | 807 +++++++++ .../__tests__/ssh-setup-progress.test.ts | 67 + .../subshell-manager-maintenance.test.ts | 4 + .../__tests__/subshell-manager-mcp.test.ts | 4 + .../__tests__/subshell-manager-remote.test.ts | 4 + .../__tests__/subshell-manager-ssh.test.ts | 368 ++++ .../subshell-manager-titling.test.ts | 4 + .../__tests__/subshell-manager-view.test.ts | 11 + .../subshell-manager.service.test.ts | 4 + .../subshells-exit-ssh-sweep.test.ts | 157 ++ .../subshells-terminate-ssh-sweep.test.ts | 179 ++ .../backups/__tests__/backups.test.ts | 21 + .../api/src/services/backups/database.ts | 4 +- apps/server/api/src/services/backups/paths.ts | 1 + .../api/src/services/channels.service.ts | 7 +- apps/server/api/src/services/expo-push.ts | 6 +- .../nodes/__tests__/argv-parity.test.ts | 14 +- .../__tests__/local-launcher-ssh.test.ts | 166 ++ .../nodes/__tests__/local-plugins.test.ts | 1 + .../nodes/__tests__/node-events.test.ts | 2 + .../nodes/__tests__/node-ws-handler.test.ts | 295 ++++ .../nodes/__tests__/relay-frames.test.ts | 159 ++ .../nodes/__tests__/remote-launcher.test.ts | 51 + .../nodes/__tests__/ssh-enabled.test.ts | 293 ++++ .../__tests__/ssh-identity-register.test.ts | 245 +++ .../api/src/services/nodes/local-launcher.ts | 64 +- .../api/src/services/nodes/node-events.ts | 17 +- .../api/src/services/nodes/node-launcher.ts | 19 +- .../api/src/services/nodes/node-ws-handler.ts | 75 +- .../api/src/services/nodes/relay-frames.ts | 155 ++ .../api/src/services/nodes/remote-launcher.ts | 42 +- .../api/src/services/nodes/reregister-node.ts | 11 + .../api/src/services/nodes/ssh-enabled.ts | 157 ++ .../api/src/services/nodes/ssh-identity.ts | 139 ++ apps/server/api/src/services/nodes/ssh-rpc.ts | 310 ++++ .../api/src/services/nodes/subshell-paths.ts | 15 + .../server/api/src/services/notify.service.ts | 76 +- .../api/src/services/ssh-host-pins.service.ts | 452 +++++ .../api/src/services/ssh-launch.service.ts | 634 +++++++ .../api/src/services/ssh-local-identity.ts | 108 ++ .../api/src/services/ssh-local-participant.ts | 117 ++ .../src/services/ssh-machine-pins.service.ts | 237 +++ .../api/src/services/ssh-policy.service.ts | 75 + .../src/services/ssh-relay-launch.service.ts | 177 ++ .../api/src/services/ssh-relay.service.ts | 745 ++++++++ .../src/services/ssh-setup-here.service.ts | 527 ++++++ .../api/src/services/ssh-setup-progress.ts | 95 ++ .../src/services/subshell-manager.service.ts | 204 ++- .../api/src/services/subshells.service.ts | 141 +- .../src/ws/__tests__/attach-resolve.test.ts | 72 + .../ws/__tests__/remote-subshell-ws.test.ts | 84 +- apps/server/api/src/ws/attach-resolve.ts | 23 +- apps/server/api/src/ws/remote-subshell-ws.ts | 26 +- apps/server/api/src/ws/subshell-ws.ts | 10 +- apps/server/api/tsconfig.build.json | 12 +- apps/server/api/tsconfig.json | 10 +- apps/server/web/AGENTS.md | 31 + apps/server/web/docs/connect-page.md | 26 + apps/server/web/docs/launch-form-picker.md | 45 + apps/server/web/package.json | 2 +- .../__tests__/app-sidebar-account.test.tsx | 1 + .../__tests__/app-sidebar-group.test.tsx | 3 + .../app-sidebar-node-groups.test.tsx | 61 +- .../components/__tests__/app-sidebar.test.ts | 18 +- .../__tests__/clone-subshell-dialog.test.tsx | 1 + .../__tests__/existing-subshell-list.test.tsx | 1 + .../__tests__/network-plugin-card.test.tsx | 10 +- .../__tests__/new-subshell-form.test.tsx | 1 + .../components/__tests__/node-row.test.tsx | 1 + .../__tests__/open-in-browser.test.tsx | 2 + .../__tests__/pane-diagnostics-hud.test.tsx | 1 + .../components/__tests__/quick-add.test.tsx | 4 +- .../__tests__/setup-here-dialog.test.tsx | 203 +++ .../components/__tests__/sidebar-nav.test.ts | 7 +- .../__tests__/ssh-pane-labels.test.tsx | 55 + .../__tests__/subshell-actions-menu.test.tsx | 35 + .../__tests__/subshell-card.test.tsx | 2 + .../__tests__/subshell-manager-table.test.tsx | 1 + .../__tests__/subshell-status.test.tsx | 1 + .../__tests__/switch-preset-dialog.test.tsx | 1 + .../__tests__/terminal-overlay-stack.test.tsx | 1 + .../__tests__/workspace-pane-actions.test.tsx | 1 + .../connect/__tests__/connect-panel.test.tsx | 701 ++++++++ .../__tests__/destination-options.test.ts | 90 + .../__tests__/saved-hosts-section.test.tsx | 138 ++ .../connect/__tests__/ssh-refusal.test.ts | 165 ++ .../connect/__tests__/ssh-wizard.test.tsx | 529 ++++++ .../src/components/connect/connect-panel.tsx | 478 ++++++ .../components/connect/destination-field.tsx | 141 ++ .../components/connect/destination-options.ts | 98 ++ .../src/components/connect/no-ssh-targets.tsx | 23 + .../connect/saved-hosts-section.tsx | 111 ++ .../connect/ssh-destination-step.tsx | 72 + .../connect/ssh-enrollment-step.tsx | 80 + .../src/components/connect/ssh-key-step.tsx | 98 ++ .../components/connect/ssh-machine-step.tsx | 136 ++ .../web/src/components/connect/ssh-refusal.ts | 142 ++ .../components/connect/ssh-session-draft.ts | 113 ++ .../components/connect/ssh-wizard-dialog.tsx | 50 + .../web/src/components/connect/ssh-wizard.tsx | 287 ++++ .../__tests__/node-harness-card.test.tsx | 1 + .../nodes/__tests__/node-list-row.test.tsx | 1 + .../nodes/__tests__/node-section-nav.test.tsx | 1 + .../nodes/__tests__/node-table.test.tsx | 1 + .../nodes/__tests__/node-update-card.test.tsx | 1 + .../src/components/nodes/node-key-setup.tsx | 22 +- .../src/components/nodes/node-page-shell.tsx | 9 +- apps/server/web/src/components/quick-add.tsx | 18 +- .../web/src/components/setup-here-dialog.tsx | 136 ++ .../__tests__/launch-subshell-dialog.test.tsx | 12 +- .../__tests__/subshell-recent-row.test.tsx | 1 + .../sidebar/launch-subshell-dialog.tsx | 83 +- .../sidebar/new-workspace-dialog.tsx | 51 +- .../web/src/components/sidebar/sidebar-nav.ts | 6 + .../web/src/components/ssh-pane-labels.tsx | 27 + .../ssh/__tests__/host-pins-screen.test.tsx | 167 ++ .../ssh/__tests__/query-states.test.tsx | 52 + .../server-identity-recovery.test.tsx | 130 ++ .../ssh/__tests__/trust-section.test.tsx | 177 ++ .../src/components/ssh/host-pins-screen.tsx | 264 +++ .../web/src/components/ssh/query-status.tsx | 27 + .../ssh/server-identity-recovery.tsx | 174 ++ .../web/src/components/ssh/trust-section.tsx | 81 + .../src/components/subshell-actions-menu.tsx | 21 + .../__tests__/add-subshell-dialog.test.tsx | 1 + .../__tests__/new-subshell-form.test.tsx | 1 + .../__tests__/no-launch-targets.test.tsx | 1 + .../__tests__/ssh-launch-integration.test.tsx | 180 ++ .../subshell-picker/add-subshell-dialog.tsx | 66 +- .../subshell-picker/subshell-kind-picker.tsx | 31 + .../__tests__/use-node-harnesses.test.tsx | 1 + .../src/hooks/__tests__/use-nodes.test.tsx | 1 + .../web/src/hooks/__tests__/use-ssh.test.tsx | 128 ++ apps/server/web/src/hooks/use-nodes.ts | 3 +- .../web/src/hooks/use-ssh-enrollment.ts | 36 + apps/server/web/src/hooks/use-ssh-setup.ts | 51 + .../src/hooks/use-ssh-wizard-controller.ts | 148 ++ apps/server/web/src/hooks/use-ssh.ts | 225 +++ apps/server/web/src/lib/__tests__/api.test.ts | 7 + apps/server/web/src/lib/__tests__/ssh.test.ts | 28 + .../src/lib/__tests__/subshell-compat.test.ts | 20 + apps/server/web/src/lib/ssh-connect-search.ts | 14 + apps/server/web/src/lib/ssh.ts | 202 +++ apps/server/web/src/lib/subshell-compat.tsx | 32 +- apps/server/web/src/routeTree.gen.ts | 42 + .../web/src/routes/__tests__/connect.test.tsx | 29 + .../__tests__/home-needs-attention.test.tsx | 1 + .../routes/__tests__/nodes-detail.test.tsx | 1 + .../__tests__/nodes-page-filters.test.tsx | 2 + .../routes/__tests__/nodes-page-gate.test.tsx | 1 + .../src/routes/__tests__/nodes-tabs.test.tsx | 1 + .../routes/__tests__/settings-ssh.test.tsx | 56 + apps/server/web/src/routes/connect.tsx | 10 + apps/server/web/src/routes/new.tsx | 13 +- apps/server/web/src/routes/nodes_.$id.tsx | 18 + apps/server/web/src/routes/settings_.ssh.tsx | 27 + apps/server/web/src/routes/subshells_.$id.tsx | 6 + apps/server/web/src/test-setup.ts | 29 +- apps/server/web/src/types/subshell.ts | 2 + bun.lock | 15 + docs/design-system.md | 7 +- docs/security.md | 65 +- .../2026-10-07-ssh-anywhere-1-foundations.md | 661 ++++++++ .../2026-10-07-ssh-anywhere-2-launcher.md | 478 ++++++ ...-10-08-ssh-anywhere-3-destination-first.md | 125 ++ .../plans/2026-10-08-ssh-relay-plan.md | 328 ++++ .../specs/2026-10-07-ssh-anywhere-design.md | 500 ++++++ .../2026-10-08-ssh-agent-relay-design.md | 211 +++ e2e/AGENTS.md | 20 +- e2e/fixtures/sshd-relay.ts | 253 +++ e2e/fixtures/sshd.ts | 338 ++++ e2e/ports.ts | 25 + e2e/stack.ts | 2 +- e2e/stub/client.ts | 17 +- e2e/stub/relay-capture-preload.ts | 67 + e2e/stub/relay-spec.ts | 348 ++++ e2e/tests/08-mobile-shell.spec.ts | 17 +- e2e/tests/10-attach-geometry-paste.spec.ts | 3 +- e2e/tests/12-nodes.spec.ts | 19 +- e2e/tests/20-backups.spec.ts | 2 +- e2e/tests/22-ssh-terminal.spec.ts | 475 ++++++ e2e/tests/23-connect-flow.spec.ts | 457 +++++ e2e/tests/23-ssh-relay.spec.ts | 853 ++++++++++ e2e/tests/24-ssh-ux.spec.ts | 137 ++ e2e/tests/helpers.ts | 12 +- e2e/tsconfig.json | 7 +- .../src/__tests__/strip-ansi.test.ts | 14 + packages/backend-errors/src/error-codes.ts | 170 ++ packages/backend-errors/src/strip-ansi.ts | 20 +- .../src/__tests__/node-allowed-dirs.test.tsx | 1 + .../__tests__/node-maintenance-card.test.tsx | 1 + .../src/__tests__/node-runtime-card.test.tsx | 1 + .../src/__tests__/node-service-card.test.tsx | 1 + .../src/__tests__/ssh-trust-card.test.tsx | 124 ++ .../src/components/ssh-trust-card.tsx | 118 ++ packages/node-admin/src/index.ts | 13 +- .../node-admin/src/lib/__tests__/api.test.ts | 66 + packages/node-admin/src/lib/api.ts | 34 +- packages/node-admin/src/types/node.ts | 58 + packages/pane-runtime/package.json | 1 + .../__tests__/built-in-api-versions.test.ts | 2 +- .../src/__tests__/built-in-icons.test.ts | 4 +- .../src/__tests__/embedded-plugins.test.ts | 1 + .../src/__tests__/installed-overlay.test.ts | 2 +- .../src/__tests__/plugins-seed.test.ts | 7 +- .../src/__tests__/registry.test.ts | 16 +- packages/pane-runtime/src/index.ts | 79 + packages/pane-runtime/src/registry.ts | 5 + .../pane-runtime/src/ssh/__tests__/helpers.ts | 149 ++ .../src/ssh/__tests__/ssh-discover.test.ts | 158 ++ .../src/ssh/__tests__/ssh-render.test.ts | 386 +++++ .../src/ssh/__tests__/ssh-resolve.test.ts | 494 ++++++ .../src/ssh/relay/agent-identities.ts | 98 ++ packages/pane-runtime/src/ssh/relay/exec.ts | 184 ++ .../pane-runtime/src/ssh/relay/host-key.ts | 128 ++ .../src/ssh/relay/machine-paths.ts | 73 + .../src/ssh/relay/machine-pin-store.ts | 232 +++ .../pane-runtime/src/ssh/relay/participant.ts | 535 ++++++ .../pane-runtime/src/ssh/relay/path-policy.ts | 68 + .../pane-runtime/src/ssh/relay/peer-key.ts | 19 + .../src/ssh/relay/relay-agent-scheme.ts | 304 ++++ .../src/ssh/relay/relay-agent-socket.ts | 99 ++ .../pane-runtime/src/ssh/relay/relay-proxy.ts | 521 ++++++ .../src/ssh/relay/relay-responder.ts | 437 +++++ packages/pane-runtime/src/ssh/relay/result.ts | 3 + packages/pane-runtime/src/ssh/ssh-discover.ts | 364 ++++ packages/pane-runtime/src/ssh/ssh-render.ts | 312 ++++ packages/pane-runtime/src/ssh/ssh-resolve.ts | 411 +++++ packages/pane-runtime/src/ssh/ssh-spawn.ts | 133 ++ packages/plugins/ssh/.hash-runnerrc.json | 7 + packages/plugins/ssh/LICENSE | 203 +++ packages/plugins/ssh/icon.svg | 1 + packages/plugins/ssh/package.json | 60 + .../plugins/ssh/src/__tests__/ssh.test.ts | 79 + packages/plugins/ssh/src/index.ts | 77 + packages/plugins/ssh/src/manifest.ts | 15 + packages/plugins/ssh/tsconfig.json | 7 + packages/plugins/ssh/tsdown.config.ts | 17 + .../src/__tests__/fixtures/ssh-fixtures.ts | 120 ++ .../src/__tests__/node-frames.test.ts | 760 ++++++++- .../src/__tests__/node-results-ssh.test.ts | 250 +++ .../src/__tests__/relay-frames.test.ts | 122 ++ .../src/__tests__/ssh-base64-std.test.ts | 94 ++ .../src/__tests__/ssh-config.test.ts | 82 + .../src/__tests__/ssh-pin-store.test.ts | 140 ++ .../src/__tests__/ssh-relay.test.ts | 644 +++++++ packages/subshell-protocol/src/index.ts | 118 ++ packages/subshell-protocol/src/node-frames.ts | 432 ++++- .../subshell-protocol/src/node-results.ts | 210 ++- .../subshell-protocol/src/ssh-base64-std.ts | 68 + packages/subshell-protocol/src/ssh-config.ts | 249 +++ packages/subshell-protocol/src/ssh-errors.ts | 148 ++ packages/subshell-protocol/src/ssh-frames.ts | 686 ++++++++ packages/subshell-protocol/src/ssh-limits.ts | 265 +++ .../subshell-protocol/src/ssh-pin-store.ts | 180 ++ packages/subshell-protocol/src/ssh-relay.ts | 524 ++++++ packages/subshell-protocol/src/ssh-results.ts | 170 ++ packages/subshell-protocol/src/versions.ts | 6 +- scripts/__tests__/package-releases.test.ts | 1 + 409 files changed, 48512 insertions(+), 485 deletions(-) create mode 100644 .changeset/ssh-integration.md create mode 100644 apps/client/mobile/src/__tests__/push-routing.test.ts create mode 100644 apps/client/mobile/src/lib/push-routing.ts create mode 100644 apps/node/agent/src/__tests__/commands-ssh-exec.test.ts create mode 100644 apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts create mode 100644 apps/node/agent/src/__tests__/commands-ssh-identity.test.ts create mode 100644 apps/node/agent/src/__tests__/commands-ssh-machine-pin-repair.test.ts create mode 100644 apps/node/agent/src/__tests__/commands-ssh-relay.test.ts create mode 100644 apps/node/agent/src/__tests__/commands-ssh.test.ts create mode 100644 apps/node/agent/src/__tests__/launch-ssh.test.ts create mode 100644 apps/node/agent/src/__tests__/machine-pin-store.test.ts create mode 100644 apps/node/agent/src/__tests__/relay-agent-scheme.test.ts create mode 100644 apps/node/agent/src/__tests__/relay-agent-socket.test.ts create mode 100644 apps/node/agent/src/__tests__/relay-proxy.test.ts create mode 100644 apps/node/agent/src/__tests__/relay-responder.test.ts create mode 100644 apps/node/agent/src/__tests__/relay-send.test.ts create mode 100644 apps/node/agent/src/__tests__/ssh-dir-retention.test.ts create mode 100644 apps/node/agent/src/__tests__/ssh-enabled.test.ts create mode 100644 apps/node/agent/src/__tests__/ssh-fingerprint-report.test.ts create mode 100644 apps/node/agent/src/commands/set-ssh-enabled.ts create mode 100644 apps/node/agent/src/commands/ssh-aliases.ts create mode 100644 apps/node/agent/src/commands/ssh-exec.ts create mode 100644 apps/node/agent/src/commands/ssh-host-key.ts create mode 100644 apps/node/agent/src/commands/ssh-identity.ts create mode 100644 apps/node/agent/src/commands/ssh-machine-pin-repair.ts create mode 100644 apps/node/agent/src/commands/ssh-relay-exec.ts create mode 100644 apps/node/agent/src/commands/ssh-relay.ts create mode 100644 apps/node/agent/src/commands/ssh-shared.ts create mode 100644 apps/node/agent/src/machine-pin-store.ts create mode 100644 apps/node/agent/src/relay-agent-scheme.ts create mode 100644 apps/node/agent/src/relay-agent-socket.ts create mode 100644 apps/node/agent/src/relay-proxy.ts create mode 100644 apps/node/agent/src/relay-responder.ts create mode 100644 apps/node/agent/src/relay-send.ts create mode 100644 apps/node/agent/src/ssh-dir-retention.ts create mode 100644 apps/node/agent/src/ssh-enabled.ts create mode 100644 apps/node/agent/src/ssh-fingerprint-report.ts create mode 100644 apps/node/web/src/routes/__tests__/status-page.test.tsx create mode 100644 apps/server/api/src/api/__tests__/install-script.test.ts create mode 100644 apps/server/api/src/api/nodes/__tests__/repair-node-machine-pin.route.test.ts create mode 100644 apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts create mode 100644 apps/server/api/src/api/nodes/local-ssh-identity.route.ts create mode 100644 apps/server/api/src/api/nodes/repair-node-machine-pin.route.ts create mode 100644 apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts create mode 100644 apps/server/api/src/api/ssh/__tests__/host-pins-route.test.ts create mode 100644 apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts create mode 100644 apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts create mode 100644 apps/server/api/src/api/ssh/aliases.route.ts create mode 100644 apps/server/api/src/api/ssh/identities.route.ts create mode 100644 apps/server/api/src/api/ssh/index.ts create mode 100644 apps/server/api/src/api/ssh/launch.route.ts create mode 100644 apps/server/api/src/api/ssh/readiness.route.ts create mode 100644 apps/server/api/src/api/ssh/resolve.route.ts create mode 100644 apps/server/api/src/api/ssh/saved-hosts.route.ts create mode 100644 apps/server/api/src/api/ssh/setup-here.route.ts create mode 100644 apps/server/api/src/api/ssh/ssh-views.ts create mode 100644 apps/server/api/src/api/ssh/trust.route.ts create mode 100644 apps/server/api/src/api/subshells/__tests__/restart-ssh-refusal.route.test.ts create mode 100644 apps/server/api/src/api/subshells/__tests__/subshell-exec-ssh.route.test.ts create mode 100644 apps/server/api/src/api/subshells/__tests__/subshell-input-ssh.route.test.ts create mode 100644 apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts create mode 100644 apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts create mode 100644 apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts create mode 100644 apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts create mode 100644 apps/server/api/src/db/migrations/0050-ssh-grants.ts create mode 100644 apps/server/api/src/db/migrations/0051-ssh-request-destination.ts create mode 100644 apps/server/api/src/db/migrations/0052-subshell-key-home.ts create mode 100644 apps/server/api/src/db/migrations/0053-drop-ssh-grants.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0050-ssh-grants.test.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0051-ssh-request-destination.test.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0052-subshell-key-home.test.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0053-drop-ssh-grants.test.ts create mode 100644 apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts create mode 100644 apps/server/api/src/db/repositories/ssh-host-pins.repository.ts create mode 100644 apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts create mode 100644 apps/server/api/src/db/types/ssh-host-pins.db-types.ts create mode 100644 apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts create mode 100644 apps/server/api/src/lib/__tests__/ssh-pane-access.test.ts create mode 100644 apps/server/api/src/lib/ssh-pane-access.ts create mode 100644 apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts create mode 100644 apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-launch.service.test.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-local-identity.test.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-local-operations.test.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-local-relay.test.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-machine-pins.service.test.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-policy.service.test.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-relay.service.test.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-setup-progress.test.ts create mode 100644 apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts create mode 100644 apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts create mode 100644 apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts create mode 100644 apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts create mode 100644 apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts create mode 100644 apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts create mode 100644 apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts create mode 100644 apps/server/api/src/services/nodes/relay-frames.ts create mode 100644 apps/server/api/src/services/nodes/ssh-enabled.ts create mode 100644 apps/server/api/src/services/nodes/ssh-identity.ts create mode 100644 apps/server/api/src/services/nodes/ssh-rpc.ts create mode 100644 apps/server/api/src/services/ssh-host-pins.service.ts create mode 100644 apps/server/api/src/services/ssh-launch.service.ts create mode 100644 apps/server/api/src/services/ssh-local-identity.ts create mode 100644 apps/server/api/src/services/ssh-local-participant.ts create mode 100644 apps/server/api/src/services/ssh-machine-pins.service.ts create mode 100644 apps/server/api/src/services/ssh-policy.service.ts create mode 100644 apps/server/api/src/services/ssh-relay-launch.service.ts create mode 100644 apps/server/api/src/services/ssh-relay.service.ts create mode 100644 apps/server/api/src/services/ssh-setup-here.service.ts create mode 100644 apps/server/api/src/services/ssh-setup-progress.ts create mode 100644 apps/server/web/docs/connect-page.md create mode 100644 apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx create mode 100644 apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx create mode 100644 apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx create mode 100644 apps/server/web/src/components/connect/__tests__/destination-options.test.ts create mode 100644 apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx create mode 100644 apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts create mode 100644 apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx create mode 100644 apps/server/web/src/components/connect/connect-panel.tsx create mode 100644 apps/server/web/src/components/connect/destination-field.tsx create mode 100644 apps/server/web/src/components/connect/destination-options.ts create mode 100644 apps/server/web/src/components/connect/no-ssh-targets.tsx create mode 100644 apps/server/web/src/components/connect/saved-hosts-section.tsx create mode 100644 apps/server/web/src/components/connect/ssh-destination-step.tsx create mode 100644 apps/server/web/src/components/connect/ssh-enrollment-step.tsx create mode 100644 apps/server/web/src/components/connect/ssh-key-step.tsx create mode 100644 apps/server/web/src/components/connect/ssh-machine-step.tsx create mode 100644 apps/server/web/src/components/connect/ssh-refusal.ts create mode 100644 apps/server/web/src/components/connect/ssh-session-draft.ts create mode 100644 apps/server/web/src/components/connect/ssh-wizard-dialog.tsx create mode 100644 apps/server/web/src/components/connect/ssh-wizard.tsx create mode 100644 apps/server/web/src/components/setup-here-dialog.tsx create mode 100644 apps/server/web/src/components/ssh-pane-labels.tsx create mode 100644 apps/server/web/src/components/ssh/__tests__/host-pins-screen.test.tsx create mode 100644 apps/server/web/src/components/ssh/__tests__/query-states.test.tsx create mode 100644 apps/server/web/src/components/ssh/__tests__/server-identity-recovery.test.tsx create mode 100644 apps/server/web/src/components/ssh/__tests__/trust-section.test.tsx create mode 100644 apps/server/web/src/components/ssh/host-pins-screen.tsx create mode 100644 apps/server/web/src/components/ssh/query-status.tsx create mode 100644 apps/server/web/src/components/ssh/server-identity-recovery.tsx create mode 100644 apps/server/web/src/components/ssh/trust-section.tsx create mode 100644 apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx create mode 100644 apps/server/web/src/components/subshell-picker/subshell-kind-picker.tsx create mode 100644 apps/server/web/src/hooks/__tests__/use-ssh.test.tsx create mode 100644 apps/server/web/src/hooks/use-ssh-enrollment.ts create mode 100644 apps/server/web/src/hooks/use-ssh-setup.ts create mode 100644 apps/server/web/src/hooks/use-ssh-wizard-controller.ts create mode 100644 apps/server/web/src/hooks/use-ssh.ts create mode 100644 apps/server/web/src/lib/__tests__/ssh.test.ts create mode 100644 apps/server/web/src/lib/ssh-connect-search.ts create mode 100644 apps/server/web/src/lib/ssh.ts create mode 100644 apps/server/web/src/routes/__tests__/connect.test.tsx create mode 100644 apps/server/web/src/routes/__tests__/settings-ssh.test.tsx create mode 100644 apps/server/web/src/routes/connect.tsx create mode 100644 apps/server/web/src/routes/settings_.ssh.tsx create mode 100644 docs/superpowers/plans/2026-10-07-ssh-anywhere-1-foundations.md create mode 100644 docs/superpowers/plans/2026-10-07-ssh-anywhere-2-launcher.md create mode 100644 docs/superpowers/plans/2026-10-08-ssh-anywhere-3-destination-first.md create mode 100644 docs/superpowers/plans/2026-10-08-ssh-relay-plan.md create mode 100644 docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md create mode 100644 docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md create mode 100644 e2e/fixtures/sshd-relay.ts create mode 100644 e2e/fixtures/sshd.ts create mode 100644 e2e/stub/relay-capture-preload.ts create mode 100644 e2e/stub/relay-spec.ts create mode 100644 e2e/tests/22-ssh-terminal.spec.ts create mode 100644 e2e/tests/23-connect-flow.spec.ts create mode 100644 e2e/tests/23-ssh-relay.spec.ts create mode 100644 e2e/tests/24-ssh-ux.spec.ts create mode 100644 packages/node-admin/src/__tests__/ssh-trust-card.test.tsx create mode 100644 packages/node-admin/src/components/ssh-trust-card.tsx create mode 100644 packages/node-admin/src/lib/__tests__/api.test.ts create mode 100644 packages/pane-runtime/src/ssh/__tests__/helpers.ts create mode 100644 packages/pane-runtime/src/ssh/__tests__/ssh-discover.test.ts create mode 100644 packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts create mode 100644 packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts create mode 100644 packages/pane-runtime/src/ssh/relay/agent-identities.ts create mode 100644 packages/pane-runtime/src/ssh/relay/exec.ts create mode 100644 packages/pane-runtime/src/ssh/relay/host-key.ts create mode 100644 packages/pane-runtime/src/ssh/relay/machine-paths.ts create mode 100644 packages/pane-runtime/src/ssh/relay/machine-pin-store.ts create mode 100644 packages/pane-runtime/src/ssh/relay/participant.ts create mode 100644 packages/pane-runtime/src/ssh/relay/path-policy.ts create mode 100644 packages/pane-runtime/src/ssh/relay/peer-key.ts create mode 100644 packages/pane-runtime/src/ssh/relay/relay-agent-scheme.ts create mode 100644 packages/pane-runtime/src/ssh/relay/relay-agent-socket.ts create mode 100644 packages/pane-runtime/src/ssh/relay/relay-proxy.ts create mode 100644 packages/pane-runtime/src/ssh/relay/relay-responder.ts create mode 100644 packages/pane-runtime/src/ssh/relay/result.ts create mode 100644 packages/pane-runtime/src/ssh/ssh-discover.ts create mode 100644 packages/pane-runtime/src/ssh/ssh-render.ts create mode 100644 packages/pane-runtime/src/ssh/ssh-resolve.ts create mode 100644 packages/pane-runtime/src/ssh/ssh-spawn.ts create mode 100644 packages/plugins/ssh/.hash-runnerrc.json create mode 100644 packages/plugins/ssh/LICENSE create mode 100644 packages/plugins/ssh/icon.svg create mode 100644 packages/plugins/ssh/package.json create mode 100644 packages/plugins/ssh/src/__tests__/ssh.test.ts create mode 100644 packages/plugins/ssh/src/index.ts create mode 100644 packages/plugins/ssh/src/manifest.ts create mode 100644 packages/plugins/ssh/tsconfig.json create mode 100644 packages/plugins/ssh/tsdown.config.ts create mode 100644 packages/subshell-protocol/src/__tests__/fixtures/ssh-fixtures.ts create mode 100644 packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts create mode 100644 packages/subshell-protocol/src/__tests__/relay-frames.test.ts create mode 100644 packages/subshell-protocol/src/__tests__/ssh-base64-std.test.ts create mode 100644 packages/subshell-protocol/src/__tests__/ssh-config.test.ts create mode 100644 packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts create mode 100644 packages/subshell-protocol/src/__tests__/ssh-relay.test.ts create mode 100644 packages/subshell-protocol/src/ssh-base64-std.ts create mode 100644 packages/subshell-protocol/src/ssh-config.ts create mode 100644 packages/subshell-protocol/src/ssh-errors.ts create mode 100644 packages/subshell-protocol/src/ssh-frames.ts create mode 100644 packages/subshell-protocol/src/ssh-limits.ts create mode 100644 packages/subshell-protocol/src/ssh-pin-store.ts create mode 100644 packages/subshell-protocol/src/ssh-relay.ts create mode 100644 packages/subshell-protocol/src/ssh-results.ts diff --git a/.changeset/ssh-integration.md b/.changeset/ssh-integration.md new file mode 100644 index 000000000..3aefdd531 --- /dev/null +++ b/.changeset/ssh-integration.md @@ -0,0 +1,16 @@ +--- +"@internal/server": major +"@internal/node": major +"@internal/desktop-server": patch +"@internal/desktop-client": patch +"@internal/subshell-protocol": minor +"@internal/pane-runtime": minor +"@internal/server-web": minor +"@internal/mobile": minor +--- + +Open SSH terminals from the subshell launch dialog using local keys, a jump host, or selected SSH-agent keys on another accessible machine. The guided setup flow enables SSH, selects keys, verifies machine identities, and returns to the launch form without starting a session. Saved destinations, workspace launches, view-only access, and destination enrollment share the same flow. + +Encrypted agent relays keep private keys on their home machine and restrict signing to the selected fingerprints. Existing machine launch access authorizes both ends; there are no separate SSH grants or approval requests. Host keys and machine identities stay pinned, and changing them requires explicit recovery. The server can participate as either relay endpoint. + +Update the server before the nodes. This feature requires node protocol 19. SSH stays disabled by default on each machine. Migrations add the SSH capability, saved destinations, and trust records; migration 0053 removes obsolete grant and approval tables while preserving destination trust and audit history. diff --git a/.claude/rules/design-system.md b/.claude/rules/design-system.md index 9b4089d43..b46effba6 100644 --- a/.claude/rules/design-system.md +++ b/.claude/rules/design-system.md @@ -52,7 +52,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` / diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 75517dbb1..b18781456 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|logging.update|update|update.unknown|shares_set|local_share_changed|service` · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9a841b00a..69b422634 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/apps/client/mobile/src/__tests__/push-routing.test.ts b/apps/client/mobile/src/__tests__/push-routing.test.ts new file mode 100644 index 000000000..1dabac243 --- /dev/null +++ b/apps/client/mobile/src/__tests__/push-routing.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; +import { decidePushAction } from "../lib/push-routing"; + +/** Pure push routing decisions; the bridge owns the effects. */ +describe("decidePushAction", () => { + it("pane kinds tap to /subshell/, 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" }); + }); +}); diff --git a/apps/client/mobile/src/lib/__tests__/agent-default.test.ts b/apps/client/mobile/src/lib/__tests__/agent-default.test.ts index 113962a0b..b434cf050 100644 --- a/apps/client/mobile/src/lib/__tests__/agent-default.test.ts +++ b/apps/client/mobile/src/lib/__tests__/agent-default.test.ts @@ -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 diff --git a/apps/client/mobile/src/lib/agent-default.ts b/apps/client/mobile/src/lib/agent-default.ts index b5232b3e1..1ee2ba82c 100644 --- a/apps/client/mobile/src/lib/agent-default.ts +++ b/apps/client/mobile/src/lib/agent-default.ts @@ -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. @@ -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 @@ -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 @@ -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 + ); } /** diff --git a/apps/client/mobile/src/lib/notif-data.ts b/apps/client/mobile/src/lib/notif-data.ts index b4ceb9755..3686561cb 100644 --- a/apps/client/mobile/src/lib/notif-data.ts +++ b/apps/client/mobile/src/lib/notif-data.ts @@ -4,10 +4,10 @@ * handler share a shape. */ export interface SubshellNotifData { - /** Subshell uuid — the only identifier that crosses the relay */ + /** Subshell uuid */ sid?: string; - /** Event class driving the generic body copy */ - kind?: "turn_complete" | "needs_attention" | "exited" | "crashed" | "crashed_final"; + /** Event class driving the generic body copy and the tap's destination (the server's NotifyKind, spelled here) */ + kind?: "turn_complete" | "needs_attention" | "exited" | "crashed" | "crashed_final" | "maintenance"; /** Origin that sent it — future multi-instance routing hint */ origin?: string; } diff --git a/apps/client/mobile/src/lib/push-routing.ts b/apps/client/mobile/src/lib/push-routing.ts new file mode 100644 index 000000000..b2640c29e --- /dev/null +++ b/apps/client/mobile/src/lib/push-routing.ts @@ -0,0 +1,26 @@ +import type { SubshellNotifData } from "@/lib/notif-data"; + +/** + * The tap/action decision behind a push response, kept pure so the routing + * rule is testable without the native listener (push-bridge owns the side + * effects: origin switching, the PATCH, the navigation). + */ + +/** The settings tab, spelled like the app spells its group routes (`/(tabs)`). */ +export const SETTINGS_HREF = "/(tabs)/settings"; + +/** What the app should DO about a push, decided from the opaque payload. */ +export type PushAction = + /** Navigate to `href` (after any instance switch push-bridge performs). */ + | { action: "route"; href: string } + /** The lock-screen Silence bell: one `setNotify(sid, false)` PATCH. */ + | { action: "silence"; sid: string } + /** Do nothing at all: no navigation, no request. */ + | { action: "ignore" }; + +export function decidePushAction(data: SubshellNotifData, actionIdentifier?: string): PushAction { + const silence = actionIdentifier === "silence"; + if (!data.sid) return { action: "ignore" }; + if (silence) return { action: "silence", sid: data.sid }; + return { action: "route", href: `/subshell/${encodeURIComponent(data.sid)}` }; +} diff --git a/apps/client/mobile/src/providers/push-bridge.tsx b/apps/client/mobile/src/providers/push-bridge.tsx index e2fb87e00..963ffcb3a 100644 --- a/apps/client/mobile/src/providers/push-bridge.tsx +++ b/apps/client/mobile/src/providers/push-bridge.tsx @@ -6,6 +6,7 @@ import { SUBSHELLS_KEY } from "@/hooks/query-keys"; import { useIconBadge } from "@/hooks/use-icon-badge"; import { useApp } from "@/lib/app-state"; import type { SubshellNotifData } from "@/lib/notif-data"; +import { decidePushAction } from "@/lib/push-routing"; import { configureNotifications, enrollPush } from "@/native/push"; import { clientForOrigin } from "@/native/subshell-client-factory"; import { useSubshell } from "@/providers/subshell-provider"; @@ -53,13 +54,16 @@ export function PushBridge() { const { activeId, instances, setActive } = useApp.getState(); const origin = data.origin && data.origin !== activeId ? data.origin : null; if (origin && !instances.some((i) => i.id === origin)) return; // unknown/forgotten instance + // The helper decides (pure, pinned in src/lib/push-routing.ts); this + const action = decidePushAction(data, response.actionIdentifier); + if (action.action === "ignore") return; // The action belongs to the ORIGIN's instance, whatever is active now. const actor = origin ? clientForOrigin(origin) : client; - if (response.actionIdentifier === "silence") { + if (action.action === "silence") { // Background action: one quick PATCH; the app never opens. if (!actor) return; try { - await actor.setNotify(data.sid, false); + await actor.setNotify(action.sid, false); await qc.invalidateQueries({ queryKey: SUBSHELLS_KEY }); } catch { /* signed-out mid-flight: the bell state re-converges on next open */ @@ -67,7 +71,7 @@ export function PushBridge() { return; } if (origin) setActive(origin); // provider clears the query cache on switch - router.push(`/subshell/${encodeURIComponent(data.sid)}`); + router.push(action.href); }; const sub = Notifications.addNotificationResponseReceivedListener((r) => void respond(r)); // Cold start from a notification tap must route too, not just live taps. diff --git a/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts new file mode 100644 index 000000000..6a70bd5ed --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts @@ -0,0 +1,311 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + buildAgentSocketPath, + buildSshConfigPath, + buildSshKnownHostsPath, + type SshProcessResult, +} from "@internal/pane-runtime"; +import { NODE_RESULT_MAINTENANCE, type SshExecCommand, type SshExecStatusCommand } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { execSshExec, execSshExecStatus, resetSshExecRunsForTests, type SshExecSeams } from "../commands/ssh-exec.js"; +import { writeMaintenance } from "../maintenance.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; +import { isSubshellId } from "../subshell-meta.js"; + +/** + * The `ssh_exec` non-interactive setup run (spec 2026-10-08 §7, Task 14): the + * "Set up Subshell here" act's separate short-lived `ssh D ''` on B, + * with the `nsk_` redactor standing between the installer's output and every + * surface that outlives the command. Posture copied from the sibling ssh + * arms: the gate speaks first, the config path is byte-checked against THIS + * machine's own derivation (the launch-frame doctrine restated for the exec), + * the run is off the command chain (an installer takes minutes; the chain + * must stay free), and the status answer carries only redacted, tail-truncated + * output. The runProcess seam replaces the real ssh child; this file owns the + * arm's decisions, not OpenSSH's behavior. + */ + +const STAMP = "2026-10-08T10:00:00.000Z"; +const GATE_REFUSAL = "ssh disabled on this node"; +const EXEC_ID = "11111111-2222-4333-8444-555555555555"; +const KEY = "nsk_LYr6F2g63jWYpSF1g9MWZaqx6V9HDGJi"; + +let base: string; +let savedSshPath: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-exec-"))); + savedSshPath = process.env.SUBSHELL_SSH_PATH; + // A stub ssh so the REAL runSshProcess cases can spawn something real. + const stub = join(base, "ssh-stub.sh"); + writeFileSync(stub, "#!/bin/sh\necho '==> done.'\n", { mode: 0o755 }); + resetSshExecRunsForTests(); +}); + +afterAll(() => { + if (savedSshPath === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = savedSshPath; + resetSshExecRunsForTests(); + rmSync(base, { recursive: true, force: true }); +}); + +// The exec registry is module state (one daemon, one map); tests share the +// EXEC_ID, so each one starts from an empty map. +beforeEach(() => resetSshExecRunsForTests()); + +function makeCtx(tag: string, opts: { gateOn?: boolean; maintenanceOn?: boolean } = {}): CommandContext { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + if (opts.gateOn !== false) writeSshEnabled(dir, { on: true, changedAt: STAMP }); + if (opts.maintenanceOn) writeMaintenance(dir, { on: true, changedAt: STAMP }); + return { config: { dataDir: dir }, nowMs: () => Date.now() } as unknown as CommandContext; +} + +function execCmd(dir: string, over: Partial = {}): SshExecCommand { + return { + type: "ssh_exec", + execId: EXEC_ID, + configPath: buildSshConfigPath(dir, EXEC_ID), + fileContent: "Host *\n StrictHostKeyChecking yes\n", + presetFlags: ["-o", "BatchMode=yes", "-F", buildSshConfigPath(dir, EXEC_ID), "--", "d.example.test"], + command: `curl -fsSL "http://plane.test/install.sh?setup_key=${KEY}" | SUBSHELL_NODE_NAME="d" bash`, + relay: true, + agentSocketPath: null, + timeoutMs: 600_000, + ...over, + }; +} + +function statusCmd(execId = EXEC_ID): SshExecStatusCommand { + return { type: "ssh_exec_status", execId }; +} + +/** Bind the relay proxy socket path the way openBRelaySession left it (a file at the derived path). */ +function bindSocket(dir: string): string { + const path = buildAgentSocketPath(dir, EXEC_ID); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "stub"); + return path; +} + +/** A runProcess seam that answers a canned result and records the argv + env it was handed. */ +function fakeRun( + result: Partial, + opts: { defer?: boolean } = {}, +): { + seams: SshExecSeams; + calls: { argv: readonly string[]; env: Record; timeoutMs: number }[]; + finish: (r?: Partial) => void; +} { + const calls: { argv: readonly string[]; env: Record; timeoutMs: number }[] = []; + let settle: ((r: Partial) => void) | null = null; + const seams: SshExecSeams = { + resolveSshBin: async () => "/usr/bin/fake-ssh", + runProcess: ((argv, env, timeoutMs) => { + calls.push({ argv, env, timeoutMs }); + const full: SshProcessResult = { code: 0, stdout: "", stderr: "", timedOut: false, spawnError: false, ...result }; + if (opts.defer) { + return new Promise((resolve) => { + settle = (r) => resolve({ ...full, ...r } as SshProcessResult); + }); + } + return Promise.resolve(full); + }) as unknown as SshExecSeams["runProcess"], + }; + return { seams, calls, finish: (r) => settle?.(r ?? {}) }; +} + +describe("ssh_exec gate and path discipline", () => { + it("the gate speaks first: a mirror not ON refuses before any spawn", async () => { + const ctx = makeCtx("gate-off", { gateOn: false }); + const { seams, calls } = fakeRun({}); + const res = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + expect(calls).toHaveLength(0); + }); + + it("maintenance refuses the run (the machine is out of service for new work)", async () => { + const ctx = makeCtx("maint", { maintenanceOn: true }); + const { seams, calls } = fakeRun({}); + const res = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(res).toEqual({ ok: false, error: NODE_RESULT_MAINTENANCE }); + expect(calls).toHaveLength(0); + }); + + it("refuses a config path that is not this machine's own derivation, and writes nothing", async () => { + const ctx = makeCtx("path"); + const hostile = execCmd(ctx.config.dataDir, { configPath: "/home/u/.ssh/config" }); + const { seams, calls } = fakeRun({}); + expect(await execSshExec(ctx, hostile, seams)).toMatchObject({ ok: false }); + expect(calls).toHaveLength(0); + expect(existsSync(buildSshConfigPath(ctx.config.dataDir, EXEC_ID))).toBe(false); + }); + + it("refuses an exec id outside the path-composition guard", async () => { + const ctx = makeCtx("babadid"); + const bad = execCmd(ctx.config.dataDir, { execId: "not an id" }); + expect(isSubshellId(bad.execId)).toBe(false); + const { seams } = fakeRun({}); + expect(await execSshExec(ctx, bad, seams)).toMatchObject({ ok: false }); + }); + + it("relay mode refuses when the proxy socket is not bound (no half-running the install)", async () => { + const ctx = makeCtx("no-sock"); + const { seams, calls } = fakeRun({}); + expect(await execSshExec(ctx, execCmd(ctx.config.dataDir), seams)).toMatchObject({ ok: false }); + expect(calls).toHaveLength(0); + }); + + it("refuses when this machine has no ssh binary", async () => { + const ctx = makeCtx("nobin"); + bindSocket(ctx.config.dataDir); + const seams: SshExecSeams = { resolveSshBin: async () => null }; + expect(await execSshExec(ctx, execCmd(ctx.config.dataDir), seams)).toMatchObject({ + ok: false, + error: "ssh binary missing: ssh", + }); + }); +}); + +describe("ssh_exec kick + status", () => { + it("writes the config at the derived path, spawns ssh off-chain, and reports running then done", async () => { + const ctx = makeCtx("happy"); + const socket = bindSocket(ctx.config.dataDir); + // The relay-open also wrote the pinned known_hosts; the exec's cleanup must remove it too. + writeFileSync(buildSshKnownHostsPath(ctx.config.dataDir, EXEC_ID), "d.example.test ssh-ed25519 AAAA\n", { + mode: 0o600, + }); + const { seams, calls, finish } = fakeRun({}, { defer: true }); + const kick = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(kick).toEqual({ ok: true, data: { started: true, execId: EXEC_ID } }); + + const configPath = buildSshConfigPath(ctx.config.dataDir, EXEC_ID); + expect(existsSync(configPath)).toBe(true); + // 0600 in a 0700 dir, the launch ssh-member rule (umask cannot leak past enforceMode). + expect(statSync(configPath).mode & 0o777).toBe(0o600); + + // One argv element for the command: ssh joins operands itself. + expect(calls[0]?.argv).toEqual([ + "/usr/bin/fake-ssh", + ...execCmd(ctx.config.dataDir).presetFlags, + execCmd(ctx.config.dataDir).command, + ]); + expect(calls[0]?.env.SSH_AUTH_SOCK).toBe(socket); // derived, never claimed + expect(calls[0]?.timeoutMs).toBe(600_000); + + // The run is OFF the chain: the status arm answers while ssh is still out there. + const dup = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(dup).toMatchObject({ ok: false }); + expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({ ok: true, data: { state: "running" } }); + + finish({ code: 0, stdout: "==> downloading subshell\n==> done.\n", stderr: "" }); + // Completion schedules asynchronous file removal; a fixed 5 ms sleep + // races filesystem work under parallel suite load. + for ( + let attempt = 0; + attempt < 100 && (existsSync(configPath) || existsSync(buildSshKnownHostsPath(ctx.config.dataDir, EXEC_ID))); + attempt++ + ) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({ + ok: true, + data: { state: "done", code: 0, timedOut: false, stdout: "==> downloading subshell\n==> done.\n", stderr: "" }, + }); + // The per-act files are gone; the dir itself is the hourly sweep's garbage. + expect(existsSync(configPath)).toBe(false); + expect(existsSync(buildSshKnownHostsPath(ctx.config.dataDir, EXEC_ID))).toBe(false); + }); + + it("a captured nsk_ line NEVER survives into the status answer", async () => { + const ctx = makeCtx("redact"); + bindSocket(ctx.config.dataDir); + const leaky = [ + "==> downloading subshell (linux-x64) from http://plane.test", + `curl: (22) The requested URL returned error: 404 with ${KEY} in hand`, + `KEY="${KEY}"`, + "subshell: setup ran with the key", + "==> done.", + ].join("\n"); + const { seams } = fakeRun({ code: 0, stdout: leaky, stderr: leaky }); + const kick = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(kick.ok).toBe(true); + await new Promise((r) => setTimeout(r, 5)); + const status = await execSshExecStatus(ctx, statusCmd(), seams); + expect(status.ok).toBe(true); + const serialized = JSON.stringify(status); + expect(serialized).not.toContain("nsk_"); + expect(serialized).not.toContain(KEY); + }); + + it("redaction runs BEFORE the tail truncation: a leaking line ahead of the tail cannot be cut into a survivor", async () => { + const ctx = makeCtx("redact-tail"); + bindSocket(ctx.config.dataDir); + const filler = Array.from({ length: 4000 }, (_, i) => `filler line ${i}`).join("\n"); + const stdout = `setup_key=${KEY} at the head\n${filler}\n==> done.`; + const { seams } = fakeRun({ code: 0, stdout, stderr: "" }); + await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + await new Promise((r) => setTimeout(r, 5)); + const status = await execSshExecStatus(ctx, statusCmd(), seams); + const serialized = JSON.stringify(status); + expect(serialized).not.toContain("nsk_"); + expect(serialized).toContain("==> done."); + }); + + it("the deadline is reported honestly: timedOut true, code null", async () => { + const ctx = makeCtx("timeout"); + bindSocket(ctx.config.dataDir); + const { seams } = fakeRun({ code: null, timedOut: true, stdout: "", stderr: "killed" }); + await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + await new Promise((r) => setTimeout(r, 5)); + expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({ + ok: true, + data: { state: "done", code: null, timedOut: true, stdout: "", stderr: "killed" }, + }); + }); + + it("direct mode: no socket claim means no SSH_AUTH_SOCK; the snapshot's socket rides when named", async () => { + const ctx = makeCtx("direct"); + const named = fakeRun({}); + await execSshExec( + ctx, + execCmd(ctx.config.dataDir, { relay: false, agentSocketPath: "/run/user/501/ssh-agent.sock" }), + named.seams, + ); + expect(named.calls[0]?.env.SSH_AUTH_SOCK).toBe("/run/user/501/ssh-agent.sock"); + resetSshExecRunsForTests(); + const bare = fakeRun({}); + await execSshExec(ctx, execCmd(ctx.config.dataDir, { relay: false, agentSocketPath: null }), bare.seams); + expect("SSH_AUTH_SOCK" in (bare.calls[0]?.env ?? {})).toBe(false); + }); + + it("status refuses an unknown exec id", async () => { + const ctx = makeCtx("unknown"); + expect( + await execSshExecStatus(ctx, statusCmd(), { + resolveSshBin: async () => "/usr/bin/fake-ssh", + }), + ).toMatchObject({ ok: false }); + }); + + it("dispatch routes both arms", async () => { + const ctx = makeCtx("dispatch"); + bindSocket(ctx.config.dataDir); + // The dispatch path runs the REAL binary lookup and spawn; point the + // ladder at a stub ssh that prints the success verb and exits. + process.env.SUBSHELL_SSH_PATH = join(base, "ssh-stub.sh"); + const kick = await dispatchCommand(ctx, execCmd(ctx.config.dataDir) as never); + expect(kick.ok).toBe(true); + await new Promise((r) => setTimeout(r, 20)); + const st = await dispatchCommand(ctx, statusCmd() as never); + expect(st).toEqual({ + ok: true, + data: { state: "done", code: 0, timedOut: false, stdout: "==> done.\n", stderr: "" }, + }); + delete process.env.SUBSHELL_SSH_PATH; + }); +}); diff --git a/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts b/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts new file mode 100644 index 000000000..3fb17141b --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts @@ -0,0 +1,235 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SshProcessResult } from "@internal/pane-runtime"; +import type { JsonValue, NodeCommandBody, SshHostKeyCommand } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { execSshHostKey, type HostKeySeams, sshHostKeyCandidates } from "../commands/ssh-host-key.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; + +/** + * The `ssh_host_key` capture arm (spec 2026-10-08 §9, Task 12): A answers the + * `known_hosts` entries for one resolved destination so the plane can + * capture the pin the relay-open carries to B. Posture copied from + * commands-ssh.test.ts (real temp dirs, the gate mirror written by its own + * writer, refusals by `ok:false` + message) with the ssh-keygen run replaced + * by the executor's `runProcess` seam - the OPPOSITE of what runs against a + * real agent is what the tests pin here: the candidate SPELLINGS, the + * comment-header strip, the dedup, and the fail-closed error classes. The + * real `ssh-keygen -F` behavior against a fixture file is the same tool's + * behavior at the sshd suite's disposal; this file owns the arm's decisions. + */ + +const STAMP = "2026-10-08T10:00:00.000Z"; +const GATE_REFUSAL = "ssh disabled on this node"; +const LINE_A = "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; + +let base: string; +let savedKeygen: string | undefined; +let savedKnownHosts: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-hostkey-"))); + savedKeygen = process.env.SUBSHELL_SSH_KEYGEN_PATH; + savedKnownHosts = process.env.SUBSHELL_SSH_KNOWN_HOSTS; +}); + +afterAll(() => { + if (savedKeygen === undefined) delete process.env.SUBSHELL_SSH_KEYGEN_PATH; + else process.env.SUBSHELL_SSH_KEYGEN_PATH = savedKeygen; + if (savedKnownHosts === undefined) delete process.env.SUBSHELL_SSH_KNOWN_HOSTS; + else process.env.SUBSHELL_SSH_KNOWN_HOSTS = savedKnownHosts; + rmSync(base, { recursive: true, force: true }); +}); + +function dataDir(tag: string, gateOn: boolean): string { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + if (gateOn) writeSshEnabled(dir, { on: true, changedAt: STAMP }); + return dir; +} + +function makeCtx(tag: string, gateOn = true): CommandContext { + return { config: { dataDir: dataDir(tag, gateOn) } } as unknown as CommandContext; +} + +function cmd(over: Partial = {}): SshHostKeyCommand { + return { type: "ssh_host_key", host: "git.example.test", port: 22, user: null, ...over }; +} + +function trustFile(tag: string, content: string | null): string { + const path = join(base, tag, "known_hosts"); + mkdirSync(join(base, tag), { recursive: true }); + if (content !== null) writeFileSync(path, content, { mode: 0o600 }); + return path; +} + +interface KeygenCall { + candidate: string; + argv: readonly string[]; +} + +/** A fake ssh-keygen: canned stdout per candidate, with the call log. */ +function fakeRun(byCandidate: Record>): { + run: NonNullable; + calls: KeygenCall[]; +} { + const calls: KeygenCall[] = []; + return { + calls, + run: async (argv): Promise => { + const candidate = argv[argv.length - 1]; + calls.push({ candidate, argv }); + const canned = byCandidate[candidate] ?? {}; + return { + code: canned.code ?? 0, + stdout: canned.stdout ?? "", + stderr: canned.stderr ?? "", + timedOut: canned.timedOut ?? false, + spawnError: canned.spawnError ?? false, + }; + }, + }; +} + +function seamsFor(trust: string, run: HostKeySeams["runProcess"]): HostKeySeams { + return { + resolveKeygenBin: async () => "/usr/bin/ssh-keygen", + trustFile: () => trust, + runProcess: run ?? (async () => ({ code: 0, stdout: "", stderr: "", timedOut: false, spawnError: false })), + }; +} + +describe("ssh_host_key candidates (OpenSSH's lookup spellings)", () => { + it("uses the destination port and excludes the login account", () => { + expect(sshHostKeyCandidates(cmd())).toEqual(["git.example.test"]); + expect(sshHostKeyCandidates(cmd({ port: 2222, user: "deploy" }))).toEqual(["[git.example.test]:2222"]); + expect(sshHostKeyCandidates(cmd({ host: "[::1]", port: 2222 }))).toEqual(["[::1]:2222"]); + expect(sshHostKeyCandidates(cmd({ host: "[::1]" }))).toEqual(["::1"]); + }); +}); + +describe("ssh_host_key arm (gate ON)", () => { + it("the gate speaks before any lookup: no keygen resolution, no run", async () => { + let resolved = false; + const res = await execSshHostKey(makeCtx("gate", false), cmd(), { + resolveKeygenBin: async () => { + resolved = true; + return "/usr/bin/ssh-keygen"; + }, + }); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + expect(resolved).toBe(false); + }); + + it("an absent trust file is the honest EMPTY answer - the capture's fail-closed fact, not an error", async () => { + const missing = join(base, "never-created", "known_hosts"); + const fake = fakeRun({}); + const res = await execSshHostKey(makeCtx("missing"), cmd(), seamsFor(missing, fake.run)); + expect(res).toEqual({ ok: true, data: { lines: [] } }); + // Nothing spawned: the file's ABSENCE is answered without OpenSSH. + expect(fake.calls).toHaveLength(0); + }); + + it("a missing ssh-keygen refuses by name (the binary-ladder message class)", async () => { + const trust = trustFile("nokeygen", `${LINE_A}\n`); + const res = await execSshHostKey(makeCtx("nokeygen"), cmd(), { + resolveKeygenBin: async () => null, + trustFile: () => trust, + }); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("binary missing"); + }); + + it("answers the matching entry verbatim, headers and blanks stripped, argv spelled -f file -F candidate", async () => { + const trust = trustFile("match", `${LINE_A}\n# a comment\nother.example ssh-rsa BBB\n`); + const fake = fakeRun({ + "git.example.test": { + stdout: `# Host git.example.test found: line 2\n${LINE_A}\n`, + }, + }); + const res = await execSshHostKey(makeCtx("match"), cmd(), seamsFor(trust, fake.run)); + expect(res.ok).toBe(true); + expect((res as { data: JsonValue }).data).toEqual({ lines: [LINE_A] }); + // The ask spells the file target with `-f` (never a HOME-relative read) + // and each candidate as its own argv element - no shell string anywhere. + expect(fake.calls.map((c) => c.candidate)).toEqual(["git.example.test"]); + expect(fake.calls[0]?.argv.slice(0, 4)).toEqual(["/usr/bin/ssh-keygen", "-f", trust, "-F"]); + }); + + it("captures only the requested sshd when one host serves different keys on different ports", async () => { + const alternate = LINE_A.replace("git.example.test", "[git.example.test]:2222").replace("AAAAI000", "AAAAI111"); + const trust = trustFile("separate-ports", `${LINE_A}\n${alternate}\n`); + const fake = fakeRun({ + "git.example.test": { stdout: `${LINE_A}\n` }, + "[git.example.test]:2222": { stdout: `${alternate}\n` }, + }); + const res = await execSshHostKey(makeCtx("separate-ports"), cmd({ port: 2222 }), seamsFor(trust, fake.run)); + expect(res).toEqual({ ok: true, data: { lines: [alternate] } }); + expect(fake.calls.map((call) => call.candidate)).toEqual(["[git.example.test]:2222"]); + }); + + it("a silent non-zero run with stderr is the named unreadable-file refusal, never the empty fact", async () => { + const trust = trustFile("unreadable", `${LINE_A}\n`); + const fake = fakeRun({ + "[git.example.test]:22": { code: 1, stderr: "Load key ...: Permission denied" }, + "git.example.test": { code: 1, stderr: "Load key ...: Permission denied" }, + }); + const res = await execSshHostKey(makeCtx("unreadable"), cmd(), seamsFor(trust, fake.run)); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("could not be read"); + }); + + it("a timeout and a spawn failure each refuse by name", async () => { + const trust = trustFile("failures", `${LINE_A}\n`); + const timeout = fakeRun({ "git.example.test": { timedOut: true } }); + const t = await execSshHostKey(makeCtx("failures"), cmd(), seamsFor(trust, timeout.run)); + expect(t.ok).toBe(false); + expect(String((t as { error?: string }).error)).toInclude("deadline"); + const spawn = fakeRun({ "git.example.test": { spawnError: true } }); + const s = await execSshHostKey(makeCtx("failures"), cmd(), seamsFor(trust, spawn.run)); + expect(s.ok).toBe(false); + expect(String((s as { error?: string }).error)).toInclude("could not be started"); + }); + + it("an answer the frozen validator rejects (an over-cap line) is refused, not passed through", async () => { + const trust = trustFile("malformed", "x\n"); + const huge = `host ssh-ed25519 ${"A".repeat(5000)}`; + const fake = fakeRun({ "git.example.test": { stdout: `${huge}\n` }, "[git.example.test]:22": { code: 1 } }); + const res = await execSshHostKey(makeCtx("malformed"), cmd(), seamsFor(trust, fake.run)); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("malformed"); + }); + + it("dispatch routes ssh_host_key to the HANDLER, not to unsupported (gate ON, no keygen, no trust file -> empty fact)", async () => { + // Gate ON, a keygen that resolves, and an ABSENT trust file: only the + // handler can produce the empty ok:true answer; `unsupported` here would + // mean the dispatch arm is missing, the gate's words would mean it ran + // before the lookup. + process.env.SUBSHELL_SSH_KEYGEN_PATH = "/bin/sh"; + process.env.SUBSHELL_SSH_KNOWN_HOSTS = join(base, "dispatch-missing", "known_hosts"); + const res = await dispatchCommand(makeCtx("dispatch"), { + type: "ssh_host_key", + host: "git.example.test", + port: 22, + user: null, + } satisfies NodeCommandBody); + expect(res).toEqual({ ok: true, data: { lines: [] } }); + }); + + it("dispatch routes to the HANDLER's binary-missing class when the file EXISTS (still not `unsupported`)", async () => { + process.env.SUBSHELL_SSH_KEYGEN_PATH = "/nonexistent/ssh-keygen-binary"; + const trust = trustFile("dispatch-exists", `${LINE_A}\n`); + process.env.SUBSHELL_SSH_KNOWN_HOSTS = trust; + const res = await dispatchCommand(makeCtx("dispatch2"), { + type: "ssh_host_key", + host: "git.example.test", + port: 22, + user: null, + } satisfies NodeCommandBody); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("binary missing"); + }); +}); diff --git a/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts b/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts new file mode 100644 index 000000000..b2704dd70 --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts @@ -0,0 +1,351 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + parseNodeSshAgentIdentities, + parseNodeSshIdentity, + SSH_ROSTER_MAX_IDENTITIES, +} from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { execSshAgentIdentities } from "../commands/ssh-identity.js"; +import { loadOrCreateIdentity, signingIdentityPath } from "../identity.js"; +import { type AgentScheme, CLASSIC_SCHEME, OPENSSH_10X_SCHEME } from "../relay-agent-scheme.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; + +/** + * `ssh_register_identity` (spec 2026-10-08 §4.3): the machine answers its + * SIGNING PUBLIC JWK and nothing else. Driven through `dispatchCommand` + * exactly as the daemon drives it (the commands-ssh.test.ts posture). + * + * What this file owns: + * - the answer IS `identity.signingPublicJwk`, byte for byte, stable across + * asks (a re-report after rotation must not read as a mismatch); + * - the private half NEVER rides out: no `d` in the answer's JWK, and the + * serialized result never contains the stored private JWK; + * - NO ssh-enabled gate: registration is the machine's own act, not an SSH + * act, and a pre-M2 node hits it while its mirror still says off; + * - a corrupt signing file fails closed through dispatch's whole-switch wrap + * (identity.ts quarantines and throws; the plane learns `ok:false`). + */ + +let base: string; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-ssh-id-"))); +}); + +afterAll(() => { + rmSync(base, { recursive: true, force: true }); +}); + +function makeCtx(dataDir: string): CommandContext { + return { config: { dataDir } } as unknown as CommandContext; +} + +function freshDir(tag: string): string { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe("ssh_register_identity", () => { + it("answers the signing PUBLIC jwk byte-for-byte, stable across asks", async () => { + const dir = freshDir("basic"); + const identity = await loadOrCreateIdentity(dir); + const first = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(first.ok).toBe(true); + const parsed = parseNodeSshIdentity(first.ok ? first.data : null); + expect(parsed?.signingPublicKey).toBe(identity.signingPublicJwk); + // The §4.3 guard compares BYTES: a second ask must answer the same string. + const second = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(second.ok && second.data).toEqual(first.ok ? first.data : null); + }); + + it("the private half never leaves: no d member in the answer, no private jwk in the frame", async () => { + const dir = freshDir("private"); + const identity = await loadOrCreateIdentity(dir); + const result = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(result.ok).toBe(true); + const answer = parseNodeSshIdentity(result.ok ? result.data : null); + if (!answer) throw new Error("identity answer did not validate"); + const jwk = JSON.parse(answer.signingPublicKey) as Record; + expect("d" in jwk).toBe(false); + expect(jwk.kty).toBe("EC"); + expect(jwk.crv).toBe("P-256"); + // The whole wire frame, serialized, must not carry the stored private JWK. + expect(JSON.stringify(result)).not.toContain(identity.signingPrivateJwk); + }); + + it("ignores the ssh-enabled gate: an un-mirrored (fail-closed OFF) machine still registers", async () => { + // Deliberately NO ssh-enabled.json here: the mirror is absent, which the + // gate reads as OFF. Registration precedes the gate; a node that never + // turned SSH on must still be able to file its identity (§4.3). + const dir = freshDir("gate-off"); + expect(readdirSync(dir).length).toBe(0); + const result = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(result.ok).toBe(true); + // The pair the loader minted on this ask is exactly what the answer names. + const identity = await loadOrCreateIdentity(dir); + const parsed = parseNodeSshIdentity(result.ok ? result.data : null); + expect(parsed?.signingPublicKey).toBe(identity.signingPublicJwk); + }); + + it("a corrupt signing file fails closed: quarantine throw surfaces as ok:false", async () => { + const dir = freshDir("corrupt"); + writeFileSync(signingIdentityPath(dir), "not a keypair", { mode: 0o600 }); + // The handler may create the encryption pair, but the signing pair's + // quarantine throw must reach dispatch's whole-switch wrap as a refusal. + const result = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("refusing to overwrite existing key material"); + // The corrupt file was moved aside, not answered over. + const quarantined = readdirSync(dir).filter((f) => f.includes("corrupt-")); + expect(quarantined.length).toBe(1); + expect(readdirSync(dir)).not.toContain("node-signing-identity.json"); + }); +}); + +/* ------------------------------------------------------------------ */ +/* ssh_agent_identities (spec 2026-10-08 §5.4, Task 11) */ +/* ------------------------------------------------------------------ */ + +/** + * The roster command: probe A's agent scheme, ask the identities request, + * and answer `{ identities: [{ fingerprint, comment }] }` with the blobs + * WITHHELD. Driven with a scripted requestAgent (the relay-agent-scheme + * test's fixture posture): the probe is real (candidate order and + * positive-confirmation are what the 2026-10-08 ruling pins), only the + * socket is a stand-in. + */ + +const GATE_STAMP = "2026-10-08T10:00:00.000Z"; + +function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +function sshStr(bytes: Buffer | string): Buffer { + const b = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes; + return Buffer.concat([be32(b.length), b]); +} + +/** Independent oracle: SHA-256 over the wire blob, base64url (never the production helper). */ +function fp(wire: Uint8Array): string { + return `SHA256:${createHash("sha256").update(wire).digest("base64url")}`; +} + +const KEY_LAPTOP = Buffer.concat([sshStr("ssh-ed25519"), sshStr(Buffer.from("LAPTOP-BLOB"))]); +const KEY_BACKUP = Buffer.concat([sshStr("ssh-rsa"), sshStr(Buffer.from("BACKUP-BLOB"))]); + +function identitiesAnswer(scheme: AgentScheme, entries: { blob: Buffer; comment: string }[]): Buffer { + return Buffer.concat([ + Buffer.from([scheme.answer]), + be32(entries.length), + ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), + ]); +} + +const ROSTER = [ + { blob: KEY_LAPTOP, comment: "laptop key" }, + { blob: KEY_BACKUP, comment: "backup key" }, +]; + +/** A scripted agent: records every payload, answers per the script. */ +function scriptedAgent(script: (payload: Buffer) => Buffer): { + sent: Buffer[]; + requestAgent: (path: string, payload: Buffer) => Promise; +} { + const sent: Buffer[] = []; + return { + sent, + requestAgent: async (_path: string, payload: Buffer): Promise => { + sent.push(Buffer.from(payload)); + return script(payload); + }, + }; +} + +/** Gate-ON context in a fresh dir (the mirror written by its own writer). */ +function gatedDir(tag: string): string { + const dir = join(base, `${tag}-gated`, "data"); + mkdirSync(dir, { recursive: true }); + writeSshEnabled(dir, { on: true, changedAt: GATE_STAMP }); + return dir; +} + +describe("ssh_agent_identities", () => { + it("answers the roster as fingerprints + comments in BOTH schemes, and the blob bytes never ride out", async () => { + for (const [scheme, probeScript] of [ + // A 10.x agent: the classic candidate answers FAILURE(5), byte 11 resolves 10.x. + [ + OPENSSH_10X_SCHEME, + (p: Buffer): Buffer | null => + p.equals(Buffer.from([13])) ? Buffer.from([5]) : identitiesAnswer(OPENSSH_10X_SCHEME, ROSTER), + ], + // A classic agent resolves on the FIRST probe, no second round trip. + [ + CLASSIC_SCHEME, + (p: Buffer): Buffer | null => (p.equals(Buffer.from([13])) ? identitiesAnswer(CLASSIC_SCHEME, ROSTER) : null), + ], + ] as const) { + const agent = scriptedAgent((p) => { + const a = probeScript(p); + if (a === null) throw new Error(`unexpected roster request byte ${p[0]} for ${scheme.name}`); + return a; + }); + const result = await execSshAgentIdentities(makeCtx(gatedDir(scheme.name)), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result.ok).toBe(true); + const parsed = parseNodeSshAgentIdentities(result.ok ? result.data : null); + expect(parsed).toEqual({ + identities: [ + { fingerprint: fp(KEY_LAPTOP), comment: "laptop key" }, + { fingerprint: fp(KEY_BACKUP), comment: "backup key" }, + ], + }); + // The roster request went ONLY as the RESOLVED scheme's identities byte. + const identitiesRequests = agent.sent.filter((p) => p.length === 1 && p[0] === scheme.identities); + expect(identitiesRequests.length).toBe(2); // probe (positive confirmation) + roster ask + if (scheme === CLASSIC_SCHEME) expect(agent.sent.length).toBe(2); // no second candidate was ever sent + // Blobs withheld: no spelling of either blob appears in the serialized frame. + const wire = JSON.stringify(result); + for (const blob of [KEY_LAPTOP, KEY_BACKUP]) { + expect(wire).not.toContain(blob.toString("base64")); + expect(wire).not.toContain(blob.toString("base64url")); + } + expect(wire).not.toContain("LAPTOP-BLOB"); + expect(wire).not.toContain("BACKUP-BLOB"); + } + }); + + it("an empty roster from a live agent is the honest answer, not an error", async () => { + const agent = scriptedAgent((p) => + p.equals(Buffer.from([13])) + ? identitiesAnswer(CLASSIC_SCHEME, []) + : (() => { + throw new Error("never"); + })(), + ); + const result = await execSshAgentIdentities(makeCtx(gatedDir("empty")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result.ok && result.data).toEqual({ identities: [] }); + }); + + it("the gate speaks FIRST: an unmirrored machine refuses before the socket or the agent", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + const dir = join(base, "roster-gate-off", "data"); + mkdirSync(dir, { recursive: true }); // deliberately no mirror: fail-closed OFF + const result = await execSshAgentIdentities(makeCtx(dir), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result).toEqual({ ok: false, error: "ssh disabled on this node" }); + expect(agent.sent).toEqual([]); + }); + + it("no live agent socket is a named error and the agent is never asked", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + const result = await execSshAgentIdentities(makeCtx(gatedDir("no-sock")), { + resolveAgentSocket: () => null, + requestAgent: agent.requestAgent, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("agent socket"); + expect(agent.sent).toEqual([]); + }); + + it("an agent that resolves NO scheme is refused by name; nothing is ever asked under a guess", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + const result = await execSshAgentIdentities(makeCtx(gatedDir("no-scheme")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("numbering"); + expect(agent.sent).toEqual([Buffer.from([13]), Buffer.from([11])]); // both candidates probed, then refusal + }); + + it("a roster answer that does not parse is refused, never partially answered", async () => { + // The probe (first byte-11 ask) resolves 10.x positively; the SECOND + // byte-11 ask is the roster read, and THAT answer comes back truncated. + let elevenAsks = 0; + const agent = scriptedAgent((p) => { + if (p.equals(Buffer.from([13]))) return Buffer.from([5]); // the classic candidate fails + elevenAsks += 1; + if (elevenAsks === 1) return identitiesAnswer(OPENSSH_10X_SCHEME, ROSTER); // probe: valid, resolves + return Buffer.from([12, 0, 0, 0, 5, ...sshStr(KEY_LAPTOP).subarray(0, 3)]); // roster: truncated + }); + const result = await execSshAgentIdentities(makeCtx(gatedDir("malformed")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("did not parse"); + }); + + it("a roster past SSH_ROSTER_MAX_IDENTITIES is refused by name, never truncated; the bound itself answers", async () => { + // PR #338 review round 2: the parse takes whatever count the agent + // declares, so the COMMAND refuses an oversized roster. The no-silent- + // truncation law: cutting the list to fit would show the operator a + // partial roster as the whole truth. The refusal names the cause and + // carries no fingerprint, comment, or blob data. + const many = (n: number) => + Array.from({ length: n }, (_, i) => ({ + blob: Buffer.concat([sshStr("ssh-ed25519"), sshStr(`OVER-BLOB-${i}`)]), + comment: `over-comment-${i}`, + })); + // Classic scheme: the probe's positive confirmation and the roster read + // are the same byte-13 ask, so every ask gets the scripted answer. + const oversized = scriptedAgent(() => identitiesAnswer(CLASSIC_SCHEME, many(SSH_ROSTER_MAX_IDENTITIES + 1))); + const result = await execSshAgentIdentities(makeCtx(gatedDir("oversized")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: oversized.requestAgent, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + // The COMMAND's own named refusal, not the generic malformed-answer + // path: the operator-facing cause is an oversized roster. + expect(result.error).toInclude("oversized"); + expect(result.error).not.toInclude("malformed"); + expect(result.error).not.toInclude("over-comment-0"); + expect(result.error).not.toInclude("OVER-BLOB-0"); + expect(result.error).not.toInclude("SHA256:"); + } + // Boundary: exactly at the bound, the roster answers UNCHANGED. + const atBound = many(SSH_ROSTER_MAX_IDENTITIES); + const boundAgent = scriptedAgent(() => identitiesAnswer(CLASSIC_SCHEME, atBound)); + const bounded = await execSshAgentIdentities(makeCtx(gatedDir("at-bound")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: boundAgent.requestAgent, + }); + expect(bounded.ok).toBe(true); + const parsed = parseNodeSshAgentIdentities(bounded.ok ? bounded.data : null); + expect(parsed?.identities).toHaveLength(SSH_ROSTER_MAX_IDENTITIES); + expect(parsed?.identities[3]).toEqual({ fingerprint: fp(atBound[3].blob), comment: "over-comment-3" }); + }); + + it("dispatch routes the arm: a gate-ON machine with no SSH_AUTH_SOCK answers the handler's own named error, not `unsupported`", async () => { + const saved = process.env.SSH_AUTH_SOCK; + process.env.SSH_AUTH_SOCK = ""; // the honest no-agent spelling liveAgentSocketPath must refuse + try { + const result = await dispatchCommand(makeCtx(gatedDir("route")), { type: "ssh_agent_identities" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toInclude("agent socket"); + expect(result.error).not.toBe("unsupported"); + } + } finally { + if (saved === undefined) delete process.env.SSH_AUTH_SOCK; + else process.env.SSH_AUTH_SOCK = saved; + } + }); +}); diff --git a/apps/node/agent/src/__tests__/commands-ssh-machine-pin-repair.test.ts b/apps/node/agent/src/__tests__/commands-ssh-machine-pin-repair.test.ts new file mode 100644 index 000000000..b865e9289 --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-machine-pin-repair.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SshMachinePinRepairCommand } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { execSshMachinePinRepair } from "../commands/ssh-machine-pin-repair.js"; +import { MachinePinStore, machinePinPath } from "../machine-pin-store.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; + +/** + * The §4.5 re-pair arm (spec 2026-10-08 §4.5, Task 17). Posture copied from + * commands-ssh-relay.test.ts: a real temp data dir, the store read back + * through its own class, refusals asserted by `ok:false` + message. + * + * What THIS file owns: + * - the dispatch arm exists (never the switch's `unsupported`) and the write + * lands: after a valid repair, `check()` for the delivered pair is `ok` + * (the §13 block-clears keystone, at the COMMAND layer; the store-level + * pair lives in machine-pin-store.test.ts). + * - the arm is UNGATED by design (the `ssh_register_identity` precedent): a + * machine whose ssh-enabled mirror is OFF still repairs its trust record, + * because the recovery must reach a machine whose pairing is blocked. + * - deep key validation BEFORE any write: a private (`d`-bearing) JWK in + * either half is refused with the store untouched - the base64 encryption + * half is where the grammar cannot see, so this handler is the station + * that refuses it. + * - replace-or-add: a peer with no entry gets one (a lost store re-pairs). + * - the ack carries {repaired, peerNodeId} and NOTHING else - no key bytes + * ride the result frame, and no audit row exists on this machine (the + * trail is the plane's `node.ssh_machine_pin.repair`, ids only). + */ + +const SELF = "node-self"; +const PEER = "node-peer"; + +/** Real-length P-256 coordinate text (bytesOfJwk demands 32 decoded bytes). */ +const coord = (fill: number): string => Buffer.alloc(32, fill).toString("base64url"); +const publicJwk = (fill: number, extra: Record = {}): string => + JSON.stringify({ kty: "EC", crv: "P-256", x: coord(fill), y: coord(fill + 10), ...extra }); +const b64 = (jwkJson: string): string => Buffer.from(jwkJson, "utf8").toString("base64"); + +const SIGN_OK = publicJwk(1); +const ENC_OK = publicJwk(50); +/** A serialized PRIVATE JWK of the same public shape: the top-level `d`. */ +const SIGN_PRIVATE = publicJwk(1, { d: coord(99) }); +const ENC_PRIVATE = publicJwk(50, { d: coord(77) }); + +let base: string | null = null; +function freshDataDir(tag: string, opts: { gateOn?: boolean } = {}): string { + base ??= tmpdir(); + const dir = mkdtempSync(join(base, `subshell-repair-${tag}-`)); + if (opts.gateOn) writeSshEnabled(dir, { on: true, changedAt: "2026-10-08T10:00:00.000Z" }); + return dir; +} + +function makeCtx(dataDir: string, nodeId: string = SELF): CommandContext { + return { config: { dataDir, nodeId } } as unknown as CommandContext; +} + +function repairCmd(over: Partial = {}): SshMachinePinRepairCommand { + return { + type: "ssh_machine_pin_repair", + peerNodeId: PEER, + peerSigningPublicKey: SIGN_OK, + peerEncryptPublicKey: b64(ENC_OK), + ...over, + }; +} + +describe("ssh_machine_pin_repair dispatch (the arm exists, the write lands)", () => { + it("a valid re-pair replaces the blocked entry and check() clears, through the real switch", async () => { + const dir = freshDataDir("write", { gateOn: true }); + const store = new MachinePinStore(dir); + // The blocked state first: the peer's key moved, byte-strict refuses. + store.pin(PEER, { signing: publicJwk(90), encryption: publicJwk(110) }); + expect(store.check(PEER, { signing: SIGN_OK, encryption: ENC_OK })).toBe("changed"); + const res = await dispatchCommand(makeCtx(dir), repairCmd()); + expect(res).toEqual({ ok: true, data: { repaired: true, peerNodeId: PEER } }); + expect(store.check(PEER, { signing: SIGN_OK, encryption: ENC_OK })).toBe("ok"); + // Bytes are stored EXACTLY as delivered (the registration spelling): + // the pin round-trips byte-for-byte, not a re-serialization. + expect(store.get(PEER)).toEqual({ signing: SIGN_OK, encryption: ENC_OK }); + }); + + it("a peer with no stored entry gets one (a repair after a lost store)", async () => { + const dir = freshDataDir("add", { gateOn: true }); + const store = new MachinePinStore(dir); + expect(store.get(PEER)).toBe(null); + expect(await dispatchCommand(makeCtx(dir), repairCmd())).toEqual({ + ok: true, + data: { repaired: true, peerNodeId: PEER }, + }); + expect(store.check(PEER, { signing: SIGN_OK, encryption: ENC_OK })).toBe("ok"); + rmSync(dir, { recursive: true, force: true }); + }); + + it("the gate does NOT speak here: a machine with the mirror OFF still repairs its trust record", async () => { + // Deliberate, the `ssh_register_identity` doctrine: re-pair is a + // trust-record act on this machine's own store, and §4.5 recovery must + // reach a machine whose pairing is currently blocked. A GATED arm would + // make the repair of a relationship depend on the relationship. + const dir = freshDataDir("ungated"); // no writeSshEnabled: mirror absent + const res = await dispatchCommand(makeCtx(dir), repairCmd()); + expect(res.ok).toBe(true); + expect(new MachinePinStore(dir).get(PEER)).not.toBe(null); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("deep key validation before any write (the grammar cannot see inside base64)", () => { + function refused(dir: string, cmd: SshMachinePinRepairCommand): void { + const res = execSshMachinePinRepair(makeCtx(dir), cmd); + expect(res.ok).toBe(false); + if (res.ok === false) expect(res.error).toContain("peer key rejected"); + } + + it("refuses a PRIVATE encryption JWK carried in base64, with the store untouched", () => { + // THE deep refusal this arm owns: the outer grammar proves the base64 + // shape but cannot read the JSON inside, so `d` inside the delivered + // encryption half is caught HERE - and nothing was written. + const dir = freshDataDir("enc-private"); + refused(dir, repairCmd({ peerEncryptPublicKey: b64(ENC_PRIVATE) })); + expect(new MachinePinStore(dir).get(PEER)).toBe(null); + rmSync(dir, { recursive: true, force: true }); + }); + + it("refuses a `d`-bearing signing JWK and foreign-curve / junk halves alike", () => { + for (const [tag, over] of [ + ["sign-d", { peerSigningPublicKey: SIGN_PRIVATE }], + ["sign-junk", { peerSigningPublicKey: "not json" }], + ["sign-short-coords", { peerSigningPublicKey: '{"kty":"EC","crv":"P-256","x":"AX","y":"AY"}' }], + ["sign-wrong-kty", { peerSigningPublicKey: '{"kty":"OKP","crv":"Ed25519","x":"AX"}' }], + ["enc-junk", { peerEncryptPublicKey: b64("hello") }], + [ + "enc-wrong-curve", + { peerEncryptPublicKey: b64(`{"kty":"EC","crv":"P-384","x":"${coord(3)}","y":"${coord(4)}"}`) }, + ], + ] as const) { + const dir = freshDataDir(tag); + refused(dir, repairCmd(over as Partial)); + expect(new MachinePinStore(dir).get(PEER)).toBe(null); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("a refusal writes NOTHING: an existing pin for the peer survives verbatim", () => { + const dir = freshDataDir("no-partial"); + const store = new MachinePinStore(dir); + store.pin(PEER, { signing: SIGN_OK, encryption: ENC_OK }); + refused(dir, repairCmd({ peerEncryptPublicKey: b64(ENC_PRIVATE) })); + expect(store.get(PEER)).toEqual({ signing: SIGN_OK, encryption: ENC_OK }); + rmSync(dir, { recursive: true, force: true }); + }); + + it("a corrupt pin store fail-closes the repair instead of silently rewriting it", () => { + const dir = freshDataDir("corrupt"); + writeFileSync(machinePinPath(dir), "{ not json"); + const res = execSshMachinePinRepair(makeCtx(dir), repairCmd()); + expect(res.ok).toBe(false); + if (res.ok === false) expect(res.error).toContain("machine pin repair refused"); + // The store's quarantine doctrine ran (throws, moves the file aside); + // the delivered pair never landed as a fresh store. + expect(() => new MachinePinStore(dir).get(PEER)).toThrow("quarantined"); + rmSync(dir, { recursive: true, force: true }); + }); + + it("refuses a repair naming THIS machine as its own peer", () => { + const dir = freshDataDir("self-peer"); + const res = execSshMachinePinRepair(makeCtx(dir), repairCmd({ peerNodeId: SELF })); + expect(res.ok).toBe(false); + if (res.ok === false) expect(res.error).toContain("never its own relay peer"); + expect(new MachinePinStore(dir).get(SELF)).toBe(null); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("the ack carries ids only (no key material, no audit here)", () => { + it("the success data is exactly {repaired, peerNodeId}; the pin bytes appear nowhere in it", () => { + const dir = freshDataDir("ack-shape", { gateOn: true }); + const res = execSshMachinePinRepair(makeCtx(dir), repairCmd()); + expect(res.ok).toBe(true); + if (res.ok !== true) return; + const serialized = JSON.stringify(res.data); + expect(res.data).toEqual({ repaired: true, peerNodeId: PEER }); + // Belt for the no-key-material constraint: the delivered halves (raw and + // base64 spellings) cannot appear in the answer the plane receives. + expect(serialized).not.toContain(SIGN_OK); + expect(serialized).not.toContain(b64(ENC_OK)); + expect(serialized).not.toContain(coord(1)); + // The bytes live exactly one place on this machine: the pin file itself. + expect(readFileSync(machinePinPath(dir), "utf8")).toContain(coord(1)); + }); + + it("a refusal names the problem without echoing the delivered key material", () => { + const dir = freshDataDir("refusal-hygiene"); + const res = execSshMachinePinRepair(makeCtx(dir), repairCmd({ peerEncryptPublicKey: b64(ENC_PRIVATE) })); + expect(res.ok).toBe(false); + if (res.ok === false) { + // The error rides the RESULT channel and the plane may log it: the node + // refuses by SHAPE, never by quoting what was refused (T8 posture). + expect(res.error).not.toContain(ENC_PRIVATE); + expect(res.error).not.toContain(b64(ENC_PRIVATE)); + expect(res.error).not.toContain(coord(60)); + } + rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts new file mode 100644 index 000000000..17e024343 --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts @@ -0,0 +1,316 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type NodeCommandBody, + type RelayFrame, + SSH_RELAY_MAX_PER_NODE, + type SshRelayOpenCommand, +} from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { type BRelaySessionArgs, RelaySessions } from "../commands/ssh-relay.js"; +import { execSshRelayClose, execSshRelayOpen, type RelayOpenSeams } from "../commands/ssh-relay-exec.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; + +/** + * The relay executor arms (spec 2026-10-08 §5.1, Task 8 acceptance (a) and + * the probe-off-pump half of the T7 handoff). Posture copied from + * commands-ssh.test.ts: real temp dirs, the gate mirror written by its own + * writer, refusals asserted by `ok:false` + exact message. + * + * What THIS file owns that nothing else does: + * - the dispatch arms exist: `ssh_relay_open` and `ssh_relay_close` answer + * from the executor, never the switch's `unsupported`. + * - the GATE (mirror first) on open; and that CLOSE is not gated - teardown + * must land even while a machine is switched off. + * - acceptance (a) in both role branches: A drives the openARelaySession + * seam, B drives openBRelaySession with the COMMAND's paneId (grammar (b) + * end to end), B's answer carries the socket path the plane will byte-check. + * - the T7 obligation: the A branch does not await the numbering probe on + * the shared command chain - a stuck agent's openARelaySession must not + * serialize anything behind it (the never-resolving seam is the proof), + * and its late named refusal lands in the log, never as an uncaught throw. + * - the ctx.relay plumbing check (a daemon built without it refuses loudly). + */ + +const STAMP = "2026-10-08T10:00:00.000Z"; +const GATE_REFUSAL = "ssh disabled on this node"; +const PANE = "11111111-2222-4333-8444-555555555555"; + +let base: string; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-relay-"))); +}); + +afterAll(() => { + rmSync(base, { recursive: true, force: true }); +}); + +function dataDir(tag: string, gateOn: boolean): string { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + if (gateOn) writeSshEnabled(dir, { on: true, changedAt: STAMP }); + return dir; +} + +function makeCtx(tag: string, opts: { gateOn?: boolean; nodeId?: string; relay?: boolean } = {}) { + const data = dataDir(tag, opts.gateOn ?? true); + const sends: RelayFrame[] = []; + const relay = + opts.relay === false + ? undefined + : { sessions: new RelaySessions(), sendRelayFrame: (f: RelayFrame) => sends.push(f) }; + const ctx = { config: { dataDir: data, nodeId: opts.nodeId ?? "node-self" }, relay } as unknown as CommandContext; + return { ctx, sends, relay }; +} + +function openCmd(over: Partial = {}): SshRelayOpenCommand { + return { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-1", + role: "A", + aNodeId: "node-self", + bNodeId: "node-peer", + peerSigningPublicKey: '{"kty":"EC","crv":"P-256","x":"AX","y":"AY"}', + peerEncryptPublicKey: Buffer.from('{"kty":"EC","crv":"P-256","x":"BX","y":"BY"}', "utf8").toString("base64"), + fingerprints: ["SHA256:AAAA"], + lifetimeMs: 30_000, + paneId: PANE, + // Task 12: the relay-open's required destination pin; the B branch writes + // it to the pane's 0600 known_hosts file before binding the socket. + hostPin: "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000", + ...over, + }; +} + +describe("the dispatch arms exist (acceptance (a))", () => { + it("ssh_relay_open reaches the executor, not the switch's unsupported", async () => { + const { ctx } = makeCtx("dispatch"); + // Role B naming a different machine as B: the executor's own first guard + // answers, which is the proof the arm exists (an unwired switch says + // `unsupported`, never this sentence). + const res = await dispatchCommand(ctx, openCmd({ role: "B", aNodeId: "other", bNodeId: "not-me" })); + expect(res.ok).toBe(false); + expect(res.ok === false && res.error).toContain("not the connecting side"); + }); + + it("ssh_relay_close reaches RelaySessions.close and an unknown ref is an idempotent ok", async () => { + const { ctx, relay } = makeCtx("dispatch-close"); + const res = await dispatchCommand(ctx, { type: "ssh_relay_close", ref: "r-none", reason: "child-exit" }); + expect(res).toEqual({ ok: true, data: { ref: "r-none", closed: false } }); + const closed: string[] = []; + relay?.sessions.register("r-live", { onRelayFrame: () => {}, close: (reason) => closed.push(reason) }); + const res2 = await dispatchCommand(ctx, { + type: "ssh_relay_close", + ref: "r-live", + reason: "a-dropped", + } as NodeCommandBody); + expect(res2).toEqual({ ok: true, data: { ref: "r-live", closed: true } }); + expect(closed).toEqual(["a-dropped"]); // §5.1: the named reason reaches the endpoint + }); + + it("a daemon context WITHOUT the relay plumbing refuses, loudly", async () => { + const { ctx } = makeCtx("no-plumbing", { relay: false }); + const res = await dispatchCommand(ctx, openCmd()); + expect(res.ok).toBe(false); + expect(res.ok === false && res.error).toContain("relay plumbing"); + }); +}); + +describe("the gate (mirror first, spec §4.3)", () => { + it("an off mirror refuses ssh_relay_open before any pairing mechanics", async () => { + const { ctx } = makeCtx("gate-off", { gateOn: false }); + const res = await execSshRelayOpen(ctx, openCmd({ role: "B" })); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + }); + + it("ssh_relay_close is NOT gated: teardown answers even while the machine is switched off", async () => { + const { ctx } = makeCtx("gate-off-close", { gateOn: false }); + const res = await execSshRelayClose(ctx, { type: "ssh_relay_close", ref: "r-x", reason: "access-revoked" }); + expect(res).toEqual({ ok: true, data: { ref: "r-x", closed: false } }); + }); +}); + +describe("the A branch: the probe runs OFF the command chain (T7 handoff)", () => { + it("acks immediately while openARelaySession is still running", async () => { + const { ctx } = makeCtx("a-slow"); + let started: (() => void) | undefined; + const never = new Promise<{ relayId: string }>((resolve) => { + started = () => resolve({ relayId: "relay-1" }); + }); + const seams: RelayOpenSeams = { + openA: async () => never, + openB: async () => { + throw new Error("must not run"); + }, + }; + const exec = execSshRelayOpen(ctx, openCmd({ role: "A" }), seams); + // The proof is timing: if the executor awaited the probe, this race would + // resolve with the timeout instead of the ack. + const winner = await Promise.race([ + exec.then((r) => ({ kind: "ack" as const, r })), + new Promise<{ kind: "timeout" }>((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 25)), + ]); + expect(winner.kind).toBe("ack"); + if (winner.kind === "ack") { + expect(winner.r).toEqual({ ok: true, data: { role: "A", relayId: "relay-1", pending: true } }); + } + started?.(); // let the detached promise settle so nothing leaks a rejection + await never; + }); + + it("a late named refusal from the detached open lands in the log and never throws past the arm", async () => { + const { ctx } = makeCtx("a-refuse"); + const lines: string[] = []; + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("machine pin for node-peer has MOVED"); + }, + openB: async () => { + throw new Error("must not run"); + }, + log: (line) => lines.push(line), + }; + const res = await execSshRelayOpen(ctx, openCmd({ role: "A" }), seams); + expect(res.ok).toBe(true); // the ack was already earned; the refusal is a LOG fact (§4.5: re-pair is an operator act at the machine) + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(lines.join("\n")).toContain("MOVED"); + expect(lines.join("\n")).toContain("r-1"); + }); +}); + +describe("the B branch: the pane rides the command, the answer carries the socket", () => { + it("openBRelaySession gets the COMMAND's paneId and its socket path answers back", async () => { + const { ctx, relay } = makeCtx("b-ok"); + const seenArgs: BRelaySessionArgs[] = []; + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("must not run"); + }, + openB: async (args) => { + seenArgs.push(args); + relay?.sessions.register(args.cmd.ref, { onRelayFrame: () => {}, close: () => {} }); + return { socketPath: `${args.dataDir}/ssh/${args.paneId}/agent.sock` }; + }, + }; + const res = await execSshRelayOpen(ctx, openCmd({ role: "B", aNodeId: "node-peer", bNodeId: "node-self" }), seams); + expect(seenArgs).toHaveLength(1); + expect(seenArgs[0]?.paneId).toBe(PANE); // grammar (b): the pane arrives on the WIRE, not a side channel + expect(seenArgs[0]?.selfNodeId).toBe("node-self"); + expect(seenArgs[0]?.cmd.role).toBe("B"); + expect(res).toEqual({ + ok: true, + data: { role: "B", relayId: "relay-1", socketPath: `${ctx.config.dataDir}/${"ssh"}/${PANE}/agent.sock` }, + }); + }); + + it("a throwing open (moved pin, bad keys, owned ref) answers ok:false with the named refusal", async () => { + const { ctx } = makeCtx("b-refuse"); + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("must not run"); + }, + openB: async () => { + throw new Error("relay open refused: machine pin for node-peer has MOVED (re-pair per §4.5)"); + }, + }; + const res = await execSshRelayOpen(ctx, openCmd({ role: "B", aNodeId: "node-peer", bNodeId: "node-self" }), seams); + expect(res.ok).toBe(false); + expect(res.ok === false && res.error).toContain("MOVED"); + }); +}); + +describe("RelaySessions concurrency bound (acceptance (g), node-side defense)", () => { + const handler = { onRelayFrame: () => {}, close: () => {} }; + + it("rejects a 9th concurrent session with a named refusal and keeps the dup-ref false", () => { + const relay = new RelaySessions(); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) expect(relay.register(`r-${i}`, handler)).toBe(true); + // The existing dup-ref contract is UNCHANGED: false, no throw. + expect(relay.register("r-0", handler)).toBe(false); + // A NEW ref over the cap is a loud named refusal (defense-in-depth; the + // plane's quota is authoritative, this is the machine refusing to hold a + // 9th proxy anyway). + expect(() => relay.register(`r-${SSH_RELAY_MAX_PER_NODE}`, handler)).toThrow(/max 8/); + expect(relay.size).toBe(SSH_RELAY_MAX_PER_NODE); + // Releasing a ref frees a slot: the next register lands. + relay.close("r-3", "lifetime-expiry"); + expect(relay.register(`r-${SSH_RELAY_MAX_PER_NODE}`, handler)).toBe(true); + }); + + it("a THROWN cap refusal does not create a session and the refused opener can still release itself", () => { + // The call-site contract the executors rely on (fix round T8 MAJOR 1): + // every refusal path of register leaves the registry exactly as it was, + // so the OPENERS cleanup (its try/catch around register) is the whole + // release story - the cap throw must not half-own anything. + const relay = new RelaySessions(); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) relay.register(`r-${i}`, handler); + expect(() => relay.register("r-over", handler)).toThrow(/relay registry full/); + expect(relay.size).toBe(SSH_RELAY_MAX_PER_NODE); + expect(relay.has("r-over")).toBe(false); + }); +}); + +describe("RelaySessions close tombstones (fix round T8 MAJOR 2)", () => { + const handler = { onRelayFrame: () => {}, close: () => {} }; + + it("remembers a close of an UNKNOWN ref, refuses a late register, and expires the tombstone", () => { + // The clock and the TTL are injected: the race window (a detached open + // worst-case ~20 s deep in its probe) is real time the suite cannot pay. + let now = 1_000_000; + const relay = new RelaySessions(undefined, { nowMs: () => now, tombstoneMs: 5_000 }); + // The plane's cut lands BEFORE the open ever registers: nothing is owned, + // and the answer stays the honest `closed: false` the executor answers. + expect(relay.close("r-late", "handshake-grace")).toBe(false); + expect(relay.isTombstoned("r-late")).toBe(true); + // A late SUCCESS must not own a ref the plane already dropped. + expect(relay.register("r-late", handler)).toBe(false); + expect(relay.size).toBe(0); + // A close of an OWNED ref is NOT tombstoned: it took the normal path. + expect(relay.register("r-own", handler)).toBe(true); + expect(relay.close("r-own", "lifetime-expiry")).toBe(true); + expect(relay.isTombstoned("r-own")).toBe(false); + // The set is TTL-bounded, not a growing ledger: after the tombstone + // expires, the ref (or any other) registers normally again. + now += 5_001; + expect(relay.isTombstoned("r-late")).toBe(false); + expect(relay.register("r-late", handler)).toBe(true); + }); + + it("sweeps expired tombstones on every touch so repeated unknown closes stay bounded", () => { + let now = 0; + const lines: string[] = []; + const relay = new RelaySessions((line) => lines.push(line), { nowMs: () => now, tombstoneMs: 100 }); + for (let i = 0; i < 50; i += 1) expect(relay.close(`r-${i}`, "access-revoked")).toBe(false); + now += 101; // every tombstone is now past its TTL + expect(relay.register("sweep-probe", handler)).toBe(true); // the sweep ran at register + for (let i = 0; i < 50; i += 1) expect(relay.isTombstoned(`r-${i}`)).toBe(false); + // The remembered close is observable in the log (ids and the named + // reason only - the same refusal vocabulary every relay line keeps). + expect(lines.some((l) => l.includes("r-0") && l.includes("access-revoked"))).toBe(true); + }); +}); + +describe("the daemon seam wiring", () => { + it("the B branch's sendRelayFrame is the ctx pump (deliver-or-throw, forwarded unchanged)", async () => { + const { ctx, sends } = makeCtx("b-pump"); + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("must not run"); + }, + openB: async (args) => { + // The proxy hands the pump a frame; whatever it does is the pump's + // contract - here the ctx recorder proves identity of the seam. + args.sendRelayFrame({ type: "relay", ref: args.cmd.ref, seq: 0, direction: "B2A", blob: "QUJD" }); + return { socketPath: "/x/agent.sock" }; + }, + }; + await execSshRelayOpen(ctx, openCmd({ role: "B", aNodeId: "node-peer", bNodeId: "node-self" }), seams); + expect(sends).toHaveLength(1); + expect(sends[0]?.ref).toBe("r-1"); + }); +}); diff --git a/apps/node/agent/src/__tests__/commands-ssh.test.ts b/apps/node/agent/src/__tests__/commands-ssh.test.ts new file mode 100644 index 000000000..6efe03689 --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh.test.ts @@ -0,0 +1,177 @@ +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"; +import { captureLogs } from "./helpers/capture-logs.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 logs = captureLogs(); + try { + 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 }); + expect(logs.lines.join("\n")).toContain("SSH-DISABLED"); + expect(logs.lines.join("\n")).toContain("unreadable"); + } finally { + logs.restore(); + } + }); +}); + +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 M2's relay tier owns: the relay link frame + the open/close commands (spec 2026-10-08 §5.1). */ +it("NODE_PROTOCOL_VERSION is 18 (relay frames + ssh_relay_open/close on the wire)", () => { + expect(NODE_PROTOCOL_VERSION).toBe(19); +}); diff --git a/apps/node/agent/src/__tests__/daemon.test.ts b/apps/node/agent/src/__tests__/daemon.test.ts index bfae1f7b3..075d67950 100644 --- a/apps/node/agent/src/__tests__/daemon.test.ts +++ b/apps/node/agent/src/__tests__/daemon.test.ts @@ -16,6 +16,7 @@ import { join, resolve } from "node:path"; import type { TmuxRunner } from "@internal/pane-runtime"; import { type ControlKeyPair, + fingerprintJwk, generateControlKeys, HARNESS_BINARY_PLACEHOLDER, NODE_CLOSE_HANDSHAKE_REQUIRED, @@ -26,6 +27,7 @@ import { type NodeEvent, type NodeRuntimeReport, parseNodeEvent, + type RelayFrame, signCommand, } from "@internal/subshell-protocol"; import { @@ -37,6 +39,7 @@ import { parseLinkBinding, } from "@internal/subshell-protocol/node-link-crypto"; import { run as runCli } from "../cli.js"; +import { RelaySessions } from "../commands/ssh-relay.js"; import { TAIL_POLL_MS } from "../commands/tail.js"; import { configPath, type NodeConfig, saveConfig } from "../config.js"; import { @@ -49,6 +52,7 @@ import { wsUrlFor, } from "../daemon.js"; import { type DaemonLock, lockPath } from "../lock.js"; +import { MachinePinStore } from "../machine-pin-store.js"; import { maintenancePath, writeMaintenance } from "../maintenance.js"; import { sweepIsScheduled } from "../retention-settings.js"; import { SubshellMetaStore } from "../subshell-meta.js"; @@ -468,6 +472,7 @@ async function startDaemon( | "runtime" | "retentionMs" | "retentionPass" + | "relaySessions" > > & { routeFactory?: (config: NodeConfig) => DaemonDeps["routeSelector"]; @@ -2274,6 +2279,88 @@ describe("restart command (spec 2026-09-12 § 6.3)", () => { expect(h.plane.unparsed).toEqual([]); }); + /** + * A public EC P-256 JWK whose coordinates are 32 bytes of zero and of four: + * canonical unpadded base64url, so `fingerprintJwk` digests it, and no key + * VALUE the relay could seal to. Pin fixtures for the §4.6 block. + */ + const peerJwk = `{"kty":"EC","crv":"P-256","x":"${"A".repeat(43)}","y":"${"E".repeat(43)}"}`; + const peerJwk2 = `{"kty":"EC","crv":"P-256","x":"${"E".repeat(43)}","y":"${"A".repeat(43)}"}`; + + test("ready carries the ssh trust block computed from this machine's key files (spec §4.6)", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-daemon-sshfp-")); + daemonDirs.push(dir); + new MachinePinStore(dir).pin("f0000000-0000-4000-8000-000000000001", { + signing: peerJwk, + encryption: peerJwk2, + }); + const h = await startDaemon({ runtime: supervised, heartbeatMs: 30, config: { dataDir: dir } }); + const ready = await waitForReady(h); + expect(ready.type).toBe("ready"); + if (ready.type !== "ready") return; + const block = ready.runtime?.sshFingerprint; + expect(block).toBeDefined(); + // Own halves come from the identity files the daemon just generated, the + // peer half from the pin file seeded above, all through `fingerprintJwk`. + const sig = (JSON.parse(readFileSync(join(dir, "node-signing-identity.json"), "utf8")) as { publicJwk: string }) + .publicJwk; + const enc = (JSON.parse(readFileSync(join(dir, "identity.json"), "utf8")) as { publicJwk: string }).publicJwk; + expect(block?.own).toEqual({ signing: await fingerprintJwk(sig), encryption: await fingerprintJwk(enc) }); + expect(block?.peers).toEqual([ + { + nodeId: "f0000000-0000-4000-8000-000000000001", + signing: await fingerprintJwk(peerJwk), + encryption: await fingerprintJwk(peerJwk2), + }, + ]); + const beat = await waitFor>( + h, + (event) => event.type === "heartbeat", + "heartbeat retains SSH trust", + ); + expect(beat.runtime?.sshFingerprint).toEqual(block); + // And the frame SURVIVES the real parser on the way in (the fake plane + // parses every outbound frame with `parseNodeEvent`): a block the plane's + // grammar would drop is a block that never reaches the trust card. + expect(h.plane.unparsed).toEqual([]); + }); + + test("no runtime report means no trust block and no key reads (the block rides the runtime report)", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-daemon-sshfp-null-")); + daemonDirs.push(dir); + const h = await startDaemon({ runtime: null, config: { dataDir: dir } }); + const ready = await waitForReady(h); + expect(ready.type).toBe("ready"); + if (ready.type !== "ready") return; + expect(ready.runtime).toBeUndefined(); + // The builder never ran: a null runtime must not mint the node's keypair + // or touch the pin file just to prove the block is absent. + expect(existsSync(join(dir, "identity.json"))).toBe(false); + expect(existsSync(join(dir, "ssh-machine-pins.json"))).toBe(false); + }); + + test("an unreadable pin file costs the block, never the connection", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-daemon-sshfp-corrupt-")); + daemonDirs.push(dir); + writeFileSync(join(dir, "ssh-machine-pins.json"), "}{"); + const logs = captureLogs(); + try { + const h = await startDaemon({ runtime: supervised, config: { dataDir: dir } }); + const ready = await waitForReady(h); + expect(ready.type).toBe("ready"); + if (ready.type !== "ready") return; + expect(ready.runtime).toBeDefined(); + expect( + ready.runtime && "sshFingerprint" in ready.runtime ? ready.runtime.sshFingerprint : undefined, + ).toBeUndefined(); + expect(h.plane.unparsed).toEqual([]); + expect(logs.lines.join("\n")).toContain("ready: ssh trust block unavailable"); + expect(logs.lines.join("\n")).toContain("restore the verified pin file from backup"); + } finally { + logs.restore(); + } + }); + // The ORDER is the contract: the daemon is the only sender of `result`, so // an executor that exited itself would leave the plane waiting out a // timeout instead of seeing a success. @@ -2796,3 +2883,42 @@ test("automatic discovery follows a hot-reloaded configured endpoint", async () logs.restore(); } }); + +/* ------------------------------------------------------------------ */ +/* relay inbound routing (spec 2026-10-08 §5.1): frames are not commands */ +/* ------------------------------------------------------------------ */ + +test("an inbound relay frame reaches the relay seam and never the JWS-command path", async () => { + // The registry is INJECTED: this pins the daemon's routing decision, and the + // seam is the one Task 7's A-side responder registers into (a single + // onInboundRelayFrame dispatch, routed by the ref the relay-open paired). + const relay = new RelaySessions(); + const seen: RelayFrame[] = []; + relay.register("r-1", { onRelayFrame: (f) => seen.push(f), close: () => {} }); + const h = await startDaemon({ relaySessions: relay }); + await waitFor(h, (e) => e.type === "inventory", "connect inventory push"); + + h.plane.sendToAgent(JSON.stringify({ type: "relay", ref: "r-1", seq: 0, direction: "A2B", blob: "AAAA" })); + await waitUntil(() => seen.length === 1, "the frame routed to its session handler"); + await sleep(120); + // NOT a command: the JWS path answers every non-command text frame with a + // `verify: malformed` error - a relay frame must draw NOTHING from it. + expect(eventsAs(h, "error").length).toBe(0); + expect(count(h, (e) => e.type === "result")).toBe(0); + + // A malformed relay candidate is relay-TYPED noise: dropped, still never a + // command refusal the plane would read as a command anomaly. + h.plane.sendToAgent(JSON.stringify({ type: "relay", ref: "r-1", seq: 0, direction: "A2B", blob: "!!!!" })); + h.plane.sendToAgent(JSON.stringify({ type: "relay", ref: "", seq: 0, direction: "A2B", blob: "AAAA" })); + // A frame for an unregistered ref is dropped by the registry, not re-routed. + h.plane.sendToAgent(JSON.stringify({ type: "relay", ref: "r-none", seq: 0, direction: "A2B", blob: "AAAA" })); + await sleep(120); + expect(seen.length).toBe(1); + expect(eventsAs(h, "error").length).toBe(0); + + // And the command path is UNTOUCHED for what is not relay-shaped: the old + // non-JWS text frame still draws the ordinary malformed answer. + h.plane.sendToAgent(JSON.stringify({ jws: 42 })); + await waitFor(h, (e) => e.type === "error" && e.code === "verify", "non-relay junk still answers verify/malformed"); + expect(seen.length).toBe(1); +}); diff --git a/apps/node/agent/src/__tests__/enroll.test.ts b/apps/node/agent/src/__tests__/enroll.test.ts index fe416f0d9..c53b75004 100644 --- a/apps/node/agent/src/__tests__/enroll.test.ts +++ b/apps/node/agent/src/__tests__/enroll.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { hostname, tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; @@ -123,7 +123,17 @@ test("enroll posts the route-shaped body, persists config at 0600, exits 0", asy // (measured: `-`/`_`, no padding) and its `from_base64` REFUSES the padded // standard spelling, so this is the alphabet that survives the round trip. expect(Object.keys(seen ?? {}).sort()).toEqual( - ["agentVersion", "arch", "encryptPublicKey", "hostname", "name", "os", "publicKey", "setupKey"].sort(), + [ + "agentVersion", + "arch", + "encryptPublicKey", + "hostname", + "name", + "os", + "publicKey", + "setupKey", + "signingPublicKey", + ].sort(), ); expect(String(seen?.encryptPublicKey)).toMatch(/^[A-Za-z0-9_-]{43,44}$/); expect((["linux", "darwin", "unknown"] as unknown[]).includes(seen?.os)).toBe(true); @@ -135,6 +145,14 @@ test("enroll posts the route-shaped body, persists config at 0600, exits 0", asy const pub = JSON.parse(String(seen?.publicKey)); expect(pub).toMatchObject({ kty: "EC", crv: "P-256" }); expect("d" in pub).toBe(false); + // The relay signing key posted (spec 2026-10-08 §4.2) is the PUBLIC half of + // the persisted sibling file: the private half never leaves the machine, + // and what rides the wire is exactly the string identity generation stored. + const signing = JSON.parse(String(seen?.signingPublicKey)); + expect(signing).toMatchObject({ kty: "EC", crv: "P-256" }); + expect("d" in signing).toBe(false); + const signingFile = JSON.parse(readFileSync(join(dataDir, "node-signing-identity.json"), "utf8")); + expect(seen?.signingPublicKey).toBe(signingFile.publicJwk); const cfg = await loadConfig(); // The link pair is FRESH per enroll, so it is asserted by pairing rather diff --git a/apps/node/agent/src/__tests__/identity.test.ts b/apps/node/agent/src/__tests__/identity.test.ts index 04427d8b3..2fbcf5eef 100644 --- a/apps/node/agent/src/__tests__/identity.test.ts +++ b/apps/node/agent/src/__tests__/identity.test.ts @@ -11,7 +11,8 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { identityPath, loadOrCreateIdentity } from "../identity.js"; +import { importJWK } from "jose"; +import { identityPath, loadOrCreateIdentity, signingIdentityPath } from "../identity.js"; function freshDir(): string { return mkdtempSync(join(tmpdir(), "subshell-id-")); @@ -72,3 +73,52 @@ test("valid JSON that is not a usable identity object routes to quarantine too", await expect(loadOrCreateIdentity(dir)).rejects.toThrow(/refusing to overwrite/i); expect(readdirSync(dir).some((f) => f.endsWith(".corrupt-parse"))).toBe(true); }); + +// ── The M2 relay signing keypair (spec 2026-10-08 §4.1): an ES256 sibling of +// the ECDH pair with the SAME persistence discipline - 0600 file, ENOENT-only +// regeneration, private half never leaves the machine. + +test("first load also mints an ES256 signing keypair at 0600 in the sibling file", async () => { + const dir = freshDir(); + const id = await loadOrCreateIdentity(dir); + const pub = JSON.parse(id.signingPublicJwk); + expect(pub).toMatchObject({ kty: "EC", crv: "P-256" }); + expect(typeof pub.x).toBe("string"); + expect(typeof pub.y).toBe("string"); + expect("d" in pub).toBe(false); + expect(typeof JSON.parse(id.signingPrivateJwk).d).toBe("string"); + // A real curve point, importable for ES256 verification; not a curve point + // is exactly what the enroll route will refuse on this string. + expect(await importJWK(pub, "ES256")).toBeDefined(); + // Sibling file, not the same file, and a genuinely different key. + expect(signingIdentityPath(dir)).not.toBe(identityPath(dir)); + expect(id.signingPublicJwk).not.toBe(id.publicJwk); + expect(statSync(signingIdentityPath(dir)).mode & 0o777).toBe(0o600); +}); + +test("second load reuses the exact same signing key (persisted, no silent rotation)", async () => { + const dir = freshDir(); + const first = await loadOrCreateIdentity(dir); + const second = await loadOrCreateIdentity(dir); + expect(second.signingPublicJwk).toBe(first.signingPublicJwk); + expect(second.signingPrivateJwk).toBe(first.signingPrivateJwk); +}); + +test("corrupt signing file quarantines only itself; the ECDH identity survives", async () => { + const dir = freshDir(); + const first = await loadOrCreateIdentity(dir); + writeFileSync(signingIdentityPath(dir), "{ definitely not json"); + await expect(loadOrCreateIdentity(dir)).rejects.toThrow(/refusing to overwrite/i); + // The signing file moved aside; the encryption identity is untouched. + expect(existsSync(signingIdentityPath(dir))).toBe(false); + expect(existsSync(identityPath(dir))).toBe(true); + const quarantined = readdirSync(dir).filter((f) => f.includes("corrupt")); + expect(quarantined.length).toBe(1); + expect(quarantined[0]).toInclude("node-signing-identity"); + // Only after quarantine may a fresh signing key be minted, and the ECDH + // pair rides through unchanged - one file's corruption rotates one key. + const next = await loadOrCreateIdentity(dir); + expect(next.signingPublicJwk).not.toBe(first.signingPublicJwk); + expect(next.publicJwk).toBe(first.publicJwk); + expect(statSync(signingIdentityPath(dir)).mode & 0o777).toBe(0o600); +}); diff --git a/apps/node/agent/src/__tests__/launch-ssh.test.ts b/apps/node/agent/src/__tests__/launch-ssh.test.ts new file mode 100644 index 000000000..509838483 --- /dev/null +++ b/apps/node/agent/src/__tests__/launch-ssh.test.ts @@ -0,0 +1,363 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import { HARNESS_BINARY_PLACEHOLDER, type NodeCommandBody, type NodeEvent } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { runExitWatchTick, startExitWatcher, stopWatcher } from "../commands/report.js"; +import type { NodeConfig } from "../config.js"; +import { SubshellMetaStore } from "../subshell-meta.js"; + +/** + * Task 5 (spec 2026-10-07 decision 4): the ssh pane's rendered config, landed + * by the SPAWNING machine at the byte-derived path, and swept when the pane + * dies. + * + * Two halves, one contract: + * - `execLaunch` writes `cmd.ssh` BEFORE meta/pane, at + * `buildSshConfigPath(dataDir, id)` — the plane names nothing: a frame whose + * `configPath` is not byte-equal to this machine's own derivation is + * refused. Modes mirror the MCP block exactly (dir 0700, file 0600, + * `enforceMode` on both). + * - the exit watcher (report.ts) removes the per-pane ssh DIR when — and only + * when — the dead pane's recorded meta names harness `ssh`, best-effort and + * inside the same relaunch-ownership guard that protects the tails. + * + * Same fake-tmux discipline as commands-launch.test.ts: a plain-object double + * records ordered calls, unstubbed methods throw. + */ + +const S1 = "11111111-1111-4111-8111-111111111111"; +const FIXED_NOW = 1_700_000_000_000; +const SSH_CONTENT = "Host *\n StrictHostKeyChecking yes\n"; + +let base: string; +let claudePathOriginal: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-launch-ssh-"))); +}); + +beforeEach(() => { + claudePathOriginal = process.env.CLAUDE_PATH; + process.env.CLAUDE_PATH = "/bin/sh"; +}); + +afterAll(() => { + if (claudePathOriginal === undefined) delete process.env.CLAUDE_PATH; + else process.env.CLAUDE_PATH = claudePathOriginal; + rmSync(base, { recursive: true, force: true }); +}); + +async function freshDataDir(tag: string): Promise { + const dir = join(base, tag); + mkdirSync(dir, { recursive: true }); + return dir; +} + +type ProbeAnswer = { ok: true; names: string[] } | { ok: false; detail: string }; + +interface Spec { + newSubshell?: (socket: string, id: string, cwd: string, cmd: string, exitHook?: string) => void; + pipePane?: (socket: string, id: string, out: string) => void; + resizeWindow?: (socket: string, id: string, cols: number, rows: number) => void; + listSubshellsChecked?: (socket: string) => ProbeAnswer | Promise; + paneExitCode?: (socket: string, id: string) => number | null; + killSubshell?: (socket: string, id: string) => void; +} + +/** A one-shot promise plus its resolver (the gated-forget fixture drives these). */ +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +/** + * Models a tick stuck INSIDE `await ctx.meta.forget(...)` (the relaunch + * interleaving from commands-launch.test.ts, kept here because the ssh sweep + * must respect the SAME ownership guard). + */ +class GateHeldMetaStore extends SubshellMetaStore { + readonly forgetEntered = deferred(); + readonly forgetGate = deferred(); + + override async forget(id: string): Promise { + await super.forget(id); + this.forgetEntered.resolve(); + await this.forgetGate.promise; + } +} + +/** Build a CommandContext over a scripted double on `dataDir`; unstubbed calls throw. */ +function makeCtx( + dataDir: string, + spec: Spec, + events: NodeEvent[], + meta?: SubshellMetaStore, +): { ctx: CommandContext; calls: Array<{ method: string; args: unknown[] }> } { + const calls: Array<{ method: string; args: unknown[] }> = []; + const method = (name: keyof Spec) => { + const impl = spec[name]; + return (...args: unknown[]) => { + calls.push({ method: name, args }); + if (!impl) throw new Error(`fake tmux: unstubbed call ${String(name)}(${JSON.stringify(args)})`); + return (impl as (...a: unknown[]) => unknown)(...args); + }; + }; + const raw = { + newSubshell: method("newSubshell"), + pipePane: method("pipePane"), + resizeWindow: method("resizeWindow"), + listSubshellsChecked: (socket: string): ProbeAnswer | Promise => { + calls.push({ method: "listSubshellsChecked", args: [socket] }); + const scripted = spec.listSubshellsChecked; + if (scripted) return scripted(socket); + throw new Error("fake tmux: unstubbed listSubshellsChecked"); + }, + paneExitCode: method("paneExitCode"), + killSubshell: method("killSubshell"), + }; + const config: NodeConfig = { + serverUrl: "http://localhost:1", + nodeId: "node-1", + nodeKey: "k", + controlPublicKey: "{}", + dataDir, + name: "test-node", + }; + const ctx: CommandContext = { + config, + tmux: raw as unknown as CommandContext["tmux"], + meta: meta ?? new SubshellMetaStore(dataDir), + nowMs: () => FIXED_NOW, + ws: { send: (ev) => events.push(ev) }, + watchers: new Map(), + tails: new Map(), + uploads: new Map(), + runtime: null, + requestRestart: () => {}, + }; + return { ctx, calls }; +} + +type LaunchCmd = Extract; + +/** An ssh-harness launch frame: placeholder argv resolved via CLAUDE_PATH like every launch fixture here. */ +function sshLaunchCmd(over: Partial = {}): LaunchCmd { + return { + type: "launch", + subshellId: S1, + socket: "subshell-ssh-test", + cwd: base, + harnessId: "ssh", + preset: { name: "p", env: {}, flags: [], settings: null, configIsolation: false }, + subshellEnv: { SUBSHELL_API_KEY: "k" }, + subshellName: "s1", + argv: [HARNESS_BINARY_PLACEHOLDER], + resolve: { binaryName: "claude", envOverride: "CLAUDE_PATH" }, + ...over, + }; +} + +function spawnAndPipeStub() { + return { newSubshell: () => {}, pipePane: () => {} }; +} + +/* ------------------------------------------------------------------ */ +/* execLaunch: the config write (derived path, byte-equality, modes) */ +/* ------------------------------------------------------------------ */ + +describe("execLaunch ssh config write (spec 2026-10-07 decision 4)", () => { + it("well-formed block: file at the derived path, 0600 in a 0700 dir, content byte-equal", async () => { + const dataDir = await freshDataDir("write"); + const { ctx, calls } = makeCtx(dataDir, spawnAndPipeStub(), []); + const configPath = buildSshConfigPath(dataDir, S1); + const result = await dispatchCommand(ctx, sshLaunchCmd({ ssh: { configPath, fileContent: SSH_CONTENT } })); + expect(result).toEqual({ ok: true }); + expect(await Bun.file(configPath).text()).toBe(SSH_CONTENT); + expect(statSync(configPath).mode & 0o777).toBe(0o600); + const parent = configPath.slice(0, configPath.lastIndexOf("/")); + expect(statSync(parent).mode & 0o777).toBe(0o700); + // The write precedes nothing observable in tmux ordering terms, but the + // pane still spawns exactly once and the file survives the launch. + expect(calls.map((c) => c.method)).toEqual(["newSubshell", "pipePane"]); + stopWatcher(ctx, S1); + }); + + it("the write is visible at newSubshell time (config BEFORE spawn)", async () => { + const dataDir = await freshDataDir("write-before-spawn"); + const configPath = buildSshConfigPath(dataDir, S1); + let seenAtSpawn = false; + const { ctx } = makeCtx( + dataDir, + { + ...spawnAndPipeStub(), + newSubshell: () => { + seenAtSpawn = existsSync(configPath); + }, + }, + [], + ); + const result = await dispatchCommand(ctx, sshLaunchCmd({ ssh: { configPath, fileContent: SSH_CONTENT } })); + expect(result).toEqual({ ok: true }); + expect(seenAtSpawn).toBe(true); + stopWatcher(ctx, S1); + }); + + it("configPath not byte-equal to the derivation: refused, no file, no dir, no pane, no meta", async () => { + const dataDir = await freshDataDir("mismatch"); + const { ctx, calls } = makeCtx(dataDir, spawnAndPipeStub(), []); + // A plane naming anywhere else — a sibling path in the SAME derived shape + // included — is refused: the path is this machine's derivation alone. + const wrong = buildSshConfigPath(dataDir, `${S1}-elsewhere`); + const result = await dispatchCommand(ctx, sshLaunchCmd({ ssh: { configPath: wrong, fileContent: SSH_CONTENT } })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("ssh config path refused"); + expect(calls).toEqual([]); // refused before the spawn + expect(existsSync(join(dataDir, "ssh"))).toBe(false); // not even a dir + expect(await ctx.meta.get(S1)).toBeUndefined(); + }); + + it("absolute-path config outside dataDir: refused by the same gate (byte-equality is the first door)", async () => { + const dataDir = await freshDataDir("outside"); + const { ctx, calls } = makeCtx(dataDir, spawnAndPipeStub(), []); + const result = await dispatchCommand( + ctx, + sshLaunchCmd({ ssh: { configPath: "/etc/ssh/subshell-config", fileContent: SSH_CONTENT } }), + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("ssh config path refused"); + expect(calls).toEqual([]); + expect(existsSync(join(dataDir, "ssh"))).toBe(false); + }); + + it("no ssh block on the frame: no dir is created for an ordinary pane", async () => { + const dataDir = await freshDataDir("absent"); + const { ctx } = makeCtx(dataDir, spawnAndPipeStub(), []); + const result = await dispatchCommand(ctx, sshLaunchCmd()); + expect(result).toEqual({ ok: true }); + expect(existsSync(join(dataDir, "ssh"))).toBe(false); + stopWatcher(ctx, S1); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Exit watcher: the death sweep, gated on meta harnessId === "ssh" */ +/* ------------------------------------------------------------------ */ + +/** Seed meta with an explicit harnessId (the commands-launch twin hardcodes claude-code). */ +async function recordMetaAs( + store: SubshellMetaStore, + subshellId: string, + socket: string, + harnessId: string, +): Promise { + await store.record({ + subshellId, + cwd: base, + socket, + harnessId, + name: "m", + startedAt: "2026-10-07T00:00:00.000Z", + }); +} + +/** Create a realistic per-pane ssh dir + config under dataDir. */ +function seedSshConfig(dataDir: string, subshellId: string): { dir: string; file: string } { + const file = buildSshConfigPath(dataDir, subshellId); + const dir = file.slice(0, file.lastIndexOf("/")); + mkdirSync(dir, { recursive: true }); + writeFileSync(file, SSH_CONTENT, { mode: 0o600 }); + return { dir, file }; +} + +function deadSocketSpec(): Spec { + return { listSubshellsChecked: () => ({ ok: true, names: [] }), paneExitCode: () => 0, killSubshell: () => {} }; +} + +describe("exit watcher ssh config sweep (report.ts, decision 4)", () => { + it("ssh pane dies: the per-pane ssh dir is gone, best-effort, and the death still reports", async () => { + const dataDir = await freshDataDir("sweep-ssh"); + const events: NodeEvent[] = []; + const { ctx } = makeCtx(dataDir, deadSocketSpec(), events); + await recordMetaAs(ctx.meta, S1, "s-sock", "ssh"); + const { dir, file } = seedSshConfig(dataDir, S1); + startExitWatcher(ctx, S1, "s-sock", 10_000); // the timer never fires; the tick runs manually + + await runExitWatchTick(ctx); + + expect(events).toEqual([{ type: "exit", subshellId: S1, exitCode: 0, at: new Date(FIXED_NOW).toISOString() }]); + expect(existsSync(file)).toBe(false); + expect(existsSync(dir)).toBe(false); // the DIR goes, not just the file + expect(await ctx.meta.get(S1)).toBeUndefined(); + expect(ctx.watchTick).toBeUndefined(); + }); + + it("non-ssh pane dies: a same-named dir on disk is left untouched", async () => { + const dataDir = await freshDataDir("sweep-claude"); + const events: NodeEvent[] = []; + const { ctx } = makeCtx(dataDir, deadSocketSpec(), events); + await recordMetaAs(ctx.meta, S1, "s-sock", "claude-code"); + const { dir, file } = seedSshConfig(dataDir, S1); + startExitWatcher(ctx, S1, "s-sock", 10_000); + + await runExitWatchTick(ctx); + + expect(events.length).toBe(1); // the death still reports + expect(existsSync(file)).toBe(true); + expect(existsSync(dir)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }); + + it("pane with NO meta record: nothing is removed (the kind fact is the gate, absence never asserts)", async () => { + const dataDir = await freshDataDir("sweep-nometa"); + const events: NodeEvent[] = []; + const { ctx } = makeCtx(dataDir, deadSocketSpec(), events); + const { dir, file } = seedSshConfig(dataDir, S1); + startExitWatcher(ctx, S1, "s-sock", 10_000); + + await runExitWatchTick(ctx); + + expect(events.length).toBe(1); + expect(existsSync(file)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }); + + it("relaunch DURING the forget: the stale tick never sweeps the live relaunch's config", async () => { + const dataDir = await freshDataDir("sweep-relaunch"); + const events: NodeEvent[] = []; + const gated = new GateHeldMetaStore(dataDir); + const { ctx } = makeCtx(dataDir, { ...deadSocketSpec(), ...spawnAndPipeStub() }, events, gated); + await recordMetaAs(gated, S1, "s-sock", "ssh"); + const configPath = buildSshConfigPath(dataDir, S1); + const dir = configPath.slice(0, configPath.lastIndexOf("/")); + mkdirSync(dir, { recursive: true }); + writeFileSync(configPath, "OLD", { mode: 0o600 }); + startExitWatcher(ctx, S1, "s-sock", 10_000); + + const tick = runExitWatchTick(ctx); + try { + await gated.forgetEntered.promise; // the tick is suspended inside `await ctx.meta.forget(S1)` + // Relaunch through the REAL launch path: it re-records meta and rewrites + // the config while the old tick is still in flight. + const relaunch = await dispatchCommand( + ctx, + sshLaunchCmd({ socket: "s-sock-2", ssh: { configPath, fileContent: SSH_CONTENT } }), + ); + expect(relaunch).toEqual({ ok: true }); + } finally { + gated.forgetGate.resolve(); + } + await tick; + + expect(events).toEqual([{ type: "exit", subshellId: S1, exitCode: 0, at: new Date(FIXED_NOW).toISOString() }]); + // The relaunched pane is live and its config is INTACT: the sweep ran + // inside the ownership guard, not after the forget blindly. + expect(await Bun.file(configPath).text()).toBe(SSH_CONTENT); + expect(ctx.watchers.has(S1)).toBe(true); + stopWatcher(ctx, S1); + }); +}); diff --git a/apps/node/agent/src/__tests__/machine-pin-store.test.ts b/apps/node/agent/src/__tests__/machine-pin-store.test.ts new file mode 100644 index 000000000..06db1782b --- /dev/null +++ b/apps/node/agent/src/__tests__/machine-pin-store.test.ts @@ -0,0 +1,303 @@ +import { expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type MachinePin, MachinePinStore, machinePinPath } from "../machine-pin-store.js"; + +/** + * Relay pins are raw PUBLIC JWK strings; byte equality runs on these exact + * strings (spec 2026-10-08 §4.4), so the fixtures are just distinct opaque + * texts. Fingerprints are a display concern (ssh-pin-store.ts), not a store + * concern. + */ +const keyA: MachinePin = { + signing: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}', + encryption: '{"kty":"EC","crv":"P-256","x":"CCC","y":"DDD"}', +}; +const keyB: MachinePin = { + signing: '{"kty":"EC","crv":"P-256","x":"EEE","y":"FFF"}', + encryption: '{"kty":"EC","crv":"P-256","x":"GGG","y":"HHH"}', +}; + +function freshDir(): string { + return mkdtempSync(join(tmpdir(), "subshell-mpin-")); +} + +test("pins land in ssh-machine-pins.json, a file DISTINCT from the pane channels' peers.json", () => { + const dir = freshDir(); + // The channel TOFU store (mcp-core pin-store.ts) lives at /peers.json. + // The relay store must never read or write it, so it is a different name. + expect(machinePinPath(dir)).toBe(join(dir, "ssh-machine-pins.json")); + expect(machinePinPath(dir)).not.toBe(join(dir, "peers.json")); + + const peersFile = join(dir, "peers.json"); + writeFileSync(peersFile, '{"principal:other":"{"untouched":true}"}', { mode: 0o600 }); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + store.check("node-1", keyA); + // The channel pin file rides through byte-identical: the relay store has its + // own path and never borrows the channels'. + expect(readFileSync(peersFile, "utf8")).toBe('{"principal:other":"{"untouched":true}"}'); +}); + +test("the pin file is 0600 after write, and a pre-existing looser mode is repaired", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + expect(statSync(machinePinPath(dir)).mode & 0o777).toBe(0o600); + // A hand-chmod or a umask surprise never survives the next write: chmodSync + // after writeFileSync is load-bearing, since O_CREAT's mode is masked by the + // umask and ignored for an existing file. + chmodSync(machinePinPath(dir), 0o644); + store.pin("node-2", keyB); + expect(statSync(machinePinPath(dir)).mode & 0o777).toBe(0o600); +}); + +test("get returns the raw pinned strings verbatim; an unknown peer has no pin", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + expect(store.get("node-1")).toBe(null); + store.pin("node-1", keyA); + // Byte-for-byte round trip: the stored material is exactly what was pinned, + // not a re-serialization. + expect(store.get("node-1")).toEqual(keyA); +}); + +test("inherited Object members are never pins: get is null for __proto__/constructor/toString on an empty store", () => { + // The maps are Object.create(null)-built: a plain `{}` would hand back + // Object.prototype or a method for these ids, breaking the "null when + // never pinned" contract Tasks 6/8 branch on. + const store = new MachinePinStore(freshDir()); + expect(store.get("__proto__")).toBe(null); + expect(store.get("constructor")).toBe(null); + expect(store.get("toString")).toBe(null); + expect(store.check("toString", keyA)).toBe("changed"); + // A peer id that shadows an Object member is still ordinary data: pinning + // and reading it round-trips through the JSON file. + store.pin("toString", keyA); + expect(store.get("toString")).toEqual(keyA); + expect(store.check("toString", keyA)).toBe("ok"); + expect(new MachinePinStore(store.dataDir).get("toString")).toEqual(keyA); +}); + +test("check is byte equality on both halves: ok for the same bytes, changed for either half moving", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + expect(store.check("node-1", keyA)).toBe("ok"); + expect(store.check("node-1", { ...keyA, signing: keyB.signing })).toBe("changed"); + expect(store.check("node-1", { ...keyA, encryption: keyB.encryption })).toBe("changed"); +}); + +test("check compares the RAW strings, not fingerprints: a reordered serialization of the same key is 'changed'", () => { + // The store's comparison rule mirrors pin-store.ts: byte equality on the + // stored material (§4.4). KEY display hashes DER, but a candidate that + // re-serializes the same key is NOT the plane delivering the registered + // bytes, so strictness says "changed" here; the equality the store enforces + // is on the raw strings. + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + const reordered = JSON.parse(keyA.signing); + const reorderedSigning = JSON.stringify({ y: reordered.y, x: reordered.x, crv: reordered.crv, kty: reordered.kty }); + expect(store.check("node-1", { signing: reorderedSigning, encryption: keyA.encryption })).toBe("changed"); +}); + +test("check on an unpinned peer is 'changed': the store fails closed, it never seals-to or verifies-to an unpinned stranger", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + expect(store.check("never-seen", keyA)).toBe("changed"); +}); + +test("pins persist across store instances and multiple peers coexist; the re-pair route replaces one entry", () => { + const dir = freshDir(); + new MachinePinStore(dir).pin("node-1", keyA); + new MachinePinStore(dir).pin("node-2", keyB); + const reopened = new MachinePinStore(dir); + expect(reopened.get("node-1")).toEqual(keyA); + expect(reopened.get("node-2")).toEqual(keyB); + // §4.5's re-pair replaces the stored pin for that peer and only that peer. + // The replace route is `repair`: pin() now refuses an already-pinned peer + // (see the next test), so the re-pair writes through the sanctioned path. + reopened.repair("node-1", keyB); + expect(reopened.get("node-1")).toEqual(keyB); + expect(reopened.get("node-2")).toEqual(keyB); + expect(reopened.check("node-1", keyA)).toBe("changed"); +}); + +test("pin refuses a peer that ALREADY has an entry: a changed peer is a block, not a re-pin", () => { + // The structural guard (PR #338 review round 2): `pin` used to delegate + // straight to `repair`, so a call-site ordering bug could silently + // OVERWRITE a pinned peer. It now refuses when an entry exists; `repair` + // stays the ONLY sanctioned replace path (§4.5's re-pair route). + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + const before = readFileSync(machinePinPath(dir), "utf8"); + expect(() => store.pin("node-1", keyB)).toThrow( + "machine pin exists for node-1: a changed peer is a block, not a re-pin", + ); + // The refusal is inert: the file is byte-identical and the stored pin is + // still keyA. A moved peer stays BLOCKED (check says changed), never + // silently re-pinned by whatever asked second. + expect(readFileSync(machinePinPath(dir), "utf8")).toBe(before); + expect(store.get("node-1")).toEqual(keyA); + expect(store.check("node-1", keyB)).toBe("changed"); + // The message names the peer and the cause, never key bytes: the error + // string is log-safe material. + let message = ""; + try { + store.pin("node-1", keyB); + } catch (err) { + message = String(err); + } + expect(message).toContain("node-1"); + expect(message).not.toContain(keyA.signing); + expect(message).not.toContain(keyA.encryption); + expect(message).not.toContain(keyB.signing); + expect(message).not.toContain(keyB.encryption); + // `repair` still replaces the same peer's entry: the guard refuses pin(), + // not the re-pair route. + store.repair("node-1", keyB); + expect(store.get("node-1")).toEqual(keyB); + expect(store.check("node-1", keyB)).toBe("ok"); +}); + +test("a corrupt pin file is quarantined and throws; it is never read as an empty pin set", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + writeFileSync(machinePinPath(dir), "{ definitely not json"); + // Fail closed on FIRST read: an unreadable pin set must not silently become + // "no pins" (the pin-store.ts / identity.ts doctrine). The unreadable file + // is moved aside, its bytes preserved for the operator. + expect(() => store.get("node-1")).toThrow("restore the verified pin file from backup"); + const quarantined = readdirSync(dir).filter((f) => f.includes("corrupt")); + expect(quarantined.length).toBe(1); + expect(readFileSync(join(dir, quarantined[0]), "utf8")).toContain("definitely not json"); + // Quarantine must not reopen TOFU. Only restoring verified contents recovers. + expect(() => store.get("node-1")).toThrow("quarantined"); + writeFileSync(machinePinPath(dir), JSON.stringify({ "node-1": keyA })); + expect(store.check("node-1", keyA)).toBe("ok"); + // check() fail-closes the same way: a junk file is never an "empty pin set", + // and a wrong-shape entry is junk too. + writeFileSync(machinePinPath(dir), "[]"); + expect(() => store.check("node-1", keyA)).toThrow(); +}); + +test("SUBSHELL_CHANNEL_PIN has NO effect on this store: it is always strict", () => { + // The channel escape hatch (SUBSHELL_CHANNEL_PIN=trust) is pinned OUT of the + // relay path: setting it must not soften a byte-equality verdict nor skip + // writing the file (spec 2026-10-08 §4.4: "never its escape hatch"). + const previous = process.env.SUBSHELL_CHANNEL_PIN; + process.env.SUBSHELL_CHANNEL_PIN = "trust"; + try { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + expect(store.check("node-1", keyB)).toBe("changed"); + expect(statSync(machinePinPath(dir)).mode & 0o777).toBe(0o600); + } finally { + if (previous === undefined) delete process.env.SUBSHELL_CHANNEL_PIN; + else process.env.SUBSHELL_CHANNEL_PIN = previous; + } +}); + +test("entries() lists every pinned peer, id ascending (the §4.6 report's source)", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + // Empty store: the honest "no peers yet", not a missing method. + expect(store.entries()).toEqual([]); + // Insertion order deliberately unsorted; the list comes back sorted by id + // so the reported block (and its plane mirror) does not reshuffle on a + // re-pair of one peer. + store.pin("node-2", keyB); + store.pin("node-1", keyA); + expect(store.entries()).toEqual([ + { nodeId: "node-1", pin: keyA }, + { nodeId: "node-2", pin: keyB }, + ]); + // An entry's pin is a copy of the stored pair, not a live handle into a + // later re-pin of the same peer. + const [first] = store.entries(); + first.pin.signing = "mutated"; + expect(store.get("node-1")).toEqual(keyA); + // Fail-closed like every other read: the corrupt file quarantines and throws. + writeFileSync(machinePinPath(dir), "["); + expect(() => store.entries()).toThrow(); +}); + +test("the source never consults SUBSHELL_CHANNEL_PIN (code, not prose, is scanned)", () => { + // The header comment NAMES the env var to say the store never reads it; the + // load-bearing fact is that no CODE expression touches it. Comments stripped + // first, then even the word may not appear. + const source = readFileSync(new URL("../machine-pin-store.ts", import.meta.url), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + expect(source).not.toMatch(/SUBSHELL_CHANNEL_PIN/); + expect(source).not.toMatch(/process\.env/); +}); + +test("§4.5 keystone: repair replaces a blocked peer's entry and the block clears; an UN-repaired peer's block persists", () => { + // The design's whole recovery story in one test (spec 2026-10-08 §4.5, + // Task 17): after a peer's key genuinely rotated, byte-strict check() + // BLOCKS forever unless the owner re-pairs. `repair` is the only sanctioned + // replacement - and it touches exactly ONE peer's entry. + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + store.pin("node-2", keyA); + // The blocked state: both peers now present the rotated keyB; check refuses. + expect(store.check("node-1", keyB)).toBe("changed"); + expect(store.check("node-2", keyB)).toBe("changed"); + // The owner re-pairs peer node-1 only. + store.repair("node-1", keyB); + // ...whose block clears: the new key now checks ok, byte-equal. + expect(store.check("node-1", keyB)).toBe("ok"); + expect(store.get("node-1")).toEqual(keyB); + // ...and the UN-repaired peer's block PERSISTS: recovery is per-peer, and + // nothing here re-authorizes a peer the owner did not act on. + expect(store.check("node-2", keyB)).toBe("changed"); + expect(store.get("node-2")).toEqual(keyA); +}); + +test("repair adds an entry when the store holds none for that peer (a repair after a lost store)", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + expect(store.get("node-9")).toBe(null); + store.repair("node-9", keyB); + expect(store.get("node-9")).toEqual(keyB); + expect(store.check("node-9", keyB)).toBe("ok"); +}); + +test("repair keeps the file discipline: 0600, other peers untouched, durable across instances, corrupt file fail-closed", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + store.pin("node-2", keyA); + chmodSync(machinePinPath(dir), 0o644); + store.repair("node-1", keyB); + expect(statSync(machinePinPath(dir)).mode & 0o777).toBe(0o600); + expect(new MachinePinStore(dir).get("node-1")).toEqual(keyB); + expect(new MachinePinStore(dir).get("node-2")).toEqual(keyA); + // Fail closed like every other read: an unreadable pin set is quarantined, + // never silently rewritten by a repair. + writeFileSync(machinePinPath(dir), "{ nope"); + expect(() => store.repair("node-1", keyA)).toThrow(); + // Inherited Object members stay ordinary data (the emptyPinMap rule). + const reopened = new MachinePinStore(freshDir()); + reopened.repair("toString", keyB); + expect(reopened.get("toString")).toEqual(keyB); +}); + +test("quarantine remains a refusal across reads and fresh store instances", () => { + const dir = freshDir(); + writeFileSync(machinePinPath(dir), "broken"); + const store = new MachinePinStore(dir); + expect(() => store.get("peer")).toThrow("corrupt"); + expect(() => store.get("peer")).toThrow("quarantined"); + expect(() => new MachinePinStore(dir).pin("peer", keyB)).toThrow("quarantined"); + // Restoring the verified file is an explicit operator recovery. + writeFileSync(machinePinPath(dir), JSON.stringify({ peer: keyA })); + expect(store.check("peer", keyA)).toBe("ok"); +}); diff --git a/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts b/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts new file mode 100644 index 000000000..d8b97a988 --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts @@ -0,0 +1,325 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + type AgentScheme, + CLASSIC_SCHEME, + filterIdentitiesAnswer, + fingerprintAgentBlob, + OPENSSH_10X_SCHEME, + parseIdentitiesAnswer, + parseSignRequest, + parseSignResponse, + probeAgentScheme, + SSH2_AGENT_FAILURE, +} from "../relay-agent-scheme.js"; + +/** + * Byte-literal fixtures for the two ssh-agent numbering schemes the A-side + * responder probes between (spec 2026-10-08 §5.4, ruling 2026-10-08): the + * RFC 9987 numbering OpenSSH 10.x ships (identities 11 / answer 12 / sign 13 / + * sign response 14, sign carrying the extended grammar) and OpenSSH's classic + * numbering (13 / 14 / 15 / 16). Every codepoint here is written as a plain + * number - the constants are asserted AGAINST the literals, never derived + * from the production module, so numeric drift cannot hide. The FAILURE(5) + * answers mirror the live measurement on this fleet's OpenSSH_10.2p1: a byte + * 13 (a truncated sign under 10.x) answers FAILURE, and a byte 11 answers 12. + */ + +/* ---------------- the agent wire, spelled by hand ---------------- */ + +function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +/** An SSH string on the agent wire: 4-byte BE length + bytes. */ +function sshStr(bytes: Buffer | string): Buffer { + const b = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes; + return Buffer.concat([be32(b.length), b]); +} + +/** An independent fingerprint oracle: SHA-256 over the wire blob, base64url no pad. */ +function fp(wire: Uint8Array): string { + return `SHA256:${createHash("sha256").update(wire).digest("base64url")}`; +} + +const KEY_A = Buffer.concat([sshStr("ssh-ed25519"), sshStr(Buffer.from("KEY-A"))]); +const KEY_B = Buffer.concat([sshStr("ssh-ed25519"), sshStr(Buffer.from("KEY-B"))]); + +/** An IDENTITIES_ANSWER spelled in ONE scheme's answer byte, from raw numbers. */ +function answerWith(answerByte: number, entries: { blob: Buffer; comment: string }[]): Buffer { + return Buffer.concat([ + Buffer.from([answerByte]), + be32(entries.length), + ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), + ]); +} + +/* ---------------- the scheme data: measured literals ---------------- */ + +test("the schemes carry the measured codepoints and collide exactly where the ruling says", () => { + // RFC 9987 numbering, shipped by OpenSSH 10.x (measured OpenSSH_10.2p1: + // request 11 answers 12; request 13 and 15 answer FAILURE 5). + expect([OPENSSH_10X_SCHEME.identities, OPENSSH_10X_SCHEME.answer]).toEqual([11, 12]); + expect([OPENSSH_10X_SCHEME.sign, OPENSSH_10X_SCHEME.signResponse]).toEqual([13, 14]); + expect(OPENSSH_10X_SCHEME.extendedSign).toBe(true); + // OpenSSH's classic numbering: identities 13 / answer 14 / sign 15 / response 16. + expect([CLASSIC_SCHEME.identities, CLASSIC_SCHEME.answer]).toEqual([13, 14]); + expect([CLASSIC_SCHEME.sign, CLASSIC_SCHEME.signResponse]).toEqual([15, 16]); + expect(CLASSIC_SCHEME.extendedSign).toBe(false); + // SSH2_AGENT_FAILURE is 5 in both (the measurement's third probe). + expect(SSH2_AGENT_FAILURE).toBe(5); + // The collisions the probe exists to resolve: 13 is classic identities but + // 10.x sign; 14 is classic answer but 10.x sign response. + expect(CLASSIC_SCHEME.identities).toBe(OPENSSH_10X_SCHEME.sign); + expect(CLASSIC_SCHEME.answer).toBe(OPENSSH_10X_SCHEME.signResponse); +}); + +/* ---------------- fingerprint spelling (the grant grammar) ---------------- */ + +test("fingerprintAgentBlob spells SHA256 over the wire blob in the grant grammar's base64url form", () => { + const s = fingerprintAgentBlob(Buffer.from("hello")); + expect(s).toMatch(/^SHA256:[A-Za-z0-9_-]{43}$/); + expect(s).toBe(fp(Buffer.from("hello"))); +}); + +/* ---------------- IDENTITIES_ANSWER parsing and filtering ---------------- */ + +test("parseIdentitiesAnswer accepts only the resolved scheme's answer byte", () => { + const tenX = answerWith(12, [{ blob: KEY_A, comment: "a" }]); + const classic = answerWith(14, [{ blob: KEY_A, comment: "a" }]); + expect(parseIdentitiesAnswer(tenX, OPENSSH_10X_SCHEME).map((e) => e.comment.toString())).toEqual(["a"]); + expect(parseIdentitiesAnswer(classic, CLASSIC_SCHEME).map((e) => e.comment.toString())).toEqual(["a"]); + // Byte 14 is the ANSWER under classic and the SIGN RESPONSE under 10.x: + // a resolver must never accept it as a roster in the wrong scheme. + expect(() => parseIdentitiesAnswer(classic, OPENSSH_10X_SCHEME)).toThrow(); + expect(() => parseIdentitiesAnswer(tenX, CLASSIC_SCHEME)).toThrow(); +}); + +test("parseIdentitiesAnswer refuses truncated entries and trailing bytes", () => { + const full = answerWith(12, [{ blob: KEY_A, comment: "a" }]); + expect(() => parseIdentitiesAnswer(full.subarray(0, full.length - 1), OPENSSH_10X_SCHEME)).toThrow(); + expect(() => parseIdentitiesAnswer(Buffer.concat([full, Buffer.from([0])]), OPENSSH_10X_SCHEME)).toThrow(); + // A declared count the buffer does not carry. + const overcounted = Buffer.concat([Buffer.from([12]), be32(2), sshStr(KEY_A), sshStr("a")]); + expect(() => parseIdentitiesAnswer(overcounted, OPENSSH_10X_SCHEME)).toThrow(); + // The shortest legal answer: type + count 0. + expect(parseIdentitiesAnswer(Buffer.from([12, 0, 0, 0, 0]), OPENSSH_10X_SCHEME)).toEqual([]); +}); + +test("filterIdentitiesAnswer keeps granted entries byte-identical under BOTH schemes' answer byte", () => { + const roster = [ + { blob: KEY_A, comment: "granted" }, + { blob: KEY_B, comment: "not granted" }, + ]; + const allowed = new Set([fp(KEY_B)]); // grant the SECOND entry: order must not matter + for (const [scheme, answerByte] of [ + [OPENSSH_10X_SCHEME, 12], + [CLASSIC_SCHEME, 14], + ] as [AgentScheme, number][]) { + const filtered = filterIdentitiesAnswer(answerWith(answerByte, roster), allowed, scheme); + // The rebuilt answer carries THIS scheme's answer byte - not a foreign + // one, and never the first pass's invented 2. + expect([...filtered]).toEqual([answerByte, 0, 0, 0, 1, ...sshStr(KEY_B), ...sshStr("not granted")]); + expect(parseIdentitiesAnswer(filtered, scheme)).toEqual([{ blob: KEY_B, comment: Buffer.from("not granted") }]); + } + // An empty grant serves the count-zero answer, five bytes, nothing else. + expect([...filterIdentitiesAnswer(answerWith(12, roster), new Set(), OPENSSH_10X_SCHEME)]).toEqual([12, 0, 0, 0, 0]); + // A malformed answer throws (the responder must refuse it, never forward). + expect(() => filterIdentitiesAnswer(Buffer.from([12, 0, 0]), new Set(), OPENSSH_10X_SCHEME)).toThrow(); + expect(() => filterIdentitiesAnswer(answerWith(14, roster), new Set([fp(KEY_A)]), OPENSSH_10X_SCHEME)).toThrow(); +}); + +/* ---------------- SIGN_REQUEST grammar: classic and extended ---------------- */ + +test("parseSignRequest accepts the classic body (blob, data, flags) in the classic scheme", () => { + const body = Buffer.concat([Buffer.from([15]), sshStr(KEY_A), sshStr("DATA"), be32(0)]); + const parsed = parseSignRequest(body, CLASSIC_SCHEME); + expect(parsed.keyBlob).toEqual(KEY_A); + expect(parsed.data).toEqual(Buffer.from("DATA")); + expect(parsed.flags).toBe(0); + expect(parsed.algorithms).toBeUndefined(); +}); + +test("parseSignRequest accepts the extended 10.x body (trailing algorithms string) and the bare form", () => { + const bare = Buffer.concat([Buffer.from([13]), sshStr(KEY_A), sshStr("DATA"), be32(1)]); + expect(parseSignRequest(bare, OPENSSH_10X_SCHEME).algorithms).toBeUndefined(); + const extended = Buffer.concat([ + Buffer.from([13]), + sshStr(KEY_A), + sshStr("DATA"), + be32(0), + sshStr("ssh-ed25519,rsa-sha2-512"), + ]); + expect(parseSignRequest(extended, OPENSSH_10X_SCHEME).algorithms).toEqual(Buffer.from("ssh-ed25519,rsa-sha2-512")); +}); + +test("parseSignRequest refuses every malformed body in both schemes", () => { + const bare = (scheme: AgentScheme, tail: Buffer): Buffer => + Buffer.concat([Buffer.from([scheme.sign]), sshStr(KEY_A), tail]); + // Blob-only (no data, no flags): a body the wire never legally carries. + expect(() => parseSignRequest(bare(CLASSIC_SCHEME, Buffer.alloc(0)), CLASSIC_SCHEME)).toThrow(); + expect(() => parseSignRequest(bare(OPENSSH_10X_SCHEME, Buffer.alloc(0)), OPENSSH_10X_SCHEME)).toThrow(); + // The first pass's invented blob+flags shape: still not the wire grammar. + expect(() => parseSignRequest(bare(CLASSIC_SCHEME, be32(0)), CLASSIC_SCHEME)).toThrow(); + // Truncated string lengths, missing flags, trailing garbage. + expect(() => + parseSignRequest(Buffer.concat([Buffer.from([15]), Buffer.from([0, 0, 0, 9]), KEY_A]), CLASSIC_SCHEME), + ).toThrow(); + expect(() => + parseSignRequest(Buffer.concat([Buffer.from([15]), sshStr(KEY_A), sshStr("DATA")]), CLASSIC_SCHEME), + ).toThrow(); + expect(() => + parseSignRequest( + Buffer.concat([Buffer.from([15]), sshStr(KEY_A), sshStr("DATA"), be32(0), Buffer.from([0, 0])]), + CLASSIC_SCHEME, + ), + ).toThrow(); + // A trailing algorithms string is legal only under the extended scheme, + // and it must itself be well-formed there. + const trailing = Buffer.concat([Buffer.from([13]), sshStr(KEY_A), sshStr("DATA"), be32(0), sshStr("algos")]); + expect(() => parseSignRequest(trailing, CLASSIC_SCHEME)).toThrow(); + expect(() => + parseSignRequest( + Buffer.concat([Buffer.from([13]), sshStr(KEY_A), sshStr("DATA"), be32(0), Buffer.from([0, 0, 0, 99])]), + OPENSSH_10X_SCHEME, + ), + ).toThrow(); + // An empty key blob names nothing to scope: refused. + expect(() => + parseSignRequest( + Buffer.concat([Buffer.from([13]), sshStr(Buffer.alloc(0)), sshStr("DATA"), be32(0)]), + OPENSSH_10X_SCHEME, + ), + ).toThrow(); +}); + +/* ---------------- SIGN_RESPONSE: the body the relay gate accepts ---------------- */ + +test("parseSignResponse accepts exactly the type byte plus one string in each scheme", () => { + // The collision this strictness exists for, pinned: classic answer 14 == + // 10.x signResponse 14. The type byte alone cannot tell a signature from a + // foreign scheme's roster; only the body parse can. + expect(CLASSIC_SCHEME.answer).toBe(14); + expect(OPENSSH_10X_SCHEME.signResponse).toBe(14); + expect(parseSignResponse(Buffer.concat([Buffer.from([14]), sshStr(Buffer.from("SIG"))]), OPENSSH_10X_SCHEME)).toEqual( + Buffer.from("SIG"), + ); + expect(parseSignResponse(Buffer.concat([Buffer.from([16]), sshStr(Buffer.from("SIG"))]), CLASSIC_SCHEME)).toEqual( + Buffer.from("SIG"), + ); +}); + +test("parseSignResponse refuses a roster-shaped body, extra bytes, truncation, and a foreign type byte", () => { + // A multi-entry roster spelled under the SIGN_RESPONSE byte (the exact + // shape a mid-session classic swap answers a forwarded 13 with): refused. + const roster = Buffer.concat([Buffer.from([14]), be32(2), sshStr(KEY_A), sshStr("a"), sshStr(KEY_B), sshStr("b")]); + expect(() => parseSignResponse(roster, OPENSSH_10X_SCHEME)).toThrow(); + expect(() => + parseSignResponse(Buffer.concat([Buffer.from([16]), be32(1), sshStr(KEY_A), sshStr("a")]), CLASSIC_SCHEME), + ).toThrow(); + // Bytes after the one signature string: not the wire shape. + expect(() => + parseSignResponse( + Buffer.concat([Buffer.from([14]), sshStr(Buffer.from("SIG")), Buffer.from([0])]), + OPENSSH_10X_SCHEME, + ), + ).toThrow(); + // Empty body and a truncated length prefix. + expect(() => parseSignResponse(Buffer.from([14]), OPENSSH_10X_SCHEME)).toThrow(); + expect(() => parseSignResponse(Buffer.from([14, 0, 0, 0]), OPENSSH_10X_SCHEME)).toThrow(); + // A foreign scheme's response byte is refused in the resolved scheme. + expect(() => parseSignResponse(Buffer.concat([Buffer.from([16]), sshStr("S")]), OPENSSH_10X_SCHEME)).toThrow(); + expect(() => parseSignResponse(Buffer.concat([Buffer.from([14]), sshStr("S")]), CLASSIC_SCHEME)).toThrow(); +}); + +/* ---------------- the probe: resolving the live agent's numbering ---------------- */ + +/** Records every payload a fake agent received and answers per a script. */ +function scriptedAgent(answer: (payload: Buffer) => Buffer | "throw"): { + sent: Buffer[]; + requestAgent: (socketPath: string, payload: Buffer) => Promise; +} { + const sent: Buffer[] = []; + return { + sent, + requestAgent: async (_socketPath: string, payload: Buffer): Promise => { + sent.push(Buffer.from(payload)); + const a = answer(payload); + if (a === "throw") throw new Error("connect refused"); + return a; + }, + }; +} + +const EMPTY_ANSWER_12 = Buffer.from([12, 0, 0, 0, 0]); +const EMPTY_ANSWER_14 = Buffer.from([14, 0, 0, 0, 0]); + +test("the probe resolves openssh-10x from the 11-to-12 answer after the classic candidate answers FAILURE", async () => { + // The measured OpenSSH_10.2p1 pair: byte 13 (a truncated sign) answers 5, + // byte 11 (identities) answers a valid 12. + const agent = scriptedAgent((p) => { + if (p.equals(Buffer.from([13]))) return Buffer.from([5]); + if (p.equals(Buffer.from([11]))) return EMPTY_ANSWER_12; + return "throw"; + }); + const scheme = await probeAgentScheme("/fake/agent.sock", agent.requestAgent); + expect(scheme).toBe(OPENSSH_10X_SCHEME); + expect(agent.sent).toEqual([Buffer.from([13]), Buffer.from([11])]); +}); + +test("the probe resolves classic from the 13-to-14 answer in a single probe", async () => { + const agent = scriptedAgent((p) => (p.equals(Buffer.from([13])) ? EMPTY_ANSWER_14 : Buffer.from([5]))); + const scheme = await probeAgentScheme("/fake/agent.sock", agent.requestAgent); + expect(scheme).toBe(CLASSIC_SCHEME); + // The classic agent answered the first candidate: no second probe is sent. + expect(agent.sent).toEqual([Buffer.from([13])]); +}); + +test("an agent that answers FAILURE to both candidates resolves to nothing, never a guessed scheme", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + expect(await probeAgentScheme("/fake/agent.sock", agent.requestAgent)).toBeNull(); + expect(agent.sent).toEqual([Buffer.from([13]), Buffer.from([11])]); +}); + +test("a probe answer must PARSE as an IDENTITIES_ANSWER: a bare type byte confirms nothing", async () => { + // Type 14 with a truncated body is not a valid classic answer (and is what + // a 10.x SIGN_RESPONSE byte would look like); the probe must move on and + // confirm 10.x positively. + const agent = scriptedAgent((p) => { + if (p.equals(Buffer.from([13]))) return Buffer.from([14, 0, 0, 0, 2, 0, 0, 0, 1, 65]); + if (p.equals(Buffer.from([11]))) return EMPTY_ANSWER_12; + return Buffer.from([5]); + }); + expect(await probeAgentScheme("/fake/agent.sock", agent.requestAgent)).toBe(OPENSSH_10X_SCHEME); + expect(agent.sent.length).toBe(2); +}); + +test("an unknown answer byte and a transport failure both move the probe on and resolve to nothing", async () => { + const junk = scriptedAgent(() => Buffer.from([99])); + expect(await probeAgentScheme("/fake/agent.sock", junk.requestAgent)).toBeNull(); + expect(junk.sent.length).toBe(2); + const dead = scriptedAgent(() => "throw"); + expect(await probeAgentScheme("/fake/agent.sock", dead.requestAgent)).toBeNull(); + expect(dead.sent.length).toBe(2); // every candidate was tried before giving up +}); + +test("the probe never sends anything but one-byte identities requests (a probe must never reach a signature)", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + await probeAgentScheme("/fake/agent.sock", agent.requestAgent); + for (const payload of agent.sent) { + expect(payload.length).toBe(1); // never a sign body: a truncated sign can only ever FAILURE + expect([11, 13]).toContain(payload[0]); // only the two candidates' identities bytes + } +}); + +test("probeAgentScheme passes the socket path it was given to every candidate", async () => { + const seen: string[] = []; + const scheme = await probeAgentScheme("/run/user/1000/agent.sock", async (path, payload) => { + seen.push(path); + return payload.equals(Buffer.from([13])) ? EMPTY_ANSWER_14 : Buffer.from([5]); + }); + expect(scheme).toBe(CLASSIC_SCHEME); + expect(seen).toEqual(["/run/user/1000/agent.sock"]); +}); diff --git a/apps/node/agent/src/__tests__/relay-agent-socket.test.ts b/apps/node/agent/src/__tests__/relay-agent-socket.test.ts new file mode 100644 index 000000000..8cdb0247d --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-agent-socket.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SSH_RELAY_FRAME_MAX_BYTES } from "@internal/subshell-protocol"; +import { liveAgentSocketPath, requestLiveAgent } from "../relay-agent-socket.js"; + +/** + * The one-shot agent socket round trip the responder and the numbering probe + * share: connect, write ONE framed message, read ONE framed answer, close. + * These fixtures answer raw bytes, so the framing contract is pinned against + * the real socket layer, not a mock. + */ + +function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +function framed(payload: Buffer): Buffer { + return Buffer.concat([be32(payload.length), payload]); +} + +interface EchoServer { + path: string; + received: Buffer[]; + close(): Promise; +} + +/** A Unix socket that answers every complete framed request with `answerBytes`. */ +async function startEchoAgent(onRequest: (request: Buffer, conn: Socket) => void): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-agent-socket-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + const request = Buffer.from(buffer.subarray(4, 4 + len)); + buffer = buffer.subarray(4 + len); + received.push(request); + onRequest(request, conn); + }); + conn.on("error", () => { + /* the client closes per request; never throw out of the socket layer */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { path, received, close: () => new Promise((r) => server.close(() => r())) }; +} + +/* ---------------- liveAgentSocketPath (the ssh-resolve rule) ---------------- */ + +test("liveAgentSocketPath honors only an absolute SSH_AUTH_SOCK (the ssh-resolve rule)", () => { + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "/run/user/1000/ssh-agent.sock" })).toBe("/run/user/1000/ssh-agent.sock"); + expect(liveAgentSocketPath({})).toBeNull(); + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "" })).toBeNull(); + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "relative/path.sock" })).toBeNull(); +}); + +/* ---------------- requestLiveAgent (the round trip) ---------------- */ + +test("requestLiveAgent writes exactly one framed request and resolves the framed answer", async () => { + const server = await startEchoAgent((_request, conn) => { + conn.write(framed(Buffer.from([12, 0, 0, 0, 0]))); + }); + try { + const request = Buffer.from([11]); + const answer = await requestLiveAgent(server.path, request); + expect([...answer]).toEqual([12, 0, 0, 0, 0]); + expect(server.received.length).toBe(1); + expect(server.received[0]).toEqual(request); // one message, no framing residue, byte-exact + } finally { + await server.close(); + } +}); + +test("requestLiveAgent rejects an answer whose length header is empty or over the relay cap", async () => { + const overCap = await startEchoAgent((_request, conn) => { + conn.write(Buffer.concat([be32(SSH_RELAY_FRAME_MAX_BYTES + 1), Buffer.from([0])])); + }); + const zeroLen = await startEchoAgent((_request, conn) => { + conn.write(be32(0)); + }); + try { + await expect(requestLiveAgent(overCap.path, Buffer.from([11]))).rejects.toThrow(/cap/); + await expect(requestLiveAgent(zeroLen.path, Buffer.from([11]))).rejects.toThrow(/cap/); + } finally { + await overCap.close(); + await zeroLen.close(); + } +}); + +test("requestLiveAgent rejects when the agent closes before a complete answer", async () => { + const server = await startEchoAgent((_request, conn) => { + conn.write(Buffer.from([0, 0, 0, 5, 12])); // a header promising more than follows + conn.end(); + }); + try { + await expect(requestLiveAgent(server.path, Buffer.from([11]))).rejects.toThrow(/closed before/); + } finally { + await server.close(); + } +}); + +test("requestLiveAgent rejects on its local timeout when the agent never answers", async () => { + const server = await startEchoAgent(() => { + /* swallow: the agent thinks about it forever */ + }); + try { + await expect(requestLiveAgent(server.path, Buffer.from([11]), 80)).rejects.toThrow(/timed out/); + } finally { + await server.close(); + } +}); + +test("requestLiveAgent rejects when nothing listens on the socket", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-agent-socket-")); + await expect(requestLiveAgent(join(dir, "nobody-home.sock"), Buffer.from([11]))).rejects.toThrow(); +}); diff --git a/apps/node/agent/src/__tests__/relay-proxy.test.ts b/apps/node/agent/src/__tests__/relay-proxy.test.ts new file mode 100644 index 000000000..121ac0014 --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-proxy.test.ts @@ -0,0 +1,728 @@ +import { expect, test } from "bun:test"; +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"; +import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { + base64UrlNoPad, + newNonce, + openRelayEnvelope, + parseRelayFrame, + type RelayFrame, + SSH_RELAY_FRAME_MAX_BYTES, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import { exportJWK, generateKeyPair } from "jose"; +import { RelaySessions } from "../commands/ssh-relay.js"; +import { type AgentProxyHandle, buildAgentSocketPath, startAgentProxy } from "../relay-proxy.js"; + +/** + * The B-side ssh-agent proxy (spec 2026-10-08 §5.2/§5.6) over the Task-5 codec. + * seal/open are the REAL mcp-core pair (the production pair, injected as the + * codec's own doctrine demands), and the A-side test keys are generated the + * same way the protocol tests do it, so the envelope the proxy produces and + * consumes is the shipped wire shape end to end. + */ + +/** + * Opaque FIXTURE PAYLOADS only: the B proxy forwards agent bytes + * byte-agnostically and never parses a type byte, so these numbers carry no + * protocol meaning at this side. These tests exercise transport framing (the + * FIFO, seq, nonces, socket lifecycle); what a real agent makes of a byte is + * A's responder's business, and its scheme semantics are pinned there. + */ +const SAMPLE_AGENT_REQUEST_A = 13; +const SAMPLE_AGENT_REQUEST_B = 15; +const SAMPLE_AGENT_ANSWER_A = 12; + +interface JwkPair { + publicJwk: string; + privateJwk: string; +} + +async function es256Pair(): Promise { + const { publicKey, privateKey } = await generateKeyPair("ES256", { extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +async function ecdhPair(): Promise { + const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { crv: "P-256", extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +// One cast of keys for the whole file: generation is the slow part, and the +// pins are per-proxy inputs anyway. A = key home, B = this machine. +const aKeysReady: Promise<{ signing: JwkPair; encryption: JwkPair }> = Promise.all([es256Pair(), ecdhPair()]).then( + ([signing, encryption]) => ({ signing, encryption }), +); +const bKeysReady: Promise<{ signing: JwkPair; encryption: JwkPair }> = Promise.all([es256Pair(), ecdhPair()]).then( + ([signing, encryption]) => ({ signing, encryption }), +); +/** An ES256 pair nobody pinned: replies signed with it are forgeries. */ +const evilKeysReady: Promise = es256Pair(); + +/** The one frame grammar the tests accept: 4-byte BE length + payload. */ +function agentFrame(payload: number[]): Buffer { + const body = Buffer.from(payload); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + return Buffer.concat([header, body]); +} + +async function sleep(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)); +} + +async function waitUntil(cond: () => boolean, what: string, timeoutMs = 3000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error(`timed out waiting for ${what}`); + await sleep(5); + } +} + +function connectSocket(socketPath: string): Socket { + const conn = createConnection({ path: socketPath }); + conn.on("error", () => { + /* the tests read failures through data/close events, not error throws */ + }); + return conn; +} + +/** Collect everything a socket received, and whether it was closed. */ +function spy(conn: Socket): { chunks: Buffer[]; closed: () => boolean } { + const chunks: Buffer[] = []; + let closed = false; + conn.on("data", (d) => chunks.push(Buffer.from(d))); + conn.on("close", () => { + closed = true; + }); + return { chunks, closed: () => closed }; +} + +interface ProxyFixture { + proxy: AgentProxyHandle; + frames: RelayFrame[]; + dataDir: string; + relayId: string; + ref: string; + bNodeId: string; + cleanup(): void; +} + +async function startFixture( + paneId = "pane-1", + /** Overrides the pump entirely (a throwing pump tests deliver-or-throw); default collects. */ + send?: (frame: RelayFrame) => void, +): Promise { + const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-proxy-")); + const frames: RelayFrame[] = []; + const proxy = await startAgentProxy({ + dataDir, + paneId, + relayId: "relay-1", + ref: "r-1", + peerNodeId: "a-node", + selfNodeId: "b-node", + 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: (frame) => { + if (send) { + send(frame); + return; + } + frames.push(frame); + }, + }); + return { + proxy, + frames, + dataDir, + relayId: "relay-1", + ref: "r-1", + bNodeId: "b-node", + cleanup: () => proxy.close(), + }; +} + +/** + * Open one outbound B2A frame's blob AS A (the seal is addressed to A's + * encryption key), and verify its signature against B's public signing key: + * the mirror of the checklist the proxy runs on replies. + */ +async function openAndVerifyB2A( + fixture: ProxyFixture, + frame: RelayFrame, +): Promise<{ agentBytes: Buffer; nB: string; nA?: string; seq: number }> { + const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); + const opened = await openRelayEnvelope({ + blob: frame.blob, + own: { + principalId: "node:a-node", + publicJwk: aKeys.encryption.publicJwk, + privateJwk: aKeys.encryption.privateJwk, + }, + open: mcpOpen, + expect: { ref: frame.ref, direction: frame.direction }, + }); + const message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: JSON.parse(bKeys.signing.publicJwk) as JsonWebKey, + expect: { routingRef: fixture.ref, direction: "B2A", seq: opened.seq }, + }); + return { + agentBytes: Buffer.from(message.agentBytesB64, "base64url"), + nB: message.nB ?? "", + nA: message.nA, + seq: opened.seq, + }; +} + +/** Seal one A2B reply exactly as A's responder will (Task 7): A's signing key, B's pinned encryption key. */ +async function sealReply( + fixture: ProxyFixture, + opts: { + seq: number; + nB: string; + nA?: string; + agentBytes: number[]; + signerPrivateJwk?: string; + relaySessionId?: string; + }, +): Promise { + const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); + const blob = await sealRelayEnvelope({ + message: { + relaySessionId: opts.relaySessionId ?? fixture.relayId, + routingRef: fixture.ref, + direction: "A2B", + seq: opts.seq, + nB: opts.nB, + ...(opts.nA === undefined ? {} : { nA: opts.nA }), + agentBytesB64: base64UrlNoPad(new Uint8Array(opts.agentBytes)), + }, + privateJwk: JSON.parse(opts.signerPrivateJwk ?? aKeys.signing.privateJwk) as JsonWebKey, + recipient: { principalId: `node:${fixture.bNodeId}`, publicJwk: bKeys.encryption.publicJwk }, + seal: mcpSeal, + }); + return { type: "relay", ref: fixture.ref, seq: opts.seq, direction: "A2B", blob }; +} + +/* ------------------------------------------------------------------ */ +/* outbound: each agent request becomes exactly one sealed B2A frame */ +/* ------------------------------------------------------------------ */ + +test("a REQUEST_IDENTITIES on the socket emits exactly one B2A relay frame, seq 0, carrying the agent bytes", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const w = await new Promise((res, rej) => { + conn.on("connect", () => res()); + conn.on("error", rej); + }); + void w; + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); + await waitUntil(() => f.frames.length === 1, "one outbound relay frame"); + await sleep(30); + expect(f.frames.length).toBe(1); // exactly one + + const frame = f.frames[0]; + expect(parseRelayFrame(JSON.stringify(frame))).not.toBeNull(); // it is wire-legal + expect(frame.type).toBe("relay"); + expect(frame.ref).toBe("r-1"); + expect(frame.direction).toBe("B2A"); + expect(frame.seq).toBe(0); // the transport slot + + const opened = await openAndVerifyB2A(f, frame); + expect(opened.seq).toBe(0); // the SIGNED anti-replay seq agrees for the first request + expect(opened.agentBytes).toEqual(Buffer.from([SAMPLE_AGENT_REQUEST_A])); + expect(opened.nB).toMatch(/^[A-Za-z0-9_-]{22}$/); // a 128-bit base64url endpoint nonce + expect(opened.nA).toBeUndefined(); // B speaks first; nA has not been minted yet + conn.destroy(); + } finally { + f.cleanup(); + } +}); + +test("a second request gets seq 1 and carries the recorded nA onward", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); + await waitUntil(() => f.frames.length === 1, "first request frame"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + const nA = newNonce(); + const reply = await sealReply(f, { + seq: 0, + nB: first.nB, + nA, + agentBytes: [SAMPLE_AGENT_ANSWER_A, 0, 0, 0, 0], + }); + f.proxy.deliverInboundRelayFrame(reply); + await waitUntil(() => spyC.chunks.length === 1, "reply bytes written back"); + + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_B, 0, 1, 2, 3])); + await waitUntil(() => f.frames.length === 2, "second request frame"); + const second = await openAndVerifyB2A(f, f.frames[1]); + expect(f.frames[1].seq).toBe(1); // transport seq advanced + expect(second.seq).toBe(1); // signed anti-replay seq advanced + expect(second.nB).toBe(first.nB); // B keeps its own nonce + expect(second.nA).toBe(nA); // and signs A's onward (§5.6: recorded peer value) + expect(second.agentBytes).toEqual(Buffer.from([SAMPLE_AGENT_REQUEST_B, 0, 1, 2, 3])); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* inbound: a verified reply is written back; a refused one never is */ +/* ------------------------------------------------------------------ */ + +test("a correctly sealed+signed A reply (stub A keypair pinned) writes the agent bytes back on the socket", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + const replyBytes = [SAMPLE_AGENT_ANSWER_A, 0, 0, 0, 2, 0, 0, 0, 1, 65]; + const reply = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: replyBytes }); + f.proxy.deliverInboundRelayFrame(reply); + await waitUntil(() => spyC.chunks.length >= 1, "reply written back"); + + const written = Buffer.concat(spyC.chunks); + const len = written.readUInt32BE(0); + expect(len).toBe(replyBytes.length); + expect(written.subarray(4)).toEqual(Buffer.from(replyBytes)); + } finally { + f.cleanup(); + } +}); + +test("a reply signed by the WRONG key is refused: nothing written, the pending connection fails", async () => { + const f = await startFixture(); + const evil = await evilKeysReady; + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + const forged = await sealReply(f, { + seq: 0, + nB: first.nB, + nA: newNonce(), + agentBytes: [SAMPLE_AGENT_ANSWER_A], + signerPrivateJwk: evil.privateJwk, // not the pinned key: an origin failure + }); + f.proxy.deliverInboundRelayFrame(forged); + await waitUntil(() => spyC.closed(), "connection failed"); + expect(spyC.chunks.length).toBe(0); // NEVER a forged reply on the wire to ssh + } finally { + f.cleanup(); + } +}); + +test("a reply with a mismatched relay-session id or nB nonce is refused and not written", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + // Cross-session replay: right keys, another session's id. + const wrongSession = await sealReply(f, { + seq: 0, + nB: first.nB, + nA: newNonce(), + agentBytes: [SAMPLE_AGENT_ANSWER_A], + relaySessionId: "relay-OTHER", + }); + f.proxy.deliverInboundRelayFrame(wrongSession); + await waitUntil(() => spyC.closed(), "session-id mismatch fails the pending connection"); + expect(spyC.chunks.length).toBe(0); + + // Foreign nonce: this machine never minted it. + const conn2 = connectSocket(f.proxy.socketPath); + const spy2 = spy(conn2); + await new Promise((res) => conn2.on("connect", () => res())); + conn2.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); + await waitUntil(() => f.frames.length === 2, "second request frame out"); + const wrongNonce = await sealReply(f, { + seq: 0, + nB: newNonce(), + nA: newNonce(), + agentBytes: [SAMPLE_AGENT_ANSWER_A], + }); + f.proxy.deliverInboundRelayFrame(wrongNonce); + await waitUntil(() => spy2.closed(), "nonce mismatch fails the pending connection"); + expect(spy2.chunks.length).toBe(0); + } finally { + f.cleanup(); + } +}); + +test("a repeated seq is refused by the SeqGate and not written; a valid next-seq reply still is", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + const nA = newNonce(); + const good = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [1, 2, 3] }); + f.proxy.deliverInboundRelayFrame(good); + await waitUntil(() => spyC.chunks.length === 1, "first reply written"); + + // The plane can resend ANY envelope it routed (§5.6): a byte-identical + // resend and a rewind are both refused, and a refusal costs nothing on + // the live stream - the connection stays open for the next request. + f.proxy.deliverInboundRelayFrame(good); // resend, seq 0 again + const rewind = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [9, 9, 9] }); + f.proxy.deliverInboundRelayFrame(rewind); + const lower = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [8, 8, 8] }); + f.proxy.deliverInboundRelayFrame(lower); + await sleep(50); + expect(spyC.chunks.length).toBe(1); // nothing further was written + expect(spyC.closed()).toBe(false); // a replay refusal is not a connection failure + + // The stream continues: seq 1 over seq 0 is accepted. + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); + await waitUntil(() => f.frames.length === 2, "second request frame out"); + const next = await sealReply(f, { seq: 1, nB: first.nB, nA, agentBytes: [4, 5, 6] }); + f.proxy.deliverInboundRelayFrame(next); + await waitUntil(() => spyC.chunks.length === 2, "next-seq reply written"); + expect(spyC.chunks[1].subarray(4)).toEqual(Buffer.from([4, 5, 6])); + } finally { + f.cleanup(); + } +}); + +test("an openable but unsigned-sealed blob (plane-crafted noise, wrong recipient slot) is dropped without killing the pending request", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + + // Not even valid base64-of-a-JWE: the open fails before any trust question. + f.proxy.deliverInboundRelayFrame({ type: "relay", ref: "r-1", seq: 0, direction: "A2B", blob: "AAAAAAAAAAAAAAAA" }); + await sleep(30); + expect(spyC.chunks.length).toBe(0); + expect(spyC.closed()).toBe(false); // noise from the routing plane costs the pane nothing + + // And the session still works: the real reply lands. + const first = await openAndVerifyB2A(f, f.frames[0]); + const good = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: [7] }); + f.proxy.deliverInboundRelayFrame(good); + await waitUntil(() => spyC.chunks.length === 1, "valid reply still written"); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* FIFO integrity: a reply to a dead connection burns the slot */ +/* ------------------------------------------------------------------ */ + +test("a reply to a dead connection is absorbed: never written to the next connection's socket", async () => { + const f = await startFixture(); + try { + const x = connectSocket(f.proxy.socketPath); + const spyX = spy(x); + await new Promise((res) => x.on("connect", () => res())); + x.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); + await waitUntil(() => f.frames.length === 1, "X's request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + // Y queues behind X's outstanding slot. + const y = connectSocket(f.proxy.socketPath); + const spyY = spy(y); + await new Promise((res) => y.on("connect", () => res())); + y.write(agentFrame([SAMPLE_AGENT_REQUEST_B, 1, 2, 3])); + await sleep(30); + expect(f.frames.length).toBe(1); // queued unsealed: the slot holds X's request + + // X dies with its reply still owed. The slot BURNS: it keeps holding the + // dead request, so the queue must NOT promote yet (an immediate release + // would put Y's request on the wire while A's answer to X is still in + // flight, and that answer would then land on Y's socket). + x.destroy(); + await waitUntil(() => spyX.closed(), "X's socket closed"); + await sleep(30); + expect(f.frames.length).toBe(1); // burned slot: no early promote + + // A's genuine reply to X arrives: fresh, gate-passing seq 0, correctly + // signed. It must be verified and DISCARDED, never delivered to Y. + const nA = newNonce(); + const replyToX = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [99, 98, 97] }); + f.proxy.deliverInboundRelayFrame(replyToX); + await waitUntil(() => f.frames.length === 2, "the absorb releases the slot and Y's queued request goes out"); + expect(spyY.chunks.length).toBe(0); // NEVER X's agent answer inside Y's stream + + // Y's request left as seq 1 (X lawfully consumed 0), carrying the recorded nA. + const second = await openAndVerifyB2A(f, f.frames[1]); + expect(f.frames[1].seq).toBe(1); + expect(second.seq).toBe(1); + expect(second.nA).toBe(nA); + expect(second.agentBytes).toEqual(Buffer.from([SAMPLE_AGENT_REQUEST_B, 1, 2, 3])); + + // A's reply to Y (seq 1) arrives: THAT is what Y receives, in order. + const replyToY = await sealReply(f, { seq: 1, nB: first.nB, nA, agentBytes: [1, 2, 3] }); + f.proxy.deliverInboundRelayFrame(replyToY); + await waitUntil(() => spyY.chunks.length === 1, "Y's own reply written to Y"); + expect(spyY.chunks[0].subarray(4)).toEqual(Buffer.from([1, 2, 3])); + expect(spyY.closed()).toBe(false); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* deliver-or-throw: a refused send fails the request, never hangs it */ +/* ------------------------------------------------------------------ */ + +test("a sendRelayFrame that throws fails the connection and drains the queue; no phantom slot, no consumed seq", async () => { + const sent: RelayFrame[] = []; + let attempts = 0; + const f = await startFixture("pane-throw", (frame) => { + attempts += 1; + if (attempts === 1) throw new Error("link down: the pump dropped the send"); + sent.push(frame); + }); + try { + const a = connectSocket(f.proxy.socketPath); + const spyA = spy(a); + await new Promise((res) => a.on("connect", () => res())); + // req1 takes the slot straight away; req2 queues behind the in-flight + // seal (arrives in the same read while sealedInFlight holds). + a.write(Buffer.concat([agentFrame([SAMPLE_AGENT_REQUEST_A]), agentFrame([SAMPLE_AGENT_REQUEST_B, 9])])); + await waitUntil(() => spyA.closed(), "the connection fails on the refused send instead of hanging"); + expect(attempts).toBe(1); // only req1 reached the pump; req2 died at the liveness gate + expect(sent.length).toBe(0); // nothing ever reached A + + // The throw is NON-DELIVERY: no slot was claimed, so the session is free + // for the next request, and the failed send consumed no seq (A never saw + // the request; the retry lawfully re-uses 0). + const b = connectSocket(f.proxy.socketPath); + const spyB = spy(b); + await new Promise((res) => b.on("connect", () => res())); + b.write(agentFrame([SAMPLE_AGENT_REQUEST_A, 4, 5])); + await waitUntil(() => sent.length === 1, "the next request goes out after the failed send"); + expect(sent[0].seq).toBe(0); + const first = await openAndVerifyB2A(f, sent[0]); + expect(first.seq).toBe(0); + expect(first.agentBytes).toEqual(Buffer.from([SAMPLE_AGENT_REQUEST_A, 4, 5])); + + const reply = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: [7, 7] }); + f.proxy.deliverInboundRelayFrame(reply); + await waitUntil(() => spyB.chunks.length === 1, "the live connection's reply is written"); + expect(spyB.chunks[0].subarray(4)).toEqual(Buffer.from([7, 7])); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the socket file and its directory */ +/* ------------------------------------------------------------------ */ + +test("the socket lives at /ssh//agent.sock, mode 0600, not in the pane log dir", async () => { + const f = await startFixture("pane-42"); + try { + const expected = join(f.dataDir, "ssh", "pane-42", "agent.sock"); + expect(f.proxy.socketPath).toBe(expected); + expect(buildAgentSocketPath(f.dataDir, "pane-42")).toBe(expected); + // The M1 pane CONFIG lands in the same dir (spec §5.2 "beside the rendered + // config"); the pane LOG dir is /subshells - a different tree. + expect(f.proxy.socketPath).not.toContain(join(f.dataDir, "subshells")); + const st = statSync(f.proxy.socketPath); + expect(st.mode & 0o777).toBe(0o600); + const dir = statSync(join(f.dataDir, "ssh", "pane-42")); + expect(dir.mode & 0o777).toBe(0o700); + } finally { + f.cleanup(); + } +}); + +test("close() unbinds: the socket file is gone and a fresh connect fails", async () => { + const f = await startFixture(); + const path = f.proxy.socketPath; + f.proxy.close(); + await waitUntil(() => { + try { + statSync(path); + return false; + } catch { + return true; + } + }, "socket file unlinked"); + const conn = createConnection({ path }); + let failed = false; + conn.on("error", () => { + failed = true; + }); + await waitUntil(() => failed, "connect refused after close"); + // Idempotent: a second close must not throw. + f.proxy.close(); +}); + +test("a malformed pinned peer JWK surfaces the named refusal, not a raw SyntaxError", async () => { + const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-proxy-")); + const base = { + dataDir, + paneId: "pane-bad", + relayId: "relay-1", + ref: "r-1", + peerNodeId: "a-node", + selfNodeId: "b-node", + 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: (): void => {}, + }; + // Not JSON at all: bytesOfJwk's guard runs FIRST and turns the parse + // failure into the documented refusal (the parse now happens only after + // the guard, so a raw SyntaxError can never escape startAgentProxy). + await expect(startAgentProxy({ ...base, peerSigningJwk: "{not json" })).rejects.toThrow( + /relay proxy: pinned peer key rejected/, + ); + await expect(startAgentProxy({ ...base, peerEncryptionJwk: "!!!" })).rejects.toThrow( + /relay proxy: pinned peer key rejected/, + ); + // Deep private material in a pin half keeps the same named refusal. + await expect(startAgentProxy({ ...base, peerSigningJwk: aKeys.signing.privateJwk })).rejects.toThrow( + /relay proxy: pinned peer key rejected/, + ); + // The refusal precedes any binding: no socket name exists. + expect(() => statSync(buildAgentSocketPath(dataDir, "pane-bad"))).toThrow(); +}); + +test("an over-cap request length prefix fails the connection without emitting a frame", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + const header = Buffer.alloc(4); + header.writeUInt32BE(SSH_RELAY_FRAME_MAX_BYTES + 1, 0); + conn.write(header); + await waitUntil(() => spyC.closed(), "over-cap request drops the connection"); + expect(f.frames.length).toBe(0); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the registry seam the daemon routes into (Task 7 reuses this one) */ +/* ------------------------------------------------------------------ */ + +test("RelaySessions dispatches by ref to the owning role: registered, unknown, duplicate, throwing", () => { + const relay = new RelaySessions(); + const seenA: RelayFrame[] = []; + const seenB: RelayFrame[] = []; + let closedWith = ""; + const frame = (ref: string): RelayFrame => ({ type: "relay", ref, seq: 0, direction: "A2B", blob: "AAAA" }); + + expect( + relay.register("r-1", { + onRelayFrame: (f) => seenA.push(f), + close: (reason) => { + closedWith = reason; + }, + }), + ).toBe(true); + expect(relay.register("r-1", { onRelayFrame: (f) => seenB.push(f), close: () => {} })).toBe(false); // ref already owned + expect( + relay.register("r-2", { + onRelayFrame: () => { + throw new Error("handler boom"); + }, + close: () => {}, + }), + ).toBe(true); + + relay.onInboundRelayFrame(frame("r-1")); + expect(seenA.length).toBe(1); + expect(seenB.length).toBe(0); + + relay.onInboundRelayFrame(frame("unknown-ref")); // never throws into the frame chain + relay.onInboundRelayFrame(frame("r-2")); // a throwing owner is contained, not rethrown + + relay.closeAll("access-revoked"); + expect(closedWith).toBe("access-revoked"); + expect(relay.size).toBe(0); + 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 }); + } + }, +); diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts new file mode 100644 index 000000000..9c7f04b3a --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -0,0 +1,1500 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { + base64UrlNoPad, + newNonce, + openRelayEnvelope, + parseRelayFrame, + type RelayFrame, + SSH_RELAY_MAX_PER_NODE, + type SshRelayOpenCommand, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import { exportJWK, generateKeyPair } from "jose"; +import { openARelaySession, openBRelaySession, RelaySessions } from "../commands/ssh-relay.js"; +import { loadOrCreateIdentity } from "../identity.js"; +import { type AgentScheme, CLASSIC_SCHEME, OPENSSH_10X_SCHEME } from "../relay-agent-scheme.js"; +import { buildAgentSocketPath } from "../relay-proxy.js"; +import { startRelayResponder } from "../relay-responder.js"; + +/** + * The A-side responder (spec 2026-10-08 §5.4/§5.6) under the PROBED numbering + * ruling (ruling 2026-10-08): the session probes A's live agent once at + * relay-open to learn which codepoint scheme it speaks, caches that scheme on + * the session, then classifies and scopes every request in it. The stub + * agents below answer with real-agent byte literals for BOTH schemes - the + * fixtures are written against the measured OpenSSH_10.2p1 behavior (a byte + * 13 truncated sign answers FAILURE 5; a byte 11 answers a valid 12) and + * OpenSSH's classic behavior (13 answers 14; 15 signs with 16), never against + * constants the production module exports, so numeric drift cannot hide. + * seal/open are the REAL mcp-core pair; the test side computes fingerprints + * independently (node:crypto over the wire blob). + */ + +/* ------------------------------------------------------------------ */ +/* the agent wire, spelled by hand */ +/* ------------------------------------------------------------------ */ + +/** An independent fingerprint oracle for the tests: SHA-256, base64url no pad. */ +function fp(wire: Uint8Array): string { + return `SHA256:${createHash("sha256").update(wire).digest("base64url")}`; +} + +function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +/** An SSH string on the agent wire: 4-byte BE length + bytes. */ +function sshStr(bytes: Buffer | string): Buffer { + const b = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes; + return Buffer.concat([be32(b.length), b]); +} + +function buildIdentitiesAnswer(scheme: AgentScheme, entries: { blob: Buffer; comment: string }[]): Buffer { + return Buffer.concat([ + Buffer.from([scheme.answer]), + be32(entries.length), + ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), + ]); +} + +/** A classic-scheme SIGN_REQUEST body: blob, data, flags (the classic wire grammar). */ +function buildSignRequestClassic(keyBlob: Buffer, data = Buffer.from("DATA"), flags = 0): Buffer { + return Buffer.concat([Buffer.from([15]), sshStr(keyBlob), sshStr(data), be32(flags)]); +} + +/** A 10.x-scheme SIGN_REQUEST body: blob, data, flags, optional trailing algorithms string. */ +function buildSignRequestTenX( + keyBlob: Buffer, + opts: { data?: Buffer; flags?: number; algorithms?: string } = {}, +): Buffer { + const parts = [Buffer.from([13]), sshStr(keyBlob), sshStr(opts.data ?? Buffer.from("DATA")), be32(opts.flags ?? 0)]; + if (opts.algorithms !== undefined) parts.push(sshStr(opts.algorithms)); + return Buffer.concat(parts); +} + +/** The stub's independent sign-body validity check (mirrors the real agent's parser). */ +function isWellFormedSignBody(p: Buffer, scheme: AgentScheme): boolean { + let off = 1; + const str = (): Buffer | null => { + if (off + 4 > p.length) return null; + const n = p.readUInt32BE(off); + off += 4; + if (off + n > p.length) return null; + const v = Buffer.from(p.subarray(off, off + n)); + off += n; + return v; + }; + const blob = str(); + if (blob === null || blob.length === 0) return false; + if (str() === null) return false; // data + if (off + 4 > p.length) return false; // flags + off += 4; + if (off === p.length) return true; + if (!scheme.extendedSign) return false; // trailing bytes need the extended grammar + if (str() === null) return false; // algorithms string must be whole + return off === p.length; +} + +/** Decode an IDENTITIES_ANSWER the test way (independent of the production parser). */ +function parseAnswer(answer: Buffer): { type: number; count: number; comments: string[] } { + const type = answer[0]; + const count = answer.readUInt32BE(1); + const comments: string[] = []; + let off = 5; + for (let i = 0; i < count; i += 1) { + const blobLen = answer.readUInt32BE(off); + off += 4 + blobLen; + const cLen = answer.readUInt32BE(off); + comments.push(answer.subarray(off + 4, off + 4 + cLen).toString("utf8")); + off += 4 + cLen; + } + return { type, count, comments }; +} + +function framed(payload: Buffer): Buffer { + return Buffer.concat([be32(payload.length), payload]); +} + +/* ------------------------------------------------------------------ */ +/* the stub live agents: Unix sockets speaking one scheme's bytes */ +/* ------------------------------------------------------------------ */ + +interface StubAgent { + path: string; + /** Every request payload the stub RECEIVED, in order (the probes included). */ + received: Buffer[]; + /** + * Install an answer mutator AFTER the open-time probe (late binding): the + * probe must always see a clean agent or the scheme would never resolve; + * only later replies get corrupted to drive the response gates. + */ + setMutate(mutate: (kind: "identities" | "sign", answer: Buffer) => Buffer): void; + close(): Promise; +} + +interface RosterEntry { + blob: Buffer; + comment: string; +} + +/** + * A stub that behaves like a live agent of the given scheme: a one-byte + * identities request answers the full roster in that scheme's answer byte; a + * WELL-FORMED sign body answers SIGN_RESPONSE (it signs anything it is asked: + * "the forwarded list never contains it" is the witness that the responder + * refused before forwarding); anything else - including the probe's one-byte + * classic candidate hitting a 10.x agent, where byte 13 is a truncated sign - + * answers FAILURE(5), exactly the OpenSSH_10.2p1 measurement. {@link + * StubAgent.setMutate} can later corrupt would-be-valid answers (foreign type + * bytes, truncated rosters) to drive the responder's response gates. + */ +async function startStubAgent(roster: RosterEntry[], scheme: AgentScheme): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stub-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const state: { mutate?: (kind: "identities" | "sign", answer: Buffer) => Buffer } = {}; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + for (;;) { + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + const request = Buffer.from(buffer.subarray(4, 4 + len)); + buffer = buffer.subarray(4 + len); + received.push(request); + const type = request[0]; + if (request.length === 1 && type === scheme.identities) { + const answer = buildIdentitiesAnswer(scheme, roster); + conn.write(framed(state.mutate ? state.mutate("identities", answer) : answer)); + } else if (type === scheme.sign && isWellFormedSignBody(request, scheme)) { + const answer = Buffer.concat([Buffer.from([scheme.signResponse]), sshStr(Buffer.from("SIG"))]); + conn.write(framed(state.mutate ? state.mutate("sign", answer) : answer)); + } else { + conn.write(framed(Buffer.from([5]))); + } + } + }); + conn.on("error", () => { + /* the responder closes per request; the stub never throws */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { + path, + received, + setMutate: (mutate) => { + state.mutate = mutate; + }, + close: () => new Promise((r) => server.close(() => r())), + }; +} + +/** A stub answering FAILURE(5) to EVERY request: it speaks neither scheme. */ +async function startUnprobeableAgent(): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stub-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + received.push(Buffer.from(buffer.subarray(4, 4 + len))); + buffer = buffer.subarray(4 + len); + conn.write(framed(Buffer.from([5]))); + }); + conn.on("error", () => { + /* per-request closes; never throw */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { + path, + received, + setMutate: () => { + /* this stub never produces a valid answer to corrupt */ + }, + close: () => new Promise((r) => server.close(() => r())), + }; +} + +/* ------------------------------------------------------------------ */ +/* machine identities */ +/* ------------------------------------------------------------------ */ + +interface JwkPair { + publicJwk: string; + privateJwk: string; +} + +async function es256Pair(): Promise { + const { publicKey, privateKey } = await generateKeyPair("ES256", { extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +async function ecdhPair(): Promise { + const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { crv: "P-256", extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +async function machineKeys(): Promise<{ signing: JwkPair; encryption: JwkPair }> { + const [signing, encryption] = await Promise.all([es256Pair(), ecdhPair()]); + return { signing, encryption }; +} + +// B = the connecting machine (test-generated keys); A = this machine, whose +// identity the responder loads from the data dir through identity.ts itself. +const bKeysReady = machineKeys(); +/** An ES256 pair nobody pinned: requests signed with it are forgeries. */ +const evilKeysReady: Promise = es256Pair(); + +/* ------------------------------------------------------------------ */ +/* the fixture: a brokered A session over RelaySessions + a stub agent */ +/* ------------------------------------------------------------------ */ + +const KEY_IN = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-IN"))]); +const KEY_OUT = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-OUT"))]); +// Task 12: the destination's pinned known_hosts line the relay-open carries. +const HOST_PIN = "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; +const ROSTER: RosterEntry[] = [ + { blob: KEY_IN, comment: "granted key" }, + { blob: KEY_OUT, comment: "ungranted key" }, +]; + +interface Fixture { + relay: RelaySessions; + frames: RelayFrame[]; + stub: StubAgent; + /** Messages the stub received AFTER the open-time probe finished. */ + forwarded(): Buffer[]; + dataDir: string; + aPublic: { signing: string; encryption: string }; + aPrivate: { signing: string; encryption: string }; + bKeys: { signing: JwkPair; encryption: JwkPair }; + /** The scheme the stub agent speaks and the probe is expected to resolve. */ + scheme: AgentScheme; + cleanup(): Promise; +} + +/** + * Open the A side with the shipped command shape through the real registration + * path. The numbering probe runs INSIDE openARelaySession, so by the time this + * resolves the stub has already fielded the probe bytes; everything received + * after that point is what the responder forwarded for B's requests. + */ +async function openFixture( + overrides: Partial = {}, + opts: { + resolveAgentSocket?: () => string | null; + send?: (frame: RelayFrame) => void; + scheme?: AgentScheme; + roster?: RosterEntry[]; + unprobeable?: boolean; + mutateAnswer?: (kind: "identities" | "sign", answer: Buffer) => Buffer; + } = {}, +): Promise { + const scheme = opts.scheme ?? OPENSSH_10X_SCHEME; + const bKeys = await bKeysReady; + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-responder-")); + // The responder loads A's identity from the data dir; mint it first so the + // test side knows A's keys (loadOrCreateIdentity is idempotent per dir). + const aIdentity = await loadOrCreateIdentity(dataDir); + const stub = opts.unprobeable ? await startUnprobeableAgent() : await startStubAgent(opts.roster ?? ROSTER, scheme); + const relay = new RelaySessions(); + const frames: RelayFrame[] = []; + const cmd: SshRelayOpenCommand = { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-1", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(bKeys.encryption.publicJwk, "utf8").toString("base64"), + fingerprints: [fp(KEY_IN)], + lifetimeMs: 30_000, + paneId: "pane-a-test", // grammar (b): the command names the pane + hostPin: HOST_PIN, // grammar (Task 12): the destination's pinned key line + ...overrides, + }; + await openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd, + sendRelayFrame: + opts.send ?? + ((frame: RelayFrame): void => { + frames.push(frame); + }), + resolveAgentSocket: opts.resolveAgentSocket ?? ((): string => stub.path), + }); + const probeCount = stub.received.length; // whatever the open-time probe asked + if (opts.mutateAnswer) stub.setMutate(opts.mutateAnswer); // corrupt only post-probe answers + return { + relay, + frames, + stub, + forwarded: (): Buffer[] => stub.received.slice(probeCount), + dataDir, + aPublic: { signing: aIdentity.signingPublicJwk, encryption: aIdentity.publicJwk }, + aPrivate: { signing: aIdentity.signingPrivateJwk, encryption: aIdentity.privateJwk }, + bKeys, + scheme, + cleanup: async () => { + relay.closeAll("lifetime-expiry"); + await stub.close(); + }, + }; +} + +/** Seal one B2A request exactly as B's proxy does (Task 6 wire posture). */ +async function sealRequest( + f: Fixture, + opts: { + agentBytes: Buffer; + seq: number; + nB: string; + nA?: string; + signerPrivateJwk?: string; + relaySessionId?: string; + ref?: string; + direction?: "B2A" | "A2B"; + }, +): Promise { + const blob = await sealRelayEnvelope({ + message: { + relaySessionId: opts.relaySessionId ?? "relay-1", + routingRef: opts.ref ?? "r-1", + direction: opts.direction ?? "B2A", + seq: opts.seq, + nB: opts.nB, + ...(opts.nA === undefined ? {} : { nA: opts.nA }), + agentBytesB64: base64UrlNoPad(new Uint8Array(opts.agentBytes)), + }, + privateJwk: JSON.parse(opts.signerPrivateJwk ?? f.bKeys.signing.privateJwk) as JsonWebKey, + recipient: { principalId: "node:a-node", publicJwk: f.aPublic.encryption }, + seal: mcpSeal, + }); + const direction = opts.direction ?? "B2A"; + return { type: "relay", ref: opts.ref ?? "r-1", seq: opts.seq, direction, blob }; +} + +/** Open + verify one A2B frame AS B: the mirror of A's inbound checklist. */ +async function decodeA2B( + f: Fixture, + frame: RelayFrame, + expect: { nB: string; nA?: string }, +): Promise<{ agentBytes: Buffer; nA?: string; seq: number }> { + const opened = await openRelayEnvelope({ + blob: frame.blob, + own: { + principalId: "node:b-node", + publicJwk: f.bKeys.encryption.publicJwk, + privateJwk: f.bKeys.encryption.privateJwk, + }, + open: mcpOpen, + expect: { ref: frame.ref, direction: frame.direction }, + }); + const message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: JSON.parse(f.aPublic.signing) as JsonWebKey, + expect: { + routingRef: frame.ref, + direction: "A2B", + seq: opened.seq, + relaySessionId: "relay-1", + nB: expect.nB, + ...(expect.nA === undefined ? {} : { nA: expect.nA }), + }, + }); + return { + agentBytes: Buffer.from(message.agentBytesB64, "base64url"), + nA: message.nA, + seq: opened.seq, + }; +} + +async function sleep(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)); +} + +async function waitUntil(cond: () => boolean, what: string, timeoutMs = 3000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error(`timed out waiting for ${what}`); + await sleep(5); + } +} + +/* ------------------------------------------------------------------ */ +/* the open-time probe (the 2026-10-08 numbering ruling) */ +/* ------------------------------------------------------------------ */ + +test("opening a session on a 10.x agent probes classic-then-10.x, resolves on the 11-to-12 answer, and forwards nothing", async () => { + const f = await openFixture(); // the 10.x stub is the fleet default (measured OpenSSH_10.2p1) + try { + // Byte 13 first: the 10.x stub reads it as a truncated sign and answers + // FAILURE(5), the measured behavior. Byte 11 then answers a valid + // IDENTITIES_ANSWER(12): the scheme resolves positively on the 11-to-12 pair. + expect(f.stub.received).toEqual([Buffer.from([13]), Buffer.from([11])]); + // The probe's roster is A's own view of the world: it never rides to B. + expect(f.frames.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +test("opening a session on a classic agent resolves on the 13-to-14 answer in a single probe", async () => { + const f = await openFixture({}, { scheme: CLASSIC_SCHEME }); + try { + expect(f.stub.received).toEqual([Buffer.from([13])]); + expect(f.frames.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +test("the probe never sends anything but one-byte identities requests (it can never ask for a signature)", async () => { + const f = await openFixture(); + try { + for (const payload of f.stub.received) { + expect(payload.length).toBe(1); + expect([11, 13]).toContain(payload[0]); + } + } finally { + await f.cleanup(); + } +}); + +test("an agent the probe cannot resolve refuses every request and forwards nothing beyond the probes", async () => { + const f = await openFixture({}, { unprobeable: true }); // FAILURE(5) to everything + try { + expect(f.stub.received).toEqual([Buffer.from([13]), Buffer.from([11])]); // both candidates tried + const nB = newNonce(); + // Every byte SOME scheme would honor must be refused while the scheme is + // unresolved: never forwarded under a guessed interpretation. + const requests = [ + Buffer.from([11]), + buildSignRequestTenX(KEY_IN), + Buffer.from([13]), + buildSignRequestClassic(KEY_IN), + ]; + let nA: string | undefined; + for (const [i, agentBytes] of requests.entries()) { + const req = await sealRequest(f, { agentBytes, seq: i, nB, ...(nA === undefined ? {} : { nA }) }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === i + 1, `refusal ${i}`); + const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); + expect([...reply.agentBytes]).toEqual([5]); + nA = reply.nA; // later requests must carry the recorded nonce onward (real B's state) + } + expect(f.forwarded()).toEqual([]); // nothing beyond the two one-byte probes + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the 10.x scheme: identities 11/answer 12, sign 13/response 14 */ +/* ------------------------------------------------------------------ */ + +test("under the 10.x scheme a REQUEST_IDENTITIES (11) from pinned B gets an IDENTITIES_ANSWER (12) filtered to the grant, and the session's second request rides the recorded nA", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const req1 = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + expect(parseRelayFrame(JSON.stringify(req1))).not.toBeNull(); + f.relay.onInboundRelayFrame(req1); + await waitUntil(() => f.frames.length === 1, "one A2B reply frame"); + + const frame1 = f.frames[0]; + expect(frame1.type).toBe("relay"); + expect(frame1.ref).toBe("r-1"); + expect(frame1.direction).toBe("A2B"); + expect(frame1.seq).toBe(0); + const reply1 = await decodeA2B(f, frame1, { nB }); + expect(reply1.nA).toMatch(/^[A-Za-z0-9_-]{22}$/); // A minted its endpoint nonce (§5.6) + expect(reply1.seq).toBe(0); // the signed seq of the first reply + + const answer = parseAnswer(reply1.agentBytes); + expect(answer.type).toBe(12); // the 10.x answer byte, never an invented 2 (C-1) + expect(answer.count).toBe(1); // the stub roster had 2; only the granted fingerprint survives + expect(answer.comments).toEqual(["granted key"]); + expect(f.forwarded()[0]).toEqual(Buffer.from([11])); // the agent saw the identities request + + // The second request carries A's recorded nonce; A's expect binds it onward. + // The 10.x extended grammar: blob, data, flags, trailing algorithms string. + const signReq = buildSignRequestTenX(KEY_IN, { algorithms: "ssh-ed25519" }); + const req2 = await sealRequest(f, { agentBytes: signReq, seq: 1, nB, nA: reply1.nA }); + f.relay.onInboundRelayFrame(req2); + await waitUntil(() => f.frames.length === 2, "the second A2B reply frame"); + const reply2 = await decodeA2B(f, f.frames[1], { nB, nA: reply1.nA }); + expect(f.frames[1].seq).toBe(1); // transport seq advanced + expect(reply2.seq).toBe(1); // signed anti-replay seq advanced + expect(reply2.nA).toBe(reply1.nA); // every A2B carries the SAME nA (Task-6 handoff) + expect([...reply2.agentBytes]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); // SIGN_RESPONSE 14 + expect(f.forwarded()[1]).toEqual(signReq); // byte-identical forward, extended grammar included + } finally { + await f.cleanup(); + } +}); + +test("C-2 regression: a crafted byte-13 SIGN_REQUEST under the 10.x scheme with an OUT-of-grant blob is refused and never forwarded", async () => { + // Byte 13 is SIGN under 10.x. Before the fix the responder treated it as + // the classic identities request and forwarded it raw, unscoped, so A's + // agent would have signed an attacker-chosen blob. Now the probe resolves + // 10.x and the fingerprint scope must reject it BEFORE any forward. + const f = await openFixture(); + try { + const nB = newNonce(); + const crafted = buildSignRequestTenX(KEY_OUT); // KEY_OUT is not in the grant + const req = await sealRequest(f, { agentBytes: crafted, seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "a refusal reply frame"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); // SSH2_AGENT_FAILURE + await sleep(30); + expect(f.forwarded()).toEqual([]); // §5.4: checked at the responder, never forwarded + // And the byte-13 traffic the stub saw is only the one-byte probe, never + // the crafted body. + for (const seen of f.stub.received) { + if (seen[0] === 13) expect(seen.length).toBe(1); + } + } finally { + await f.cleanup(); + } +}); + +test("under the 10.x scheme every codepoint the scheme does not name is refused and never forwarded (default-deny)", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + // 15/16 are the CLASSIC sign request/response bytes; 14 is 10.x's own + // response byte aimed at us as a request; the rest are agent-proto.h + // mutations/locks/SSH1 codes. NONE is 10.x identities (11) or sign (13). + const disallowed = [15, 16, 14, 17, 18, 19, 22, 23, 27, 1, 200]; + let nA: string | undefined; + for (const [i, type] of disallowed.entries()) { + const req = await sealRequest(f, { + agentBytes: Buffer.from([type, 1, 2, 3]), + seq: i, + nB, + ...(nA === undefined ? {} : { nA }), + }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === i + 1, `a refusal for disallowed type ${type}`); + const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); + expect([...reply.agentBytes]).toEqual([5]); + expect(f.frames[i].seq).toBe(i); + nA = reply.nA; // later requests must carry the recorded nonce onward (real B's state) + } + await sleep(30); + expect(f.forwarded()).toEqual([]); // nothing reached A's agent + } finally { + await f.cleanup(); + } +}); + +test("under the 10.x scheme malformed sign bodies are refused and never forwarded", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + let nA: string | undefined; + const malformed: Buffer[] = [ + Buffer.from([13, 0, 0, 0, 2]), // truncated blob length + Buffer.concat([Buffer.from([13]), sshStr(KEY_IN)]), // blob-only: no data, no flags + Buffer.concat([Buffer.from([13]), sshStr(KEY_IN), be32(0)]), // blob+flags: not the wire grammar + Buffer.concat([Buffer.from([13]), sshStr(KEY_IN), sshStr("DATA")]), // missing flags + Buffer.concat([Buffer.from([13]), sshStr(Buffer.alloc(0)), sshStr("DATA"), be32(0)]), // empty blob + Buffer.concat([Buffer.from([13]), sshStr(KEY_IN), sshStr("DATA"), be32(0), Buffer.from([0, 0])]), // junk tail + Buffer.concat([ + Buffer.from([13]), + sshStr(KEY_IN), + sshStr("DATA"), + be32(0), + Buffer.from([0, 0, 0, 99]), // algorithms string longer than the buffer + ]), + ]; + for (const [i, agentBytes] of malformed.entries()) { + const req = await sealRequest(f, { agentBytes, seq: i, nB, ...(nA === undefined ? {} : { nA }) }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === i + 1, `a refusal for malformed body ${i}`); + const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); + expect([...reply.agentBytes]).toEqual([5]); + nA = reply.nA; + } + await sleep(30); + expect(f.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +test("under the 10.x scheme a REQUEST_IDENTITIES (11) must be exactly one byte (extra bytes are refused, not forwarded)", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11, 0, 1, 2, 3]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); + await sleep(30); + expect(f.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +test("under the 10.x scheme a grant naming no fingerprint serves the count-zero answer (12), never the roster", async () => { + const f = await openFixture({ fingerprints: [] }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the filtered reply frame"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + // The full two-key roster is on the stub; the answer is five bytes: 12 + count 0. + expect([...reply.agentBytes]).toEqual([12, 0, 0, 0, 0]); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the classic scheme: identities 13/answer 14, sign 15/response 16 */ +/* ------------------------------------------------------------------ */ + +test("under the classic scheme a REQUEST_IDENTITIES (13) gets an IDENTITIES_ANSWER (14) filtered to the grant", async () => { + const f = await openFixture({}, { scheme: CLASSIC_SCHEME }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([13]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "one A2B reply frame"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + const answer = parseAnswer(reply.agentBytes); + expect(answer.type).toBe(14); // the classic answer byte + expect(answer.count).toBe(1); + expect(answer.comments).toEqual(["granted key"]); + expect(f.forwarded()[0]).toEqual(Buffer.from([13])); // byte 13 IS identities here + } finally { + await f.cleanup(); + } +}); + +test("under the classic scheme a SIGN_REQUEST (15) is scoped by fingerprint and a granted one rides byte-identical", async () => { + const f = await openFixture({}, { scheme: CLASSIC_SCHEME }); + try { + const nB = newNonce(); + const outOfSet = buildSignRequestClassic(KEY_OUT); + const bad = await sealRequest(f, { agentBytes: outOfSet, seq: 0, nB }); + f.relay.onInboundRelayFrame(bad); + await waitUntil(() => f.frames.length === 1, "the out-of-set refusal"); + const refusal = await decodeA2B(f, f.frames[0], { nB }); + expect([...refusal.agentBytes]).toEqual([5]); + expect(f.forwarded()).toEqual([]); // out-of-set never reached the agent + + const inSet = buildSignRequestClassic(KEY_IN); + const good = await sealRequest(f, { agentBytes: inSet, seq: 1, nB, nA: refusal.nA }); + f.relay.onInboundRelayFrame(good); + await waitUntil(() => f.frames.length === 2, "the signature reply"); + const reply = await decodeA2B(f, f.frames[1], { nB, nA: refusal.nA }); + expect([...reply.agentBytes]).toEqual([16, 0, 0, 0, 3, ...Buffer.from("SIG")]); // SIGN_RESPONSE 16 + expect(f.forwarded()[0]).toEqual(inSet); + } finally { + await f.cleanup(); + } +}); + +test("under the classic scheme the 10.x bytes and non-grammatical bodies are refused and never forwarded", async () => { + const f = await openFixture({}, { scheme: CLASSIC_SCHEME }); + try { + const nB = newNonce(); + let nA: string | undefined; + const cases = [ + Buffer.from([11]), // 10.x identities: a classic agent has no such request + Buffer.from([12]), // 10.x's answer byte aimed at us as a request + Buffer.from([13, 9]), // classic identities must be exactly one byte + buildSignRequestClassic(KEY_IN).subarray(0, 5), // truncated before data + buildSignRequestTenX(KEY_IN), // byte 13 with a full body: 13 must be one byte here + Buffer.concat([buildSignRequestClassic(KEY_IN), sshStr("ssh-ed25519")]), // trailing algorithms: the extended grammar belongs to 10.x + ]; + for (const [i, agentBytes] of cases.entries()) { + const req = await sealRequest(f, { agentBytes, seq: i, nB, ...(nA === undefined ? {} : { nA }) }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === i + 1, `a refusal for case ${i}`); + const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); + expect([...reply.agentBytes]).toEqual([5]); + nA = reply.nA; + } + await sleep(30); + expect(f.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* agent answers that misbehave (the response gates) */ +/* ------------------------------------------------------------------ */ + +test("an agent answering identities with a FOREIGN type byte becomes a refusal, never a forward-through", async () => { + // The 10.x session asks [11]; the stub answers a well-formed roster spelled + // with the classic 14 (the collision byte). The resolved scheme's answer + // gate must refuse it: 14 is a SIGN_RESPONSE here, not a roster. + const f = await openFixture({}, { mutateAnswer: (_kind, answer) => Buffer.from([14, ...answer.subarray(1)]) }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); + } finally { + await f.cleanup(); + } +}); + +test("an unparseable roster never rides on: partial parsing is exactly what §5.4 exists to prevent", async () => { + // A valid 12 header whose declared entries the buffer does not carry. + const f = await openFixture({}, { mutateAnswer: (_kind, answer) => answer.subarray(0, answer.length - 4) }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); + } finally { + await f.cleanup(); + } +}); + +test("regression: a SIGN_RESPONSE of exactly the type byte plus one string rides on in BOTH schemes", async () => { + // The strict body parse accepts the honest shape in each scheme and forwards + // it byte-identical: 10.x's [14, string] and classic's [16, string]. + for (const [scheme, responseByte, signReq] of [ + [OPENSSH_10X_SCHEME, 14, buildSignRequestTenX(KEY_IN)], + [CLASSIC_SCHEME, 16, buildSignRequestClassic(KEY_IN)], + ] as [AgentScheme, number, Buffer][]) { + const f = await openFixture({}, { scheme }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: signReq, seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, `the ${scheme.name} signature reply`); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([responseByte, 0, 0, 0, 3, ...Buffer.from("SIG")]); + } finally { + await f.cleanup(); + } + } +}); + +test("collision regression: a classic roster answering under byte 14 is refused by a 10.x session's sign gate", async () => { + // classic answer 14 == 10.x signResponse 14. A mid-session swap of A's agent + // to a classic build reads the forwarded byte 13 (10.x sign) as classic + // REQUEST_IDENTITIES and answers its FULL UNFILTERED roster under type 14 - + // exactly the byte a type-only gate would wave through to B as a sign + // response, leaking public blobs plus comments. The strictly parsed body + // must refuse the roster shape; B sees only the clean FAILURE. + const rosterUnder14 = Buffer.concat([ + Buffer.from([14]), + be32(2), + sshStr(KEY_IN), + sshStr("granted key"), + sshStr(KEY_OUT), + sshStr("ungranted key"), + ]); + const f = await openFixture( + {}, + { scheme: OPENSSH_10X_SCHEME, mutateAnswer: (kind, answer) => (kind === "sign" ? rosterUnder14 : answer) }, + ); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: buildSignRequestTenX(KEY_IN), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); // never the roster + expect(f.forwarded().length).toBe(1); // the scoped sign request DID reach the agent + } finally { + await f.cleanup(); + } +}); + +test("regression: a roster-shaped body under classic's SIGN_RESPONSE byte 16 is refused too", async () => { + // Not a real-world collision (classic's roster answer is 14): the point is + // the gate parses the BODY, so a multi-string roster under the response + // byte is refused whichever agent spelled it. + const rosterUnder16 = Buffer.concat([ + Buffer.from([16]), + be32(2), + sshStr(KEY_IN), + sshStr("granted key"), + sshStr(KEY_OUT), + sshStr("ungranted key"), + ]); + const f = await openFixture( + {}, + { scheme: CLASSIC_SCHEME, mutateAnswer: (kind, answer) => (kind === "sign" ? rosterUnder16 : answer) }, + ); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: buildSignRequestClassic(KEY_IN), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); // never the roster + expect(f.forwarded().length).toBe(1); + } finally { + await f.cleanup(); + } +}); + +test("an agent answering a granted sign with a FOREIGN response byte becomes a refusal (the signature never rides)", async () => { + // The 10.x session forwards byte 13; the stub answers type 16 (the classic + // SIGN_RESPONSE) with a signature: under the resolved 10.x scheme that is + // not a legal answer, and the bytes must never reach B. + const f = await openFixture( + {}, + { mutateAnswer: (kind, answer) => (kind === "sign" ? Buffer.from([16, ...answer.subarray(1)]) : answer) }, + ); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: buildSignRequestTenX(KEY_IN), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); + expect(f.forwarded().length).toBe(1); // the scoped request DID ride; its answer did not + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* origin and replay: a refusal sends nothing and forwards nothing */ +/* ------------------------------------------------------------------ */ + +test("a request signed by a key that is not B's pin is refused: no reply frame, nothing forwarded", async () => { + const f = await openFixture(); + const evil = await evilKeysReady; + try { + const req = await sealRequest(f, { + agentBytes: Buffer.from([11]), + seq: 0, + nB: newNonce(), + signerPrivateJwk: evil.privateJwk, + }); + f.relay.onInboundRelayFrame(req); + await sleep(50); + expect(f.frames.length).toBe(0); + expect(f.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +test("a request bound to a foreign relay-session id or a foreign nB nonce is refused", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + // Cross-session replay: right keys, another session's id. + const wrongSession = await sealRequest(f, { + agentBytes: Buffer.from([11]), + seq: 0, + nB, + relaySessionId: "relay-EVIL", + }); + f.relay.onInboundRelayFrame(wrongSession); + await sleep(50); + expect(f.frames.length).toBe(0); + + // Land the genuine first request so nB is recorded... + const good = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(good); + await waitUntil(() => f.frames.length === 1, "the genuine reply"); + const first = await decodeA2B(f, f.frames[0], { nB }); + + // ...then a second-seq request carrying a nonce this session never saw. + const foreignNonce = await sealRequest(f, { + agentBytes: Buffer.from([11]), + seq: 1, + nB: newNonce(), + nA: first.nA, + }); + f.relay.onInboundRelayFrame(foreignNonce); + await sleep(50); + expect(f.frames.length).toBe(1); // no reply to the foreign nonce + expect(f.forwarded().length).toBe(1); // and nothing further reached the agent + } finally { + await f.cleanup(); + } +}); + +test("a replayed or non-increasing-seq request is refused: no reply frame, nothing forwarded; the session continues", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const good = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(good); + await waitUntil(() => f.frames.length === 1, "the first genuine reply"); + const first = await decodeA2B(f, f.frames[0], { nB }); + + // Byte-identical resend of the first frame (the plane can resend anything). + f.relay.onInboundRelayFrame(good); + // A freshly signed request that rewinds the seq (the signature binds seq). + const rewind = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB, nA: first.nA }); + f.relay.onInboundRelayFrame(rewind); + await sleep(50); + expect(f.frames.length).toBe(1); + expect(f.forwarded().length).toBe(1); + + // The session still works after refusals: seq 1 advances. + const next = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 1, nB, nA: first.nA }); + f.relay.onInboundRelayFrame(next); + await waitUntil(() => f.frames.length === 2, "the next-seq reply still lands"); + } finally { + await f.cleanup(); + } +}); + +test("a frame on another ref or with the A2B direction never reaches the responder path", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + // Another ref: the registry itself drops it (the unbrokered-session case). + const foreignRef = await sealRequest(f, { + agentBytes: Buffer.from([11]), + seq: 0, + nB, + ref: "r-EVIL", + }); + f.relay.onInboundRelayFrame(foreignRef); + // The wrong direction for A's side (B's own echo posture). + const echo = await sealRequest(f, { + agentBytes: Buffer.from([11]), + seq: 0, + nB, + direction: "A2B", + }); + f.relay.onInboundRelayFrame(echo); + await sleep(50); + expect(f.frames.length).toBe(0); + expect(f.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* registration: openARelaySession's guards + the relay-open carriage */ +/* ------------------------------------------------------------------ */ + +type OpenCmdBase = Omit; + +test("openARelaySession refuses a command that does not name this machine in its role, and the B branch guards its own", async () => { + const bKeys = await bKeysReady; + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-responder-")); + const encB64 = Buffer.from(bKeys.encryption.publicJwk, "utf8").toString("base64"); + const base: OpenCmdBase = { + type: "ssh_relay_open", + relayId: "relay-1", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + fingerprints: [], + lifetimeMs: 30_000, + paneId: "pane-a-test", + hostPin: HOST_PIN, + }; + // role B: the A branch is not for this command. + await expect( + openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: { + ...base, + role: "B", + ref: "r-9", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: encB64, + }, + sendRelayFrame: () => {}, + resolveAgentSocket: () => null, + }), + ).rejects.toThrow(/not the A side/); + // role A but aNodeId names someone else: not OUR brokered session. + await expect( + openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: { + ...base, + aNodeId: "someone-else", + ref: "r-9", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: encB64, + }, + sendRelayFrame: () => {}, + resolveAgentSocket: () => null, + }), + ).rejects.toThrow(/not the key home/); + // The mirrored guard on the B branch: bNodeId must name the receiver too. + await expect( + openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-guard", + cmd: { + ...base, + role: "B", + bNodeId: "someone-else", + ref: "r-8", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: encB64, + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/not the connecting side/); + expect(relay.size).toBe(0); +}); + +test("openARelaySession enforces §4.4 byte-equality on B's pin and the duplicate-ref rule", async () => { + const f = await openFixture(); // first pairing pinned b-node with bKeys + try { + const otherB = await machineKeys(); + const base: OpenCmdBase = { + type: "ssh_relay_open", + relayId: "relay-2", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + fingerprints: [], + lifetimeMs: 30_000, + paneId: "pane-a-test", + hostPin: HOST_PIN, + }; + // A moved signing half under the SAME peer id: a hard block, never a silent overwrite. + await expect( + openARelaySession({ + relay: f.relay, + dataDir: f.dataDir, + selfNodeId: "a-node", + cmd: { + ...base, + ref: "r-2", + peerSigningPublicKey: otherB.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(otherB.encryption.publicJwk, "utf8").toString("base64"), + }, + sendRelayFrame: () => {}, + resolveAgentSocket: () => null, + }), + ).rejects.toThrow(/pin for b-node has MOVED/); + // Same pinned pairing, already-owned ref: refused, registry untouched. + await expect( + openARelaySession({ + relay: f.relay, + dataDir: f.dataDir, + selfNodeId: "a-node", + cmd: { + ...base, + relayId: "relay-3", + ref: "r-1", + peerSigningPublicKey: f.bKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(f.bKeys.encryption.publicJwk, "utf8").toString("base64"), + }, + sendRelayFrame: () => {}, + resolveAgentSocket: () => null, + }), + ).rejects.toThrow(/routing ref already owned/); + expect(f.relay.size).toBe(1); + } finally { + await f.cleanup(); + } +}); + +test("a pinned peer key that is not a usable public P-256 JWK rejects the session before anything binds", async () => { + const bKeys = await bKeysReady; + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-responder-")); + // A PRIVATE signing JWK as the pin: bytesOfJwk's deep refusal, not a raw parse error. + await expect( + openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-1", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: bKeys.signing.privateJwk, + peerEncryptPublicKey: Buffer.from(bKeys.encryption.publicJwk, "utf8").toString("base64"), + fingerprints: [], + lifetimeMs: 30_000, + paneId: "pane-a-test", + hostPin: HOST_PIN, + }, + sendRelayFrame: () => {}, + resolveAgentSocket: () => null, + }), + ).rejects.toThrow(/relay open refused/); + expect(relay.size).toBe(0); +}); + +test("relay.close tears the responder down: later frames produce nothing", async () => { + const f = await openFixture(); + try { + expect(f.relay.has("r-1")).toBe(true); + expect(f.relay.close("r-1", "access-revoked")).toBe(true); + expect(f.relay.has("r-1")).toBe(false); + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); // the registry: unknown ref now, dropped + await sleep(30); + expect(f.frames.length).toBe(0); + expect(f.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the live agent socket seam */ +/* ------------------------------------------------------------------ */ + +test("with no live agent socket the session opens with no scheme and answers SSH2_AGENT_FAILURE, touching nothing", async () => { + const f = await openFixture({}, { resolveAgentSocket: () => null }); + try { + // Nothing connected at open either: the probe only runs when a socket exists. + expect(f.stub.received.length).toBe(0); + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the no-agent refusal reply"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); + expect(f.stub.received.length).toBe(0); // the stub socket was never connected + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* refused opens release what they bound (fix round T8 MAJOR 1 + 2) */ +/* ------------------------------------------------------------------ */ + +/** + * A stub agent that answers EVERY request with FAILURE(5) only after + * `delayMs`: the open-time numbering probe stays in flight for the whole + * (delayed) round trip, which is the window the race test drops the plane's + * close into. Same one-shot wire posture as the real round trips. + */ +async function startLaggingAgent(delayMs: number): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-lag-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + received.push(Buffer.from(buffer.subarray(4, 4 + len))); + buffer = buffer.subarray(4 + len); + setTimeout(() => { + if (!conn.destroyed) conn.write(framed(Buffer.from([5]))); + }, delayMs); + }); + conn.on("error", () => { + /* per-request closes; never throw */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { + path, + received, + setMutate: () => { + /* nothing well-formed to corrupt */ + }, + close: () => new Promise((r) => server.close(() => r())), + }; +} + +const REFUSAL_FILLER = { onRelayFrame: () => {}, close: () => {} }; + +/** A command whose peer keys are REAL P-256 material: these tests must reach + * the register step, which sits past every pin gate, so a placeholder JWK + * would refuse in the wrong place. */ +async function refusalCmd(over: Partial): Promise { + const bKeys = await bKeysReady; // here they stand for the PEER's registered keys + return { + type: "ssh_relay_open", + relayId: "relay-refusal", + ref: "r-refusal", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(bKeys.encryption.publicJwk, "utf8").toString("base64"), + fingerprints: [], + lifetimeMs: 30_000, + paneId: "pane-refusal", + hostPin: HOST_PIN, + ...over, + }; +} + +test("a B open refused at the node cap releases its bound proxy: no socket file survives (fix MAJOR 1)", async () => { + const relay = new RelaySessions(); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) expect(relay.register(`fill-${i}`, REFUSAL_FILLER)).toBe(true); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-cap-")); + const socketPath = buildAgentSocketPath(dataDir, "pane-cap"); + await expect( + openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-cap", + cmd: await refusalCmd({ role: "B", ref: "r-cap-9", relayId: "relay-cap-9" }), + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/relay registry full/); + // The proxy had already listen()ed when the cap THREW out of register: the + // throw path must run the same release the dup-ref path always ran - the + // listener closed and the socket name unlinked (close() does both, so the + // absent name is the witness that the release ran). + expect(existsSync(socketPath)).toBe(false); + expect(relay.size).toBe(SSH_RELAY_MAX_PER_NODE); + relay.closeAll("lifetime-expiry"); +}); + +test("an A open at the node cap refuses before probing or starting a responder", async () => { + const relay = new RelaySessions(); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) expect(relay.register(`fill-${i}`, REFUSAL_FILLER)).toBe(true); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-acap-")); + const lines: string[] = []; + await expect( + openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: await refusalCmd({ ref: "r-cap-a", relayId: "relay-cap-a" }), + sendRelayFrame: () => {}, + resolveAgentSocket: () => null, // no probe: the cap refusal is the registry's own + log: (line) => lines.push(line), + }), + ).rejects.toThrow(/relay registry full/); + // The responder existed when register threw; the symmetric catch closes it. + expect(lines).toHaveLength(0); + expect(relay.size).toBe(SSH_RELAY_MAX_PER_NODE); + relay.closeAll("lifetime-expiry"); +}); + +test("a close that lands mid-probe wins the race: the late A open is refused and releases its responder (fix MAJOR 2)", async () => { + const lag = await startLaggingAgent(80); // each probe answer is ~80 ms late + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-race-")); + const lines: string[] = []; + try { + // The ack has already left the executor (pending: true); the detached + // open is inside its probe when the plane's cut arrives - worst-case + // ~20 s of agent round trips, here 2 x 80 ms of lagging answers. + const pending = openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: await refusalCmd({ ref: "r-race", relayId: "relay-race" }), + sendRelayFrame: () => {}, + resolveAgentSocket: () => lag.path, + log: (line) => lines.push(line), + }); + await sleep(20); // mid-probe: nothing owns the ref yet + expect(lag.received.length).toBeGreaterThan(0); // the probe really is running + expect(relay.close("r-race", "handshake-grace")).toBe(true); // the plane's ssh_relay_close lands first + await expect(pending).rejects.toThrow(/already closed by the plane/); + // (b) release: the responder's own close line ran (the probe's one-shot + // connections are gone by then; nothing was ever forwarded). + expect(lines.some((l) => l.includes("A-side responder closed"))).toBe(false); + // (c) no orphan: the refused late register took no slot... + expect(relay.size).toBe(0); + // (d) and the tombstone punishes only THAT ref: a genuinely new session + // registers normally (the TTL-bounded expiry itself is pinned in + // commands-ssh-relay.test.ts with an injected clock). + expect(relay.register("r-fresh", REFUSAL_FILLER)).toBe(true); + expect(relay.isTombstoned("r-race")).toBe(true); + } finally { + relay.closeAll("lifetime-expiry"); + await lag.close(); + } +}); + +test("a late B open for a ref the plane already closed unbinds its proxy (fix MAJOR 2, B twin)", async () => { + const relay = new RelaySessions(); + // The plane's cut lands before the open ever registers. + expect(relay.close("r-late-b", "handshake-grace")).toBe(false); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-btomb-")); + const socketPath = buildAgentSocketPath(dataDir, "pane-late"); + await expect( + openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-late", + cmd: await refusalCmd({ role: "B", ref: "r-late-b", relayId: "relay-late-b" }), + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/already closed by the plane/); + // The proxy bound, the refused register released it: no socket remains and + // no slot was taken. + expect(existsSync(socketPath)).toBe(false); + expect(relay.size).toBe(0); + expect(relay.isTombstoned("r-late-b")).toBe(true); +}); + +/* ------------------------------------------------------------------ */ +/* startRelayResponder's own gates */ +/* ------------------------------------------------------------------ */ + +test("startRelayResponder rejects a pinned peer key that is not public P-256 material (the deep bytesOfJwk gate)", () => { + expect(() => + startRelayResponder({ + relayId: "relay-1", + ref: "r-1", + selfNodeId: "a-node", + peerNodeId: "b-node", + peerSigningJwk: "not even json", + peerEncryptionJwk: "not even json", + ownEncryptionPublicJwk: "{}", + ownEncryptionPrivateJwk: "{}", + ownSigningPrivateJwk: "{}", + fingerprints: [], + agentScheme: OPENSSH_10X_SCHEME, + seal: mcpSeal, + open: mcpOpen, + sendRelayFrame: () => {}, + }), + ).toThrow(/relay responder: pinned peer key rejected/); +}); + +/* ------------------------------------------------------------------ */ +/* Task 12: the B branch writes the destination's pinned host-key file */ +/* ------------------------------------------------------------------ */ + +test("openBRelaySession writes the delivered pin to the pane's 0600 known_hosts before binding", async () => { + const aKeys = await machineKeys(); + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-hostpin-")); + const cmd: SshRelayOpenCommand = { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-pin", + role: "B", + aNodeId: "a-node", + bNodeId: "b-node", // selfNodeId below matches: this machine is B + peerSigningPublicKey: aKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(aKeys.encryption.publicJwk, "utf8").toString("base64"), + fingerprints: [fp(KEY_IN)], + lifetimeMs: 30_000, + paneId: "pane-b-test", + hostPin: HOST_PIN, + }; + const { socketPath } = await openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-b-test", + cmd, + sendRelayFrame: () => {}, + }); + try { + const pinPath = join(dataDir, "ssh", "pane-b-test", "known_hosts"); + // The file is the delivered line, byte-for-byte, plus the trailing + // newline ssh_config's own file reader expects. + expect(readFileSync(pinPath, "utf8")).toBe(`${HOST_PIN}\n`); + // 0600 (enforced past any umask), and the socket bound beside it. + expect(statSync(pinPath).mode & 0o777).toBe(0o600); + expect(existsSync(socketPath)).toBe(true); + } finally { + relay.closeAll("lifetime-expiry"); + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("openBRelaySession refuses a malformed host pin WITHOUT writing a file", async () => { + const aKeys = await machineKeys(); + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-badmin-")); + const cmd: SshRelayOpenCommand = { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-bad", + role: "B", + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: aKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(aKeys.encryption.publicJwk, "utf8").toString("base64"), + fingerprints: [fp(KEY_IN)], + lifetimeMs: 30_000, + paneId: "pane-bad", + // A smuggled second entry: the executor's last-station shape check (the + // grammar refuses this on the wire too, but the write is the trust file). + hostPin: `host ssh-rsa AAA\nother-host ssh-rsa BBB`, + }; + await expect( + openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-bad", + cmd, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/host pin/); + // Nothing bound, nothing written: the pane's dir does not even exist. + expect(existsSync(join(dataDir, "ssh", "pane-bad", "known_hosts"))).toBe(false); + expect(relay.size).toBe(0); + rmSync(dataDir, { recursive: true, force: true }); +}); diff --git a/apps/node/agent/src/__tests__/relay-send.test.ts b/apps/node/agent/src/__tests__/relay-send.test.ts new file mode 100644 index 000000000..a58df881e --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-send.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; +import type { RelayFrame } from "@internal/subshell-protocol"; +import { NODE_MAX_FRAME_BYTES } from "@internal/subshell-protocol"; +import { createDaemonRelaySend, type RelaySendState } from "../relay-send.js"; + +/** + * The daemon's per-socket relay pump (Task 8 acceptance (c), the node-side + * half of the deliver-or-throw contract): the frame that leaves this function + * either hit the CURRENT socket through the ESTABLISHED link or the call + * THREW. The proxy and the responder both act on the throw (fail the request, + * consume no seq) - a silent return is what parked phantom replies in the + * T6 review, so every drop branch here is a named raise. + */ + +const frame: RelayFrame = { type: "relay", ref: "r-1", seq: 0, direction: "B2A", blob: "QUJD" }; + +/** A negotiator-shaped fake link; `up: false` mirrors the not-yet-established window. */ +function fakeLink(up = true): RelaySendState["link"] { + return { + established: () => up, + session: () => (up ? { sealFrame: (text: string) => new TextEncoder().encode(`SEALED:${text}`) } : undefined), + }; +} + +describe("createDaemonRelaySend", () => { + it("seals the JSON frame and writes a Buffer onto the live link", () => { + const sealed: (string | Buffer)[] = []; + const relaySend = createDaemonRelaySend(() => ({ + ws: { send: (d: string | Buffer) => sealed.push(d) }, + link: fakeLink(), + })); + relaySend(frame); + expect(sealed).toHaveLength(1); + const payload = sealed[0]; + expect(Buffer.isBuffer(payload)).toBe(true); // never a bare Uint8Array (the Bun framing fact) + expect((payload as Buffer).toString("utf8")).toBe(`SEALED:${JSON.stringify(frame)}`); + }); + + it("THROWS when there is no socket (between connections), when the link is down, and when the write fails", () => { + // Between connections: the plane re-pumps on the fresh socket, but THIS + // caller must know its frame did not leave. + const noWs = createDaemonRelaySend(() => ({ ws: undefined, link: undefined })); + expect(() => noWs(frame)).toThrow(/no live socket/); + // The open→established window (or after the close): a protocol-18 socket + // has no plaintext path, so a relay frame simply cannot leave. + const noLink = createDaemonRelaySend(() => ({ ws: { send: () => {} }, link: fakeLink(false) })); + expect(() => noLink(frame)).toThrow(/link/); + const noLinkAtAll = createDaemonRelaySend(() => ({ ws: { send: () => {} }, link: undefined })); + expect(() => noLinkAtAll(frame)).toThrow(/link/); + // A throwing write propagates as the named refusal, never silence. + const thrower = createDaemonRelaySend(() => ({ + ws: { + send: () => { + throw new Error("socket write failed"); + }, + }, + link: fakeLink(), + })); + expect(() => thrower(frame)).toThrow(/socket write failed/); + }); + + it("refuses an oversize frame before touching the socket (the cap is law both directions)", () => { + const sealed: (string | Buffer)[] = []; + const giant = createDaemonRelaySend(() => ({ + ws: { send: (d: string | Buffer) => sealed.push(d) }, + link: { + established: () => true, + session: () => ({ sealFrame: (t: string) => new TextEncoder().encode(t) }), + }, + })); + expect(() => giant({ ...frame, blob: "A".repeat(NODE_MAX_FRAME_BYTES) })).toThrow(/exceeds/); + expect(sealed).toHaveLength(0); // no seal, no ratchet step spent on a doomed frame + }); +}); diff --git a/apps/node/agent/src/__tests__/ssh-dir-retention.test.ts b/apps/node/agent/src/__tests__/ssh-dir-retention.test.ts new file mode 100644 index 000000000..ada1cec3a --- /dev/null +++ b/apps/node/agent/src/__tests__/ssh-dir-retention.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { + existsSync, + lutimesSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ORPHAN_SSH_DIR_MIN_AGE_MS, sweepOrphanSshDirs } from "../ssh-dir-retention.js"; +import type { SubshellMeta, SubshellMetaStore } from "../subshell-meta.js"; + +const HOUR_MS = 3_600_000; + +// Dir names are uuid-shaped: `isSubshellId` gates the name shape, so the +// tests use ids that pass it and stray names that cannot (same discipline as +// the pane-log retention tests beside this file). +const ID_TRACKED = "aaaaaaaa-0000-4000-8000-000000000001"; +const ID_YOUNG = "bbbbbbbb-0000-4000-8000-000000000002"; +const ID_AGED = "cccccccc-0000-4000-8000-000000000003"; +const ID_NESTED = "dddddddd-0000-4000-8000-000000000004"; +const ID_SYMLINK = "eeeeeeee-0000-4000-8000-000000000005"; +const ID_OUTSIDE = "ffffffff-0000-4000-8000-000000000006"; + +function meta(id: string): SubshellMeta { + return { + subshellId: id, + cwd: "/tmp/whatever", + socket: "sock1", + harnessId: "ssh", + name: id, + startedAt: new Date(0).toISOString(), + }; +} + +let dataDir: string; +let sshDir: string; + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), "subshell-ssh-sweep-")); + sshDir = join(dataDir, "ssh"); + mkdirSync(sshDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(dataDir, { recursive: true, force: true }); +}); + +/** Create `/` (with a `config` file, the pane's real shape), backdated `ageMs`. */ +function makePaneDir(id: string, ageMs: number): string { + const dir = join(sshDir, id); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config"), "Host *\n"); + const t = (Date.now() - ageMs) / 1000; + utimesSync(dir, t, t); + return dir; +} + +/** One sweep against the temp dir with a fake meta store (the retention tests' style). */ +async function sweep(ids: string[], nowMs = Date.now()) { + return await sweepOrphanSshDirs( + dataDir, + { list: async () => ids.map(meta) } as unknown as Pick, + nowMs, + ); +} + +describe("sweepOrphanSshDirs", () => { + it("keeps the dir of an id PRESENT in meta, however aged (live/queued panes are never garbage)", async () => { + const dir = makePaneDir(ID_TRACKED, 30 * 24 * HOUR_MS); + expect(await sweep([ID_TRACKED])).toEqual({ removed: 0 }); + expect(existsSync(dir)).toBe(true); + expect(existsSync(join(dir, "config"))).toBe(true); + }); + + it("keeps an orphan younger than the hour floor (the execLaunch write-before-meta-record window)", async () => { + const dir = makePaneDir(ID_YOUNG, ORPHAN_SSH_DIR_MIN_AGE_MS / 60); // one minute old, untracked + expect(await sweep([])).toEqual({ removed: 0 }); + expect(existsSync(dir)).toBe(true); + }); + + it("removes an aged orphan (the delete-after-death leak this sweep exists for)", async () => { + const dir = makePaneDir(ID_AGED, HOUR_MS + 60_000); + expect(await sweep([])).toEqual({ removed: 1 }); + expect(existsSync(dir)).toBe(false); // DIR gone, not just the config file + }); + + it("removes a validly-named orphan dir NESTED JUNK AND ALL (the rm is recursive)", async () => { + const dir = join(sshDir, ID_NESTED); + mkdirSync(join(dir, "nested", "deeper"), { recursive: true }); + writeFileSync(join(dir, "nested", "deeper", "junk"), "x"); + const t = (Date.now() - HOUR_MS - 60_000) / 1000; + utimesSync(dir, t, t); + expect(await sweep([])).toEqual({ removed: 1 }); + expect(existsSync(dir)).toBe(false); + }); + + it("leaves non-id names untouched (stray shapes are nobody's to remove)", async () => { + // Names the name-gate cannot match: dots, and letters outside hex. + // (An "a/b" child name cannot exist; the nested case is a parent dir that + // is itself not id-shaped, kept with everything under it.) + const strays = ["..config", "cfg", `${ID_AGED}.log`, "ssh-host-config"]; + for (const name of strays) mkdirSync(join(sshDir, name), { recursive: true }); + mkdirSync(join(sshDir, "cfg", "b"), { recursive: true }); + const aged = makePaneDir(ID_AGED, HOUR_MS + 60_000); // the one entry that IS eligible + const t = (Date.now() - HOUR_MS - 60_000) / 1000; + for (const name of strays) utimesSync(join(sshDir, name), t, t); + expect(await sweep([])).toEqual({ removed: 1 }); + expect(existsSync(aged)).toBe(false); + for (const name of strays) expect(existsSync(join(sshDir, name))).toBe(true); + expect(existsSync(join(sshDir, "cfg", "b"))).toBe(true); + }); + + it("the name-gate is case-insensitive hex (an UPPER-CASE id shape is eligible, guard truth)", async () => { + // `isNodeSubshellId` accepts [0-9a-fA-F-]: uppercase is NOT a stray shape, + // and the sweep's rule is the guard's rule, pinned rather than assumed. + const upper = makePaneDir("AAAAAAAA-0000-4000-8000-000000000001", HOUR_MS + 60_000); + expect(await sweep([])).toEqual({ removed: 1 }); + expect(existsSync(upper)).toBe(false); + }); + + it("does not follow an id-named SYMLINK at a real dir elsewhere (lstat gate, never rm through it)", async () => { + const outside = join(dataDir, "outside"); + mkdirSync(outside); + writeFileSync(join(outside, "keep"), "not ssh garbage"); + symlinkSync(outside, join(sshDir, ID_SYMLINK)); + const t = (Date.now() - HOUR_MS - 60_000) / 1000; + lutimesSync(join(sshDir, ID_SYMLINK), t, t); // age the LINK (utimesSync would follow it to the target) + expect(await sweep([])).toEqual({ removed: 0 }); + expect(existsSync(outside)).toBe(true); // the target survives, contents and all + expect(existsSync(join(outside, "keep"))).toBe(true); + expect(existsSync(join(sshDir, ID_SYMLINK))).toBe(true); // and the link itself is left, not unlinked through + }); + + it("an absent ssh dir is a silent no-op (a node that never hosted an ssh pane)", async () => { + rmSync(sshDir, { recursive: true, force: true }); + expect(await sweep([])).toEqual({ removed: 0 }); + }); + + it("a tracked id does not shield another id's aged orphan (the gate is per-name)", async () => { + const tracked = makePaneDir(ID_TRACKED, HOUR_MS + 60_000); + const orphan = makePaneDir(ID_OUTSIDE, HOUR_MS + 60_000); + expect(await sweep([ID_TRACKED])).toEqual({ removed: 1 }); + expect(existsSync(tracked)).toBe(true); + expect(existsSync(orphan)).toBe(false); + }); + + it("the age floor is exclusive: YOUNGER keeps, exactly-one-hour-old goes", async () => { + const now = Date.now(); + // mtime pinned to EXACTLY `now - 1h`: the guard is "younger than an hour", + // so this boundary row is eligible (only strict youth is kept). + const dir = join(sshDir, ID_AGED); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config"), "Host *\n"); + const t = (now - ORPHAN_SSH_DIR_MIN_AGE_MS) / 1000; + utimesSync(dir, t, t); + expect(await sweep([], now)).toEqual({ removed: 1 }); + expect(existsSync(dir)).toBe(false); + }); +}); diff --git a/apps/node/agent/src/__tests__/ssh-enabled.test.ts b/apps/node/agent/src/__tests__/ssh-enabled.test.ts new file mode 100644 index 000000000..2b164bf2b --- /dev/null +++ b/apps/node/agent/src/__tests__/ssh-enabled.test.ts @@ -0,0 +1,290 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { NodeCommandBody, NodeEvent } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { maybeReportSshEnabled, seedSshEnabledMemo } from "../commands/report.js"; +import type { NodeConfig } from "../config.js"; +import { readSshEnabled, reportableSshEnabled, sshAllowed, sshEnabledPath, writeSshEnabled } from "../ssh-enabled.js"; +import { SubshellMetaStore } from "../subshell-meta.js"; +import { captureLogs } from "./helpers/capture-logs.js"; + +/** + * The SSH-capability mirror (spec 2026-10-07 §4.3): persistence, the + * file-mode doctrine borrowed from `maintenance.ts`, and the FAIL-CLOSED read + * — but with the default INVERTED. Maintenance refuses when it reads `on`; + * this refuses unless it reads `on`, because the plane is the only writer and + * "never told" is not "allowed" for a widening of egress and keys. + * + * The wiring half goes through `dispatchCommand`, so the frame parser and the + * switch are exercised too (the `commands-maintenance.test.ts` pattern). + */ + +const STAMP = "2026-10-07T10:00:00.000Z"; +const made: string[] = []; + +function freshDataDir(): string { + // realpath'd like the commands-maintenance harness: macOS hides /private + // behind the temp dir, and an assertion about the file path should test the + // path the daemon itself would write. + const dir = realpathSync(mkdtempSync(join(tmpdir(), "subshell-ssh-on-"))); + made.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** The mirror's own mtime, as the unreadable branch stamps it. */ +function mtimeOf(dataDir: string): string { + return statSync(sshEnabledPath(dataDir)).mtime.toISOString(); +} + +/** A context whose tmux THROWS on anything unstubbed (the maintenance harness). */ +function makeCtx(dataDir: string, events: NodeEvent[] = []): CommandContext { + const config: NodeConfig = { + serverUrl: "http://localhost:1", + nodeId: "node-1", + nodeKey: "k", + controlPublicKey: "{}", + dataDir, + name: "test-node", + }; + const tmux = new Proxy( + {}, + { + get: () => () => { + throw new Error("tmux must not be reached"); + }, + }, + ); + return { + config, + tmux: tmux as unknown as CommandContext["tmux"], + meta: new SubshellMetaStore(dataDir), + nowMs: () => 1_700_000_000_000, + ws: { send: (ev) => events.push(ev) }, + watchers: new Map(), + tails: new Map(), + uploads: new Map(), + runtime: null, + requestRestart: () => {}, + }; +} + +describe("persistence", () => { + it("an empty dir reads `absent`: nothing has said yes, so SSH is refused", () => { + expect(readSshEnabled(freshDataDir())).toEqual({ kind: "absent" }); + }); + + it("round-trips the exact stamp it was given", () => { + const dataDir = freshDataDir(); + expect(writeSshEnabled(dataDir, { on: true, changedAt: STAMP })).toEqual({ on: true, changedAt: STAMP }); + expect(readSshEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP }); + }); + + it("stores the whole wire verbatim — `ready.sshEnabled` is buildable from what was written", () => { + const dataDir = freshDataDir(); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(JSON.parse(readFileSync(sshEnabledPath(dataDir), "utf8"))).toEqual({ on: true, changedAt: STAMP }); + }); + + it("stores `on: false` as a value too, but the mirror's ANSWER for it is refusal", () => { + // The plane stamps the file when it turns SSH off, and the bytes stay on + // disk; what the reader answers is the gate's answer, and an off file and + // no file give the same one. There is no node-side writer, so the off + // stamp is not something anyone later reconciles against — the plane's + // own row is the record, and it agrees. + const dataDir = freshDataDir(); + const state = { on: false, changedAt: "2026-10-07T11:00:00.000Z" }; + writeSshEnabled(dataDir, state); + expect(JSON.parse(readFileSync(sshEnabledPath(dataDir), "utf8"))).toEqual(state); + expect(readSshEnabled(dataDir)).toEqual({ kind: "absent" }); + }); + + it("writes the file 0600, and creates a missing data dir rather than throwing", () => { + const dataDir = freshDataDir(); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(statSync(sshEnabledPath(dataDir)).mode & 0o777).toBe(0o600); + const nested = join(freshDataDir(), "nested", "data"); + writeSshEnabled(nested, { on: true, changedAt: STAMP }); + expect(exists(sshEnabledPath(nested))).toBe(true); + }); + + it("leaves no temp file behind, and the file it leaves is whole", () => { + // Atomic because a half-written file parses as corrupt, and corrupt means + // REFUSED here — a torn write would cut SSH for the duration of the write. + const dataDir = freshDataDir(); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + writeSshEnabled(dataDir, { on: false, changedAt: "2026-10-07T12:00:00.000Z" }); + expect(readdirSync(dataDir)).toEqual(["ssh-enabled.json"]); + }); + + function exists(file: string): boolean { + try { + statSync(file); + return true; + } catch { + return false; + } + } +}); + +describe("reading is fail-CLOSED", () => { + it("answers `unreadable` for corrupt JSON, carries the file's mtime, and says so once", () => { + const dataDir = freshDataDir(); + writeFileSync(sshEnabledPath(dataDir), "{not json"); + const cap = captureLogs(); + try { + expect(readSshEnabled(dataDir)).toEqual({ kind: "unreadable", changedAt: mtimeOf(dataDir) }); + } finally { + cap.restore(); + } + expect(cap.lines.filter((l) => l.includes("ssh-enabled"))).toHaveLength(1); + }); + + it("answers `unreadable` for a well-formed file missing a field, and for a non-boolean on", () => { + // Same classification maintenance gives garbage, and the same reason: both + // halves or nothing. The brief's unreadable fixtures are content fixtures + // (a corrupt file) rather than chmod-000 — an EACCES read fails into the + // same branch, so the three spellings of "cannot read" must not answer + // differently. + const dataDir = freshDataDir(); + writeFileSync(sshEnabledPath(dataDir), JSON.stringify({ on: true })); + const cap = captureLogs(); + try { + expect(readSshEnabled(dataDir)).toEqual({ kind: "unreadable", changedAt: mtimeOf(dataDir) }); + writeFileSync(sshEnabledPath(dataDir), JSON.stringify({ on: "yes", changedAt: STAMP })); + expect(readSshEnabled(dataDir).kind).toBe("unreadable"); + } finally { + cap.restore(); + } + }); +}); + +describe("the gate classifier", () => { + it("allows ONLY a parsed on:true mirror; absent and unreadable both refuse", () => { + // The whole point of the flag: a node that cannot read its own setting + // refuses — the opposite of the directory allowlist's deliberate fail-open + // (spec 2026-10-07 §4.3). + expect(sshAllowed({ kind: "on", changedAt: STAMP })).toBe(true); + expect(sshAllowed({ kind: "absent" })).toBe(false); + expect(sshAllowed({ kind: "unreadable", changedAt: STAMP })).toBe(false); + expect(sshAllowed({ kind: "unreadable" })).toBe(false); + }); +}); + +describe("what a read is worth announcing", () => { + it("announces `on` verbatim; an unreadable mirror as the REFUSAL it produces", () => { + // Maintenance reports an unreadable mirror as `on` because that is what + // it refuses with; here the refusal is spelled `on: false`. Either way + // the plane hears the answer the machine is actually acting on, and a row + // that disagrees earns the push that rewrites the broken file. + expect(reportableSshEnabled({ kind: "on", changedAt: STAMP })).toEqual({ on: true, changedAt: STAMP }); + expect(reportableSshEnabled({ kind: "unreadable", changedAt: STAMP })).toEqual({ on: false, changedAt: STAMP }); + }); + + it("sends nothing for absent, and nothing an unstamped unreadable", () => { + expect(reportableSshEnabled({ kind: "absent" })).toBeUndefined(); + expect(reportableSshEnabled({ kind: "unreadable" })).toBeUndefined(); + }); +}); + +describe("set_ssh_enabled (the plane's half)", () => { + it("persists the plane's exact bytes and memoizes them — no echo, no re-stamp", async () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + const cmd = { type: "set_ssh_enabled", on: true, changedAt: STAMP } satisfies NodeCommandBody; + expect(await dispatchCommand(ctx, cmd)).toEqual({ ok: true }); + expect(readSshEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP }); + expect(ctx.lastReportedSshEnabled).toEqual({ on: true, changedAt: STAMP }); + expect(events).toEqual([]); // the command answers with a result; it announces nothing + }); + + it("an off flip also lands verbatim, and the machine says nothing about it", async () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + await dispatchCommand(ctx, { type: "set_ssh_enabled", on: false, changedAt: "2026-10-07T13:00:00.000Z" }); + maybeReportSshEnabled(ctx); + expect(events).toEqual([]); + }); +}); + +describe("the heartbeat belt", () => { + it("reports a mirror that moved outside the plane's own push, once", () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); // as a plane push would, memo unmoved + + maybeReportSshEnabled(ctx); + maybeReportSshEnabled(ctx); + + expect(events).toEqual([{ type: "ssh_enabled", on: true, changedAt: STAMP }]); + expect(ctx.lastReportedSshEnabled).toEqual({ on: true, changedAt: STAMP }); + }); + + it("announces an unreadable mirror once, as the refusal, and repairs nothing itself", () => { + // The repair is the plane's push (its row wins); a node overwriting the + // corrupt file would destroy the bytes an operator may want to look at, + // and inventing an `off` here would mask the disagreement the report + // exists to raise. + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + writeFileSync(sshEnabledPath(dataDir), "{not json"); + const stamp = mtimeOf(dataDir); + const ctx = makeCtx(dataDir, events); + const cap = captureLogs(); + try { + maybeReportSshEnabled(ctx); + maybeReportSshEnabled(ctx); + } finally { + cap.restore(); + } + expect(events).toEqual([{ type: "ssh_enabled", on: false, changedAt: stamp }]); + expect(readSshEnabled(dataDir).kind).toBe("unreadable"); + }); + + it("a mirror deleted under a live connection is NOT restored — the plane is the only writer", () => { + // Maintenance re-writes a vanished mirror from its memo because a CLI at + // the keyboard can delete it legitimately. There is no such writer here: + // hand-deleting this file is not an interface, and the next `ready` makes + // the plane push the row value back. + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + seedSshEnabledMemo(ctx); + unlinkSync(sshEnabledPath(dataDir)); + + maybeReportSshEnabled(ctx); + + expect(events).toEqual([]); + expect(readSshEnabled(dataDir)).toEqual({ kind: "absent" }); + }); + + it("seed returns the value ready carries and silences the first heartbeat for it", () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(seedSshEnabledMemo(ctx)).toEqual({ on: true, changedAt: STAMP }); + maybeReportSshEnabled(ctx); + expect(events).toEqual([]); + expect(seedSshEnabledMemo(makeCtx(freshDataDir()))).toBeUndefined(); + }); +}); diff --git a/apps/node/agent/src/__tests__/ssh-fingerprint-report.test.ts b/apps/node/agent/src/__tests__/ssh-fingerprint-report.test.ts new file mode 100644 index 000000000..d198a8d1c --- /dev/null +++ b/apps/node/agent/src/__tests__/ssh-fingerprint-report.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { base64UrlNoPad } from "@internal/subshell-protocol"; +import { identityPath, signingIdentityPath } from "../identity.js"; +import { MachinePinStore, machinePinPath } from "../machine-pin-store.js"; +import { buildSshFingerprintReport } from "../ssh-fingerprint-report.js"; + +/** + * The §4.6 trust block the node reports from its OWN files (spec 2026-10-08). + * The expected digests are derived through node:crypto here, independently of + * `fingerprintJwk` (whose DER preimage is pinned byte-identical to node:crypto's + * own encoder in the protocol package): the composition pins WHICH key lands + * in WHICH slot, not the hash. + */ + +/** node:crypto's independent digest of a public JWK string, unpadded base64url. */ +function nodeFp(jwkJson: string): string { + const key = createPublicKey({ key: JSON.parse(jwkJson), format: "jwk" }); + const spki = key.export({ type: "spki", format: "der" }); + return base64UrlNoPad(new Uint8Array(createHash("sha256").update(spki).digest())); +} + +/** A fresh P-256 public key as its raw JWK string. */ +function freshJwk(): string { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + return JSON.stringify(publicKey.export({ format: "jwk" })); +} + +function freshDir(): string { + return mkdtempSync(join(tmpdir(), "subshell-sshfp-")); +} + +/** The public half stored in one keypair file. */ +function publicJwkIn(file: string): string { + return (JSON.parse(readFileSync(file, "utf8")) as { publicJwk: string }).publicJwk; +} + +test("own comes from the identity files: signing from node-signing-identity.json, encryption from identity.json", async () => { + const dir = freshDir(); + const report = await buildSshFingerprintReport(dir); + // First run generates through the ordinary loadOrCreateIdentity path, and + // the report then reads back exactly what the files hold. + expect(report.own.encryption).toBe(`SHA256:${nodeFp(publicJwkIn(identityPath(dir)))}`); + expect(report.own.signing).toBe(`SHA256:${nodeFp(publicJwkIn(signingIdentityPath(dir)))}`); + expect(report.peers).toEqual([]); + // The builder is a READ path: it never mints the pin file just to say "empty". + expect(existsSync(machinePinPath(dir))).toBe(false); +}); + +test("peers come from the machine pin store, sorted by nodeId, both halves fingerprinted", async () => { + const dir = freshDir(); + const a = freshJwk(); + const b = freshJwk(); + // Insertion order is deliberately reversed against the sorted output. + new MachinePinStore(dir).pin("f0000000-0000-4000-8000-000000000002", { signing: b, encryption: a }); + new MachinePinStore(dir).pin("f0000000-0000-4000-8000-000000000001", { signing: a, encryption: b }); + const report = await buildSshFingerprintReport(dir); + expect(report.peers).toEqual([ + { + nodeId: "f0000000-0000-4000-8000-000000000001", + signing: `SHA256:${nodeFp(a)}`, + encryption: `SHA256:${nodeFp(b)}`, + }, + { + nodeId: "f0000000-0000-4000-8000-000000000002", + signing: `SHA256:${nodeFp(b)}`, + encryption: `SHA256:${nodeFp(a)}`, + }, + ]); +}); + +test("a corrupt pin file throws (fail closed, the store's doctrine), and the report never invents a peer set", async () => { + const dir = freshDir(); + writeFileSync(machinePinPath(dir), "}{ not json"); + await expect(buildSshFingerprintReport(dir)).rejects.toThrow(); +}); diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index b66727f99..11056083f 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -5,11 +5,14 @@ import type { NodeEvent, NodeMaintenanceWire, NodeRuntimeReport, + NodeSshEnabledWire, + RelayFrame, } from "@internal/subshell-protocol"; import type { NodeConfig } from "../config.js"; import type { ServiceDeps } from "../service.js"; import type { SubshellMetaStore } from "../subshell-meta.js"; import type { NodeBinaryDeps } from "../update.js"; +import type { RelaySessions } from "./ssh-relay.js"; /** * The command-executor seam (spec 2026-08-31 §7): everything an executor may @@ -108,6 +111,25 @@ export interface CommandContext { binaryDeps?: NodeBinaryDeps; /** Outbound event seam (inventory events now; tail output/exit events later). */ ws: CommandWs; + /** + * The sealed-agent-relay plumbing (spec 2026-10-08 §5.1, Task 8): the + * daemon's routing-ref registry plus the per-socket relay pump, handed to + * the `ssh_relay_open` branches and their sessions. OPTIONAL by type so + * every pre-relay context fake keeps compiling; the real daemon always + * provides it, and the relay arms refuse loudly when a context does not. + */ + relay?: { + /** The per-daemon ref registry; sessions outlive the socket they opened on. */ + sessions: RelaySessions; + /** + * Hand one relay frame to the CURRENT established link. DELIVER-OR-THROW + * (acceptance (c), mirroring the endpoints' T6/T7 contract): a frame that + * cannot leave RAISES - the proxy fails its request and the responder + * consumes no seq, because a silent return parks a phantom reply whose + * answer never comes. + */ + sendRelayFrame(frame: RelayFrame): void; + }; /** * The panes supervised for natural death, keyed by subshellId → * {@link WatcherRegistration} (socket + registration token + unreachable @@ -155,6 +177,21 @@ export interface CommandContext { * from echoing the plane's own write back at it one tick later. */ lastReportedMaintenance?: NodeMaintenanceWire; + /** + * The `{ on, changedAt }` this connection last told the plane about its SSH + * capability, or undefined when it has said nothing (spec 2026-10-07 §4.3). + * + * The same echo-suppression the maintenance memo exists for — but with a + * simpler job here, because the plane is this flag's ONLY writer: the memo + * exists so the heartbeat belt reports a mirror that moved by accident + * (corruption, mostly) and never the plane's own push come back one tick + * later. Seeded at every connect from the same read that built `ready`. + * + * There is deliberately no `mirrorRestoreLogged` partner: SSH has no + * restore-from-memo path (no node-side writer whose deletion deserves + * repair), so there is no failing restore to say anything about. + */ + lastReportedSshEnabled?: NodeSshEnabledWire; /** * Whether this connection has already reported a mirror it could not * rewrite (see `restoreMirrorFromMemo`). diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 8ff435dca..bcd354bc6 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -27,6 +27,13 @@ import { execService } from "./service.js"; import { execSetLogLevel } from "./set-log-level.js"; import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; +import { execSetSshEnabled } from "./set-ssh-enabled.js"; +import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; +import { execSshExec, execSshExecStatus } from "./ssh-exec.js"; +import { execSshHostKey } from "./ssh-host-key.js"; +import { execSshAgentIdentities, execSshRegisterIdentity } from "./ssh-identity.js"; +import { execSshMachinePinRepair } from "./ssh-machine-pin-repair.js"; +import { execSshRelayClose, execSshRelayOpen } from "./ssh-relay-exec.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; import { execTransferWrite } from "./transfer-write.js"; import { execTreeManifest } from "./tree-manifest.js"; @@ -47,7 +54,35 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * the one command whose wire shape is frozen, because the plane sends it to * agents whose protocol it does not share), and the five archive-transfer * commands `archive_create`, `file_read`, `transfer_write`, `archive_extract` - * and `tree_manifest` (spec 2026-10-01 §4, protocol 15). Any + * and `tree_manifest` (spec 2026-10-01 §4, protocol 15), and + * `set_ssh_enabled` (spec 2026-10-07 §4.3, protocol 16 — the SSH gate's plane- + * to-machine write), the two SSH read arms `ssh_discover_aliases` and + * `ssh_resolve_config` (spec 2026-10-07 §5, protocol 17 — both refuse on the + * local gate mirror before any lookup or spawn), the M2 §4.3 bootstrap + * `ssh_register_identity` (spec 2026-10-08 §4.3 - the machine's own signing + * PUBLIC key, ungated like `set_ssh_enabled`: registration is not an SSH act), + * the roster read `ssh_agent_identities` (spec 2026-10-08 §5.4 - the live + * agent's public identities as fingerprints plus comments, blobs withheld; + * gated like the config arms, because reading the account's agent IS an SSH + * act, and it probes the agent's numbering before asking, never guessing), + * the host-key capture `ssh_host_key` (spec 2026-10-08 §9, Task 12 - the + * `known_hosts` entries for one destination, evaluated by `ssh-keygen -F` so + * OpenSSH's own matching answers; gated like the config arms, and an absent + * file is the honest empty answer the capture fails closed on), + * and the brokered relay pair `ssh_relay_open` / `ssh_relay_close` + * (spec 2026-10-08 §5.1, protocol 18 - open consults the gate then drives the + * T6/T7 pairing branches by the command's own role; close is ungated teardown, + * because ending a session is not an SSH act), and the non-interactive setup + * pair `ssh_exec` / `ssh_exec_status` (spec 2026-10-08 §7, Task 14 - the + * "Set up Subshell here" install runs on its own short-lived ssh, off the + * command chain, its captured output `nsk_`-redacted before it is kept), + * and the §4.5 recovery `ssh_machine_pin_repair` (Task 17 - replaces ONE + * peer's stored machine pin with the peer's registered public pair the plane + * re-delivers; deliberately UNGATED like `ssh_register_identity`, because it + * is a trust-record act on this machine's own store and the recovery must + * reach a machine whose pairing is blocked; deep public-only key validation + * before any write, audit stays plane-side). + * Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. * @@ -122,6 +157,28 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return execSetLogLevel(ctx, cmd); case "set_maintenance": return execSetMaintenance(ctx, cmd); + case "set_ssh_enabled": + return execSetSshEnabled(ctx, cmd); + case "ssh_discover_aliases": + return await execSshDiscoverAliases(ctx); + case "ssh_resolve_config": + return await execSshResolveConfig(ctx, cmd); + case "ssh_register_identity": + return await execSshRegisterIdentity(ctx); + case "ssh_agent_identities": + return await execSshAgentIdentities(ctx); + case "ssh_host_key": + return await execSshHostKey(ctx, cmd); + case "ssh_relay_open": + return await execSshRelayOpen(ctx, cmd); + case "ssh_relay_close": + return execSshRelayClose(ctx, cmd); + case "ssh_exec": + return await execSshExec(ctx, cmd); + case "ssh_exec_status": + return await execSshExecStatus(ctx, cmd); + case "ssh_machine_pin_repair": + return execSshMachinePinRepair(ctx, cmd); case "set_server_url": return await execSetServerUrl(ctx, cmd); case "update": diff --git a/apps/node/agent/src/commands/launch.ts b/apps/node/agent/src/commands/launch.ts index 3f2a3c664..e299e5331 100644 --- a/apps/node/agent/src/commands/launch.ts +++ b/apps/node/agent/src/commands/launch.ts @@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { assembleHarnessCommand, + buildSshConfigPath, enforceMode, exitHookFor, findBinary, @@ -37,8 +38,9 @@ import { reportMaintenance, startExitWatcher } from "./report.js"; * Start one harness subshell on this node. * * Step order is contractual (interfaces, brief §Task 4): id gate → allowlist - * gate → argv + binary resolution → MCP file (policy-gated) → meta record → - * pane argv assembly → `newSubshell` (rollback on throw) → log attach + * gate → argv + binary resolution → MCP file (policy-gated) → ssh config + * (byte-derived path, policy-gated; spec 2026-10-07 decision 4) → meta record + * → pane argv assembly → `newSubshell` (rollback on throw) → log attach * (strict by default; `bestEffortLog` downgrades the whole attach region) → * optional resize (cosmetic) → exit watcher. A strict log-attach throw * PROPAGATES (the dispatcher answers `{ok:false}`) exactly like @@ -131,6 +133,34 @@ export async function execLaunch(ctx: CommandContext, cmd: Cmd<"launch">): Promi await enforceMode(cmd.mcp.path, 0o600); // Task-2 re-tightening: umask cannot leak bits here } + // (3b) SSH pane config (spec 2026-10-07 decision 4). The MCP block above is + // the posture this mirrors — file-placement is a NODE control, not plugin + // knowledge — with one sharper rule: the MCP path is the plane's to name, + // the ssh path is not. This machine derives it + // (`buildSshConfigPath(dataDir, id)`), and refuses unless the frame's + // `configPath` is BYTE-equal to that derivation. The plane may name nothing + // else: `buildSshConfigPath` composes from the two facts each side already + // holds (dataDir, id), so the file always lands where this machine's own + // exit sweep and the plane's delete-side `subshellArtifacts` will both look + // for it — and a plane that could point the write anywhere would turn a + // signed launch into an arbitrary-file write. + if (cmd.ssh) { + const derivedPath = buildSshConfigPath(ctx.config.dataDir, cmd.subshellId); + if (cmd.ssh.configPath !== derivedPath) { + // A bare message, no path interpolation: the byte-equality failure is + // the whole fact, and the paths here are this machine's own layout. + return { ok: false, error: "ssh config path refused: not the derived path" }; + } + if (!(await pathAllowed(derivedPath, await realpathRoots([ctx.config.dataDir])))) { + return { ok: false, error: "ssh config path refused" }; + } + const dir = dirname(derivedPath); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await enforceMode(dir, 0o700); + await writeFile(derivedPath, cmd.ssh.fileContent, { mode: 0o600 }); + await enforceMode(derivedPath, 0o600); // umask cannot leak bits past this + } + // (4) Record meta FIRST: path policy + socket lookup must see the cwd even // while later steps are in flight (and the rollback below has something to // roll back). `record` creates /subshells with 0700. diff --git a/apps/node/agent/src/commands/report.ts b/apps/node/agent/src/commands/report.ts index a681c4429..af53109d5 100644 --- a/apps/node/agent/src/commands/report.ts +++ b/apps/node/agent/src/commands/report.ts @@ -1,6 +1,10 @@ -import type { NodeEvent, NodeMaintenanceWire } from "@internal/subshell-protocol"; +import { rm } from "node:fs/promises"; +import { dirname } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import type { NodeEvent, NodeMaintenanceWire, NodeSshEnabledWire } from "@internal/subshell-protocol"; import { log } from "../log.js"; import { readMaintenance, reportableMaintenance, writeMaintenance } from "../maintenance.js"; +import { readSshEnabled, reportableSshEnabled } from "../ssh-enabled.js"; import type { CommandContext, WatcherRegistration } from "./context.js"; /** @@ -145,6 +149,65 @@ export function seedMaintenanceMemo(ctx: CommandContext): NodeMaintenanceWire | return ctx.lastReportedMaintenance; } +/** + * Announce one SSH-capability mirror state to the plane and remember having + * done so (spec 2026-10-07 §4.3). + * + * The memo update is not bookkeeping: it is what keeps the next heartbeat tick + * silent about a value the plane already holds — and what keeps a + * `set_ssh_enabled` the plane just pushed from coming back as an event. + * + * @param ctx - the daemon's command context (owns the socket seam and the memo) + * @param state - the mirror's answer, sent verbatim (never re-stamped here) + */ +export function reportSshEnabled(ctx: CommandContext, state: NodeSshEnabledWire): void { + ctx.ws.send({ type: "ssh_enabled", on: state.on, changedAt: state.changedAt }); + ctx.lastReportedSshEnabled = state; +} + +/** + * Report the SSH mirror IF it has moved since this connection last spoke + * about it — the heartbeat belt, and by far the quieter one. + * + * With no node-side writer, the common steady state says nothing: an + * off-or-absent mirror has nothing to announce (the plane's own row is + * authoritative, and silence reads as refusal on both ends), and an on mirror + * only speaks up once. What this loop exists to catch is the file changing + * UNDER the connection — corruption flipping a machine from allowed to + * refused mid-session — so the plane learns it now and pushes its row (the + * repair) rather than discovering the disagreement at the next reconnect. + * + * There is no absent-restore half (the maintenance path above has one for its + * keyboard writer): hand-deleting this file is not an interface, and the next + * `ready` reconciles it in the plane's direction. + * + * @param ctx - the daemon's command context + */ +export function maybeReportSshEnabled(ctx: CommandContext): void { + const state = reportableSshEnabled(readSshEnabled(ctx.config.dataDir)); + if (!state) return; + const last = ctx.lastReportedSshEnabled; + if (last?.on === state.on && last.changedAt === state.changedAt) return; + reportSshEnabled(ctx, state); +} + +/** + * Seed this connection's SSH memo from the mirror on disk. + * + * Called once per connect, immediately before `ready` — which carries the same + * value, so the first heartbeat must not repeat it — and again on every + * reconnect, because a new socket has told the plane nothing. The one read of + * the file builds both, so the frame and the memo cannot disagree. + * + * @param ctx - the daemon's command context + * @returns the value `ready` should carry, or undefined when there is nothing + * truthful to report + */ +export function seedSshEnabledMemo(ctx: CommandContext): NodeSshEnabledWire | undefined { + ctx.lastReportedSshEnabled = reportableSshEnabled(readSshEnabled(ctx.config.dataDir)); + return ctx.lastReportedSshEnabled; +} + /** Stop the shared tick if one is running (idempotent). */ function stopLoop(ctx: CommandContext): void { if (ctx.watchTick !== undefined) { @@ -173,7 +236,9 @@ export function stopWatcher(ctx: CommandContext, subshellId: string): void { * it — or stops ANSWERING for {@link NODE_EXIT_UNREACHABLE_TICKS} consecutive * ticks (design §1: one blip never reports a live pane dead) — the shared tick * emits exactly one `exit` event and cleans up (unregister, - * forget the meta record, drop the subshell's tail pumps). Re-registering for + * forget the meta record, drop the subshell's tail pumps, and — for a pane + * whose meta named the ssh harness — best-effort remove its rendered ssh + * config dir, spec 2026-10-07 decision 4). Re-registering for * the same id REPLACES the old entry with a fresh registration token — a * relaunch on a restarted row rotates its socket, and the tick that * snapshotted the old pane sees the token mismatch and never reports the live @@ -314,6 +379,13 @@ async function reportDeath(ctx: CommandContext, socket: string, subshellId: stri // to report. (On the unreachable path the socket is not answering, so // this reads null — the same shape a dead server always produced.) const exitCode = (await ctx.tmux.paneExitCode(socket, subshellId)) ?? null; + // Read the meta's KIND here, before the forget below erases the record: + // the ssh sweep at the end of this sequence fires only for a pane whose + // record named the ssh harness (spec 2026-10-07 decision 4). Absence is + // not a kind — an ssh pane that lost its meta file keeps its config (a + // leftover file is swept by the delete path; wiping an unknown pane's + // directory is not). + const paneMeta = await ctx.meta.get(subshellId); ctx.watchers.delete(subshellId); // stop-first: at most one event per registration // LOAD-BEARING ORDER (spec 2026-09-14 §4.3): the flag goes out BEFORE the // death it explains. The plane serialises frames per socket, so a @@ -350,6 +422,22 @@ async function reportDeath(ctx: CommandContext, socket: string, subshellId: stri // Already gone is the outcome we wanted; anything else costs an idle // process, never correctness. } + // Sweep the ssh config (spec 2026-10-07 decision 4). The pane was its only + // reader; leaving it means a signed launch leaves a file forever. Gated on + // the meta kind read BEFORE the forget (only the ssh harness ever owned + // one), and on the SAME relaunch re-check that guards the tails and the + // reap: a relaunched pane owns the path now. The whole per-pane ssh DIR + // goes, not just the file — the launch may have added host keys beside the + // config. Best-effort by contract: a throwing rm costs a log line and + // NEVER refuses the death report or unwinds the sequence. + if (paneMeta?.harnessId === "ssh") { + const sshDir = dirname(buildSshConfigPath(ctx.config.dataDir, subshellId)); + try { + await rm(sshDir, { recursive: true, force: true }); + } catch (err) { + log(`ssh config sweep failed for ${subshellId}: ${err instanceof Error ? err.message : String(err)}`); + } + } } } diff --git a/apps/node/agent/src/commands/set-ssh-enabled.ts b/apps/node/agent/src/commands/set-ssh-enabled.ts new file mode 100644 index 000000000..4e3e75553 --- /dev/null +++ b/apps/node/agent/src/commands/set-ssh-enabled.ts @@ -0,0 +1,30 @@ +import { writeSshEnabled } from "../ssh-enabled.js"; +import type { Cmd, CommandContext, CommandResult } from "./context.js"; + +/** + * `set_ssh_enabled` (spec 2026-10-07 §4.3): the plane's half of the SSH gate. + * + * It writes the file and NOTHING else. In particular it kills no panes and + * revokes nothing: the gate is checked when an SSH act is asked for, so + * turning it off takes effect on the next one, and turning it on grants no + * pane anything that was not already reachable by other means. + * + * The plane's own `changedAt` is stored verbatim, so both copies end + * byte-identical; re-stamping would leave the next `ready` reporting a + * disagreement the link latency invented. + * + * A failing write propagates: `dispatchCommand` turns the throw into + * `{ ok: false }`, and the plane learns its push did not land rather than + * recording a mirror that does not exist. + * + * @param ctx - the per-daemon execution context (data dir + the report memo) + * @param cmd - the verified `set_ssh_enabled` command + * @returns `{ ok: true }` once the file is on disk + */ +export function execSetSshEnabled(ctx: CommandContext, cmd: Cmd<"set_ssh_enabled">): CommandResult { + // Memoized as REPORTED, not merely written: the plane is the source of this + // value — the ONLY source it will ever have — so sending it back on the next + // heartbeat tick would be an echo the plane then reconciles against itself. + ctx.lastReportedSshEnabled = writeSshEnabled(ctx.config.dataDir, { on: cmd.on, changedAt: cmd.changedAt }); + return { ok: true }; +} diff --git a/apps/node/agent/src/commands/ssh-aliases.ts b/apps/node/agent/src/commands/ssh-aliases.ts new file mode 100644 index 000000000..50091fae8 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-aliases.ts @@ -0,0 +1,73 @@ +import { defaultSshConfigPath, discoverSshAliases, resolveSshAliasConfig } from "@internal/pane-runtime"; +import { type JsonValue, parseNodeSshAliasList, parseNodeSshResolveOutcome } from "@internal/subshell-protocol"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import type { Cmd, CommandContext, CommandResult } from "./context.js"; +import { connectingHomeDir, resolveSshBin, SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +/** + * The two human-config SSH executors that touch no connection state (spec + * 2026-10-07 §5/§7): + * + * - `ssh_discover_aliases` — bounded config parse answering NAMES only + * (discovery answers names, never config file contents). The answer is + * run through the frozen wire validator on the way out. + * - `ssh_resolve_config` — `ssh -G` evaluation of one alias through + * pane-runtime's `resolveSshAliasConfig`, plus this machine's connecting + * account name on the outcome's `connectingAccount`: the review step + * needs the connecting OS account named alongside the resolved + * destination. A refusal is a SUCCESSFUL answer (the outcome + * rides `accepted:false` in the data) so the human reads WHICH setting + * blocked; only a missing ssh binary is a command failure. + * + * EVERY arm consults this machine's ssh-enabled mirror FIRST (§4.3's gate + * doctrine): a machine whose local copy is not ON answers + * {@link SSH_GATE_REFUSAL} before any binary lookup, any config read, or any + * spawn — regardless of what the plane believes about it. The mirror is + * fail-closed, so absent and unreadable refuse exactly like off. + * + * The disclosure the resolve step owes the human (the engine's note in + * pane-runtime `ssh-resolve.ts`: a `Match exec` the bounded config walk + * cannot see still executes locally under `ssh -G`) belongs to the + * human-facing surface, never the node: in this tier it rides the plane's + * resolve and launch route descriptions (`/api/ssh/resolve`, + * `/api/ssh/launch`), and the launcher UI copy ships with Plan 3. + */ + +/** Does this machine permit SSH right now? (the one question every arm asks first) */ +function gateOpen(ctx: CommandContext): boolean { + return sshAllowed(readSshEnabled(ctx.config.dataDir)); +} + +/** Execute `ssh_discover_aliases`. */ +export async function execSshDiscoverAliases(ctx: CommandContext): Promise { + if (!gateOpen(ctx)) return { ok: false, error: SSH_GATE_REFUSAL }; + const home = connectingHomeDir(); + const found = discoverSshAliases({ homeDir: home, configPath: defaultSshConfigPath(home) }); + const validated = parseNodeSshAliasList({ + aliases: found.aliases, + includeCycle: found.includeCycle, + truncated: found.truncated, + }); + if (validated === null) return { ok: false, error: "malformed alias discovery" }; + // The seam cast: JSON-safe by construction, contract owned by `node-results.ts`. + return { ok: true, data: validated as unknown as JsonValue }; +} + +/** Execute `ssh_resolve_config` for one alias. */ +export async function execSshResolveConfig( + ctx: CommandContext, + cmd: Cmd<"ssh_resolve_config">, +): Promise { + if (!gateOpen(ctx)) return { ok: false, error: SSH_GATE_REFUSAL }; + const sshBin = await resolveSshBin(); + if (sshBin === null) return { ok: false, error: "ssh binary missing: ssh" }; + const home = connectingHomeDir(); + const outcome = await resolveSshAliasConfig(cmd.alias, { + sshBin, + homeDir: home, + configPath: defaultSshConfigPath(home), + }); + const validated = parseNodeSshResolveOutcome(outcome); + if (validated === null) return { ok: false, error: "malformed resolve outcome" }; + return { ok: true, data: validated as unknown as JsonValue }; +} diff --git a/apps/node/agent/src/commands/ssh-exec.ts b/apps/node/agent/src/commands/ssh-exec.ts new file mode 100644 index 000000000..b82fd50e2 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-exec.ts @@ -0,0 +1,29 @@ +import { machineSshExec, machineSshExecStatus, type SshExecSeams } from "@internal/pane-runtime"; +import { NODE_RESULT_MAINTENANCE, type SshExecCommand, type SshExecStatusCommand } from "@internal/subshell-protocol"; +import { readMaintenance } from "../maintenance.js"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import type { CommandContext, CommandResult } from "./context.js"; +import { SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +export { resetSshExecRunsForTests, type SshExecSeams } from "@internal/pane-runtime"; +/** Enforce daemon policy before invoking the shared machine executor. */ +export async function execSshExec( + ctx: CommandContext, + cmd: SshExecCommand, + seams?: SshExecSeams, +): Promise { + if (!sshAllowed(readSshEnabled(ctx.config.dataDir))) return { ok: false, error: SSH_GATE_REFUSAL }; + const maintenance = readMaintenance(ctx.config.dataDir); + if (maintenance.kind === "unreadable" || (maintenance.kind === "state" && maintenance.state.on)) + return { ok: false, error: NODE_RESULT_MAINTENANCE }; + return machineSshExec(ctx.config.dataDir, cmd, seams); +} +/** Read the bounded result retained by the shared executor. */ +export async function execSshExecStatus( + ctx: CommandContext, + cmd: SshExecStatusCommand, + seams?: SshExecSeams, +): Promise { + if (!sshAllowed(readSshEnabled(ctx.config.dataDir))) return { ok: false, error: SSH_GATE_REFUSAL }; + return machineSshExecStatus(ctx.config.dataDir, cmd, seams); +} diff --git a/apps/node/agent/src/commands/ssh-host-key.ts b/apps/node/agent/src/commands/ssh-host-key.ts new file mode 100644 index 000000000..436ce8372 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-host-key.ts @@ -0,0 +1,16 @@ +import { type HostKeySeams, readMachineHostKey } from "@internal/pane-runtime"; +import type { SshHostKeyCommand } from "@internal/subshell-protocol"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import type { CommandContext, CommandResult } from "./context.js"; +import { SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +export { type HostKeySeams, sshHostKeyCandidates } from "@internal/pane-runtime"; +/** Gate machine trust capture before reading the account's file. */ +export async function execSshHostKey( + ctx: CommandContext, + cmd: SshHostKeyCommand, + seams?: HostKeySeams, +): Promise { + if (!sshAllowed(readSshEnabled(ctx.config.dataDir))) return { ok: false, error: SSH_GATE_REFUSAL }; + return readMachineHostKey(cmd, seams); +} diff --git a/apps/node/agent/src/commands/ssh-identity.ts b/apps/node/agent/src/commands/ssh-identity.ts new file mode 100644 index 000000000..cd81cffa9 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-identity.ts @@ -0,0 +1,46 @@ +import { type AgentIdentitiesSeams, readMachineAgentIdentities } from "@internal/pane-runtime"; +import { type JsonValue, parseNodeSshIdentity } from "@internal/subshell-protocol"; +import { loadOrCreateIdentity } from "../identity.js"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import type { CommandContext, CommandResult } from "./context.js"; +import { SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +/** + * `ssh_register_identity` (spec 2026-10-08 §4.3): the machine reports its + * ES256 relay SIGNING public key so a node that enrolled before M2 fills the + * plane's `signingPublicKey` slot over the already-authenticated, app-layer- + * encrypted node link. + * + * The answer is run through the frozen wire validator on the way out (the + * `ssh-aliases.ts` posture), and only `signingPublicJwk` is read: the private + * half never enters a result frame. The bytes are STABLE by construction - + * `loadOrCreateIdentity` never rotates a present file, and a corrupt one + * quarantines and throws (dispatch answers `ok:false`, the plane stores + * nothing) rather than letting a silent rotation ride out as a "re-report" + * the plane's §4.3 own-registration guard would refuse. + * + * NO ssh-enabled gate check, unlike the two config arms: this is the + * machine's own registration, not an SSH act. `set_ssh_enabled` is the + * precedent for a command the gate cannot govern, and a pre-M2 node reaches + * this BEFORE anyone has ever enabled SSH on it. + * + * @param ctx - the per-daemon execution context (data dir) + * @returns `{ ok: true, data: { signingPublicKey } }` with the PUBLIC JWK only + */ +export async function execSshRegisterIdentity(ctx: CommandContext): Promise { + const identity = await loadOrCreateIdentity(ctx.config.dataDir); + const validated = parseNodeSshIdentity({ signingPublicKey: identity.signingPublicJwk }); + if (validated === null) return { ok: false, error: "malformed signing identity" }; + // The seam cast: JSON-safe by construction, contract owned by `node-results.ts`. + return { ok: true, data: validated as unknown as JsonValue }; +} + +export type { AgentIdentitiesSeams } from "@internal/pane-runtime"; +/** Gate agent enumeration before any socket lookup. */ +export async function execSshAgentIdentities( + ctx: CommandContext, + seams?: AgentIdentitiesSeams, +): Promise { + if (!sshAllowed(readSshEnabled(ctx.config.dataDir))) return { ok: false, error: SSH_GATE_REFUSAL }; + return readMachineAgentIdentities(seams); +} diff --git a/apps/node/agent/src/commands/ssh-machine-pin-repair.ts b/apps/node/agent/src/commands/ssh-machine-pin-repair.ts new file mode 100644 index 000000000..07c53cc32 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-machine-pin-repair.ts @@ -0,0 +1,79 @@ +import { + bytesOfJwk, + type JsonValue, + parseNodeSshMachinePinRepair, + type SshMachinePinRepairCommand, +} from "@internal/subshell-protocol"; +import { MachinePinStore } from "../machine-pin-store.js"; +import type { CommandContext, CommandResult } from "./context.js"; +import { decodePeerEncryptionJwk } from "./ssh-shared.js"; + +/** + * `ssh_machine_pin_repair` (spec 2026-10-08 §4.5, Task 17): replace ONE + * peer's entry in this machine's pin store with the peer's registered public + * pair, which the plane re-delivers in the command. This is the recovery for + * a genuinely rotated peer key (a re-enroll or a replaced data dir, §4.2) - + * the ONLY sanctioned way a pinned entry changes, and the design has no + * "trust anyway" escape beside it: byte-strict {@link MachinePinStore.check} + * still governs every NORMAL pairing, so an UN-repaired peer stays blocked. + * + * The three rules this arm keeps: + * - **Validation before any write.** The grammar already refused a top-level + * `d`; this is the deep half (the relay-open branches' own standard, beside + * the import): `bytesOfJwk` on the signing half, decode + `bytesOfJwk` on + * the encryption half. A JWK that is not an importable PUBLIC P-256 key - + * private material anywhere, a foreign curve, junk - refuses with nothing + * written. The store keeps its single job: byte-faithful persistence. + * - **The gate does NOT speak here** (deliberate, the `ssh_register_identity` + * precedent restated): re-pair is a trust-record act on this machine's own + * store, not an SSH act through the connecting account - it spawns nothing, + * reads no config, touches no agent. And the recovery must reach a machine + * whose pairing is currently BLOCKED; requiring the gate to be open would + * make the repair of a stuck relationship depend on the relationship. The + * authorization is the plane's owner-of-A gate plus the command signature; + * the mirror is not consulted and the store's always-strict doctrine + * (`SUBSHELL_CHANNEL_PIN` ignored) is unchanged. + * - **No audit row here, no key bytes anywhere near a log line.** The audit + * is the plane's `node.ssh_machine_pin.repair`, ids only (docs/security.md + * §10); the ack echoes the peer id and nothing else. The refusal strings + * name the peer id and the shape of the problem, never the delivered + * material (node refusals ride the RESULT channel and the plane may log + * them - the T8 posture inherited). + */ +export function execSshMachinePinRepair(ctx: CommandContext, cmd: SshMachinePinRepairCommand): CommandResult { + // A machine pinning ITSELF is a contradiction the pairing design has no + // word for; the plane's own service refuses it upstream, and the machine + // re-checks because the transport that got the command here is the only + // witness to which machine it was addressed to. + if (cmd.peerNodeId === ctx.config.nodeId) { + return { ok: false, error: "machine pin repair refused: a machine is never its own relay peer" }; + } + let pin: { signing: string; encryption: string }; + try { + pin = { + signing: cmd.peerSigningPublicKey, + encryption: decodePeerEncryptionJwk(cmd.peerEncryptPublicKey), + }; + bytesOfJwk(pin.signing); // deep public-only validity on the signing half too + } catch (err) { + return { + ok: false, + error: `machine pin repair refused: peer key rejected: ${err instanceof Error ? err.message : String(err)}`, + }; + } + const pins = new MachinePinStore(ctx.config.dataDir); + try { + pins.repair(cmd.peerNodeId, pin); + } catch (err) { + // The store's fail-closed read (a corrupt file quarantines and THROWS): + // the repair of one peer must never ride out over an unreadable pin set. + return { + ok: false, + error: `machine pin repair refused: ${err instanceof Error ? err.message : String(err)}`, + }; + } + const validated = parseNodeSshMachinePinRepair({ repaired: true, peerNodeId: cmd.peerNodeId }); + if (validated === null) return { ok: false, error: "malformed repair ack" }; + // The seam cast: JSON-safe by construction, contract owned by `node-results.ts`. + return { ok: true, data: validated as unknown as JsonValue }; +} diff --git a/apps/node/agent/src/commands/ssh-relay-exec.ts b/apps/node/agent/src/commands/ssh-relay-exec.ts new file mode 100644 index 000000000..d657503a8 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-relay-exec.ts @@ -0,0 +1,135 @@ +import type { JsonValue, SshRelayCloseCommand, SshRelayOpenCommand } from "@internal/subshell-protocol"; +import { log } from "../log.js"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import type { CommandContext, CommandResult } from "./context.js"; +import { type ARelaySessionArgs, type BRelaySessionArgs, openARelaySession, openBRelaySession } from "./ssh-relay.js"; +import { SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +/** + * The two relay command arms (spec 2026-10-08 §5.1, Task 8 acceptance (a)): + * the daemon-side executors the plane's signed `ssh_relay_open` / + * `ssh_relay_close` land on. The mechanics they drive are T6/T7's reviewed + * code ({@link openBRelaySession} / {@link openARelaySession} in + * `ssh-relay.ts`); this file owns the DELIVERY decisions: + * + * - **Gate first, open only.** Every SSH arm consults this machine's + * `ssh_enabled` mirror before pairing mechanics (spec §4.3), and this one + * refuses {@link SSH_GATE_REFUSAL} exactly like the discovery arms. The + * CLOSE is deliberately NOT gated: a switch-off mid-session must still be + * able to end the session - teardown is not an SSH act (the + * `set_ssh_enabled` precedent), and refusing it would strand a bound proxy + * socket on a machine that has decided to stop serving SSH. + * - **The A branch answers immediately, the probe runs off the chain** + * (Task 7's handoff, binding): `openARelaySession` awaits the numbering + * probe, which can worst-case run two ~10 s agent round trips against a + * hung agent. The daemon's executor chain serializes every command, so + * awaiting it here would park `input`, `capture`, and every unrelated + * command behind someone else's agent. The ack says what it means - + * `pending: true`, the pairing is DELIVERED, not yet PROBED. A named + * refusal that arrives after the ack (a MOVED pin, an owned ref) is a log + * fact and a dead session the plane's cuts end; §4.5 makes re-pair an + * operator act at the machine, and the answer's absence (never a frame + * from the responder) is what B's side reads as the refusal. + * - **The B branch answers with its socket path.** Binding is local and fast, + * so it runs ON the chain and answers `socketPath` - which the plane + * re-derives from its own facts and byte-checks (the launch-config + * doctrine restated for the agent socket). The pane comes from the COMMAND + * (`cmd.paneId`, grammar (b)), never a launch-side correlation. + * - **The pump is the daemon's** (`ctx.relay.sendRelayFrame`), forwarded to + * both branches unchanged: its deliver-or-throw contract (acceptance (c)) + * is the daemon's `relay-send.ts`, and a context built without the relay + * plumbing refuses loudly instead of half-opening. + * + * The seams (`{@link RelayOpenSeams}`) exist for the probe-off-chain and + * refusal-observability tests; production omits them and gets the real pair. + */ + +/** Injectable pairing seams (test-only; production is the T6/T7 pair). */ +export interface RelayOpenSeams { + openA(args: ARelaySessionArgs): Promise<{ relayId: string }>; + openB(args: BRelaySessionArgs): Promise<{ socketPath: string }>; + /** Line sink for the detached A-branch's outcome (defaults to the agent log). */ + log?: (line: string) => void; +} + +/** Does this machine permit SSH right now? (the one question every SSH arm asks first) */ +function gateOpen(ctx: CommandContext): boolean { + return sshAllowed(readSshEnabled(ctx.config.dataDir)); +} + +/** Execute `ssh_relay_open`: branch on the command's own `role`. */ +export async function execSshRelayOpen( + ctx: CommandContext, + cmd: SshRelayOpenCommand, + seams?: RelayOpenSeams, +): Promise { + if (!gateOpen(ctx)) return { ok: false, error: SSH_GATE_REFUSAL }; + const relay = ctx.relay; + if (!relay) { + // Not a user-facing condition: a daemon that runs commands without the + // relay plumbing would half-open sessions with no pump, so this is the + // impossible-state guard, answered loudly. + return { ok: false, error: "relay plumbing unavailable in this daemon context" }; + } + const say = seams?.log ?? ((line: string): void => log(line)); + if (cmd.role === "A") { + const openA = seams?.openA ?? openARelaySession; + // OFF the chain, deliberately (the module doc names the cost). The guard + // refusals inside openARelaySession (role/self/pin/ref) become log lines + // here, never a second answer to the plane - the ack already left. + void openA({ + relay: relay.sessions, + dataDir: ctx.config.dataDir, + selfNodeId: ctx.config.nodeId, + cmd, + sendRelayFrame: relay.sendRelayFrame, + }).then( + (r) => say(`relay session ${cmd.ref}: A side live (relay ${r.relayId})`), + (err: unknown) => say(`relay session ${cmd.ref}: A open refused after ack: ${String(err)}`), + ); + // The seam cast is the JSON-safety posture the sibling executors use + // (`node-results.ts` owns the answer contract; a relay ack is a plain + // object by the plan's interface note). + return { ok: true, data: { role: "A", relayId: cmd.relayId, pending: true } as unknown as JsonValue }; + } + const openB = seams?.openB ?? openBRelaySession; + try { + const { socketPath } = await openB({ + relay: relay.sessions, + dataDir: ctx.config.dataDir, + selfNodeId: ctx.config.nodeId, + paneId: cmd.paneId, + cmd, + sendRelayFrame: relay.sendRelayFrame, + }); + return { ok: true, data: { role: "B", relayId: cmd.relayId, socketPath } as unknown as JsonValue }; + } catch (err: unknown) { + // Named refusals (MOVED pin, malformed peer key, owned ref, registry + // cap, already-closed ref): the plane's openRelay turns the rejection + // into its own named refusal. The string travels the RESULT channel - + // and the plane ALSO interpolates a rejected command's reason into the + // THROWN `SshRelayRefusal("handshake", ...)` that openRelay raises, so + // anything that ever logs either shape, at any level, inherits this as + // load-bearing: node refusals name ids and the §4.5 remedy, never key + // material (NIT, fix round T8). + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Execute `ssh_relay_close`: stop the session named by the ref and hand the + * named reason to the local owner (§5.6's endpoint line). Ungated by design + * (module doc). An unknown ref answers `closed: false` - a close racing the + * plane's own cut is routine, and a false answer to a redundant close is the + * honest record, not an error. The registry additionally TOMBSTONES an + * unknown ref for a bounded TTL (RelaySessions, fix round T8 MAJOR 2), so + * the close also disarms the one race that answer used to hide: a detached + * A-open that succeeds AFTER this cut can no longer register a responder + * nothing will ever close. + */ +export function execSshRelayClose(ctx: CommandContext, cmd: SshRelayCloseCommand): CommandResult { + const relay = ctx.relay; + if (!relay) return { ok: false, error: "relay plumbing unavailable in this daemon context" }; + const closed = relay.sessions.close(cmd.ref, cmd.reason); + return { ok: true, data: { ref: cmd.ref, closed } as unknown as JsonValue }; +} diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts new file mode 100644 index 000000000..b68225df2 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -0,0 +1,21 @@ +import { open, seal } from "@internal/mcp-core"; +import { + openARelaySession as openA, + openBRelaySession as openB, + type ARelaySessionArgs as SharedAArgs, + type BRelaySessionArgs as SharedBArgs, +} from "@internal/pane-runtime"; +import { loadOrCreateIdentity } from "../identity.js"; +import { log } from "../log.js"; + +export { RELAY_CLOSE_TOMBSTONE_MS, type RelaySessionHandler, RelaySessions } from "@internal/pane-runtime"; +export type ARelaySessionArgs = Omit; +export type BRelaySessionArgs = Omit; +/** Node adapter: preserve this daemon's identity location and log. */ +export function openARelaySession(args: ARelaySessionArgs): Promise<{ relayId: string }> { + return openA({ ...args, identity: () => loadOrCreateIdentity(args.dataDir), seal, open, log: args.log ?? log }); +} +/** Node adapter for the connecting side. */ +export function openBRelaySession(args: BRelaySessionArgs): Promise<{ socketPath: string }> { + return openB({ ...args, identity: () => loadOrCreateIdentity(args.dataDir), seal, open, log: args.log ?? log }); +} diff --git a/apps/node/agent/src/commands/ssh-shared.ts b/apps/node/agent/src/commands/ssh-shared.ts new file mode 100644 index 000000000..b1461dd48 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-shared.ts @@ -0,0 +1,10 @@ +export { + connectingHomeDir, + decodePeerEncryptionJwk, + knownHostsPath, + resolveSshBin, + resolveSshKeygenBin, + SSH_BINARY_ENV, + SSH_GATE_REFUSAL, + SSH_KEYGEN_BINARY_ENV, +} from "@internal/pane-runtime"; diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 8dbc3bf4b..e8263099b 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -10,13 +10,23 @@ import { type NodeEvent, type NodeMaintenanceWire, type NodeRuntimeReport, + type NodeSshEnabledWire, + parseRelayFrame, + relayFrameRefIfOverCap, SeqTracker, verifyCommand, } from "@internal/subshell-protocol"; import { backoffDelay } from "./backoff.js"; import type { CommandContext, CommandResult, CommandWs } from "./commands/context.js"; import { dispatchCommand } from "./commands/index.js"; -import { buildSubshellsReport, maybeReportMaintenance, seedMaintenanceMemo } from "./commands/report.js"; +import { + buildSubshellsReport, + maybeReportMaintenance, + maybeReportSshEnabled, + seedMaintenanceMemo, + seedSshEnabledMemo, +} from "./commands/report.js"; +import { RelaySessions } from "./commands/ssh-relay.js"; import { stopAllTails } from "./commands/tail.js"; import { sweepStaleTransfers } from "./commands/transfer-sweep.js"; import { cleanupStaleUploads } from "./commands/write-file.js"; @@ -30,10 +40,13 @@ import { binaryPayload, createLinkNegotiator, type LinkNegotiator, type LinkNego import { clearLock, writeLock } from "./lock.js"; import { log } from "./log.js"; import { createRetentionPass, PANE_LOG_RETENTION_PASS_MS, resolveLogRetention } from "./pane-log-retention.js"; +import { createDaemonRelaySend } from "./relay-send.js"; import { noteSweepScheduled } from "./retention-settings.js"; import { type NodeRoute, NodeRouteSelector } from "./route-selection.js"; import { collectRuntime, refreshServiceRuntime } from "./runtime.js"; import { selfInvokePrefix } from "./self-invoke.js"; +import { sweepOrphanSshDirs } from "./ssh-dir-retention.js"; +import { buildSshFingerprintReport } from "./ssh-fingerprint-report.js"; import { SubshellMetaStore } from "./subshell-meta.js"; import { completeUpdate, revertAfterRefusal } from "./update.js"; import { NODE_VERSION } from "./version.js"; @@ -200,6 +213,13 @@ export interface DaemonDeps { * "plaintext mode": spec §6 forbids a v14 socket ever running unsealed. */ link?: (args: LinkNegotiatorArgs) => LinkNegotiator; + /** + * The relay-session registry the inbound `relay` frames route into (spec + * 2026-10-08 §5.1/§5.2; default: a fresh {@link RelaySessions}). + * @internal test seam: the daemon test injects a capturing registry to pin + * the routing decision; production runs on the one this call constructs. + */ + relaySessions?: RelaySessions; } interface WsClose { @@ -339,6 +359,7 @@ function readyEvent( config: NodeConfig, runtime: NodeRuntimeReport | null, maintenance: NodeMaintenanceWire | undefined, + sshEnabled: NodeSshEnabledWire | undefined, ): Extract { return { type: "ready", @@ -385,6 +406,13 @@ function readyEvent( // every launch here, and a plane told nothing about that has no row to // reconcile and no reason to push the clean file that repairs it. ...(maintenance ? { maintenance } : {}), + // This machine's SSH-capability mirror (spec 2026-10-07 §4.3), carried + // exactly as maintenance is: OMITTED when absent (an off file or no file — + // silence means REFUSED here, and a row that says on answers it with the + // push), and an UNREADABLE file carried as the refusal it acts on under + // its own mtime, so the plane can repair the file instead of holding a + // silent disagreement until the next reconnect. + ...(sshEnabled ? { sshEnabled } : {}), }; } @@ -433,6 +461,30 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // Keep process identity stable while refreshing the service manager's facts. const runtime = deps.runtime === undefined ? await collectRuntime().catch(() => null) : deps.runtime; + // The `ready` frame's runtime, with the §4.6 trust block merged in when + // this machine can state it. Recomputed PER DIAL, unlike `runtime` itself: + // the process facts cannot change while the pid does not, but peers get + // paired (and re-paired) during a process's life, and a trust block frozen + // at boot would under-report the pin store the whole comparison is about. + let sshRuntime: NodeRuntimeReport | null = runtime; + + /** + * The runtime report plus this machine's live trust block, or the bare + * report when the block cannot be built. A failure is logged once per dial + * and never costs the connection: a machine whose pin file is corrupt still + * comes online and keeps launching; only its trust card stays empty, on the + * plane and on its own dashboard alike. + */ + async function withSshFingerprint(report: NodeRuntimeReport | null): Promise { + if (report === null) return null; // no runtime report to ride: no block, no key reads + try { + return { ...report, sshFingerprint: await buildSshFingerprintReport(config.dataDir) }; + } catch (err) { + log(`ready: ssh trust block unavailable: ${err instanceof Error ? err.message : String(err)}`); + return report; + } + } + // PER-PROCESS lifetimes (mixing these up is a security bug — see VerifyContext in node-signing): const jtiLru = new JtiLru(); // survives every reconnect: a replayed jti never gets a second evaluation const seqTracker = new SeqTracker(); // per-CONNECTION value; reset on every open below @@ -441,6 +493,13 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // a jti that has run once NEVER runs twice. const idempotent = new Map(); + // The relay-session registry (spec 2026-10-08 §5.1/§5.2): PER-DAEMON, like + // the idempotence map: a relay session belongs to the pairing, not to the + // socket it first rode, and the plane re-pumps frames on the fresh link. + // Inbound `relay` frames route through it (see `onFrame`); B proxies (and + // from Task 7, the A responder) register into it by routing ref. + const relaySessions = deps.relaySessions ?? new RelaySessions(log); + // PER-PROCESS executor context (spec §3.4/§7): built once, survives every // reconnect. `ws` is a STABLE wrapper routing to the CURRENT socket — // executors hold `ctx` across reconnects. The serial chain does NOT keep @@ -476,6 +535,16 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom tails: new Map(), uploads: new Map(), runtime, + // The relay plumbing (spec 2026-10-08 §5.1, Task 8): the per-daemon + // registry (sessions survive reconnects - the plane re-pumps on the fresh + // socket) plus the DELIVER-OR-THROW pump. The pump reads the CURRENT + // socket and link through a getter, exactly like the stale-socket guard in + // `send` above: a frame sent after this socket died targets the new link + // if one exists, and throws if none does (acceptance (c)). + relay: { + sessions: relaySessions, + sendRelayFrame: createDaemonRelaySend(() => ({ ws: currentWs, link })), + }, requestRestart: () => { // Deferred, because the daemon is the only sender of `result`: the // executor returns `{ ok: true }`, the frame leaves on this turn, and @@ -576,6 +645,26 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom }, deps.retentionMs ?? PANE_LOG_RETENTION_PASS_MS); retentionTimer.unref?.(); } + // Orphaned per-pane ssh config dirs (spec 2026-10-07 decision 4's + // machine-side half): `remove_paths` unlinks FILES, so a delete-after- + // death removed `//config` and left the DIR behind forever. The + // agent drops those dirs here — boot pass plus the same hourly beat as the + // retention timer above, unref'd and fire-and-forget like it (and like the + // transfer sweep), because a wedged fs probe must not cost the node its + // first connection. Deliberately NOT inside the `forever` gate above it: + // the pane-log keep-forever pair is an operator choice about TRANSCRIPTS, + // and it says nothing about a dead pane's empty config dir — that sweep's + // only grace is the one-hour launch-window floor inside the pass itself. + const sweepSshDirs = (): Promise<{ removed: number }> => sweepOrphanSshDirs(config.dataDir, ctx.meta, Date.now()); + void sweepSshDirs().catch((err: unknown) => { + log(`ssh dir sweep failed: ${err instanceof Error ? err.message : String(err)}`); + }); + const sshDirSweepTimer = setInterval(() => { + void sweepSshDirs().catch((err: unknown) => { + log(`ssh dir sweep failed: ${err instanceof Error ? err.message : String(err)}`); + }); + }, deps.retentionMs ?? PANE_LOG_RETENTION_PASS_MS); + sshDirSweepTimer.unref?.(); // SERIAL command executor (spec §3.4): verified commands queue here so pane // effects land in arrival order across the whole daemon life — surviving // reconnects. A command verified pre-`seqTracker.reset` executing post-reset @@ -906,14 +995,34 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom return null; }; - /** One ADMITTED frame: jws extraction → verify → execute (spec §4). */ + /** One ADMITTED frame: relay routing (spec 2026-10-08 §5.1) OR jws → verify → execute (§4). */ const onFrame = async (ws: WsLike, data: string): Promise => { - let jws: unknown; + let parsed: unknown; try { - jws = (JSON.parse(data) as { jws?: unknown }).jws; + parsed = JSON.parse(data); } catch { - jws = undefined; + parsed = undefined; + } + // The `relay` link frame is NOT a command (§5.1: no per-message signing, + // the link authenticates the machine, origin rides the inner ES256 + // signature): it must never draw the command grammar's `verify: malformed` + // answer, which the plane reads as a command anomaly. Recognized by its + // `type` BEFORE the JWS path; a relay-shaped-but-malformed candidate is + // relay noise (dropped with a line; over-cap ones name their ref, the + // session's named close itself is the broker's), and every non-relay frame + // falls through to the untouched command path below. + if (typeof parsed === "object" && parsed !== null && (parsed as { type?: unknown }).type === "relay") { + const frame = parseRelayFrame(parsed as object); + if (frame !== null) relaySessions.onInboundRelayFrame(frame); + else { + const overCapRef = relayFrameRefIfOverCap(parsed); + if (overCapRef !== null) + log(`relay frame over the size cap for ref ${overCapRef}: refused (§5.1; the named close is the broker's)`); + else log("malformed relay frame dropped"); + } + return; } + const jws = (parsed as { jws?: unknown } | undefined)?.jws; if (typeof jws !== "string") { send(ws, { type: "error", code: "verify", message: "malformed" }); return; @@ -1122,7 +1231,10 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // The mirror is read SYNCHRONOUSLY too, and by the seeder itself: the // value `ready` carries and the memo that stops the first heartbeat // repeating it are one read, so they cannot disagree. - send(ws, readyEvent(config, runtime, seedMaintenanceMemo(ctx))); + // The SSH mirror rides the same synchronous read + seed as the + // maintenance one (spec 2026-10-07 §4.3): the value `ready` carries and + // the memo that stops the first heartbeat repeating it are one read. + send(ws, readyEvent(config, sshRuntime, seedMaintenanceMemo(ctx), seedSshEnabledMemo(ctx))); // Connect-time `subshells_report` (spec §3.3): re-projects the panes // that survived an agent restart so the control plane heals its rows. // Fire-and-forget with catch-log — a scan failure (junk meta, tmux @@ -1157,7 +1269,18 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom heartbeat = setInterval(() => { const tickAt = new Date(nowMs()).toISOString(); // Liveness must not wait for a service manager that might be slow. - send(ws, { type: "heartbeat", ts: tickAt, ...(runtime ? { runtime } : {}) }); + send(ws, { + type: "heartbeat", + ts: tickAt, + ...(runtime + ? { + runtime: { + ...runtime, + ...(sshRuntime?.sshFingerprint ? { sshFingerprint: sshRuntime.sshFingerprint } : {}), + }, + } + : {}), + }); void refreshRuntime(); setDaemonState({ connected: true, lastHeartbeatAt: tickAt }); writeLiveness(); // every heartbeat tick doubles as the local-liveness refresh @@ -1186,6 +1309,11 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom `config reload failed: ${err instanceof Error ? err.message : String(err)}; keeping the running identity`, ); }); + // The SSH gate's belt (spec 2026-10-07 §4.3): quieter by construction + // (no writer but the plane, so the steady state announces nothing), + // and this tick is its ONLY mid-session path — there are no deaths + // for it to ride ahead of, because an SSH flip kills no panes. + maybeReportSshEnabled(ctx); }, heartbeatMs); // Periodic `inventory` push — the "every 5 min" leg of spec §7 // (P3-T8c). The timer's lifecycle mirrors the heartbeat's — armed @@ -1269,6 +1397,7 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom if (shuttingDown) stop(0); await provisioning; // §5: R7's provisioning close must not redial past its own key write await refreshConnectionConfig(); + sshRuntime = await withSshFingerprint(runtime); const configuredWsUrl = resolveWsUrl(config); const scope = connectionIdentity(config); // Repointing and credential reloads must discard the previous route scope. diff --git a/apps/node/agent/src/dashboard/__tests__/dashboard.test.ts b/apps/node/agent/src/dashboard/__tests__/dashboard.test.ts index 76ad51257..658952d0e 100644 --- a/apps/node/agent/src/dashboard/__tests__/dashboard.test.ts +++ b/apps/node/agent/src/dashboard/__tests__/dashboard.test.ts @@ -1,9 +1,12 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, readFileSync } from "node:fs"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fingerprintJwk } from "@internal/subshell-protocol"; import type { UpdateExecContext } from "../../commands/update.js"; import { type NodeConfig, saveConfig } from "../../config.js"; +import { MachinePinStore } from "../../machine-pin-store.js"; import { noteSweepScheduled } from "../../retention-settings.js"; import { newHome } from "../../test-preload.js"; import { NODE_VERSION } from "../../version.js"; @@ -11,6 +14,12 @@ import { refuseRequest } from "../guards.js"; import { buildRoutes } from "../routes.js"; import { getDaemonState, registerRestart, setDaemonState } from "../state.js"; +/** A fresh P-256 public key as its raw JWK string. */ +function freshJwk(): string { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + return JSON.stringify(publicKey.export({ format: "jwk" })); +} + /** * The dashboard's routes against a throwaway enrolled config — the contract * the extracted `@internal/node-admin` cards fetch, pinned HERE because the @@ -94,6 +103,39 @@ test("GET /api/nodes/:id answers the local view — online, owner-managed, serve expect(v.runningSubshells).toBe(0); }); +test("the view carries the §4.6 trust block from the node-local store (spec 2026-10-08 §4.6)", async () => { + const cfg = await enrolled(); + const a = freshJwk(); + const b = freshJwk(); + new MachinePinStore(cfg.dataDir).pin("f0000000-0000-4000-8000-000000000001", { signing: a, encryption: b }); + const v = await (await call(cfg, "GET", "/api/nodes/self")).json(); + expect(v.sshTrust.stale).toBe(false); // the machine answering about ITSELF is never stale + expect(v.sshTrust.peers).toEqual([ + { + nodeId: "f0000000-0000-4000-8000-000000000001", + signing: await fingerprintJwk(a), + encryption: await fingerprintJwk(b), + }, + ]); + // Own halves from this machine's identity files, not from anything a plane said. + const enc = (JSON.parse(readFileSync(join(cfg.dataDir, "identity.json"), "utf8")) as { publicJwk: string }).publicJwk; + const sig = ( + JSON.parse(readFileSync(join(cfg.dataDir, "node-signing-identity.json"), "utf8")) as { publicJwk: string } + ).publicJwk; + expect(v.sshTrust.own.encryption).toBe(await fingerprintJwk(enc)); + expect(v.sshTrust.own.signing).toBe(await fingerprintJwk(sig)); +}); + +test("a corrupt pin file costs the trust field, never the page", async () => { + const cfg = await enrolled(); + writeFileSync(join(cfg.dataDir, "ssh-machine-pins.json"), "}{"); + const r = await call(cfg, "GET", "/api/nodes/self"); + expect(r.status).toBe(200); + const v = await r.json(); + expect("sshTrust" in v).toBe(false); // absent, not a broken half-block + expect(v.id).toBe("node-abc"); // the rest of the view renders +}); + test("the maintenance flip round-trips through the real mirror, and `off` carries stopped: []", async () => { const cfg = await enrolled(); // `on` with no panes running is the clean case the CLI answers identically. diff --git a/apps/node/agent/src/dashboard/view.ts b/apps/node/agent/src/dashboard/view.ts index 8f877d583..6e823b0dd 100644 --- a/apps/node/agent/src/dashboard/view.ts +++ b/apps/node/agent/src/dashboard/view.ts @@ -6,6 +6,7 @@ import type { NodeConfig } from "../config.js"; import { mapOs } from "../enroll.js"; import { readMaintenance } from "../maintenance.js"; import { collectRuntime } from "../runtime.js"; +import { buildSshFingerprintReport } from "../ssh-fingerprint-report.js"; import { SubshellMetaStore } from "../subshell-meta.js"; import { NODE_VERSION } from "../version.js"; import { getDaemonState } from "./state.js"; @@ -98,6 +99,15 @@ export async function liveSubshellCount(dataDir: string): Promise { export async function buildLocalNodeView(cfg: NodeConfig) { const runtime = await runtimeForView(); const m = maintenanceFields(cfg.dataDir); + // The §4.6 trust block from the node-local store (identity files plus + // `ssh-machine-pins.json`), the plane-independent side of the out-of-band + // compare. `stale: false` by definition: this page is the machine answering + // about itself, never a mirror of a departed connection. An unreadable pin + // set costs the FIELD (the card renders nothing), never the page, and the + // store's quarantine is the operator's repair. + const sshTrust = await buildSshFingerprintReport(cfg.dataDir) + .then((block) => ({ ...block, stale: false })) + .catch(() => null); // The `ready` frame's capability list, restated for the view so the card // shows what the plane is shown. const capabilities = ["uploads", "mcp"]; @@ -130,6 +140,7 @@ export async function buildLocalNodeView(cfg: NodeConfig) { held: null, serverUrl: cfg.serverUrl, runtime, + ...(sshTrust ? { sshTrust } : {}), runningSubshells: await liveSubshellCount(cfg.dataDir), }; } diff --git a/apps/node/agent/src/enroll.ts b/apps/node/agent/src/enroll.ts index ebb874683..80693caff 100644 --- a/apps/node/agent/src/enroll.ts +++ b/apps/node/agent/src/enroll.ts @@ -113,6 +113,9 @@ export async function runEnroll(opts: EnrollOptions): Promise { hostname: hostname(), agentVersion: NODE_VERSION, publicKey: identity.publicJwk, + // The ES256 relay signing half (spec 2026-10-08 §4.2): the public JWK + // only, registered beside the encryption key in the same node: record. + signingPublicKey: identity.signingPublicJwk, encryptPublicKey: link.publicKey, }; diff --git a/apps/node/agent/src/identity.ts b/apps/node/agent/src/identity.ts index e87dbf143..720213e1b 100644 --- a/apps/node/agent/src/identity.ts +++ b/apps/node/agent/src/identity.ts @@ -1,34 +1,68 @@ /** - * Port of backend mcp/identity-store.ts (spec 2026-08-31, task 12) — the same - * fail-closed rules and quarantine wording, simplified to ONE keypair per data - * dir (a node has no principal-per-file; it IS the principal). Deliberately a - * copy: the dependency direction is agent → packages, never agent → backend. + * Port of backend mcp/identity-store.ts (spec 2026-08-31, task 12): the same + * fail-closed rules and quarantine wording, simplified to one keypair FILE per + * role (a node has no principal-per-file; it IS the principal). Deliberately a + * copy: the dependency direction is agent -> packages, never agent -> backend. + * + * Two keypairs, one file each, for the protocol-18 SSH relay (spec 2026-10-08 §4.1): the + * ECDH-ES encryption pair in `identity.json` (sealed delivery) and the ES256 + * signing pair in the sibling `node-signing-identity.json` (SSH relay payloads, + * M2). The signing pair is generated at the same moment and posture as the + * encryption pair and follows the exact same discipline below. * * Fail-closed: ONLY a genuinely missing file (ENOENT) generates a fresh key. * Any other read/parse failure on a PRESENT file means key material exists we - * cannot read — the file is moved aside (best-effort) and the call throws, + * cannot read - the file is moved aside (best-effort) and the call throws, * never silently rotating over the old key and orphaning sealed history. */ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { exportJWK, generateKeyPair } from "jose"; -/** The node's persisted keypair (JWKs as JSON strings, mirroring the backend store). */ +/** + * The node's persisted keypairs. JWKs as JSON strings, mirroring the backend + * store; each pair lives in its own file, so one file's corruption can only + * ever rotate its own key. + */ export interface NodeIdentity { - /** Public JWK JSON (P-256 / ECDH-ES) — sent at enroll, registered for sealed delivery. */ + /** Public JWK JSON (P-256 / ECDH-ES) - sent at enroll, registered for sealed delivery. */ + publicJwk: string; + /** Private JWK JSON - never leaves this file. */ + privateJwk: string; + /** + * Public JWK JSON (P-256 / ES256) - the machine's signing identity for the + * SSH agent relay (spec 2026-10-08 §4.1); sent at enroll, registered beside + * the encryption key in the same `node:` identities record (§4.2). + */ + signingPublicJwk: string; + /** Signing private JWK JSON - never leaves this machine; signs relay payloads only. */ + signingPrivateJwk: string; +} + +/** The on-disk shape of one keypair file; both files share it. */ +interface KeypairFile { publicJwk: string; - /** Private JWK JSON — never leaves this file. */ privateJwk: string; } -/** Absolute path of the identity file inside a data dir. */ +/** Absolute path of the encryption identity file inside a data dir. */ export function identityPath(dataDir: string): string { return join(dataDir, "identity.json"); } -/** Loads the node's keypair, generating and persisting one on first run. */ +/** Absolute path of the ES256 signing identity file inside a data dir. */ +export function signingIdentityPath(dataDir: string): string { + return join(dataDir, "node-signing-identity.json"); +} + +/** + * Loads the node's keypairs (encryption + signing), generating and persisting + * any missing one on first run. An existing ECDH file is never rewritten by + * this call, so a pre-M2 node upgrades into a fresh signing pair without its + * encryption identity moving. + */ export async function loadOrCreateIdentity(dataDir: string): Promise { - // The dir will hold the private key material, so a dir WE create gets 0700 — + // The dir will hold the private key material, so a dir WE create gets 0700 - // unconditionally chmod'd, because mkdir's `mode` option is masked by the // umask and cannot guarantee it. A dir that already exists is left alone: // whoever created it chose its mode (shared mount, pre-seeded permissions, @@ -37,15 +71,30 @@ export async function loadOrCreateIdentity(dataDir: string): Promise { try { const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); - // Valid JSON that is not a usable identity is corruption of a PRESENT file, - // not a first run — the SyntaxError routes it into quarantine below. - if (!isIdentity(parsed)) throw new SyntaxError("identity file is valid JSON but not an identity object"); + // Valid JSON that is not a usable keypair is corruption of a PRESENT file, + // not a first run - the SyntaxError routes it into quarantine below. + if (!isKeypairFile(parsed)) throw new SyntaxError("identity file is valid JSON but not an identity object"); return parsed; } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return await writeFresh(file); + if ((err as NodeJS.ErrnoException).code === "ENOENT") return await writeFresh(file, alg); const reason = err instanceof SyntaxError ? "parse" : ((err as NodeJS.ErrnoException).code ?? "read"); let aside = `${file}.corrupt-${reason}`; if (existsSync(aside)) aside = `${aside}-${Date.now()}`; @@ -63,23 +112,24 @@ export async function loadOrCreateIdentity(dataDir: string): Promise { +async function writeFresh(file: string, alg: "ECDH-ES" | "ES256"): Promise { // Bun's crypto.subtle lacks ECDH generateKey; jose falls back to node:crypto - // internally (same path @internal/mcp-core's crypto.ts documents). - const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { crv: "P-256", extractable: true }); - const identity: NodeIdentity = { + // internally (same path @internal/mcp-core's crypto.ts documents). ES256 + // rides the same generator - both are P-256, the algorithm is the purpose. + const { publicKey, privateKey } = await generateKeyPair(alg, { crv: "P-256", extractable: true }); + const keypair: KeypairFile = { publicJwk: JSON.stringify(await exportJWK(publicKey)), privateJwk: JSON.stringify(await exportJWK(privateKey)), }; - writeFileSync(file, JSON.stringify(identity, null, 2), { mode: 0o600 }); - return identity; + writeFileSync(file, JSON.stringify(keypair, null, 2), { mode: 0o600 }); + return keypair; } diff --git a/apps/node/agent/src/index.ts b/apps/node/agent/src/index.ts index 0ac2f878a..7b512bdcd 100644 --- a/apps/node/agent/src/index.ts +++ b/apps/node/agent/src/index.ts @@ -17,7 +17,7 @@ export { wsUrlFor, } from "./daemon.js"; export { type EnrollOptions, type EnrollResult, mapOs, type NodeOs, runEnroll } from "./enroll.js"; -export { identityPath, loadOrCreateIdentity, type NodeIdentity } from "./identity.js"; +export { identityPath, loadOrCreateIdentity, type NodeIdentity, signingIdentityPath } from "./identity.js"; export { buildInventoryEvent, type InventoryEvent } from "./inventory.js"; export { clearLock, type DaemonLock, isPidAlive, lockPath, readLock, writeLock } from "./lock.js"; export { runNodeMcp } from "./mcp/main.js"; diff --git a/apps/node/agent/src/machine-pin-store.ts b/apps/node/agent/src/machine-pin-store.ts new file mode 100644 index 000000000..c41226343 --- /dev/null +++ b/apps/node/agent/src/machine-pin-store.ts @@ -0,0 +1 @@ +export { type MachinePin, MachinePinStore, type MachinePinVerdict, machinePinPath } from "@internal/pane-runtime"; diff --git a/apps/node/agent/src/path-policy.ts b/apps/node/agent/src/path-policy.ts index b45246230..ad8f8e14e 100644 --- a/apps/node/agent/src/path-policy.ts +++ b/apps/node/agent/src/path-policy.ts @@ -1,68 +1 @@ -import { lstat, realpath } from "node:fs/promises"; -import { resolve, sep } from "node:path"; - -/** - * The agent-side path allowlist (spec 2026-08-31 §7): `write_file` and - * `remove_paths` are accepted only under or a tracked subshell's - * launch cwd. Defense-in-depth against a compromised control plane — the - * commands are signed, but signing proves WHO, not WHETHER. - */ - -/** Realpath each root (symlinked roots must not smuggle an escape through the prefix test). */ -export async function realpathRoots(roots: string[]): Promise { - const out: string[] = []; - for (const root of roots) { - try { - out.push(await realpath(root)); - } catch { - // A vanished root cannot authorize anything; drop it (tracked cwd deleted under a dead subshell). - } - } - return out; -} - -/** True when the raw input carries a `..` path segment (the literal `..`, a leading `../`, or any interior one). */ -function hasDotDotSegment(raw: string): boolean { - return raw.split(sep).includes(".."); -} - -/** Deepest existing ancestor of `p`, realpath'd — a new file's parents may embed a symlink. */ -async function realpathExisting(p: string): Promise { - let cur = p; - for (;;) { - try { - return await realpath(cur); - } catch { - const parent = cur.lastIndexOf(sep); - if (parent <= 0) return null; // stop never probes `/` itself — fail-closed, deliberate - cur = cur.slice(0, parent); - } - } -} - -/** - * True when `rawPath` (existing or not) resolves inside one of the - * `realpathRoots`-normalized `roots`. `..` segments are refused outright — - * spec §7 says so, and resolve()-collapse is symlink-blind: `/root/l/../evil` - * collapses to `/root/evil` (inside) while the kernel opens `/outside/evil` - * through `l → /outside`. Symlinked ancestors are caught via the ancestor - * walk; a symlink LEAF is denied by lstat (a dangling one would otherwise - * pass the walk and be followed by a later open(O_CREAT)). - * @param rawPath - the path to gate, relative or absolute; never throws on denies. - * @param roots - allowed roots; realpath'd here, so callers may pass raw paths. - */ -export async function pathAllowed(rawPath: string, roots: string[]): Promise { - if (hasDotDotSegment(rawPath)) return false; - const abs = resolve(rawPath); - try { - // Any leaf symlink is denied outright: an existing target is caught by - // realpath's first iteration anyway, and a dangling one is not (see above). - if ((await lstat(abs)).isSymbolicLink()) return false; - } catch { - // Leaf doesn't exist yet — fine; the ancestor walk covers its parents. - } - const resolved = await realpathExisting(abs); - if (resolved === null) return false; - const normalized = await realpathRoots(roots); - return normalized.some((root) => resolved === root || resolved.startsWith(root + sep)); -} +export { pathAllowed, realpathRoots } from "@internal/pane-runtime"; diff --git a/apps/node/agent/src/relay-agent-scheme.ts b/apps/node/agent/src/relay-agent-scheme.ts new file mode 100644 index 000000000..d2727ea02 --- /dev/null +++ b/apps/node/agent/src/relay-agent-scheme.ts @@ -0,0 +1,15 @@ +export { + type AgentIdentity, + type AgentRequestFn, + type AgentScheme, + CLASSIC_SCHEME, + filterIdentitiesAnswer, + fingerprintAgentBlob, + OPENSSH_10X_SCHEME, + parseIdentitiesAnswer, + parseSignRequest, + parseSignResponse, + probeAgentScheme, + type SignRequestBody, + SSH2_AGENT_FAILURE, +} from "@internal/pane-runtime"; diff --git a/apps/node/agent/src/relay-agent-socket.ts b/apps/node/agent/src/relay-agent-socket.ts new file mode 100644 index 000000000..0762c8b70 --- /dev/null +++ b/apps/node/agent/src/relay-agent-socket.ts @@ -0,0 +1 @@ +export { liveAgentSocketPath, requestLiveAgent } from "@internal/pane-runtime"; diff --git a/apps/node/agent/src/relay-proxy.ts b/apps/node/agent/src/relay-proxy.ts new file mode 100644 index 000000000..e74d034f5 --- /dev/null +++ b/apps/node/agent/src/relay-proxy.ts @@ -0,0 +1,6 @@ +export { + type AgentProxyArgs, + type AgentProxyHandle, + buildAgentSocketPath, + startAgentProxy, +} from "@internal/pane-runtime"; diff --git a/apps/node/agent/src/relay-responder.ts b/apps/node/agent/src/relay-responder.ts new file mode 100644 index 000000000..3937e9f35 --- /dev/null +++ b/apps/node/agent/src/relay-responder.ts @@ -0,0 +1 @@ +export { type RelayResponderArgs, type RelayResponderHandle, startRelayResponder } from "@internal/pane-runtime"; diff --git a/apps/node/agent/src/relay-send.ts b/apps/node/agent/src/relay-send.ts new file mode 100644 index 000000000..2edc7ac2d --- /dev/null +++ b/apps/node/agent/src/relay-send.ts @@ -0,0 +1,53 @@ +import { NODE_MAX_FRAME_BYTES, type RelayFrame } from "@internal/subshell-protocol"; +import { binaryPayload } from "./link-crypto.js"; + +/** + * The daemon's relay pump, factored out of `daemon.ts` so the + * deliver-or-throw contract is testable without a socket (Task 8 acceptance + * (c), the node-side mirror of the plane's `sendRelayFrameOverNodeSocket`). + * + * `send` in the daemon LOGS a dropped event; a relay frame may not be dropped + * quietly. The proxy parks a phantom request on a send it believes delivered + * (T6 review, restated in T7's handoff), and the responder consumes a signed + * seq for a reply that never left - so every non-delivery here RAISES and the + * endpoints act on the throw (fail the request, consume no seq, stall to the + * plane's timers). State is read through a getter because the pump is handed + * to sessions that OUTLIVE a socket: the registry survives reconnects and the + * plane re-pumps on the fresh link, so the frame targets whatever socket is + * CURRENT at send time, never the socket the open rode in on. + */ + +/** The negotiator slice the pump reads: the per-CONNECTION link, possibly mid-handshake. */ +export interface RelaySendLink { + established(): boolean; + session(): { sealFrame(text: string): Uint8Array } | undefined; +} + +/** The live facts at send time: the CURRENT socket (or none) and its link (or none). */ +export interface RelaySendState { + ws?: { send(data: string | Buffer): unknown } | undefined; + link?: RelaySendLink | undefined; +} + +/** + * Build the pump over a state getter. Throws {@link Error} with a named + * reason on: no live socket (between connections), no established link (a + * protocol-18 socket has no plaintext path at all), an oversize envelope, or + * a failing write. On success the socket received a Buffer of sealed bytes. + */ +export function createDaemonRelaySend(getState: () => RelaySendState): (frame: RelayFrame) => void { + return (frame: RelayFrame): void => { + const { ws, link } = getState(); + if (!ws) throw new Error("relay send refused: no live socket"); + const session = link?.established() ? link.session() : undefined; + if (!session) { + throw new Error("relay send refused: the encrypted link is not established"); + } + const payload = JSON.stringify(frame); + const size = Buffer.byteLength(payload); + if (size > NODE_MAX_FRAME_BYTES) { + throw new Error(`relay send refused: frame ${size}B exceeds the ${NODE_MAX_FRAME_BYTES}B link cap`); + } + ws.send(binaryPayload(session.sealFrame(payload))); + }; +} diff --git a/apps/node/agent/src/ssh-dir-retention.ts b/apps/node/agent/src/ssh-dir-retention.ts new file mode 100644 index 000000000..7ab02eac1 --- /dev/null +++ b/apps/node/agent/src/ssh-dir-retention.ts @@ -0,0 +1,135 @@ +import { lstat, readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { log } from "./log.js"; +import { pathAllowed } from "./path-policy.js"; +import { isSubshellId, type SubshellMetaStore } from "./subshell-meta.js"; + +/** + * Node-side sweep of orphaned per-pane ssh config dirs — the agent's own + * pass over `/ssh/` (spec 2026-10-07 ssh-anywhere, decision 4's + * machine-side half). + * + * **Why this exists on the node at all.** An ssh pane's generated config is + * a DIRECTORY (`/ssh//config`), but the plane-commanded + * `remove_paths` executor unlinks FILES only — so a delete-after-death on an + * AGENT node removed the config and left the empty per-pane dir forever + * (the local twin of that leak was closed plane-side in the same wave; this + * is the machine's copy of the rule, same doctrine as + * `pane-log-retention.ts`). Nothing else ever names these paths: the id is + * retired, the row is gone, and the dir outlives every fact that explains it. + * + * The rules, each inherited from the pane-log sweep's doctrines: + * + * - **Only immediate children of `/ssh` whose name passes + * `isSubshellId`.** Nothing else in the tree is ours; a name that is not + * a pane id is nobody's to remove. The gate is hex digits and hyphens + * only (`/^[0-9a-fA-F-]{1,64}$/`, uppercase hex included), so a name + * carries no separator and no dot: the join cannot escape the dir, and + * `pathAllowed` + * re-checks anyway — the `remove_paths` rule, which is what refuses a + * symlinked `ssh` parent pointing outside the data dir. + * - **Never follow a symlink** (`lstat` first: a leaf whose name looks like + * an id is skipped with a log line, and a `rm -rf` never runs through it). + * - **A tracked pane's dir survives** whatever its mtime says: `meta.list()` + * is the record of live AND queued panes (the exit watcher forgets only on + * death), so a running pane's config is untouchable even mid-restart. + * - **Age floor, not a retention window:** a dir younger than one hour is + * kept. This guards the `execLaunch` write-before-meta-record window — a + * pane that launched seconds ago has a dir and no meta yet, and the + * tracked-pane rule alone could not save it. Unlike the pane-log sweep + * there is no operator window here and no keep-forever pair: a dead pane's + * empty config dir is pure garbage, and one hour is the grace, not a + * policy. That is also why the daemon schedules this OUTSIDE the log + * sweep's `forever` gate. + * - **Total by construction:** every fs failure costs one log line and the + * pass continues; an absent `ssh` dir is a silent no-op (a node that has + * never hosted an ssh pane). + */ + +const HOUR_MS = 3_600_000; + +/** Younger than this, the dir is kept: the launch write→meta-record grace. */ +export const ORPHAN_SSH_DIR_MIN_AGE_MS = HOUR_MS; + +/** The dir the sweep walks. File-private: every consumer goes through {@link sweepOrphanSshDirs}, which names it itself. */ +function sshDirsDir(dataDir: string): string { + return join(dataDir, "ssh"); +} + +/** + * Remove per-pane ssh config dirs no pane owns anymore. + * + * @param dataDir - the node's data dir; the sweep touches only `/ssh/`. + * @param meta - the launch records; `list()` IS "live or queued here" — a + * recorded id's dir is never garbage. (Narrowed to `list` exactly as + * `pane-log-retention.ts` narrows it, so a fake-meta test needs no store.) + * @param nowMs - epoch-ms clock; the age floor is judged against THIS, so a + * test pins the boundary without sleeping. + * @returns the number of dirs removed. Never rejects on fs failures (only a + * throwing `meta.list` can reject, same posture as the pane-log sweep, and + * the daemon's `.catch` covers that). + */ +export async function sweepOrphanSshDirs( + dataDir: string, + meta: Pick, + nowMs: number, +): Promise<{ removed: number }> { + const dir = sshDirsDir(dataDir); + let names: string[]; + try { + names = await readdir(dir); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTDIR") { + log(`ssh dir sweep: could not read ${dir}: ${err instanceof Error ? err.message : String(err)}`); + } + return { removed: 0 }; + } + const candidates = names.filter((n) => isSubshellId(n)); + if (candidates.length === 0) return { removed: 0 }; // nothing id-shaped — skip even the meta read + + // The tracked set FIRST: live AND queued panes are never garbage, whatever + // their dir says. A record with a dead socket (node was offline for the + // death) keeps the dir until the death report lands — the NEXT pass then + // ages it out; unknown-tracked is kept, not guessed. + const tracked = new Set((await meta.list()).map((m) => m.subshellId)); + + let removed = 0; + for (const name of candidates) { + if (tracked.has(name)) continue; + const path = join(dir, name); + let st; + try { + st = await lstat(path); + } catch (err) { + // A dir that vanished under us is the sweep's job done by someone else. + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + log(`ssh dir sweep: could not stat ${name}: ${err instanceof Error ? err.message : String(err)}`); + } + continue; + } + if (st.isSymbolicLink()) { + // An id-named symlink is not ours to follow: log and leave it (a + // hostile or accidental link out of the tree must not be `rm -rf`ed + // through — the pane-log sweep's symlink-leaf refusal, made explicit). + log(`ssh dir sweep: ${name} is a symlink; not following it`); + continue; + } + if (st.mtimeMs > nowMs - ORPHAN_SSH_DIR_MIN_AGE_MS) continue; // the launch write→meta grace + if (!(await pathAllowed(path, [dataDir]))) { + // The name gate makes this unreachable for a plain child; it is the + // belt for a symlinked `ssh` PARENT (realpath lands outside the data + // dir), which a recursive delete must never follow. + log(`ssh dir sweep: ${path} failed the path policy; left in place`); + continue; + } + try { + await rm(path, { recursive: true, force: true }); + removed += 1; + } catch (err) { + log(`ssh dir sweep: could not remove ${name}: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (removed > 0) log(`ssh dir sweep: removed ${removed} orphaned config dir(s)`); + return { removed }; +} diff --git a/apps/node/agent/src/ssh-enabled.ts b/apps/node/agent/src/ssh-enabled.ts new file mode 100644 index 000000000..84f825596 --- /dev/null +++ b/apps/node/agent/src/ssh-enabled.ts @@ -0,0 +1,174 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { type NodeSshEnabledWire, parseNodeSshEnabled } from "@internal/subshell-protocol"; +import { logger } from "./log.js"; + +/** + * This node's MIRROR of the SSH capability flag (spec 2026-10-07 §4.3): one + * word meaning "this machine may take part in Subshell SSH at all". + * + * It is the maintenance flag's twin in mechanics and its inversion in default. + * Same file-permission doctrine (0600, temp+rename, its own file beside + * `maintenance.json` and for the same two reasons — `config.json` is + * snapshotted at boot and it is the node key's only home). Same fail-CLOSED + * read: here the risk is what ENABLING lets Subshell do (egress over SSH, this + * machine's keys and agent), so a setting this process cannot read is not a + * permission. Absent refuses too — that is the inversion: maintenance's + * absent meant "behave as before" and launches flowed; SSH has no before. + * + * **The plane is the sole writer.** There is no `subshell ssh-enabled` verb, + * because turning a machine's egress on from a keyboard at that machine is + * exactly the decision this gate exists to make someone make in a browser, as + * the owner, with an audit row. So the mirror has only two authors — a + * `set_ssh_enabled` push and a repair — and its only reader is the SSH gate + * (this process, fail-closed) and the `ready` report that lets the plane + * notice when the two copies disagree. + * + * Because no one stamps this file locally, nothing compares stamps across the + * two ends: reconciliation never runs newer-wins here, the plane pushes its + * row, and the node's copy converges by obeying rather than by bargaining. + */ + +/** File name inside the agent data dir. */ +const FILE = "ssh-enabled.json"; + +/** Absolute path of the SSH-capability mirror for a data dir. */ +export function sshEnabledPath(dataDir: string): string { + return join(dataDir, FILE); +} + +/** + * What the mirror says, as the three answers a caller must tell apart — the + * gate's answers, not the file's biography. + * + * `on` is the only kind that permits, and it carries the stamp because + * `ready.sshEnabled` must be buildable from what was written. A file that + * parses to `{ on: false, changedAt }` and a file that does not exist give the + * SAME answer to every reader (refuse; and the plane's row already says off, + * so there is nothing to report), and no node-side writer exists whose stamp + * anyone would later reconcile — so the off file reads as `absent` while its + * bytes stay on disk. `unreadable` refuses too, but unlike the other two it + * says so: the answer it produces may match a quiet row, and when the row says + * on it is a disagreement the plane can only repair if it hears about it. + */ +export type SshEnabledRead = + /** Nothing permits here: no file, or a file that says off. */ + | { kind: "absent" } + /** The mirror parsed and says yes: SSH may run here. */ + | { + kind: "on"; + /** The stored stamp, verbatim — `ready.sshEnabled` is built from it. */ + changedAt: string; + } + /** The file exists but is not a state: REFUSED (fail-closed), reported under the file's mtime. */ + | { + kind: "unreadable"; + /** The file's mtime, ISO — absent only when the stat failed too, leaving nothing truthful to send. */ + changedAt?: string; + }; + +/** + * The gate's own answer, spelled once: ONLY a parsed `on` permits. + * + * Exported so the classifier the spec names ("absent and unreadable refuse, + * only on:true allows") lives in one place for every reader — the SSH launch + * path and any later status verb — rather than as `kind === "on"` typed out at + * each call site and drifting into an `|| absent` someday. + */ +export function sshAllowed(read: SshEnabledRead): boolean { + return read.kind === "on"; +} + +/** + * Reads the mirror. + * + * Total — every failure answers `unreadable` (one warn line) rather than + * throwing, because every caller is on a path where a throw would cost more + * than the wrong answer: the SSH gate, a heartbeat tick, and the `ready` + * builder. + * + * The parse is the WIRE parser, not a private one: the file holds exactly what + * travels, and a second validator here would be a second opinion about what + * `{ on, changedAt }` means. + */ +export function readSshEnabled(dataDir: string): SshEnabledRead { + const file = sshEnabledPath(dataDir); + if (!existsSync(file)) return { kind: "absent" }; + try { + const state = parseNodeSshEnabled(JSON.parse(readFileSync(file, "utf8"))); + if (!state) throw new SyntaxError("not an ssh-enabled state"); + return state.on ? { kind: "on", changedAt: state.changedAt } : { kind: "absent" }; + } catch (err) { + logger.withError(err).warn(`ssh-enabled: ${file} unreadable; treating this node as SSH-DISABLED`); + // The mtime, never `now`: this answer refuses, so the plane has to hear + // about it (a row that says on would push the clean file that repairs it), + // and the one timestamp available that nobody invented is when the file + // last changed. A fresh `now` per read would defeat the report memo and + // put one frame on the wire per heartbeat, forever. A stat that fails in + // turn leaves the stamp off: the refusal stands, and there is nothing to + // send. + const changedAt = mtimeOf(file); + return changedAt ? { kind: "unreadable", changedAt } : { kind: "unreadable" }; + } +} + +/** + * A file's mtime as an ISO stamp. TOTAL — every caller is on a path where the + * answer is optional and a throw would cost the whole read. + */ +function mtimeOf(file: string): string | undefined { + try { + return statSync(file).mtime.toISOString(); + } catch { + return undefined; + } +} + +/** + * The wire value a read is worth announcing, or `undefined` when there is + * nothing truthful to say. + * + * Every reporting path goes through this so the answers cannot be classified + * differently in three places: `ready`, the heartbeat memo check and (from + * Plan 2) the SSH gate all owe the plane the same value for the same file. An + * unreadable mirror announces the REFUSAL it actually produces — the inverse + * of maintenance's unreadable, which announces the `on` IT produces. + * + * @param read - what {@link readSshEnabled} answered + * @returns the `{ on, changedAt }` to send, or `undefined` for absent (the + * plane's row needs no echo from a node agreeing off) and for an unstamped + * unreadable + */ +export function reportableSshEnabled(read: SshEnabledRead): NodeSshEnabledWire | undefined { + if (read.kind === "on") return { on: true, changedAt: read.changedAt }; + if (read.kind === "unreadable" && read.changedAt) return { on: false, changedAt: read.changedAt }; + return undefined; +} + +/** + * Persists the mirror, 0600, via temp + rename. + * + * Atomic for the maintenance reason and sharper: a gate may read this at any + * instant, and a half-written file parses as corrupt — which under the + * fail-closed rule REFUSES every SSH act for as long as the write takes, + * mid-connect rather than merely wide of a flag. + * + * The `state` is stored VERBATIM. The plane sent it; re-stamping here would + * make the two copies differ by exactly the link delay, and the next `ready` + * would report a disagreement this process invented — which the plane would + * "repair" by pushing the same value again, forever. + * + * @returns the state as stored + */ +export function writeSshEnabled(dataDir: string, state: NodeSshEnabledWire): NodeSshEnabledWire { + const file = sshEnabledPath(dataDir); + const body: NodeSshEnabledWire = { on: state.on, changedAt: state.changedAt }; + // 0700, matching the data dir `identity.ts` creates. mkdir's mode is + // umask-clamped, so it is a floor; the file's own 0600 below is set at + // creation and is the one that matters. + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(body, null, 2), { mode: 0o600 }); + renameSync(tmp, file); + return body; +} diff --git a/apps/node/agent/src/ssh-fingerprint-report.ts b/apps/node/agent/src/ssh-fingerprint-report.ts new file mode 100644 index 000000000..ecff708d9 --- /dev/null +++ b/apps/node/agent/src/ssh-fingerprint-report.ts @@ -0,0 +1,52 @@ +/** + * The §4.6 SSH trust block, computed from THIS machine's own files (spec + * 2026-10-08 §4.5/§4.6): both fingerprints of the node's own keypair (from + * `identity.ts`) and both fingerprints of every relay peer pinned in + * `machine-pin-store.ts`. + * + * This is the out-of-band check's node half: an operator compares what one + * machine prints here against what the PEER computes from its own private + * keys, on the two machines' own surfaces, and the only agreement that counts + * is byte equality of the `SHA256:` strings. Fingerprints are the DISPLAY + * form; the store next door still enforces the raw JWK strings. + * + * The fingerprints come from `fingerprintJwk` (canonical SHA-256 over the DER + * SubjectPublicKeyInfo, JWK member order cannot move it) - never a re-derive: + * a second hash rule would let two honest machines disagree about the same + * key, which is the exact failure this surface exists to catch. + * + * THROWING BY CONTRACT: an unreadable pin file or an unusable identity is + * fail-closed noise, not an empty block, and every caller decides what the + * absence costs (the `ready` carriage drops the block and says nothing; the + * dashboard card simply has nothing to render). + */ +import { + fingerprintJwk, + type NodeSshFingerprintReport, + type NodeSshPeerFingerprint, +} from "@internal/subshell-protocol"; +import { loadOrCreateIdentity } from "./identity.js"; +import { MachinePinStore } from "./machine-pin-store.js"; + +/** + * Build the trust block from the node's data dir: own halves from the two + * identity files (generating a pre-M2 node's signing pair through the + * ordinary first-run path), peers from `ssh-machine-pins.json`, id ascending. + * @param dataDir - the agent's configured data dir (`loadConfig()` value) + */ +export async function buildSshFingerprintReport(dataDir: string): Promise { + const identity = await loadOrCreateIdentity(dataDir); + const own = { + signing: await fingerprintJwk(identity.signingPublicJwk), + encryption: await fingerprintJwk(identity.publicJwk), + }; + const peers: NodeSshPeerFingerprint[] = []; + for (const { nodeId, pin } of new MachinePinStore(dataDir).entries()) { + peers.push({ + nodeId, + signing: await fingerprintJwk(pin.signing), + encryption: await fingerprintJwk(pin.encryption), + }); + } + return { own, peers }; +} diff --git a/apps/node/agent/src/version.ts b/apps/node/agent/src/version.ts index c8227fdb9..e7e0da6f1 100644 --- a/apps/node/agent/src/version.ts +++ b/apps/node/agent/src/version.ts @@ -1,8 +1,12 @@ -import pkg from "../package.json"; +import pkg from "../package.json" with { type: "json" }; /** * The agent's own version, sourced from package.json (the single source of * truth). resolveJsonModule lets tsc type it, Bun's bundler inlines it for - * both tsdown and `bun build --compile`. + * both tsdown and `bun build --compile`. The `with { type: "json" }` attribute + * is what the `@internal/server` typecheck needs: its tests import the agent's + * modules in place under NodeNext, which requires the attribute on a JSON + * import - the spelling the server's own version.ts and this package's + * release script already use. */ export const NODE_VERSION: string = pkg.version; diff --git a/apps/node/web/src/routes/__tests__/status-page.test.tsx b/apps/node/web/src/routes/__tests__/status-page.test.tsx new file mode 100644 index 000000000..46d44c0a3 --- /dev/null +++ b/apps/node/web/src/routes/__tests__/status-page.test.tsx @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { NodeDetail } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen } from "@testing-library/react"; +import { StatusPage } from "../index"; + +/** + * The status page is the §4.6 out-of-band compare's LOCAL half (spec + * 2026-10-08): the check happens on the two machines' OWN loopback + * dashboards, because a fingerprint read off the plane proves nothing about + * the machine behind it. So this page must render the trust card, fed from + * the agent view's own `sshTrust` (the node-local key files, `stale: false` + * by definition on the machine's own surface). + * + * The fetcher is stubbed: the page's `useNode("self")` speaks the node's + * mirror of `/api/nodes/:id`, and `NodeLogCard` polls `/logs` even though + * this suite is about the trust block, not the tail. + */ + +const FP_OWN_SIGN = `SHA256:${"A".repeat(43)}`; +const FP_OWN_ENC = `SHA256:${"B".repeat(43)}`; +const FP_PEER_SIGN = `SHA256:${"C".repeat(43)}`; +const FP_PEER_ENC = `SHA256:${"D".repeat(43)}`; +const PEER_ID = "0f8e2c1a-0000-4000-8000-000000000001"; + +/** This machine answering `GET /api/nodes/self` about itself, as `view.ts` shapes it. */ +function localView(): NodeDetail { + return { + id: "self", + name: "devbox", + kind: "agent", + os: "linux", + arch: "x64", + hostname: "devbox", + status: "online", + lastSeenAt: "2026-10-08T10:00:00.000Z", + agentVersion: "1.0.0", + protocolVersion: 18, + // The loopback view's standing answers: no viewer identity exists here, + // and the human at this machine outranks any web grant (view.ts). + access: "owner", + canManage: true, + canRetire: true, + canLaunch: true, + allowedDirs: [], + capabilities: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: true, + held: null, + serverUrl: "http://127.0.0.1:3080", + // No `runtime`: the page then skips the Runtime/Service cards, and this + // suite stays about the trust block. The trust card needs no runtime. + sshTrust: { + own: { signing: FP_OWN_SIGN, encryption: FP_OWN_ENC }, + peers: [{ nodeId: PEER_ID, signing: FP_PEER_SIGN, encryption: FP_PEER_ENC }], + stale: false, + }, + runningSubshells: 0, + }; +} + +let realFetch: typeof globalThis.fetch; + +beforeEach(() => { + realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const body = url.includes("/logs") ? { text: "", nextByte: 0, size: 0, truncated: false } : localView(); + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +}); + +afterEach(() => { + cleanup(); + globalThis.fetch = realFetch; +}); + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + + , + ); +} + +describe("status page — machine trust", () => { + it("renders own and peer fingerprints from the agent view, unmarked (the local truth is never stale)", async () => { + renderPage(); + expect(await screen.findByText("Machine trust")).toBeTruthy(); + expect(screen.getByText(FP_OWN_SIGN)).toBeTruthy(); + expect(screen.getByText(FP_OWN_ENC)).toBeTruthy(); + expect(screen.getByText(`Peer ${PEER_ID}`)).toBeTruthy(); + expect(screen.getByText(FP_PEER_SIGN)).toBeTruthy(); + expect(screen.getByText(FP_PEER_ENC)).toBeTruthy(); + // No stale marking on the machine's own surface: this page is the local + // truth, never a mirror of a departed connection. + expect(screen.queryByText(/stale/i)).toBeNull(); + }); + + it("renders no trust card when the node's own store reported no block", async () => { + // The unreadable-pin-set case: `view.ts` costs the FIELD, never the page. + const base = localView(); + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const body = url.includes("/logs") + ? { text: "", nextByte: 0, size: 0, truncated: false } + : { ...base, sshTrust: undefined }; + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + renderPage(); + await screen.findByText("This machine"); + expect(screen.queryByText("Machine trust")).toBeNull(); + }); +}); diff --git a/apps/node/web/src/routes/index.tsx b/apps/node/web/src/routes/index.tsx index 262da8462..e50b1ec42 100644 --- a/apps/node/web/src/routes/index.tsx +++ b/apps/node/web/src/routes/index.tsx @@ -6,6 +6,7 @@ import { NodeMaintenanceCard, NodeRuntimeCard, NodeServiceCard, + NodeSshTrustCard, relativeElapsed, useNode, } from "@internal/node-admin"; @@ -25,7 +26,8 @@ export const Route = createFileRoute("/")({ component: StatusPage }); * not make you click between tabs. The cards are the shared ones, unmodified — * which is the point of the extraction. */ -function StatusPage() { +/** Exported for the dashboard test; the route wires it as its component. */ +export function StatusPage() { const node = useNode("self"); if (node.isError) { @@ -77,6 +79,8 @@ function StatusPage() {

This node did not report how it runs.

)} + + ); diff --git a/apps/server/api/src/api/__tests__/backups-route.test.ts b/apps/server/api/src/api/__tests__/backups-route.test.ts index 00982ee1c..386732e6f 100644 --- a/apps/server/api/src/api/__tests__/backups-route.test.ts +++ b/apps/server/api/src/api/__tests__/backups-route.test.ts @@ -287,10 +287,18 @@ describe("admin archive endpoints", () => { (await app.fetch(authedRequest(`/api/admin/backups/jobs/${id}`, admin.token, { method: "DELETE" }))).status, ).toBe(200); expect((await app.fetch(authedRequest(`/api/admin/backups/download/${id}`, admin.token))).status).toBe(409); + // The cancel retires the job record on the async cleanup path, NOT inside + // the DELETE response, so the 404 arrives a beat later. CI's throttled + // 0.4-CPU slice starved the old fixed 100x10ms (~1s) budget past ~1.8s and + // this test flaked twice across the ssh stack (#336, #337) while passing on + // rerun - a runner-speed-dependent budget, which is never deterministic. A + // wall deadline is: it exits the instant 404 lands and only spends the extra + // room when cleanup is genuinely slow. let status = 200; - for (let attempt = 0; attempt < 100 && status !== 404; attempt++) { + const goneBy = Date.now() + 8_000; + while (status !== 404 && Date.now() < goneBy) { status = (await app.fetch(authedRequest(`/api/admin/backups/jobs/${id}`, admin.token))).status; - if (status !== 404) await Bun.sleep(10); + if (status !== 404) await Bun.sleep(20); } expect(status).toBe(404); }); diff --git a/apps/server/api/src/api/__tests__/downloads-shelf-serve.test.ts b/apps/server/api/src/api/__tests__/downloads-shelf-serve.test.ts index d670dbe2a..9244f0cdd 100644 --- a/apps/server/api/src/api/__tests__/downloads-shelf-serve.test.ts +++ b/apps/server/api/src/api/__tests__/downloads-shelf-serve.test.ts @@ -195,7 +195,7 @@ describe("/api/downloads/node — the shelf answers for itself", () => { app.handle(new Request(`http://localhost${path}`, { headers: { cookie: `better-auth.session_token=${cookie}` } })); it("a shelf BEHIND the release serves the release, and the digest route announces the SAME answer", async () => { - const shelf = plantShelf(shelfScript("1.0.0", NODE_PROTOCOL_VERSION)); + const shelf = plantShelf(shelfScript(MIN_NODE_VERSION, NODE_PROTOCOL_VERSION)); const res = await get(`/api/downloads/node/${TARGET}`); expect(await res.text()).toBe(NEWBODY); // Operator bytes, no ledger entry: streamed PAST, never overwritten. @@ -233,7 +233,7 @@ describe("/api/downloads/node — the shelf answers for itself", () => { it("no compatible release AND a wrong-protocol shelf: both routes refuse, naming the mismatch", async () => { manifestProtocol = NODE_PROTOCOL_VERSION - 1; - plantShelf(shelfScript("1.0.0", NODE_PROTOCOL_VERSION - 1)); + plantShelf(shelfScript(MIN_NODE_VERSION, NODE_PROTOCOL_VERSION - 1)); const res = await get(`/api/downloads/node/${TARGET}`); expect(res.status).toBe(404); const body = (await res.json()) as { code: string; message: string }; @@ -244,7 +244,7 @@ describe("/api/downloads/node — the shelf answers for itself", () => { it("no compatible release but a shelf that CAN connect: the shelf serves (dev-loop and air-gap-adjacent cells)", async () => { manifestProtocol = NODE_PROTOCOL_VERSION - 1; - const shelf = plantShelf(shelfScript("1.0.0", NODE_PROTOCOL_VERSION)); + const shelf = plantShelf(shelfScript(MIN_NODE_VERSION, NODE_PROTOCOL_VERSION)); const res = await get(`/api/downloads/node/${TARGET}`); expect(await res.text()).toBe(shelf); }); @@ -257,7 +257,7 @@ describe("/api/downloads/node — the shelf answers for itself", () => { it("air-gapped: a connectable shelf is the only source and wins, behind or ahead", async () => { setReleaseUrlForTests(null); - const behind = plantShelf(shelfScript("1.0.0", NODE_PROTOCOL_VERSION)); + const behind = plantShelf(shelfScript(MIN_NODE_VERSION, NODE_PROTOCOL_VERSION)); expect(await (await get(`/api/downloads/node/${TARGET}`)).text()).toBe(behind); const sha = await get(`/api/downloads/node/${TARGET}.sha256`); expect((await sha.text()).trim()).toBe(hex(behind)); @@ -336,7 +336,7 @@ describe("/api/downloads/node — the shelf answers for itself", () => { }); it("the release the decision chose cannot be fetched: 404, and the shelf is NOT served under a release announcement", async () => { - plantShelf(shelfScript("1.0.0", NODE_PROTOCOL_VERSION)); + plantShelf(shelfScript(MIN_NODE_VERSION, NODE_PROTOCOL_VERSION)); failBin = true; try { const res = await get(`/api/downloads/node/${TARGET}`); @@ -347,7 +347,7 @@ describe("/api/downloads/node — the shelf answers for itself", () => { }); it("bytes this INSTANCE fetched are cache the release may replace", async () => { - const shelf = plantShelf(shelfScript("1.0.0", NODE_PROTOCOL_VERSION)); + const shelf = plantShelf(shelfScript(MIN_NODE_VERSION, NODE_PROTOCOL_VERSION)); writeFileSync( ledgerPath, JSON.stringify({ [TARGET]: { tag: "cli-node-v0.0.1", digest: hex(shelf), fetchedAt: "x" } }), @@ -360,7 +360,7 @@ describe("/api/downloads/node — the shelf answers for itself", () => { it("release unreachable with a connectable shelf: the older agent still installs (outdated beats refused)", async () => { setReleaseUrlForTests("http://127.0.0.1:1/releases"); - const shelf = plantShelf(shelfScript("1.0.0", NODE_PROTOCOL_VERSION)); + const shelf = plantShelf(shelfScript(MIN_NODE_VERSION, NODE_PROTOCOL_VERSION)); const res = await get(`/api/downloads/node/${TARGET}`); expect(res.status).toBe(200); expect(await res.text()).toBe(shelf); @@ -368,7 +368,7 @@ describe("/api/downloads/node — the shelf answers for itself", () => { it("release unreachable with a wrong-protocol shelf: still the 404, never the refusal-loop installer", async () => { setReleaseUrlForTests("http://127.0.0.1:1/releases"); - plantShelf(shelfScript("1.0.0", NODE_PROTOCOL_VERSION - 1)); + plantShelf(shelfScript(MIN_NODE_VERSION, NODE_PROTOCOL_VERSION - 1)); const res = await get(`/api/downloads/node/${TARGET}`); expect(res.status).toBe(404); }); @@ -394,7 +394,7 @@ describe("/api/downloads/node — the shelf answers for itself", () => { it("an inconsistent shelf AND no compatible release: refused, and the sentence says REPUBLISH", async () => { manifestProtocol = NODE_PROTOCOL_VERSION - 1; - plantShelf(shelfScript("1.0.0", NODE_PROTOCOL_VERSION)); + plantShelf(shelfScript(MIN_NODE_VERSION, NODE_PROTOCOL_VERSION)); writeFileSync(`${binaryPath}.sha256`, `${"f".repeat(64)}\n`); const res = await get(`/api/downloads/node/${TARGET}`); expect(res.status).toBe(404); @@ -409,7 +409,7 @@ describe("/api/downloads/node — the shelf answers for itself", () => { // precedence entirely (rule 1's broken cell) — and because these bytes // are the OPERATOR's (no ledger entry), the release is streamed through // without touching them: the operator republishes, the server does not. - const shelf = plantShelf(shelfScript("1.0.0", NODE_PROTOCOL_VERSION)); + const shelf = plantShelf(shelfScript(MIN_NODE_VERSION, NODE_PROTOCOL_VERSION)); writeFileSync(`${binaryPath}.sha256`, `${"f".repeat(64)}\n`); const res = await get(`/api/downloads/node/${TARGET}`); expect(await res.text()).toBe(NEWBODY); diff --git a/apps/server/api/src/api/__tests__/install-script.test.ts b/apps/server/api/src/api/__tests__/install-script.test.ts new file mode 100644 index 000000000..456e59417 --- /dev/null +++ b/apps/server/api/src/api/__tests__/install-script.test.ts @@ -0,0 +1,87 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { installScriptRoute } from "@/api/install-script.js"; +import { db } from "@/db/index.js"; +import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; + +/** + * `GET /install.sh` render rules (spec 2026-08-31 §8; the placeholder rule + * added by spec 2026-10-08 §7, Task 14). The "Set up Subshell here" act runs + * this exact script through a non-interactive exec and shows only a parsed + * status verb, so ANY line the script prints must be safe to capture: the + * 404 advice branch used to echo `--key $KEY`, which bash expands to the live + * setup key at RUNTIME - invisible to the rendered-text tests, visible in the + * captured output. It now prints the literal `--key `: a copy- + * paste template, never a value. + * + * The oracle rule stays: absent, unknown, spent, and expired keys render + * byte-identically (the endpoint is not a setup-key oracle). + */ + +const app = new Elysia().use(errorHandlerPlugin).use(installScriptRoute); +const repo = new NodeSetupKeysRepository(db); +const email = `install-${crypto.randomUUID()}@subshell.local`; +let userId = ""; +const keyIds: string[] = []; + +async function mkKey(ttlMs?: number): Promise { + const row = await repo.create(userId, ttlMs); + keyIds.push(row.id); + return row.key; +} + +async function install(key?: string): Promise { + const q = key ? `?setup_key=${encodeURIComponent(key)}` : ""; + return app.fetch(new Request(`http://localhost:3080/install.sh${q}`)); +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("install-1"), + role: "user", + }); +}); + +afterAll(async () => { + for (const id of keyIds) await repo.deleteById(id, userId).catch(() => {}); + await deleteUserByEmailOrId(email).catch(() => {}); +}); + +describe("install.sh 404 advice branch (spec 2026-10-08 §7)", () => { + it("prints the literal `--key ` placeholder and never the key value", async () => { + const key = await mkKey(); + const body = await (await install(key)).text(); + expect(body).toContain("--key "); + // The runtime-expanding spelling is gone: bash would interpolate $KEY into + // the advice line, and the "Set up Subshell here" parser captures that + // line verbatim on a D that cannot fetch the node binary. + expect(body).not.toContain("--key $KEY"); + // The key still appears EXACTLY once: the KEY= assignment the render doc + // names as its only home. + expect(body.split(key).length - 1).toBe(1); + }); +}); + +describe("install.sh failure rendering is one byte-identical answer (never an oracle)", () => { + it("absent, unknown, spent, and expired keys render the same usage script", async () => { + const absent = await (await install()).text(); + const unknown = await (await install(`nsk_${"x".repeat(32)}`)).text(); + const spentKey = await mkKey(); + await repo.consume(spentKey, crypto.randomUUID()); + const spent = await (await install(spentKey)).text(); + const expired = await (await install(await mkKey(-60_000))).text(); + expect(unknown).toBe(absent); + expect(spent).toBe(absent); + expect(expired).toBe(absent); + expect(absent).toContain("exit 2"); + }); +}); diff --git a/apps/server/api/src/api/__tests__/installed-plugin-launch.test.ts b/apps/server/api/src/api/__tests__/installed-plugin-launch.test.ts index 4648ce50a..e4d6d1734 100644 --- a/apps/server/api/src/api/__tests__/installed-plugin-launch.test.ts +++ b/apps/server/api/src/api/__tests__/installed-plugin-launch.test.ts @@ -121,7 +121,7 @@ afterAll(async () => { }); describe("an installed plugin resolves on the plane's side of a launch", () => { - it("detectSpecs ships acme's lookup rule to nodes, beside the six built-ins", () => { + it("detectSpecs ships acme's lookup rule to nodes, beside the seven built-ins", () => { const specs = detectSpecs(); expect(specs.find((s) => s.id === "acme")).toEqual({ id: "acme", @@ -130,9 +130,10 @@ describe("an installed plugin resolves on the plane's side of a launch", () => { knownPaths: [], }); // The built-ins did not move (the shadow/merge rule costs nothing), and - // the sixth is here too — terminal's SHELL rule reaching every node is - // what makes a clean machine launchable there. - for (const id of ["claude-code", "codex", "hermes", "opencode", "pi", "terminal"]) { + // terminal's and ssh's rules are here too: terminal's SHELL rule is what + // makes a clean machine launchable there, and ssh's is the ladder the + // ssh launcher's resolved binary comes from. + for (const id of ["claude-code", "codex", "hermes", "opencode", "pi", "ssh", "terminal"]) { expect(specs.some((s) => s.id === id)).toBe(true); } }); diff --git a/apps/server/api/src/api/channels/__tests__/channels-route.test.ts b/apps/server/api/src/api/channels/__tests__/channels-route.test.ts index 7734f9f83..5a109b055 100644 --- a/apps/server/api/src/api/channels/__tests__/channels-route.test.ts +++ b/apps/server/api/src/api/channels/__tests__/channels-route.test.ts @@ -442,4 +442,108 @@ describe("channels route", () => { setNudgeTransportForTests(null); } }); + + it("nudge NEVER types into an ssh pane (raw remote shell, owner-only input, spec §5.4)", async () => { + nudges.length = 0; + class FakeTmux extends TmuxRunner { + override async sendInput(socket: string, subshellName: string, input: string): Promise { + nudges.push({ socket, name: subshellName, text: input }); + } + } + setNudgeTransportForTests(new FakeTmux()); + try { + await registerIdentity(aliceToken); + await call(channelRoutes, "/api/channels", aliceToken, postJson({ name: "sshwake" })); + + // A RUNNING ssh-pane row: alive, with a tmux socket, snapshot column + // set — every fact the plain nudge guard reads passes; only `ssh` + // distinguishes it. (A tmux-less or dead row would be skipped for the + // ordinary reasons and prove nothing about the §5.4 carve-out.) + async function sshPane(id: string): Promise { + createdSubshells.push(id); + await new SubshellsRepository(db).create({ + id, + userId: bob, + presetId: null, + harnessId: "ssh", + name: "s", + workingDir: "/tmp", + tmuxSocket: `sock-${id}`, + alive: 1, + ssh: JSON.stringify({ destination: "host.example", user: "theo", port: 22 }), + }); + const key = await issueSubshellToken(id, bob); + const { publicJwk } = await generateKeypair(); + await identityRoutes.fetch( + new Request("http://localhost:3080/api/identities", { + method: "POST", + headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, + body: JSON.stringify({ publicKey: publicJwk }), + }), + ); + await channelRoutes.fetch( + new Request("http://localhost:3080/api/channels/sshwake/members", { + method: "POST", + headers: { authorization: `Bearer ${key}` }, + }), + ); + } + + const sshId = crypto.randomUUID(); + const plainId = crypto.randomUUID(); + await sshPane(sshId); + // A plain running recipient pane posted to in the SAME breath: it must + // still be nudged (the ssh skip is targeted, not a blanket stop). + createdSubshells.push(plainId); + await new SubshellsRepository(db).create({ + id: plainId, + userId: bob, + presetId: null, + harnessId: "claude-code", + name: "p", + workingDir: "/tmp", + tmuxSocket: `sock-${plainId}`, + alive: 1, + }); + const plainKey = await issueSubshellToken(plainId, bob); + const { publicJwk: plainJwk } = await generateKeypair(); + await identityRoutes.fetch( + new Request("http://localhost:3080/api/identities", { + method: "POST", + headers: { authorization: `Bearer ${plainKey}`, "content-type": "application/json" }, + body: JSON.stringify({ publicKey: plainJwk }), + }), + ); + await channelRoutes.fetch( + new Request("http://localhost:3080/api/channels/sshwake/members", { + method: "POST", + headers: { authorization: `Bearer ${plainKey}` }, + }), + ); + + await call( + channelRoutes, + "/api/channels/sshwake/posts", + aliceToken, + postJson({ + envelope: envelope("go", [`sess:${sshId}`, `sess:${plainId}`]), + recipientIds: [`sess:${sshId}`, `sess:${plainId}`], + nudge: true, + }), + ); + + // The ssh pane receives NOTHING (typing the cue into a raw shell under + // the owner's ssh identity is input, and input there is owner-only); + // the plain pane receives its usual inert line. + expect(nudges.map((n) => n.name)).toEqual([plainId]); + expect(nudges[0].text).toContain("#sshwake"); + + for (const n of [sshId, plainId]) { + const row = await new SubshellsRepository(db).findById(n); + if (row?.apiKeyId) authDatabase().run("DELETE FROM apikey WHERE id = ?", [row.apiKeyId]); + } + } finally { + setNudgeTransportForTests(null); + } + }); }); diff --git a/apps/server/api/src/api/identities.route.ts b/apps/server/api/src/api/identities.route.ts index 0a655dd56..b5e656270 100644 --- a/apps/server/api/src/api/identities.route.ts +++ b/apps/server/api/src/api/identities.route.ts @@ -42,9 +42,13 @@ export const identityRoutes = new Elysia({ prefix: "/api/identities" }) // Must be a P-256 PUBLIC key jose can IMPORT — a merely parseable // object would poison seal() for every co-member (see public-jwk.ts). await assertImportablePublicJwk(parsed); + // Pane/user principals have no relay signing identity: the slot is + // stated null so the machine-only column (spec 2026-10-08 §4.2) is + // never filled through this door. return await new IdentitiesRepository(db).register({ principalId: principal, publicKey: body.publicKey, + signingPublicKey: null, displayName: body.displayName ?? null, }); }, diff --git a/apps/server/api/src/api/install-script.ts b/apps/server/api/src/api/install-script.ts index a88140734..b2f7ff35c 100644 --- a/apps/server/api/src/api/install-script.ts +++ b/apps/server/api/src/api/install-script.ts @@ -361,7 +361,8 @@ else echo " cli-node-vX.Y.Z GitHub Release into that dir, or run" >&2 echo " 'bun run release:cli-node' from a checkout on the server host." >&2 echo " Or install the node for this machine another way and run setup directly:" >&2 - echo " subshell setup --server $SERVER --key $KEY\${DATA_DIR:+ --data-dir \\"$DATA_DIR\\"}" >&2 + # Keep credentials out of captured setup output. The user supplies this placeholder. + echo " subshell setup --server $SERVER --key \${DATA_DIR:+ --data-dir \\"$DATA_DIR\\"}" >&2 exit 1 ;; *) diff --git a/apps/server/api/src/api/models.ts b/apps/server/api/src/api/models.ts index 863f0e6ce..a2b33a908 100644 --- a/apps/server/api/src/api/models.ts +++ b/apps/server/api/src/api/models.ts @@ -66,6 +66,9 @@ export const SubshellSchema = t.Object({ access: t.Union([t.Literal("owner"), t.Literal("edit"), t.Literal("view")], { description: "Caller's effective access to this subshell (viewer-relative; never 'none' on a returned row)", }), + ssh: t.Boolean({ + description: "True when this pane is an SSH-terminal pane: only its owner can type into it.", + }), nodeOffline: t.Boolean({ description: "True when the subshell's node is disconnected. The subshell may still be running there. Always false for subshells on the server host.", diff --git a/apps/server/api/src/api/nodes/__tests__/enroll-route.test.ts b/apps/server/api/src/api/nodes/__tests__/enroll-route.test.ts index f91c99709..092b34451 100644 --- a/apps/server/api/src/api/nodes/__tests__/enroll-route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/enroll-route.test.ts @@ -28,6 +28,15 @@ async function jwkString(withD = false): Promise { return JSON.stringify(await exportJWK(withD ? privateKey : publicKey)); } +/** + * Fresh ES256 signing keypair as a JWK string (the M2 relay identity, spec + * 2026-10-08 §4.1); `withD` exports the PRIVATE half, which enroll must refuse. + */ +async function signingJwkString(withD = false): Promise { + const { publicKey, privateKey } = await generateKeyPair("ES256", { crv: "P-256", extractable: true }); + return JSON.stringify(await exportJWK(withD ? privateKey : publicKey)); +} + /** * Snapshot of the constants module taken before any `mock.module` swap, so the * wsUrl subpath test can restore the real values for the suites that share this @@ -53,6 +62,7 @@ describe("/api/nodes/enroll", () => { const createdNodeIds: string[] = []; const createdSetupKeyIds: string[] = []; let goodJwk: string; + let goodSigningJwk: string; beforeAll(async () => { await setupAuthTables(); @@ -64,6 +74,7 @@ describe("/api/nodes/enroll", () => { }); await signIn(aliceEmail, pw); // exercise the real auth stack; enroll itself needs no cookie goodJwk = await jwkString(); + goodSigningJwk = await signingJwkString(); }); afterAll(async () => { @@ -105,6 +116,10 @@ describe("/api/nodes/enroll", () => { hostname: "host-a", agentVersion: "0.1.0", publicKey: goodJwk, + // The M2 relay signing key (spec 2026-10-08 §4.2): a post-M2 agent + // carries it; the pre-M2 posture is `signingPublicKey: undefined`, + // which JSON.stringify drops from the body. + signingPublicKey: goodSigningJwk, ...over, }; } @@ -129,9 +144,11 @@ describe("/api/nodes/enroll", () => { expect(node?.agentVersion).toBe("0.1.0"); expect(node?.apiKeyId).toBeTruthy(); - // The identity row carries the exact validated JWK string under node:. + // The identity row carries the exact validated JWK string under node:, + // and the M2 signing slot (spec 2026-10-08 §4.2) rides the SAME record. const identity = await identities.findByPrincipal(`node:${body.nodeId}`); expect(identity?.publicKey).toBe(goodJwk); + expect(identity?.signingPublicKey).toBe(goodSigningJwk); expect(identity?.displayName).toBe("mini-one"); // The node key is a real `subshell_` bearer; the setup key cannot redeem again. @@ -194,6 +211,7 @@ describe("/api/nodes/enroll", () => { timer: setTimeout(() => {}, 30000), }); const freshJwk = await jwkString(); + const freshSigningJwk = await signingJwkString(); const link = await generateLinkKeyPair(); const freshEncryptPublicKey = link.publicKey; const res = await enroll( @@ -201,6 +219,7 @@ describe("/api/nodes/enroll", () => { name: "ignored replacement name", hostname: "replacement-host", publicKey: freshJwk, + signingPublicKey: freshSigningJwk, encryptPublicKey: freshEncryptPublicKey, }), ); @@ -222,7 +241,11 @@ describe("/api/nodes/enroll", () => { expect(current.hostname).toBe("replacement-host"); expect(current.publicKey).toBe(freshJwk); expect(current.encryptPublicKey).toBe(freshEncryptPublicKey); - expect((await identities.findByPrincipal(`node:${original.nodeId}`))?.publicKey).toBe(freshJwk); + // Both identity halves swap together: re-registration replaces the whole + // machine identity, signing slot included (spec 2026-10-08 §4.2). + const recoveredIdentity = await identities.findByPrincipal(`node:${original.nodeId}`); + expect(recoveredIdentity?.publicKey).toBe(freshJwk); + expect(recoveredIdentity?.signingPublicKey).toBe(freshSigningJwk); expect(await db.selectFrom("nodeShares").selectAll().where("nodeId", "=", original.nodeId).execute()).toHaveLength( 1, ); @@ -495,6 +518,73 @@ describe("/api/nodes/enroll", () => { expect(await repo.peekValid(setupKey)).toBe(true); }); + // ── Relay signing identity (spec 2026-10-08 §4.1/§4.2): enroll carries the + // machine's ES256 public key into the SAME `node:` identity record, and a + // bad one burns nothing. + + it("a valid signingPublicKey registers beside the encryption key", async () => { + const setupKey = await makeKey(); + const res = await enroll(bodyFor(setupKey, { name: "relay-id" })); + expect(res.status).toBe(201); + const { nodeId } = (await res.json()) as { nodeId: string }; + createdNodeIds.push(nodeId); + const identity = await identities.findByPrincipal(`node:${nodeId}`); + expect(identity?.signingPublicKey).toBe(goodSigningJwk); + // The relay half lives only in the identities record, never on the node row. + expect(await nodes.findById(nodeId)).not.toHaveProperty("signingPublicKey"); + }); + + it("an absent signingPublicKey (pre-M2 agent) still enrolls and registers NULL", async () => { + // The field is optional so an old binary enrolls unchanged; §4.3's + // bootstrap delivers the signing key over the node link later. Until then + // the slot is NULL, exactly the shape every pre-M2 row carries. + const setupKey = await makeKey(); + const res = await enroll(bodyFor(setupKey, { name: "pre-m2-one", signingPublicKey: undefined })); + expect(res.status).toBe(201); + const { nodeId } = (await res.json()) as { nodeId: string }; + createdNodeIds.push(nodeId); + const identity = await identities.findByPrincipal(`node:${nodeId}`); + expect(identity?.publicKey).toBe(goodJwk); + expect(identity?.signingPublicKey).toBeNull(); + }); + + it("malformed signingPublicKey fails BEFORE consume — key stays redeemable", async () => { + // THE ordering test for the new field (same position the publicKey checks + // hold): a body that cannot be stored must not spend the single-use key. + // The fixture stays above the schema's minLength so it genuinely reaches + // the route's own decode branches. + const setupKey = await makeKey(); + + const notJson = await enroll(bodyFor(setupKey, { signingPublicKey: "this-is-not-json-at-all" })); + expect(notJson.status).toBe(400); + expect(((await notJson.json()) as { message: string }).message).toInclude("signingPublicKey"); + expect(await repo.peekValid(setupKey)).toBe(true); + + // Well-formed, right shape, coordinates off the curve: only the real + // import proves a curve point (the publicKey discipline, measured). + const offCurve = await enroll( + bodyFor(setupKey, { + signingPublicKey: JSON.stringify({ kty: "EC", crv: "P-256", x: "A".repeat(43), y: "B".repeat(43) }), + }), + ); + expect(offCurve.status).toBe(400); + expect(((await offCurve.json()) as { message: string }).message).toInclude("signingPublicKey"); + expect(await repo.peekValid(setupKey)).toBe(true); + + // The very same key still redeems with a good signing JWK. + const ok = await enroll(bodyFor(setupKey)); + expect(ok.status).toBe(201); + createdNodeIds.push(((await ok.json()) as { nodeId: string }).nodeId); + }); + + it("private signing JWK (carries `d`) → 400 + key unconsumed", async () => { + const setupKey = await makeKey(); + const res = await enroll(bodyFor(setupKey, { signingPublicKey: await signingJwkString(true) })); + expect(res.status).toBe(400); + expect(((await res.json()) as { message: string }).message).toInclude("signingPublicKey"); + expect(await repo.peekValid(setupKey)).toBe(true); + }); + it("os outside linux|darwin|unknown → 400 + key unconsumed", async () => { const setupKey = await makeKey(); const res = await enroll(bodyFor(setupKey, { os: "windows" })); diff --git a/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts index 6cc555bd3..9bb87c325 100644 --- a/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts @@ -1,10 +1,12 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { hashPassword } from "better-auth/crypto"; import { Elysia } from "elysia"; +import { exportJWK, generateKeyPair } from "jose"; import { nodesRoutes } from "@/api/nodes/index.js"; import { authDatabase } from "@/auth/database.js"; import { getAuth } from "@/auth.js"; import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; @@ -21,6 +23,7 @@ import { } from "@/services/nodes/node-registry.js"; import { NodeRpcError } from "@/services/nodes/node-rpc.js"; import { ensureLocalNode, localPlatform } from "@/services/nodes/seed-local.js"; +import { deliverSigningKey } from "@/services/nodes/ssh-identity.js"; import { issueSubshellToken } from "@/services/subshell-tokens.js"; import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; @@ -98,6 +101,7 @@ describe("/api/nodes registry CRUD", () => { const app = new Elysia().use(errorHandlerPlugin).use(nodesRoutes); const nodes = new NodesRepository(db); const nodeShares = new NodeSharesRepository(db); + const identities = new IdentitiesRepository(db); let aliceCookie = ""; let bobCookie = ""; @@ -670,6 +674,62 @@ describe("/api/nodes registry CRUD", () => { resetNodeRegistryForTests(); }); + it("rotate: clears the signing slot too, and the SAME-bytes redial re-files it (no block, no peer re-pair)", async () => { + // Spec §13's rotation units, plane side. Rotation replaces the BEARER and + // everything entrusted under its link, and it clears BOTH machine-key + // slots: the X25519 link pin (previous case) and the ES256 signing slot + // here. What makes this safe rather than destructive is that the machine + // keys themselves never moved: the redial's §4.3 bootstrap answers with + // the SAME bytes (a quiet re-file, not a refusal), and the node's own + // peer pins are untouched by anything the plane does, so the relay keeps + // byte-equality and no peer has to re-pair. The fingerprint MIRROR also + // survives: the machine's reported trust is about keys that did not move. + const n = await mkNode(aliceId, `rot-sign-${crypto.randomUUID().slice(0, 8)}`); + const { publicKey } = await generateKeyPair("ES256", { crv: "P-256", extractable: true }); + const signingJwk = JSON.stringify(await exportJWK(publicKey)); + await identities.register({ + principalId: `node:${n.id}`, + publicKey: `{"kty":"EC","crv":"P-256","x":"${"B".repeat(43)}","y":"${"A".repeat(43)}"}`, + signingPublicKey: signingJwk, + displayName: null, + }); + const block = { + own: { signing: `SHA256:${"A".repeat(43)}`, encryption: `SHA256:${"B".repeat(43)}` }, + peers: [ + { + nodeId: "0f8e2c1a-0000-4000-8000-000000000001", + signing: `SHA256:${"C".repeat(43)}`, + encryption: `SHA256:${"D".repeat(43)}`, + }, + ], + }; + await nodes.setEncryptPublicKey(n.id, "AAECzaWRtZXgtcHVibGljLWtleQ"); + await nodes.setSshFingerprint(n.id, JSON.stringify(block)); + + const sock = fakeSocket(); + attachConnection(n.id, sock); + try { + expect((await req("POST", `/${n.id}/rotate-key`, { cookie: aliceCookie })).status).toBe(200); + + // Cleared: the link pin and the signing slot. Preserved: the mirror + // (both halves and the peer entries, byte for byte). + const row = await nodes.findById(n.id); + expect(row?.encryptPublicKey).toBeNull(); + expect((await identities.findByPrincipal(`node:${n.id}`))?.signingPublicKey).toBeNull(); + expect(row?.sshFingerprint).toBe(JSON.stringify(block)); + + // The redial's §4.3 delivery, SAME bytes into the now-empty slot: stored + // quietly (a DIFFERENT key here would be refused against a filled slot, + // and the cleared slot plus the unchanged disk identity make that + // branch unreachable for an honest agent). + expect(await deliverSigningKey(n.id, signingJwk)).toBe(signingJwk); + expect((await identities.findByPrincipal(`node:${n.id}`))?.signingPublicKey).toBe(signingJwk); + } finally { + await db.deleteFrom("identities").where("principalId", "=", `node:${n.id}`).execute(); + resetNodeRegistryForTests(); + } + }); + it("rotate: view-grantee 403, admin on an agent node 403", async () => { const n = await mkNode(aliceId, `rot-gate-${crypto.randomUUID().slice(0, 8)}`); await nodeShares.replaceForNode(n.id, [{ granteeUserId: bobId, permission: "edit" }], aliceId); diff --git a/apps/server/api/src/api/nodes/__tests__/repair-node-machine-pin.route.test.ts b/apps/server/api/src/api/nodes/__tests__/repair-node-machine-pin.route.test.ts new file mode 100644 index 000000000..ecefffab0 --- /dev/null +++ b/apps/server/api/src/api/nodes/__tests__/repair-node-machine-pin.route.test.ts @@ -0,0 +1,361 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { buildAgentSocketPath, MachinePinStore } from "@internal/pane-runtime"; +import { fingerprintJwk } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { nodesRoutes } from "@/api/nodes/index.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; +import { + ensureLocalRelayIdentity, + localRelayIdentityDir, + resetLocalRelayIdentity, +} from "@/services/ssh-local-identity.js"; +import { setSshMachinePinsDepsForTests } from "@/services/ssh-machine-pins.service.js"; +import { createRelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +/** + * `POST /api/nodes/:id/machine-pins/:peerNodeId/repair` (spec 2026-10-08 + * §4.5, Task 17) - the §4.5 machine trust re-pair's HTTP door. The + * posture this file pins is the ONE the design makes load-bearing: + * + * - EXACT owner only, on BOTH machines' facts: `gate.row.ownerUserId === + * user.id`. An `edit` grantee is 403 (re-authorizing a peer's key on + * someone else's machine is not a share level confers); the ADMIN is 403 + * on a foreign agent (the instance-wide boost stops at a trust act, and + * the `canManage` local exception must not leak here); `local` never + * repairs (its owner is the system service user, so every human hits the + * owner door); a stranger is 404 (an invisible node stays invisible); + * a bearer is refused (cookie-only like every node write). + * - The success path names the acting human in exactly one + * `node.ssh_machine_pin.repair` row carrying `{nodeIdOfA, peerNodeId}` - + * the serialized row asserts free of the key sentinels. + * - A offline answers 409 NODE_OFFLINE and writes NO audit row (the service + * audited nothing, which is the no-success-audit rule at the HTTP edge). + * + * The command delivery is the service seam; nothing here touches a socket. + */ + +const pw = "repair-1"; +const ownerEmail = `rp-owner-${crypto.randomUUID()}@subshell.local`; +const editorEmail = `rp-editor-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `rp-other-${crypto.randomUUID()}@subshell.local`; +const adminEmail = `rp-admin-${crypto.randomUUID()}@subshell.local`; +let ownerId: string; +let editorId: string; +let ownerCookie: string; +let editorCookie: string; +let otherCookie: string; +let adminCookie: string; +let subshellKey = ""; + +const NODE_A = `n-rp-a-${crypto.randomUUID()}`; +const PEER_B = `n-rp-b-${crypto.randomUUID()}`; +const PEER_NAKED = `n-rp-k-${crypto.randomUUID()}`; + +const PEER_SIGNING = '{"kty":"EC","crv":"P-256","x":"RPSIGN-SENTINEL","y":"RPSIGN-SENTINEL2"}'; +const PEER_ENCRYPT = '{"kty":"EC","crv":"P-256","x":"RPENC-SENTINEL","y":"RPENC-SENTINEL2"}'; +const PEER_ENCRYPT_B64 = Buffer.from(PEER_ENCRYPT, "utf8").toString("base64"); + +let sentCmds: unknown[] = []; +let sendBehavior: (nodeId: string, cmd: unknown) => Promise<{ repaired: true; peerNodeId: string }> = async ( + _n, + cmd, +) => ({ + repaired: true, + peerNodeId: (cmd as { peerNodeId: string }).peerNodeId, +}); + +async function repairRows(): Promise<{ actorUserId: string | null; metadataJson: string | null }[]> { + return await db + .selectFrom("auditEvents") + .select(["actorUserId", "metadataJson"]) + .where("action", "=", "node.ssh_machine_pin.repair") + .where("targetId", "=", NODE_A) + .execute(); +} + +function post(nodeId: string, peerId: string, cookie: string | null, bearer?: string) { + const headers: Record = { "content-type": "application/json" }; + if (cookie) headers.cookie = `better-auth.session_token=${cookie}`; + if (bearer) headers.authorization = `Bearer ${bearer}`; + return nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${nodeId}/machine-pins/${peerId}/repair`, { method: "POST", headers }), + ); +} + +beforeAll(async () => { + await setupAuthTables(); + ownerId = await new UsersRepository(db).createUser({ + email: ownerEmail, + name: ownerEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + editorId = await new UsersRepository(db).createUser({ + email: editorEmail, + name: editorEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + await new UsersRepository(db).createUser({ + email: otherEmail, + name: otherEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + await new UsersRepository(db).createUser({ + email: adminEmail, + name: adminEmail, + passwordHash: await hashPassword(pw), + role: "admin", + }); + ownerCookie = await signIn(ownerEmail, pw); + editorCookie = await signIn(editorEmail, pw); + otherCookie = await signIn(otherEmail, pw); + adminCookie = await signIn(adminEmail, pw); + await ensureLocalNode(db); + + const now = new Date().toISOString(); + for (const [id, kind] of [ + [NODE_A, "agent"], + [PEER_B, "agent"], + [PEER_NAKED, "agent"], + ] as const) { + await db + .insertInto("nodes") + .values({ id, ownerUserId: ownerId, name: id, kind, status: "offline", createdAt: now, updatedAt: now } as never) + .execute(); + } + await new NodeSharesRepository(db).replaceForNode(NODE_A, [{ granteeUserId: editorId, permission: "edit" }], ownerId); + await new IdentitiesRepository(db).register({ + principalId: `node:${PEER_B}`, + publicKey: PEER_ENCRYPT, + signingPublicKey: PEER_SIGNING, + displayName: null, + }); + + // A pane row of the owner's, for the bearer refusal: a machine credential + // may not speak to this door at all. + const paneId = crypto.randomUUID(); + await db + .insertInto("subshells") + .values({ + id: paneId, + userId: ownerId, + name: "rp-probe", + harnessId: "claude-code", + workingDir: "/tmp", + tmuxSocket: "subshell-rp-probe", + nodeId: NODE_A, + status: "terminated", + alive: 0, + } as never) + .execute(); + subshellKey = await issueSubshellToken(paneId, ownerId); +}); + +afterAll(async () => { + setSshMachinePinsDepsForTests(null); + await db.deleteFrom("subshells").where("nodeId", "=", NODE_A).execute(); + await db.deleteFrom("nodes").where("id", "in", [NODE_A, PEER_B, PEER_NAKED]).execute(); + await db.deleteFrom("identities").where("principalId", "=", `node:${PEER_B}`).execute(); + for (const email of [ownerEmail, editorEmail, otherEmail, adminEmail]) await deleteUserByEmailOrId(email); +}); + +beforeEach(() => { + sentCmds = []; + sendBehavior = async (_n, cmd) => ({ repaired: true, peerNodeId: (cmd as { peerNodeId: string }).peerNodeId }); + setSshMachinePinsDepsForTests({ + sendRepair: async (nodeId, cmd) => { + sentCmds.push(cmd); + return sendBehavior(nodeId, cmd); + }, + }); +}); + +describe("the door: EXACTLY the owner", () => { + it("the owner re-pairs: 200, the registered pair sent, one ids-only audit row", async () => { + const res = await post(NODE_A, PEER_B, ownerCookie); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ repaired: true }); + expect(sentCmds).toEqual([ + { + type: "ssh_machine_pin_repair", + peerNodeId: PEER_B, + peerSigningPublicKey: PEER_SIGNING, + peerEncryptPublicKey: PEER_ENCRYPT_B64, + }, + ]); + const rows = await repairRows(); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBe(ownerId); // the human, wired at the route + expect(rows[0].metadataJson).toBe(JSON.stringify({ nodeIdOfA: NODE_A, peerNodeId: PEER_B })); + const serialized = JSON.stringify(rows); + for (const secret of [PEER_SIGNING, PEER_ENCRYPT, PEER_ENCRYPT_B64, "SENTINEL"]) { + expect(serialized).not.toContain(secret); // §10: ids only, no key bytes in the row + } + }); + + it("an edit grantee is 403: a share level does not re-authorize trust on another machine", async () => { + const before = (await repairRows()).length; + expect((await post(NODE_A, PEER_B, editorCookie)).status).toBe(403); + expect(sentCmds).toHaveLength(0); + expect((await repairRows()).length).toBe(before); + }); + + it("an admin cannot repair a foreign node but manages server pins", async () => { + // The exact-owner rule, not `canManage`: the admin boost (effective edit) + // and the seeded-local manage exception both stop at a trust act. + const before = (await repairRows()).length; + expect((await post(NODE_A, PEER_B, adminCookie)).status).toBe(403); + expect((await post(LOCAL_NODE_ID, PEER_B, adminCookie)).status).toBe(200); + expect((await post(LOCAL_NODE_ID, PEER_B, ownerCookie)).status).toBe(403); + expect(sentCmds).toHaveLength(1); + expect((await repairRows()).length).toBe(before); + }); + + it("a stranger is 404 and an unknown node is 404: invisible stays invisible", async () => { + expect((await post(NODE_A, PEER_B, otherCookie)).status).toBe(404); + expect((await post(`n-rp-ghost-${crypto.randomUUID()}`, PEER_B, ownerCookie)).status).toBe(404); + }); + + it("a bearer machine credential is refused outright (cookie-only, like every node write)", async () => { + const before = (await repairRows()).length; + expect((await post(NODE_A, PEER_B, null, subshellKey)).status).toBe(403); + expect((await post(NODE_A, PEER_B, null)).status).toBe(401); + expect(sentCmds).toHaveLength(0); + expect((await repairRows()).length).toBe(before); + }); +}); + +describe("the service doors the route renders", () => { + it("A offline: 409 NODE_OFFLINE, no command audited as a success", async () => { + sendBehavior = async () => { + throw new SshRpcError("offline", "no live socket", NODE_A); + }; + const before = (await repairRows()).length; + const res = await post(NODE_A, PEER_B, ownerCookie); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string }; + expect(body.code).toBe("NODE_OFFLINE"); + expect((await repairRows()).length).toBe(before); + }); + + it("a peer without its registered signing half: 409 SSH_RELAY_IDENTITY_MISSING", async () => { + const res = await post(NODE_A, PEER_NAKED, ownerCookie); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string }; + expect(body.code).toBe("SSH_RELAY_IDENTITY_MISSING"); + }); + + it("same-node is a 400, and a machine refusal is 502 SSH_NODE_REFUSED", async () => { + expect((await post(NODE_A, NODE_A, ownerCookie)).status).toBe(400); + sendBehavior = async () => { + throw new SshRpcError("refused", "peer key rejected", NODE_A, "peer key rejected"); + }; + const res = await post(NODE_A, PEER_B, ownerCookie); + expect(res.status).toBe(502); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe("SSH_NODE_REFUSED"); + // The node's words stay in the service log, not in the human's refusal - + // and no key material could ride either way (this copy is fixed). + expect(body.message).not.toContain("SENTINEL"); + }); +}); + +describe("server relay identity recovery", () => { + it("requires an admin cookie and both verified disk fingerprints; it never rewrites peer pins", async () => { + const identity = await ensureLocalRelayIdentity(); + const repo = new IdentitiesRepository(db); + const original = await repo.findByPrincipal("node:local"); + if (!original) throw new Error("missing server identity"); + const call = (method: string, cookie: string, body?: unknown) => + nodesRoutes.fetch( + new Request(`http://localhost/api/nodes/local/ssh-identity${method === "POST" ? "/repair" : ""}`, { + method, + headers: { cookie: `better-auth.session_token=${cookie}`, "content-type": "application/json" }, + ...(body ? { body: JSON.stringify(body) } : {}), + }), + ); + const own = { + signing: await fingerprintJwk(identity.signingPublicJwk), + encryption: await fingerprintJwk(identity.publicJwk), + }; + const pins = new MachinePinStore(localRelayIdentityDir()); + pins.repair("peer-recovery-test", { signing: identity.signingPublicJwk, encryption: identity.publicJwk }); + const before = pins.entries(); + const closeStarted = Promise.withResolvers(); + const closeAck = Promise.withResolvers(); + const broker = createRelayBroker({ + authorize: async () => true, + nodeRow: async (nodeId) => ({ kind: nodeId === "local" ? "local" : "agent", sshEnabled: 1 }), + nodeDataDir: () => "/data/peer", + sendCommand: async (_nodeId, cmd) => { + if (cmd.type === "ssh_relay_close") { + closeStarted.resolve(); + await closeAck.promise; + } + return cmd.type === "ssh_relay_open" && cmd.role === "B" + ? { socketPath: buildAgentSocketPath("/data/peer", cmd.paneId) } + : { ok: true }; + }, + sendRelayFrame: () => {}, + audit: async () => {}, + nowMs: Date.now, + schedule: () => 0, + cancel: () => {}, + log: () => {}, + }); + const peer = { signingPublicKey: identity.signingPublicJwk, encryptionPublicJwk: identity.publicJwk }; + const opening = { + userId: ownerId, + paneId: crypto.randomUUID(), + aNode: "local", + bNode: PEER_B, + aPeer: peer, + bPeer: peer, + fingerprints: ["SHA256:AAAA"], + hostPin: `example.test ssh-ed25519 ${"A".repeat(51)}`, + identityGenerations: { a: broker.identityGeneration("local"), b: broker.identityGeneration(PEER_B) }, + }; + setRelayBrokerForTests(broker); + try { + await broker.openRelay(opening); + await repo.register({ ...original, signingPublicKey: identity.publicJwk }); + expect((await call("GET", ownerCookie)).status).toBe(403); + expect((await call("POST", ownerCookie, own)).status).toBe(403); + const status = await call("GET", adminCookie); + expect(status.status).toBe(200); + expect(((await status.json()) as { matches: boolean }).matches).toBe(false); + expect((await call("POST", adminCookie, { ...own, signing: "wrong" })).status).toBe(409); + expect((await repo.findByPrincipal("node:local"))?.signingPublicKey).toBe(identity.publicJwk); + const repairing = call("POST", adminCookie, own); + await closeStarted.promise; + // Registration remains old until teardown completes; neither a new open + // nor prepared old keys can get a participant past the repair barrier. + expect((await repo.findByPrincipal("node:local"))?.signingPublicKey).toBe(identity.publicJwk); + await expect(broker.openRelay(opening)).rejects.toMatchObject({ code: "identity-repair" }); + expect((await call("POST", adminCookie, own)).status).toBe(409); + closeAck.resolve(); + expect((await repairing).status).toBe(200); + expect(broker.activeRelayCount("local")).toBe(0); + await expect(broker.openRelay(opening)).rejects.toMatchObject({ code: "identity-repair" }); + expect((await ensureLocalRelayIdentity()).signingPublicJwk).toBe(identity.signingPublicJwk); + expect((await repo.findByPrincipal("node:local"))?.signingPublicKey).toBe(identity.signingPublicJwk); + expect(pins.entries()).toEqual(before); + } finally { + closeAck.resolve(); + broker.reset(); + setRelayBrokerForTests(null); + await repo.register(original); + resetLocalRelayIdentity(); + } + }); +}); diff --git a/apps/server/api/src/api/nodes/__tests__/route-discovery.test.ts b/apps/server/api/src/api/nodes/__tests__/route-discovery.test.ts index 64029f2e8..df97e3dc6 100644 --- a/apps/server/api/src/api/nodes/__tests__/route-discovery.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/route-discovery.test.ts @@ -31,6 +31,7 @@ async function fixture() { applyInventory: async () => undefined, touch: async () => undefined, setStatus: async () => undefined, + setSshFingerprint: async () => undefined, }, }, }; diff --git a/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts b/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts index 674ab47b4..655ecee9e 100644 --- a/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts @@ -456,4 +456,94 @@ describe("/api/nodes service + runtime", () => { resetNodeRegistryForTests(); } }); + + // ── GET /api/nodes/:id sshTrust (spec 2026-10-08 §4.6) ────────────────── + + /** A canonical trust block: `SHA256:` + 43 unpadded base64url chars each. */ + const TRUST_BLOCK = { + own: { signing: `SHA256:${"A".repeat(43)}`, encryption: `SHA256:${"B".repeat(43)}` }, + peers: [ + { + nodeId: "0f8e2c1a-0000-4000-8000-000000000001", + signing: `SHA256:${"C".repeat(43)}`, + encryption: `SHA256:${"D".repeat(43)}`, + }, + ], + }; + + it("sshTrust serializes under the runtime gate: live for owner/edit, never for view", async () => { + const id = await mkNode(); + await nodeShares.replaceForNode(id, [{ granteeUserId: carolId, permission: "view" }], aliceId); + // The `view` grantee is refused even with a MIRROR on the row: the gate is + // about WHO, not whether the node is live (spec §4.6/§6). + await nodes.setSshFingerprint(id, JSON.stringify(TRUST_BLOCK)); + const offline = (await (await req("GET", `/api/nodes/${id}`, { cookie: carolCookie })).json()) as Record< + string, + unknown + >; + expect("sshTrust" in offline).toBe(false); + goOnline(id, { ...runtime, sshFingerprint: TRUST_BLOCK }); + try { + const asOwner = (await (await req("GET", `/api/nodes/${id}`, { cookie: aliceCookie })).json()) as { + sshTrust?: { own: unknown; peers: unknown; stale: boolean }; + }; + expect(asOwner.sshTrust).toEqual({ ...TRUST_BLOCK, stale: false }); + const asViewer = (await (await req("GET", `/api/nodes/${id}`, { cookie: carolCookie })).json()) as Record< + string, + unknown + >; + expect("sshTrust" in asViewer).toBe(false); + // The named gate has THREE actors: owner (above), `view` (above), and + // the `edit` grantee, who configures this machine and so sees the same + // disclosure the owner does, live (the same gate as `runtime`). + await nodeShares.replaceForNode(id, [{ granteeUserId: carolId, permission: "edit" }], aliceId); + const asEditor = (await (await req("GET", `/api/nodes/${id}`, { cookie: carolCookie })).json()) as { + sshTrust?: { own: unknown; peers: unknown; stale: boolean }; + }; + expect(asEditor.sshTrust).toEqual({ ...TRUST_BLOCK, stale: false }); + } finally { + resetNodeRegistryForTests(); + } + }); + + it("an offline agent shows the mirrored block STALE-marked, never as live", async () => { + const id = await mkNode(); + await nodes.setSshFingerprint(id, JSON.stringify(TRUST_BLOCK)); + const view = (await (await req("GET", `/api/nodes/${id}`, { cookie: aliceCookie })).json()) as { + sshTrust?: { own: unknown; peers: unknown; stale: boolean }; + }; + // The mirror IS the card's content offline, and it says so: `stale: true` + // is what separates "this machine last said" from "this socket says". + expect(view.sshTrust).toEqual({ ...TRUST_BLOCK, stale: true }); + }); + + it("an online agent that reports no block falls back to the mirror, stale", async () => { + const id = await mkNode(); + await nodes.setSshFingerprint(id, JSON.stringify(TRUST_BLOCK)); + goOnline(id, runtime); // live connection, runtime WITHOUT a trust block + try { + const view = (await (await req("GET", `/api/nodes/${id}`, { cookie: aliceCookie })).json()) as { + sshTrust?: { stale: boolean }; + }; + expect(view.sshTrust?.stale).toBe(true); + } finally { + resetNodeRegistryForTests(); + } + }); + + it("sshTrust is never serialized for `local`, not even with a mirror set on the row", async () => { + // The control-plane host runs no relay agent and reports no block; a + // hand-set column must still not reach a view. `alice` holds the seeded + // Everyone/`edit` grant on `local`, so this is the config-capable case. + await nodes.setSshFingerprint("local", JSON.stringify(TRUST_BLOCK)); + try { + const view = (await (await req("GET", "/api/nodes/local", { cookie: aliceCookie })).json()) as Record< + string, + unknown + >; + expect("sshTrust" in view).toBe(false); + } finally { + await nodes.setSshFingerprint("local", null); + } + }); }); diff --git a/apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts b/apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts new file mode 100644 index 000000000..7c59fb915 --- /dev/null +++ b/apps/server/api/src/api/nodes/__tests__/set-node-ssh-enabled.route.test.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { nodesRoutes } from "@/api/nodes/index.js"; +import { db } from "@/db/index.js"; +import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +/** + * `PUT /api/nodes/:id/ssh-enabled` (spec 4.3) — the per-node SSH capability + * gate, opt-in and off by default. + * + * OWNER-only (`gate.canManage`, with the seeded-`local` admin exception), the + * same gate as maintenance, delete and re-share — deliberately NOT the + * `nodeCanConfigure` gate an `edit` grantee holds. The act is what decides it: + * flipping this on lets the plane dial out of and serve keys from an OS + * account this machine owns; a grantee may restart the agent or read its log, + * but widening what SSH can do from someone else's machine is not theirs. + * + * The route performs no write and records no audit itself — `setNodeSshEnabled` + * is the one change-site (row + audit, and from Task 7 the push), so this file + * asserts the ROUTE wired the acting human into that row. + */ +describe("node ssh-enabled route", () => { + const pw = "ssh-enabled-1"; + const ownerEmail = `sh-owner-${crypto.randomUUID()}@subshell.local`; + const editorEmail = `sh-editor-${crypto.randomUUID()}@subshell.local`; + const otherEmail = `sh-other-${crypto.randomUUID()}@subshell.local`; + const adminEmail = `sh-admin-${crypto.randomUUID()}@subshell.local`; + let ownerId: string; + let editorId: string; + let ownerCookie: string; + let editorCookie: string; + let otherCookie: string; + let adminCookie: string; + const NODE = `n-sh-${crypto.randomUUID()}`; + + async function mkUser(email: string, role: "user" | "admin" = "user"): Promise { + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role, + }); + } + + /** Every `node.ssh_enabled.update` row this file's node has collected, id-keyed. */ + async function sshAudit(): Promise> { + const rows = await db + .selectFrom("auditEvents") + .select(["id", "actorUserId", "metadataJson"]) + .where("targetId", "=", NODE) + .where("action", "=", "node.ssh_enabled.update") + .execute(); + return new Map(rows.map((r) => [r.id, { actorUserId: r.actorUserId, metadataJson: r.metadataJson }])); + } + + function put(nodeId: string, cookie: string, on: boolean) { + return nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${nodeId}/ssh-enabled`, { + method: "PUT", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${cookie}` }, + body: JSON.stringify({ on }), + }), + ); + } + + beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(ownerEmail); + editorId = await mkUser(editorEmail); + await mkUser(otherEmail); + await mkUser(adminEmail, "admin"); + ownerCookie = await signIn(ownerEmail, pw); + editorCookie = await signIn(editorEmail, pw); + otherCookie = await signIn(otherEmail, pw); + adminCookie = await signIn(adminEmail, pw); + await ensureLocalNode(db); + + const now = new Date().toISOString(); + await db + .insertInto("nodes") + .values({ + id: NODE, + ownerUserId: ownerId, + name: NODE, + kind: "agent", + status: "offline", + createdAt: now, + updatedAt: now, + } as never) + .execute(); + await new NodeSharesRepository(db).replaceForNode(NODE, [{ granteeUserId: editorId, permission: "edit" }], ownerId); + }); + + afterAll(async () => { + await db.deleteFrom("nodes").where("id", "=", NODE).execute(); + // Leave the shared `local` row exactly as found: every file in this + // package gets its own database, but a flipped host gate still reads as + // the fleet default if a later test in THIS file's process re-reads it. + await db + .updateTable("nodes") + .set({ sshEnabled: 0, sshEnabledAt: null } as never) + .where("id", "=", LOCAL_NODE_ID) + .execute(); + for (const email of [ownerEmail, editorEmail, otherEmail, adminEmail]) await deleteUserByEmailOrId(email); + }); + + it("the owner turns it on, and the answer is the node view carrying the flag", async () => { + const res = await put(NODE, ownerCookie, true); + expect(res.status).toBe(200); + const view = (await res.json()) as { sshEnabled: boolean }; + expect(view.sshEnabled).toBe(true); + }); + + it("names the acting human and carries ONLY {on} in the audit row", async () => { + // The ROUTE is the only place `actorUserId: user.id` is wired in — the + // service takes it as an argument, so a regression to a null actor passes + // every test of the service alone. + await put(NODE, ownerCookie, false); + const before = await sshAudit(); + + expect((await put(NODE, ownerCookie, true)).status).toBe(200); + // Keyed on which row is NEW rather than on "the latest": rows share a + // millisecond stamp and their ids are unordered, so a newest-first read + // cannot name this act's row. + const added = [...(await sshAudit())].filter(([id]) => !before.has(id)); + expect(added).toHaveLength(1); + expect(added[0][1].actorUserId).toBe(ownerId); + // The metadata is the STRING `"{\"on\":true}"`: the flag and nothing else + // — no key material, no config values (spec 4.3; audit never carries secrets). + expect(added[0][1].metadataJson).toBe('{"on":true}'); + }); + + it("turning it off lands on the row and the view", async () => { + const res = await put(NODE, ownerCookie, false); + expect(res.status).toBe(200); + expect(((await res.json()) as { sshEnabled: boolean }).sshEnabled).toBe(false); + }); + + it("an edit grantee is refused — it is not their machine to widen", async () => { + // Deliberately NOT `nodeCanConfigure`: a grantee may re-check and read + // logs; enabling SSH through someone else's OS account is a different act. + expect((await put(NODE, editorCookie, true)).status).toBe(403); + }); + + it("a stranger gets 404, never 403 — an invisible node stays invisible", async () => { + expect((await put(NODE, otherCookie, true)).status).toBe(404); + }); + + it("answers 404 for an unknown node", async () => { + expect((await put(`n-missing-${crypto.randomUUID()}`, ownerCookie, true)).status).toBe(404); + }); + + it("an admin is refused on a node they do not own, and allowed on `local`", async () => { + // The admin boost is instance-wide EDIT, never ownership: `canManage` is + // the real owner, plus admins on the seeded host alone. + expect((await put(NODE, adminCookie, true)).status).toBe(403); + // A member sees `local` through the seeded Everyone/edit grant but holds + // no management there — refused, not invisible (a node on the page must + // not answer "not found"). + expect((await put(LOCAL_NODE_ID, otherCookie, true)).status).toBe(403); + // Unlike maintenance, this flip stops nothing, so ON is safe to test on + // the shared host row; `afterAll` restores it. + const res = await put(LOCAL_NODE_ID, adminCookie, true); + expect(res.status).toBe(200); + expect(((await res.json()) as { sshEnabled: boolean }).sshEnabled).toBe(true); + }); +}); diff --git a/apps/server/api/src/api/nodes/enroll.route.ts b/apps/server/api/src/api/nodes/enroll.route.ts index f286c5345..e1013c1c6 100644 --- a/apps/server/api/src/api/nodes/enroll.route.ts +++ b/apps/server/api/src/api/nodes/enroll.route.ts @@ -3,7 +3,7 @@ import { NODE_NAME_MAX_UNITS, normalizeNodeName } from "@internal/subshell-proto import { ensureSodium } from "@internal/subshell-protocol/node-link-crypto"; import { Elysia, t } from "elysia"; import { HttpError } from "@/api/auth-guard.js"; -import { assertImportablePublicJwk } from "@/api/public-jwk.js"; +import { assertImportablePublicJwk, assertImportableSigningJwk } from "@/api/public-jwk.js"; import type { CreatedApiKey, NodeKeyMetadata } from "@/auth/apikey-store.js"; import { getAuth } from "@/auth.js"; import { APP_BASE_URL } from "@/constants.js"; @@ -50,6 +50,18 @@ const EnrollBodySchema = t.Object({ maxLength: 2048, description: "JSON-serialized P-256 ECDH-ES PUBLIC JWK (no private component) for sealed delivery to this node", }), + // Optional so a pre-M2 agent still enrolls (spec 2026-10-08 §4.3): the + // identities slot stays NULL until the §4.3 bootstrap delivers the key over + // the node link. A PRESENT value is decoded and imported below (like + // `publicKey` above), BEFORE the setup key is spent. + signingPublicKey: t.Optional( + t.String({ + minLength: 16, + maxLength: 2048, + description: + "JSON-serialized P-256 ES256 PUBLIC JWK (no private component): the machine's SSH relay signing key, registered in the same node: identity record", + }), + ), // Optional so a pre-v14 one-liner still enrolls (spec 2026-09-24 §5): the row // then stores NULL and the node registers its static on first connect. A // PRESENT value is decoded and length-checked below — like `publicKey` above, @@ -201,6 +213,38 @@ export const enrollRoute = new Elysia().use(apiModels).post( ); } + // The relay signing key (spec 2026-10-08 §4.1/§4.2), validated in the SAME + // pre-consume position as publicKey above: a malformed value must not burn + // the single-use setup key. An absent field is the pre-M2 posture (NULL + // slot, filled later by the §4.3 bootstrap), never a refusal. + let signingPublicKey: string | null = null; + if (body.signingPublicKey !== undefined) { + let signingParsed: unknown; + try { + signingParsed = JSON.parse(body.signingPublicKey); + } catch { + return status( + 400, + apiErrorBody({ + code: BackendErrorCodes.INPUT_VALIDATION_ERROR, + message: "signingPublicKey must be a JSON JWK", + }), + ); + } + try { + await assertImportableSigningJwk(signingParsed); + } catch (err) { + return status( + 400, + apiErrorBody({ + code: BackendErrorCodes.INPUT_VALIDATION_ERROR, + message: err instanceof HttpError ? err.message : "signingPublicKey must be a valid P-256 public JWK", + }), + ); + } + signingPublicKey = body.signingPublicKey; + } + // The link-encryption static (spec 2026-09-24 §3), validated in the SAME // pre-consume position as publicKey above — a malformed body must not burn // the single-use key. Shape is schema-checked (43-44 chars); the decode @@ -269,6 +313,9 @@ export const enrollRoute = new Elysia().use(apiModels).post( agentVersion: body.agentVersion, }, encryptPublicKey, + // Both identity halves swap together on re-registration (§4.2); the + // validated value, or NULL for a pre-M2 body. + signingPublicKey, ); return status(201, { ...result, controlPublicKey, controlEncryptPublicKey, wsUrl }); } catch (err) { @@ -304,9 +351,12 @@ export const enrollRoute = new Elysia().use(apiModels).post( encryptPublicKey, }); nodeRowCreated = true; + // The signing key lands in the SAME node: record (§4.2): enroll is the + // trust event the key inherits, and node deletion cascades both halves. await new IdentitiesRepository(db).register({ principalId: `node:${nodeId}`, publicKey: body.publicKey, + signingPublicKey, displayName: name, }); // Node key: long-lived (no expiresIn — revocation is delete-node, diff --git a/apps/server/api/src/api/nodes/get-node.route.ts b/apps/server/api/src/api/nodes/get-node.route.ts index 4a4638940..3ecb6f716 100644 --- a/apps/server/api/src/api/nodes/get-node.route.ts +++ b/apps/server/api/src/api/nodes/get-node.route.ts @@ -2,7 +2,7 @@ import { BackendErrorCodes } from "@internal/backend-errors"; import { Elysia } from "elysia"; import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; import { loadNodeGate } from "@/api/nodes/node-gate.js"; -import { GetNodeResponseSchema, toNodeShareViews, toNodeView } from "@/api/nodes/node-view.js"; +import { GetNodeResponseSchema, sshTrustView, toNodeShareViews, toNodeView } from "@/api/nodes/node-view.js"; import { db } from "@/db/index.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { apiErrorBody } from "@/lib/api-error.js"; @@ -11,6 +11,7 @@ import { apiModels } from "@/schema/index.js"; import { detectOnNodeBestEffort } from "@/services/nodes/inventory.js"; import { getLive } from "@/services/nodes/node-registry.js"; import { nodeUpdateBlockReason } from "@/services/server-update.js"; +import { localSshTrustReport } from "@/services/ssh-local-participant.js"; /** * `GET /api/nodes/:id` — one node view for the caller. Missing and invisible @@ -49,6 +50,16 @@ export const getNodeRoute = new Elysia() // stale by definition. `local` reports nothing: the control-plane host's // own deployment is the Service page's subject, not a node's. const runtime = gate.row.kind === "agent" ? getLive(gate.row.id)?.agent?.runtime : undefined; + // The §4.6 trust block, on the SAME config-capable gate as the two + // fields around it and for the same reason: the fingerprints name which + // machines this one has paired with, which is machine-relationship + // disclosure, not a launch need (spec 2026-10-08 §4.6/§6). Live report + // when the socket has one; the durable mirror, STALE-marked, otherwise; + // Server trust is read directly for administrators; enrolled nodes use their live report or mirror. + const sshTrust = + gate.row.kind === "local" && gate.isAdmin + ? { ...(await localSshTrustReport()), stale: false } + : sshTrustView(gate.row, runtime?.sshFingerprint); // What entering maintenance would cost, on a NARROWER gate than the two // fields above (spec 2026-09-14 §5.5): `canManage`, not // `nodeCanConfigure`. Only a manager can flip that switch, so only a @@ -63,6 +74,7 @@ export const getNodeRoute = new Elysia() updateBlockedReason: nodeUpdateBlockReason(), shares: await toNodeShareViews(gate.shares), ...(runtime ? { runtime } : {}), + ...(sshTrust ? { sshTrust } : {}), ...(runningSubshells === undefined ? {} : { runningSubshells }), }; }, diff --git a/apps/server/api/src/api/nodes/index.ts b/apps/server/api/src/api/nodes/index.ts index 9dbfd9907..97d371ca7 100644 --- a/apps/server/api/src/api/nodes/index.ts +++ b/apps/server/api/src/api/nodes/index.ts @@ -7,10 +7,12 @@ import { getNodeRoute } from "@/api/nodes/get-node.route.js"; import { getNodeSharesRoute } from "@/api/nodes/get-node-shares.route.js"; import { listNodesRoute } from "@/api/nodes/list-nodes.route.js"; import { listSetupKeyRoute } from "@/api/nodes/list-setup-keys.route.js"; +import { localSshIdentityRoute } from "@/api/nodes/local-ssh-identity.route.js"; import { nodeLoggingRoute } from "@/api/nodes/node-logging.route.js"; import { nodeLogsRoute } from "@/api/nodes/node-logs.route.js"; import { recheckNodeRoute } from "@/api/nodes/recheck-node.route.js"; import { renameNodeRoute } from "@/api/nodes/rename-node.route.js"; +import { repairNodeMachinePinRoute } from "@/api/nodes/repair-node-machine-pin.route.js"; import { reregisterNodeRoute } from "@/api/nodes/reregister-node.route.js"; import { rotateNodeKeyRoute } from "@/api/nodes/rotate-node-key.route.js"; import { serviceNodeRoute } from "@/api/nodes/service-node.route.js"; @@ -18,6 +20,7 @@ import { setNodeAllowedDirsRoute } from "@/api/nodes/set-node-allowed-dirs.route import { setNodeMaintenanceRoute } from "@/api/nodes/set-node-maintenance.route.js"; import { setNodeServerUrlRoute } from "@/api/nodes/set-node-server-url.route.js"; import { setNodeSharesRoute } from "@/api/nodes/set-node-shares.route.js"; +import { setNodeSshEnabledRoute } from "@/api/nodes/set-node-ssh-enabled.route.js"; import { updateNodeRoute } from "@/api/nodes/update-node.route.js"; import { routeDiscoveryRoute } from "./route-discovery.route.js"; @@ -35,6 +38,7 @@ import { routeDiscoveryRoute } from "./route-discovery.route.js"; * modules on this instance rather than growing a flat chain upstream. */ export const nodesRoutes = new Elysia({ prefix: "/api/nodes" }) + .use(localSshIdentityRoute) .use(enrollRoute) .use(routeDiscoveryRoute) .use(createSetupKeyRoute) @@ -48,6 +52,8 @@ export const nodesRoutes = new Elysia({ prefix: "/api/nodes" }) .use(setNodeSharesRoute) .use(setNodeAllowedDirsRoute) .use(setNodeMaintenanceRoute) + .use(setNodeSshEnabledRoute) + .use(repairNodeMachinePinRoute) .use(rotateNodeKeyRoute) .use(reregisterNodeRoute) .use(recheckNodeRoute) diff --git a/apps/server/api/src/api/nodes/local-ssh-identity.route.ts b/apps/server/api/src/api/nodes/local-ssh-identity.route.ts new file mode 100644 index 000000000..21983b392 --- /dev/null +++ b/apps/server/api/src/api/nodes/local-ssh-identity.route.ts @@ -0,0 +1,95 @@ +import { fingerprintJwk } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { HttpError, requireAdmin } from "@/api/auth-guard.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { audit } from "@/services/audit.js"; +import { + ensureLocalRelayIdentity, + localRelayIdentityDir, + readLocalRelayIdentity, + resetLocalRelayIdentity, +} from "@/services/ssh-local-identity.js"; +import { getRelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; + +const FingerprintsSchema = t.Object({ + signing: t.String({ description: "Verified server relay signing fingerprint" }), + encryption: t.String({ description: "Verified server relay encryption fingerprint" }), +}); + +/** Admin-only recovery of the server's registered identity. Peer trust pins remain strict and require separate repair. */ +export const localSshIdentityRoute = new Elysia() + .use(requireAdmin) + .get( + "/local/ssh-identity", + async () => { + // First-use initialization is safe; a mismatched registration stays refused, + // while inspection must still show the disk fingerprints needed for explicit recovery. + await ensureLocalRelayIdentity().catch(() => undefined); + const identity = await readLocalRelayIdentity(localRelayIdentityDir()); + const registered = await new IdentitiesRepository(db).findByPrincipal("node:local"); + return { + own: { + signing: await fingerprintJwk(identity.signingPublicJwk), + encryption: await fingerprintJwk(identity.publicJwk), + }, + registered: registered?.signingPublicKey + ? { + signing: await fingerprintJwk(registered.signingPublicKey), + encryption: await fingerprintJwk(registered.publicKey), + } + : null, + matches: + registered?.publicKey === identity.publicJwk && registered.signingPublicKey === identity.signingPublicJwk, + }; + }, + { + response: t.Object({ + own: FingerprintsSchema, + registered: t.Nullable(FingerprintsSchema, { + description: "Currently registered public fingerprints, or null", + }), + matches: t.Boolean({ description: "Whether both stored public keys agree with the server identity file" }), + }), + }, + ) + .post( + "/local/ssh-identity/repair", + async ({ body, user }) => { + const identity = await readLocalRelayIdentity(localRelayIdentityDir()); + if ( + body.signing !== (await fingerprintJwk(identity.signingPublicJwk)) || + body.encryption !== (await fingerprintJwk(identity.publicJwk)) + ) + throw new HttpError(409, "The server SSH identity changed; verify both fingerprints again."); + try { + await getRelayBroker().withNodeIdentityRepair("local", async () => { + await new IdentitiesRepository(db).register({ + principalId: "node:local", + publicKey: identity.publicJwk, + signingPublicKey: identity.signingPublicJwk, + displayName: null, + }); + resetLocalRelayIdentity(); + }); + } catch (err) { + if (err instanceof SshRelayRefusal && err.code === "identity-repair") + throw new HttpError(409, "The server SSH identity is already being repaired. Retry when it finishes."); + throw err; + } + await audit({ + actorUserId: user.id, + action: "node.ssh_identity.repair", + targetType: "node", + targetId: "local", + metadataJson: null, + }); + return { repaired: true }; + }, + { + body: FingerprintsSchema, + response: t.Object({ + repaired: t.Boolean({ description: "Registration replaced with the explicitly verified server identity" }), + }), + }, + ); diff --git a/apps/server/api/src/api/nodes/node-view.ts b/apps/server/api/src/api/nodes/node-view.ts index c970980df..a57663fbf 100644 --- a/apps/server/api/src/api/nodes/node-view.ts +++ b/apps/server/api/src/api/nodes/node-view.ts @@ -1,3 +1,4 @@ +import { type NodeSshFingerprintReport, parseNodeSshFingerprintReport } from "@internal/subshell-protocol"; import { type Static, t } from "elysia"; import { db } from "@/db/index.js"; import { NodeAllowedDirsRepository } from "@/db/repositories/node-allowed-dirs.repository.js"; @@ -155,6 +156,10 @@ export const NodeViewSchema = t.Object({ "Which end declared the current value (a browser or the machine's own `subshell maintenance` verb), so a person reading the page learns whether someone at the keyboard did this; null when it was never set", }, ), + sshEnabled: t.Boolean({ + description: + "Whether this machine may be used for Subshell SSH (outbound ssh and serving its keys). Opt-in per node, off by default on every row including the control-plane host; the launch gate refuses SSH here while false, whoever asks", + }), held: t.Nullable( t.Object({ reason: t.Union([t.Literal("below-floor"), t.Literal("protocol-mismatch"), t.Literal("encryption-required")], { @@ -262,6 +267,40 @@ export const NodeRuntimeSchema = t.Object({ binaryPath: t.String({ description: "The node binary this process re-enters" }), }); +/** One machine's public relay halves as `SHA256:` fingerprints (spec 2026-10-08 §4.6). */ +const SshFingerprintPairSchema = t.Object({ + signing: t.String({ description: "Fingerprint of the ES256 machine-signing public key" }), + encryption: t.String({ description: "Fingerprint of the ECDH-ES encryption public key" }), +}); + +/** One pinned relay peer of that machine, named by node id. */ +const SshFingerprintPeerSchema = t.Object({ + nodeId: t.String({ description: "The pinned peer's node id" }), + signing: t.String({ description: "Fingerprint of that peer's signing key, as this machine pins it" }), + encryption: t.String({ description: "Fingerprint of that peer's encryption key, as this machine pins it" }), +}); + +/** + * The §4.6 trust card's payload: the protocol's fingerprint block plus ONE + * derived flag the node itself cannot honestly state about a future read. + * + * The fingerprints are public identifiers (display of the trust the node + * enforces as byte equality), never key material: they serialize under the + * SAME gate as the live `runtime` block they mostly come from (config-capable + * viewer, agent node), because a `view` grantee's business is launching a + * pane here, not auditing which machines this one has paired with. + */ +export const SshTrustSchema = t.Object({ + own: SshFingerprintPairSchema, + peers: t.Array(SshFingerprintPeerSchema, { + description: "Every relay peer this machine pins, id ascending; empty means none paired yet", + }), + stale: t.Boolean({ + description: + "true = the plane's durable MIRROR of this machine's last report (offline node, or one that reported no block), so the card is last-known truth, not current; false = what the live connection reported this connect", + }), +}); + /** `GET /api/nodes/:id` — the view plus the grant set, ONLY when the viewer can configure. */ export const GetNodeResponseSchema = t.Object({ updateBlockedReason: t.Optional( @@ -275,6 +314,12 @@ export const GetNodeResponseSchema = t.Object({ "How the node runs, present only while the node is online, only for config-capable viewers, and only on machine nodes", }), ), + sshTrust: t.Optional( + t.Object(SshTrustSchema.properties, { + description: + "This machine's SSH trust block (own + pinned-peer fingerprints, spec 2026-10-08 §4.6): the live report's when the node is connected, else the durable mirror with `stale: true`. Config-capable viewers on enrolled nodes; administrators on the server host", + }), + ), runningSubshells: t.Optional( t.Number({ description: @@ -323,6 +368,39 @@ function parseCapabilities(json: string | null): string[] { } } +/** The stored `nodes.ssh_fingerprint` JSON → block, or null. Never throws. */ +function readMirrorBlock(json: string | null): NodeSshFingerprintReport | null { + if (!json) return null; + try { + return parseNodeSshFingerprintReport(JSON.parse(json)); + } catch { + // A hand-edited or half-written column costs the trust card, never the + // node page: the view renders without the block rather than 500-ing on + // someone's stray bytes. (The same grammar the live report went through + // at ingest; re-running it here refuses whatever the column is not.) + return null; + } +} + +/** + * The detail view's `sshTrust` (spec 2026-10-08 §4.6), under the caller's + * already-checked config gate. The live connection's block wins when it has + * one (`stale: false`); otherwise the durable mirror renders marked + * (`stale: true`), because the offline card an operator compares during an + * incident is worth its last-known truth and the word that says so. the server host + * is reported directly by its local adapter; an absent mirror/absent + * live block means the field is ABSENT on the view, not null. + */ +export function sshTrustView( + row: NodeTable, + liveBlock: NodeSshFingerprintReport | undefined, +): Static | null { + if (row.kind !== "agent") return null; + if (liveBlock) return { ...liveBlock, stale: false }; + const mirror = readMirrorBlock(row.sshFingerprint); + return mirror ? { ...mirror, stale: true } : null; +} + /** * Everything but the harness merge — the one place row→view fields are mapped. * `local`'s os/arch fall back to this process (see the inline note) — the only @@ -371,6 +449,7 @@ function nodeViewBase( maintenance: row.maintenance === 1, maintenanceAt: row.maintenanceAt, maintenanceSource: row.maintenanceSource, + sshEnabled: row.sshEnabled === 1, // Read from the LIVE registry rather than the row, for the same reason // `runtime` is: being held is a fact about a socket that exists right now, // and a column would go stale the moment the process ends. It is NOT diff --git a/apps/server/api/src/api/nodes/repair-node-machine-pin.route.ts b/apps/server/api/src/api/nodes/repair-node-machine-pin.route.ts new file mode 100644 index 000000000..ed29db6a3 --- /dev/null +++ b/apps/server/api/src/api/nodes/repair-node-machine-pin.route.ts @@ -0,0 +1,64 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { authGuard, ForbiddenError, requireCookieActor } from "@/api/auth-guard.js"; +import { loadNodeGate } from "@/api/nodes/node-gate.js"; +import { apiErrorBody } from "@/lib/api-error.js"; +import { apiModels } from "@/schema/index.js"; +import { + machinePinRepairRefusal, + repairMachinePin, + SshMachinePinRepairError, +} from "@/services/ssh-machine-pins.service.js"; + +const RepairedSchema = t.Object({ + repaired: t.Boolean({ description: "Always true on success: the machine's ack confirmed the write" }), +}); + +/** Repair one peer pin: cookie owner on an enrolled node, cookie admin on the server host. */ +export const repairNodeMachinePinRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .post( + "/:id/machine-pins/:peerNodeId/repair", + async ({ params, user, actor, status }) => { + requireCookieActor(actor, "Machine trust re-pairing is restricted to browser sessions"); + const gate = await loadNodeGate(user.id, params.id); + if (!gate) { + return status(404, apiErrorBody({ code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Node not found" })); + } + if (!gate.canManage) { + throw new ForbiddenError(); + } + try { + await repairMachinePin({ actorUserId: user.id, nodeId: gate.row.id, peerNodeId: params.peerNodeId }); + } catch (err) { + if (err instanceof SshMachinePinRepairError) { + const refusal = machinePinRepairRefusal(err); + return status(refusal.status, apiErrorBody({ code: refusal.code, message: refusal.message })); + } + throw err; + } + return { repaired: true }; + }, + { + params: t.Object({ + id: t.String({ description: "Node id (the machine whose pin store is repaired)" }), + peerNodeId: t.String({ description: "Peer node id (the stored pin being replaced)" }), + }), + response: { + 200: RepairedSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "repairNodeMachinePin", + tags: ["nodes"], + description: + "Re-pair one peer's machine trust pin on a machine you own: the plane re-delivers the peer's registered public keys to it, replacing that peer's stored pin (spec 2026-10-08 §4.5). The machine must be online; the act is audited naming the peer id only.", + }, + }, + ); diff --git a/apps/server/api/src/api/nodes/rotate-node-key.route.ts b/apps/server/api/src/api/nodes/rotate-node-key.route.ts index e2aae89f3..447d8114c 100644 --- a/apps/server/api/src/api/nodes/rotate-node-key.route.ts +++ b/apps/server/api/src/api/nodes/rotate-node-key.route.ts @@ -58,7 +58,8 @@ export const rotateNodeKeyRoute = new Elysia() }, })) as unknown as CreatedApiKey; - // 2. Flip the binding, clear the link pin, THEN 3. disable the old key. + // 2. Flip the binding, clear the link pin (and 2b, the signing slot), + // THEN 3. disable the old key. // `encrypt_public_key` goes to NULL with the bearer it was trusted // under (spec 2026-09-24 §5): the row falls back to legacy mode and // the agent's redial re-provisions a fresh encryption identity @@ -79,6 +80,28 @@ export const rotateNodeKeyRoute = new Elysia() .where("targetNodeId", "=", gate.row.id) .where("usedAt", "is", null) .execute(); + // 2b. The ES256 signing slot goes with the link pin (spec 2026-10-08 + // §13): it was filed under the OLD bearer's trust, and rotation + // is the operator saying that trust ends now. This clears the + // PLANE's record, never the machine's key: the redial's §4.3 + // bootstrap answers `ssh_register_identity` from the disk pair, + // the empty slot takes the SAME bytes back quietly, the node's + // own peer pins are untouched by anything the plane does, and + // `nodes.ssh_fingerprint` (the trust card's mirror) deliberately + // SURVIVES, because the fingerprints it holds are digests of + // keys that did not move. What the clearing does NOT do is + // police the redial: the slot stands empty, so §4.3's + // first-fill branch takes ANY reported key quietly (its + // same-bytes refusal guards a FILLED slot, which is the steady + // state an honest agent returns the key to). A machine that + // re-files a DIFFERENT identity is blocked by its peers' own + // pins instead: the relay's byte-equality check fails it, as + // §4.6 intends. + await tx + .updateTable("identities") + .set({ signingPublicKey: null }) + .where("principalId", "=", `node:${gate.row.id}`) + .execute(); if (gate.row.apiKeyId) { await sql`UPDATE apikey SET enabled = 0, "updatedAt" = ${new Date().toISOString()} WHERE id = ${gate.row.apiKeyId}`.execute( tx, diff --git a/apps/server/api/src/api/nodes/set-node-shares.route.ts b/apps/server/api/src/api/nodes/set-node-shares.route.ts index 00c6875da..188cd89ec 100644 --- a/apps/server/api/src/api/nodes/set-node-shares.route.ts +++ b/apps/server/api/src/api/nodes/set-node-shares.route.ts @@ -10,6 +10,7 @@ import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import { apiErrorBody } from "@/lib/api-error.js"; import { apiModels } from "@/schema/index.js"; import { audit } from "@/services/audit.js"; +import { getRelayBroker } from "@/services/ssh-relay.service.js"; /** * `PUT /api/nodes/:id/shares` `{shares:[{granteeUserId, permission}]}` — @@ -18,9 +19,8 @@ import { audit } from "@/services/audit.js"; * or ADMIN for `local` — where the route accepts any valid list (the UI only * ever toggles Everyone/edit, but the API stays the honest superset). * - * Revoking a grant takes effect on the next request (access is resolved from - * these rows per request); no key is invalidated, so no socket teardown is - * needed here. + * Access is resolved from these rows on each request. After replacement, + * close relay sessions whose users no longer have launch access to both ends. */ export const setNodeSharesRoute = new Elysia() .use(authGuard) @@ -48,6 +48,7 @@ export const setNodeSharesRoute = new Elysia() } } const rows = await new NodeSharesRepository(db).replaceForNode(gate.row.id, entries, user.id); + await getRelayBroker().closeUnauthorizedForNode(gate.row.id); await audit({ actorUserId: user.id, // Spec §9 names only `node.local_share_changed` (the local-launch diff --git a/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts b/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts new file mode 100644 index 000000000..3e23d5435 --- /dev/null +++ b/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts @@ -0,0 +1,82 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { authGuard, ForbiddenError, requireCookieActor } from "@/api/auth-guard.js"; +import { loadNodeGate } from "@/api/nodes/node-gate.js"; +import { NodeViewSchema, toNodeView } from "@/api/nodes/node-view.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { apiErrorBody } from "@/lib/api-error.js"; +import { apiModels } from "@/schema/index.js"; +import { setNodeSshEnabled } from "@/services/nodes/ssh-enabled.js"; + +const SshEnabledBodySchema = t.Object({ + on: t.Boolean({ + description: + "Allow this machine to be used for Subshell SSH (outbound ssh and serving its keys). Off by default. Owner only; admin on the control-plane host.", + }), +}); + +/** + * `PUT /api/nodes/:id/ssh-enabled` `{on}` — open or close Subshell SSH on one + * machine (spec 4.3). Cookie-only. + * + * **OWNER-only** (`gate.canManage`, with the seeded-`local` admin exception), + * the same gate as maintenance, delete and re-share — deliberately NOT the + * `nodeCanConfigure` gate an `edit` grantee holds. Enabling SSH widens what + * can be done THROUGH this machine's OS account (dial out with its keys, + * serve them to peers); restarting the agent or reading its log is + * administration, deciding who may SSH from it is a different act. + * + * The route writes nothing and audits nothing itself: `setNodeSshEnabled` is + * the one change-site (row + audit, and from Task 7 the best-effort push to + * the machine), so no caller — including the node-side paths Task 7 adds — + * can perform half the act. + * + * It answers on an OFFLINE node rather than refusing: the flag is the plane's + * record, and the machine learns the value at its next `ready` (Task 7's + * reconcile). Refusing would make the flag settable only while the machine is + * reachable, which is backwards for a gate that exists to be closed. + */ +export const setNodeSshEnabledRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .put( + "/:id/ssh-enabled", + async ({ params, body, user, actor, status }) => { + requireCookieActor(actor, "Node SSH enablement is restricted to browser sessions"); + const gate = await loadNodeGate(user.id, params.id); + if (!gate) { + return status(404, apiErrorBody({ code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Node not found" })); + } + if (!gate.canManage) throw new ForbiddenError(); + + await setNodeSshEnabled({ + nodeId: gate.row.id, + on: body.on, + changedAt: new Date().toISOString(), + actorUserId: user.id, + }); + // Re-read rather than patching the gate's snapshot: the service wrote + // the row, and the view must render what is stored, not what this + // handler believes it asked for. + const row = (await new NodesRepository(db).findById(gate.row.id)) ?? gate.row; + return await toNodeView(row, gate.access, gate.isAdmin, gate.granted); + }, + { + params: t.Object({ id: t.String({ description: "Node id" }) }), + body: SshEnabledBodySchema, + response: { + 200: NodeViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "setNodeSshEnabled", + tags: ["nodes"], + description: + "Allow or refuse Subshell SSH on a node (owner only; admin on the control-plane host). Off by default; enabling lets the plane dial out of and serve keys from this machine.", + }, + }, + ); diff --git a/apps/server/api/src/api/public-jwk.ts b/apps/server/api/src/api/public-jwk.ts index f0bcfe32b..8fc2421e6 100644 --- a/apps/server/api/src/api/public-jwk.ts +++ b/apps/server/api/src/api/public-jwk.ts @@ -7,19 +7,14 @@ type JwkInput = Parameters[0]; /** Base64url charset; coordinate LENGTH is proven by the real import below. */ const BASE64URL = /^[A-Za-z0-9_-]+$/; -const INVALID = "publicKey must be a valid P-256 public JWK"; - /** - * Proves a parsed JSON value is a PUBLIC P-256 key that jose can actually - * import for ECDH-ES — the same operation `seal()` performs for every roster - * key at post time. Registration only ever saw "parses as a JSON object", so - * a garbage key that passed would make EVERY later `post_channel` throw - * for EVERY member of every channel the registrant joined (channel-wide DoS). - * Structural checks are not enough either: only the import round-trip catches - * coordinates that are well-formed base64url but not a curve point. - * @throws HttpError 400 with a single generic message on any failure. + * Shared shape gate for both P-256 public-key registrations: an EC/P-256 + * object with base64url coordinates and NO private component - a public-key + * store that accepted `d` would be storing a private key it was handed. The + * refusal names the caller's field and nothing else (one generic message, so + * the answer cannot be probed for WHY the key was refused). */ -export async function assertImportablePublicJwk(parsed: unknown): Promise { +function assertP256PublicJwkShape(parsed: unknown, label: string): Record { const jwk = parsed as Record | null; const structurallyOk = typeof parsed === "object" && @@ -35,10 +30,44 @@ export async function assertImportablePublicJwk(parsed: unknown): Promise BASE64URL.test(jwk.y) && // Public-key store: the private component must never be registered. !("d" in jwk); - if (!structurallyOk) throw new HttpError(400, INVALID); + if (!structurallyOk) throw new HttpError(400, `${label} must be a valid P-256 public JWK`); + return jwk; +} + +/** + * Proves a parsed JSON value is a PUBLIC P-256 key that jose can actually + * import for ECDH-ES - the same operation `seal()` performs for every roster + * key at post time. Registration only ever saw "parses as a JSON object", so + * a garbage key that passed would make EVERY later `post_channel` throw + * for EVERY member of every channel the registrant joined (channel-wide DoS). + * Structural checks are not enough either: only the import round-trip catches + * coordinates that are well-formed base64url but not a curve point. + * @throws HttpError 400 with a single generic message on any failure. + */ +export async function assertImportablePublicJwk(parsed: unknown): Promise { + const jwk = assertP256PublicJwkShape(parsed, "publicKey"); try { await importJWK(jwk as unknown as JwkInput, "ECDH-ES"); } catch { - throw new HttpError(400, INVALID); + throw new HttpError(400, "publicKey must be a valid P-256 public JWK"); + } +} + +/** + * Proves a parsed JSON value is a PUBLIC P-256 key that jose can import for + * ES256 verification - the operation the SSH relay performs with a machine's + * signing key (spec 2026-10-08 §4.1). Same discipline as + * {@link assertImportablePublicJwk}: shape first, then the real import, which + * is the only check that refuses well-formed-looking coordinates that are not + * a curve point. A garbage registration here would poison every later + * signature check against this machine, so nothing that fails it is stored. + * @throws HttpError 400 with a single generic message on any failure. + */ +export async function assertImportableSigningJwk(parsed: unknown): Promise { + const jwk = assertP256PublicJwkShape(parsed, "signingPublicKey"); + try { + await importJWK(jwk as unknown as JwkInput, "ES256"); + } catch { + throw new HttpError(400, "signingPublicKey must be a valid P-256 public JWK"); } } diff --git a/apps/server/api/src/api/routes.ts b/apps/server/api/src/api/routes.ts index 93b011a5d..150769651 100644 --- a/apps/server/api/src/api/routes.ts +++ b/apps/server/api/src/api/routes.ts @@ -24,6 +24,7 @@ import { instancePublicRoutes } from "@/api/settings-public.route.js"; import { setupRoutes } from "@/api/setup.route.js"; import { setupAgentInstallRoute } from "@/api/setup-agent-install.route.js"; import { setupTmuxInstallRoute } from "@/api/setup-tmux-install.route.js"; +import { sshRoutes } from "@/api/ssh/index.js"; import { subshellRoutes } from "@/api/subshells/index.js"; import { systemKeysRoutes } from "@/api/system-keys.route.js"; import { transferRoutes } from "@/api/transfers/index.js"; @@ -80,10 +81,12 @@ const paneRoutes = new Elysia() .use(filesRoutes) .use(workspaceRoutes); -// The machine-side surfaces: node administration and the node-to-node transfer -// relay. Its own group (not an eighth `.use()` on the pane basket) keeps the -// composed type shallow enough to compile. -const nodeRoutes = new Elysia().use(nodesRoutes).use(transferRoutes); +// The machine-side surfaces: node administration, the node-to-node transfer +// relay, and the SSH launcher (every `/api/ssh` act is gated on a NODE row, +// so it belongs with the machine basket it asks about). Its own group (not an +// eighth `.use()` on the pane basket) keeps the composed type shallow enough +// to compile — the depth budget the grouping header exists to protect. +const nodeRoutes = new Elysia().use(nodesRoutes).use(transferRoutes).use(sshRoutes); const commsRoutes = new Elysia().use(notificationsRoutes).use(devicesRoutes).use(channelRoutes); diff --git a/apps/server/api/src/api/ssh/__tests__/host-pins-route.test.ts b/apps/server/api/src/api/ssh/__tests__/host-pins-route.test.ts new file mode 100644 index 000000000..18b9230ea --- /dev/null +++ b/apps/server/api/src/api/ssh/__tests__/host-pins-route.test.ts @@ -0,0 +1,242 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { sshRoutes } from "@/api/ssh/index.js"; +import { db } from "@/db/index.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { setSshHostPinsDepsForTests } from "@/services/ssh-host-pins.service.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; + +/** + * `/api/ssh/host-pins` (spec 2026-10-08 §8-§9, Task 12): the destination + * trust screen's HTTP surface. Pinned here: + * - the cookie doctrine of every ssh sibling (empty cookie 401, a bearer + * token 403), owner-scoped reads (a foreign destination is the SAME 404 an + * absent one is on DELETE); + * - the serialized view carries destination + `SHA256:` fingerprint and NEVER + * the key bytes; + * - POST supplies an explicit pin (grammar-checked destination and line, the + * §9 hard block as a 409 `SSH_HOST_PIN_CHANGED`), DELETE removes a pin and + * the follow-up re-capture succeeds (the TOFU recovery), and the audit trail + * names only destination + fingerprint. + * + * No key home is asked by these routes (the screen SUPPLIES lines), so no + * node, broker, or fetch is wired - the pins service's fetch seam is installed + * with a never-called script to keep the promise. + */ +const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); + +const pw = "hostpinsapi-1"; +const ownerEmail = `hostpinsapi-owner-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `hostpinsapi-other-${crypto.randomUUID()}@subshell.local`; +let ownerId: string; +let ownerCookie: string; +let otherCookie: string; +const emails: string[] = []; + +const KEY_BYTES = Buffer.concat([ + Buffer.from([0, 0, 0, 11]), + Buffer.from("ssh-ed25519"), + Buffer.from([0, 0, 0, 32]), + Buffer.alloc(32, 5), +]); +const LINE = `git.example.test ssh-ed25519 ${KEY_BYTES.toString("base64")}`; +const FP = `SHA256:${createHash("sha256").update(KEY_BYTES).digest("base64url")}`; +const DEST = "git.example.test:22"; +const probeSubshellIds: string[] = []; + +function fetchPins( + path = "/api/ssh/host-pins", + init: { method?: string; body?: unknown; cookie?: string; bearer?: string } = {}, +) { + const headers: Record = { "content-type": "application/json" }; + if (init.cookie) headers.cookie = `better-auth.session_token=${init.cookie}`; + if (init.bearer) headers.authorization = `Bearer ${init.bearer}`; + return app.fetch( + new Request(`http://localhost:3099${path}`, { + method: init.method ?? "GET", + headers, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }), + ); +} + +beforeAll(async () => { + await setupAuthTables(); + await ensureMigratedTestDb(); + const mk = async (email: string) => + await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerId = await mk(ownerEmail); + await mk(otherEmail); + emails.push(ownerEmail, otherEmail); + ownerCookie = await signIn(ownerEmail, pw); + otherCookie = await signIn(otherEmail, pw); + // No fetch door on these routes; a call here is a bug the test would surface. + setSshHostPinsDepsForTests({ + nowIso: () => new Date().toISOString(), + fetchHostKey: async () => { + throw new Error("the trust screen must never ask a key home"); + }, + }); +}); + +afterAll(async () => { + setSshHostPinsDepsForTests(null); + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("auditEvents").execute(); + for (const id of probeSubshellIds) await new SubshellsRepository(db).delete(id).catch(() => {}); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +beforeEach(async () => { + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("auditEvents").execute(); +}); + +describe("the cookie doctrine", () => { + it("an empty cookie is 401 and a real subshell token is 403 on every door", async () => { + // A MACHINE credential must be refused the same way the sibling ssh doors + // refuse it (the fake-token path 401s at better-auth before the guard). + const subshellId = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: subshellId, + userId: ownerId, + harnessId: "terminal", + name: "hostpins-bearer-probe", + workingDir: mkdtempSync(join(tmpdir(), "hostpins-bearer-")), + tmuxSocket: null, + nodeId: LOCAL_NODE_ID, + }); + probeSubshellIds.push(subshellId); + const key = await issueSubshellToken(subshellId, ownerId); + expect((await fetchPins()).status).toBe(401); + expect((await fetchPins(undefined, { bearer: key })).status).toBe(403); + expect((await fetchPins(undefined, { method: "POST", body: { destination: DEST, hostKey: LINE } })).status).toBe( + 401, + ); + expect((await fetchPins(`/api/ssh/host-pins/${DEST}`, { method: "DELETE", bearer: key })).status).toBe(403); + }); +}); + +describe("POST /api/ssh/host-pins (explicit pin)", () => { + it("supplies a pin: the view carries destination + fingerprint, never the key bytes", async () => { + const res = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: LINE }, + }); + expect(res.status).toBe(200); + const json = (await res.json()) as { pin: Record }; + expect(json.pin.destination).toBe(DEST); + expect(json.pin.fingerprint).toBe(FP); + expect(JSON.stringify(json)).not.toContain(KEY_BYTES.toString("base64")); + // The audit row names destination + fingerprint ONLY. + const row = await db + .selectFrom("auditEvents") + .select("metadataJson") + .where("action", "=", "node.ssh_host_pin.create") + .executeTakeFirstOrThrow(); + const meta = JSON.parse(row.metadataJson ?? "{}") as Record; + expect(meta).toMatchObject({ destination: DEST, fingerprint: FP, via: "explicit" }); + expect(JSON.stringify(meta)).not.toContain(KEY_BYTES.toString("base64")); + }); + + it("refuses a non-canonical destination and a malformed key line with the named 400", async () => { + const badDest = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: "not-canonical", hostKey: LINE }, + }); + expect(badDest.status).toBe(400); + expect(((await badDest.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_HOST_PIN_INVALID); + const badLine = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: "git.example.test just-a-token" }, + }); + expect(badLine.status).toBe(400); + const smuggled = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: `a b\nsecond ssh-ed25519 AAA` }, + }); + expect(smuggled.status).toBe(400); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toHaveLength(0); + }); + + it("a destination already pinned to a DIFFERENT key is the §9 hard block, never an overwrite", async () => { + await fetchPins(undefined, { method: "POST", cookie: ownerCookie, body: { destination: DEST, hostKey: LINE } }); + const other = Buffer.concat([Buffer.from([0, 0, 0, 11]), Buffer.from("ssh-ed25519"), Buffer.alloc(32, 9)]).toString( + "base64", + ); + const res = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: `git.example.test ssh-ed25519 ${other}` }, + }); + expect(res.status).toBe(409); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_HOST_PIN_CHANGED); + // The standing pin is untouched. + const standing = await db.selectFrom("sshHostPins").select("hostKey").executeTakeFirstOrThrow(); + expect(standing.hostKey).toBe(LINE); + }); +}); + +describe("GET list and the owner axis", () => { + it("the list shows the caller's pins only, as fingerprint views", async () => { + await fetchPins(undefined, { method: "POST", cookie: ownerCookie, body: { destination: DEST, hostKey: LINE } }); + const mine = (await (await fetchPins(undefined, { cookie: ownerCookie })).json()) as { pins: unknown[] }; + const theirs = (await (await fetchPins(undefined, { cookie: otherCookie })).json()) as { pins: unknown[] }; + expect(mine.pins).toHaveLength(1); + expect(theirs.pins).toHaveLength(0); + expect(JSON.stringify(mine.pins)).not.toContain(KEY_BYTES.toString("base64")); + }); +}); + +describe("DELETE /api/ssh/host-pins/:destination (the recovery)", () => { + it("deletes the pin (audited with fingerprint) and the next supply at a NEW key succeeds as a fresh TOFU", async () => { + await fetchPins(undefined, { method: "POST", cookie: ownerCookie, body: { destination: DEST, hostKey: LINE } }); + const del = await fetchPins(`/api/ssh/host-pins/${DEST}`, { method: "DELETE", cookie: ownerCookie }); + expect(del.status).toBe(200); + expect(((await del.json()) as { deleted: boolean }).deleted).toBe(true); + const delAudit = await db + .selectFrom("auditEvents") + .select("metadataJson") + .where("action", "=", "node.ssh_host_pin.delete") + .executeTakeFirstOrThrow(); + expect(JSON.parse(delAudit.metadataJson ?? "{}")).toMatchObject({ destination: DEST, fingerprint: FP }); + const other = Buffer.concat([Buffer.from([0, 0, 0, 11]), Buffer.from("ssh-ed25519"), Buffer.alloc(32, 9)]).toString( + "base64", + ); + const re = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: `git.example.test ssh-ed25519 ${other}` }, + }); + expect(re.status).toBe(200); + }); + + it("a foreign or absent destination is the same 404", async () => { + await fetchPins(undefined, { method: "POST", cookie: ownerCookie, body: { destination: DEST, hostKey: LINE } }); + expect((await fetchPins(`/api/ssh/host-pins/${DEST}`, { method: "DELETE", cookie: otherCookie })).status).toBe(404); + expect( + (await fetchPins("/api/ssh/host-pins/nobody.test:22", { method: "DELETE", cookie: ownerCookie })).status, + ).toBe(404); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toHaveLength(1); + }); +}); diff --git a/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts b/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts new file mode 100644 index 000000000..982af471b --- /dev/null +++ b/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts @@ -0,0 +1,147 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { sshRoutes } from "@/api/ssh/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { sshSetupTracker } from "@/services/ssh-setup-progress.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; + +/** + * `POST /api/ssh/setup-here` - the HTTP surface of the destination upgrade + * (spec 2026-10-08 §7, Task 14). The act itself - re-open, exec, redaction, + * the bounded `ready` wait - is pinned by `ssh-setup-here.service.test.ts` + * against the real RPC chain; this file owns the doors in front of it: the + * surface is browser sessions only (the launcher family's posture), a body + * that is not a pane id is a 400, and a foreign pane is the 404 the + * ownership axis demands, identical to an absent one. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); +const port = "api/ssh/setup-here" as const; + +function post(body: unknown, opts: { cookie?: string; bearer?: string } = {}) { + const headers: Record = { "content-type": "application/json" }; + if (opts.cookie) headers.cookie = `better-auth.session_token=${opts.cookie}`; + if (opts.bearer) headers.authorization = `Bearer ${opts.bearer}`; + return app.fetch( + new Request(`http://localhost:3099/${port}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }), + ); +} + +const pw = "setup-route-1"; +const ownerEmail = `sh-route-o-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `sh-route-x-${crypto.randomUUID()}@subshell.local`; +let owner = ""; +let _other = ""; +let ownerCookie = ""; +let otherCookie = ""; +const createdPanes: string[] = []; + +async function mkPane(userId: string): Promise { + const id = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id, + userId, + harnessId: "terminal", + name: `route-probe-${id.slice(0, 8)}`, + workingDir: "/tmp", + tmuxSocket: null, + nodeId: LOCAL_NODE_ID, + }); + createdPanes.push(id); + return id; +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + owner = await new UsersRepository(db).createUser({ + email: ownerEmail, + name: ownerEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + _other = await new UsersRepository(db).createUser({ + email: otherEmail, + name: otherEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerCookie = await signIn(ownerEmail, pw); + otherCookie = await signIn(otherEmail, pw); + await ensureLocalNode(db); +}); + +afterAll(async () => { + for (const id of createdPanes) await new SubshellsRepository(db).delete(id).catch(() => {}); + await new NodesRepository(db).deleteById(LOCAL_NODE_ID).catch(() => {}); + await deleteUserByEmailOrId(ownerEmail).catch(() => {}); + await deleteUserByEmailOrId(otherEmail).catch(() => {}); +}); + +describe("POST /api/ssh/setup-here doors", () => { + it("an empty cookie is a failed authentication (401), like every launcher door", async () => { + expect((await post({ paneId: "whatever" }, { cookie: "" })).status).toBe(401); + }); + + it("bearer credentials are refused at the door: this surface is browser sessions only", async () => { + const paneId = await mkPane(owner); + const key = await issueSubshellToken(paneId, owner); + const res = await post({ paneId }, { bearer: key }); + expect(res.status).toBe(403); + }); + + it("a body without a pane id is a 400 INPUT_VALIDATION_ERROR, not a service call", async () => { + const res = await post({}, { cookie: ownerCookie }); + expect(res.status).toBe(400); + }); + + it("a foreign pane and an absent pane are one 404 (the ordinary invisibility)", async () => { + const paneId = await mkPane(owner); + const foreign = await post({ paneId }, { cookie: otherCookie }); + expect(foreign.status).toBe(404); + const absent = await post({ paneId: crypto.randomUUID() }, { cookie: ownerCookie }); + expect(absent.status).toBe(404); + }); + + it("a non-ssh pane refuses by name through the service's own door", async () => { + const paneId = await mkPane(owner); + 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("Open an SSH subshell"); + }); +}); + +it("setup status survives a new request and remains cookie-only and owner-scoped", async () => { + const paneId = crypto.randomUUID(); + await sshSetupTracker.run(owner, paneId, async () => ({ ok: true, value: { nodeId: "new-node" } })); + const get = (cookie?: string, bearer?: string) => + app.fetch( + new Request(`http://localhost:3099/${port}/${paneId}`, { + headers: { + ...(cookie ? { cookie: `better-auth.session_token=${cookie}` } : {}), + ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), + }, + }), + ); + expect((await get()).status).toBe(401); + const own = await get(ownerCookie); + expect(own.status).toBe(200); + expect(await own.json()).toMatchObject({ operation: { stage: "complete", nodeId: "new-node" } }); + expect(await (await get(otherCookie)).json()).toEqual({ operation: null }); + const token = await issueSubshellToken(await mkPane(owner), owner); + expect((await get(undefined, token)).status).toBe(403); +}); diff --git a/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts new file mode 100644 index 000000000..8d8ddcc4e --- /dev/null +++ b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts @@ -0,0 +1,902 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { + buildAgentSocketPath, + buildSshConfigPath, + OPENSSH_10X_SCHEME, + renderSshConfigContents, + sshDestinationToken, + sshOptionTokens, +} from "@internal/pane-runtime"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { setNodeSharesRoute } from "@/api/nodes/set-node-shares.route.js"; +import { sshRoutes } from "@/api/ssh/index.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SettingsRepository } from "@/db/repositories/settings.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID, type NodeTable } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { ROSTER, startStubAgent } from "@/services/__tests__/helpers/ssh-relay-stack.js"; +import { audit } from "@/services/audit.js"; +import { getHeld, holdConnection, releaseHeld, resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { sendCommand } from "@/services/nodes/node-rpc.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { gateSshNode } from "@/services/ssh-policy.service.js"; +import { createRelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { attachScriptedNode, ok, SCRIPTED_DATA_DIR, statDirEcho } from "@/test-helpers/scripted-node.js"; + +/** + * The `/api/ssh` surface (spec 2026-10-07 §4.3/§5/§7) — the gate doctrine, the + * refusal shapes, and the compose the launch answers with. + * + * The properties this matrix exists to pin, each by its own case: + * - **Gate order**: the plane's row + `nodeCanSsh` answer lands BEFORE any + * `sendCommand`; a held agent is refused before the resolve round trip; + * discovery, resolve and launch all sit behind the same gate. + * - **Refusal placement**: `POST /resolve` answers a refusal outcome IN THE + * DATA (200, tier-1 grammar); `POST /launch` and `PUT /saved-hosts` refuse a + * refusal-shaped outcome with 422 carrying `{outcome}`. + * - **Compose**: the launch frame carries the config at the NODE's derived + * path, the rendered bytes, and the option tail `-F … -- host`; the row + * carries the approved snapshot; `SSH_AUTH_SOCK` rides only when the + * snapshot names a socket. + * - **Saved hosts**: lists never carry another owner's rows; a foreign delete + * is the same 404 as an absent one; preferences store the id and the read + * tolerates the node vanishing. + * - **Audit**: `ssh.launch` lands on SUCCESS only, with `{nodeId, destination, + * subshellId}` and nothing else; saved-host CRUD audits nothing (prompts precedent). + * + * The agent answers come from the scripted node (the real `sendCommand` chain, + * the real frame parser) — the `ssh`/`resolve` frames this surface emits are + * what the shipped agent would switch on. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); + +const pw = "ssh-api-1"; +const ownerEmail = `ssha-owner-${crypto.randomUUID()}@subshell.local`; +const editorEmail = `ssha-editor-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `ssha-other-${crypto.randomUUID()}@subshell.local`; +const adminEmail = `ssha-admin-${crypto.randomUUID()}@subshell.local`; +let ownerId: string; +let editorId: string; +let ownerCookie: string; +let editorCookie: string; +let otherCookie: string; +let adminCookie: string; + +/** The agent node this file's launch cases run on (SSH row switched ON). */ +const NODE_ON = `n-ssh-on-${crypto.randomUUID()}`; +/** Same machine with the SSH row OFF — the gate's negative fixture. */ +const NODE_OFF = `n-ssh-off-${crypto.randomUUID()}`; + +const nodes = new NodesRepository(db); + +/** An approved snapshot in its wire spelling (the shape `parseSshConnectionSnapshot` rebuilds). */ +function snapshot(over: Record = {}): Record { + return { + alias: "work", + host: "example.test", + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...over, + }; +} +const CANON = "example.test:22"; +const ACCEPTED = snapshot(); +const REFUSED = { accepted: false, code: "unsupported_setting", settings: ["ProxyCommand"] }; + +function sshFetch(path: string, init: { method?: string; body?: unknown; cookie?: string; bearer?: string } = {}) { + const headers: Record = { "content-type": "application/json" }; + if (init.cookie) headers.cookie = `better-auth.session_token=${init.cookie}`; + if (init.bearer) headers.authorization = `Bearer ${init.bearer}`; + return app.fetch( + new Request(`http://localhost:3099${path}`, { + method: init.method ?? "GET", + headers, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }), + ); +} + +const aliases = (node: string, cookie: string) => sshFetch(`/api/ssh/aliases?node=${node}`, { cookie }); +const resolve = (node: string, alias: string, cookie: string) => + sshFetch("/api/ssh/resolve", { method: "POST", body: { node, alias }, cookie }); +const launch = (body: Record, cookie: string) => + sshFetch("/api/ssh/launch", { method: "POST", body, cookie }); + +async function auditRows(): Promise< + { id: string; targetId: string | null; metadata: Record | null }[] +> { + const rows = await db + .selectFrom("auditEvents") + .select(["id", "targetId", "metadataJson"]) + .where("action", "=", "ssh.launch") + .execute(); + return rows.map((r) => ({ + id: r.id, + targetId: r.targetId, + metadata: r.metadataJson ? JSON.parse(r.metadataJson) : null, + })); +} + +async function mkNode(id: string, sshEnabled: boolean, owner = ownerId): Promise { + await nodes.create({ id, ownerUserId: owner, name: `sshapi-${id.slice(0, 20)}`, kind: "agent", status: "offline" }); + if (sshEnabled) await nodes.setSshEnabled(id, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + // A fresh detection that found the ssh binary — the launch's per-node + // usability gate reads it exactly as the picker does (spec §6.2). + await db + .updateTable("nodes") + .set({ + inventoryJson: JSON.stringify([{ harnessId: "ssh", installed: true, binaryPath: "/usr/bin/ssh", version: "1" }]), + inventoryAt: new Date().toISOString(), + } as never) + .where("id", "=", id) + .execute(); + return (await nodes.findById(id)) as NodeTable; +} + +/** The scripted node every gate-order case observes from the outside. */ +function silentScript(nodeId: string) { + return attachScriptedNode(nodeId, { + ssh_discover_aliases: () => ({ aliases: ["work"], includeCycle: false, truncated: false }), + ssh_resolve_config: (cmd) => + cmd.type === "ssh_resolve_config" && cmd.alias === "work" ? { accepted: true, snapshot: ACCEPTED } : REFUSED, + launch: ok, + stat_dir: statDirEcho, + }); +} + +const createdSubshellIds: string[] = []; +const createdNodeIds: string[] = []; +const emails: string[] = []; + +beforeAll(async () => { + await setupAuthTables(); + const mk = async (email: string, role: "user" | "admin") => + await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role, + }); + ownerId = await mk(ownerEmail, "user"); + editorId = await mk(editorEmail, "user"); + await mk(otherEmail, "user"); + await mk(adminEmail, "admin"); + emails.push(ownerEmail, editorEmail, otherEmail, adminEmail); + ownerCookie = await signIn(ownerEmail, pw); + editorCookie = await signIn(editorEmail, pw); + otherCookie = await signIn(otherEmail, pw); + adminCookie = await signIn(adminEmail, pw); + await ensureLocalNode(db); + await mkNode(NODE_ON, true); + await mkNode(NODE_OFF, false); + await new NodeSharesRepository(db).replaceForNode( + NODE_ON, + [{ granteeUserId: editorId, permission: "edit" }], + ownerId, + ); +}); + +afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await new SubshellsRepository(db).delete(id).catch(() => {}); + await db.deleteFrom("sshSavedHosts").execute(); + for (const id of createdNodeIds.concat(NODE_ON, NODE_OFF)) await nodes.deleteById(id).catch(() => {}); + await db + .updateTable("nodes") + .set({ sshEnabled: 0, sshEnabledAt: null } as never) + .where("id", "=", LOCAL_NODE_ID) + .execute(); + await db.deleteFrom("settings").where("key", "like", "ssh.defaultNode:%").execute(); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +describe("/api/ssh gate order (row + nodeCanSsh BEFORE any sendCommand)", () => { + it("a node whose row is OFF never receives a command: aliases, resolve and launch all 403 ssh_gate_off", async () => { + const scripted = silentScript(NODE_OFF); + try { + for (const res of [ + await aliases(NODE_OFF, ownerCookie), + await resolve(NODE_OFF, "work", ownerCookie), + await launch({ node: NODE_OFF, destination: "work" }, ownerCookie), + ]) { + expect(res.status).toBe(403); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GATE_OFF); + } + // The load-bearing assertion: the gate answered before the machine was + // asked. A sendCommand anywhere in the path would have put a frame here. + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("a held agent is refused with the update remedy before any resolve; even ahead of destination validation", async () => { + const scripted = silentScript(NODE_ON); + const held = holdConnection(NODE_ON, scripted.conn, { + reason: "protocol-mismatch", + agentVersion: "0.0.1", + protocolVersion: 12, + os: "linux", + arch: "x64", + onIdle: () => {}, + }); + try { + const res = await aliases(NODE_ON, ownerCookie); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe(BackendErrorCodes.NODE_PROTOCOL_HELD); + expect(body.message.toLowerCase()).toContain("update"); + // Held BEFORE the destination check: an unsafe token still answers 409. + expect((await launch({ node: NODE_ON, destination: "-oProxyCommand=x" }, ownerCookie)).status).toBe(409); + expect(scripted.cmdTypes()).toEqual([]); + } finally { + if (getHeld(NODE_ON)) releaseHeld(NODE_ON, held.conn); + scripted.detach(); + } + }); + + it("owners, launch grantees and admins use SSH; invisible machines stay 404", async () => { + const scripted = silentScript(NODE_ON); + try { + // Row ON, but SSH USE is owner-only: the grantee sees the node (it is on + // their screen) and is refused, not hidden. + expect((await aliases(NODE_ON, editorCookie)).status).toBe(200); + expect((await resolve(NODE_ON, "work", editorCookie)).status).toBe(200); + // The admin boost is instance-wide EDIT, never ownership — SSH use stays + // with the machine's owner (spec §4.3: broker an OS account's keys). + expect((await aliases(NODE_ON, adminCookie)).status).toBe(200); + expect((await aliases(NODE_ON, otherCookie)).status).toBe(404); + expect((await launch({ node: NODE_ON, destination: "work" }, otherCookie)).status).toBe(404); + // An unknown id answers the same 404 a foreign one did (no existence oracle). + expect((await aliases(`n-never-${crypto.randomUUID()}`, ownerCookie)).status).toBe(404); + } finally { + scripted.detach(); + } + }); + + it("bearer credentials are refused at the door: this surface is browser sessions only", async () => { + // A real subshell token (its own row, its owner's key) — the actor kind is + // refused regardless of the owner it resolves to. + const subshellId = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: subshellId, + userId: ownerId, + harnessId: "terminal", + name: "bearer-probe", + workingDir: tmpdir(), + tmuxSocket: null, + nodeId: LOCAL_NODE_ID, + }); + createdSubshellIds.push(subshellId); + const key = await issueSubshellToken(subshellId, ownerId); + // An empty cookie is just a failed authentication (401), before any actor kind exists. + expect((await sshFetch(`/api/ssh/aliases?node=${NODE_ON}`, { cookie: "" })).status).toBe(401); + const res = await sshFetch(`/api/ssh/aliases?node=${NODE_ON}`, { bearer: key }); + expect(res.status).toBe(403); + }); +}); + +describe("/api/ssh/resolve (refusal rides IN THE DATA)", () => { + it("an accepted alias returns the full outcome, and the machine got exactly one resolve frame", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await resolve(NODE_ON, "work", ownerCookie); + expect(res.status).toBe(200); + const body = (await res.json()) as { accepted: boolean; snapshot?: { host: string } }; + expect(body.accepted).toBe(true); + expect(body.snapshot?.host).toBe("example.test"); + expect(scripted.cmdsOf("ssh_resolve_config").map((c) => c.alias)).toEqual(["work"]); + } finally { + scripted.detach(); + } + }); + + it("a refused resolution is a 200 carrying the refusal (settings named), never an error status", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await resolve(NODE_ON, "proxy-box", ownerCookie); + expect(res.status).toBe(200); + const body = (await res.json()) as { accepted: boolean; code: string; settings: string[] }; + expect(body.accepted).toBe(false); + expect(body.code).toBe("unsupported_setting"); + expect(body.settings).toEqual(["ProxyCommand"]); + } finally { + scripted.detach(); + } + }); + + it("an unsafe destination is 400 alias_unsafe and never reaches the machine", async () => { + const scripted = silentScript(NODE_ON); + try { + for (const alias of ["-x", "-oProxyCommand=evil", "has space", "escape"]) { + const res = await resolve(NODE_ON, alias, ownerCookie); + expect(res.status).toBe(400); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.ALIAS_UNSAFE); + } + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("an offline agent answers 409 NODE_OFFLINE, a machine-level refusal answers 502 SSH_NODE_REFUSED", async () => { + // No socket at all: the row says ON, the machine is not there. + const resOffline = await resolve(NODE_ON, "work", ownerCookie); + expect(resOffline.status).toBe(409); + expect(((await resOffline.json()) as { code: string }).code).toBe(BackendErrorCodes.NODE_OFFLINE); + // Attached but answering `ok:false` (no ssh binary / gate mirror says no): + // the machine's own refusal maps to the 502 family, verbatim text NOT echoed. + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => new Error("ssh binary missing: ssh"), + }); + try { + const res = await resolve(NODE_ON, "work", ownerCookie); + expect(res.status).toBe(502); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe(BackendErrorCodes.SSH_NODE_REFUSED); + expect(body.message).not.toContain("ssh binary missing"); + } finally { + scripted.detach(); + } + }); +}); + +describe("POST /api/ssh/launch", () => { + it("refuses fingerprints without a key home before resolving a destination", async () => { + const scripted = silentScript(NODE_ON); + try { + const response = await launch( + { node: NODE_ON, destination: "work", fingerprints: [`SHA256:${"A".repeat(43)}`] }, + ownerCookie, + ); + expect(response.status).toBe(400); + expect(((await response.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_KEYS_INVALID); + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("refuses a refusal-shaped outcome with 422 carrying {outcome}, and launches nothing", async () => { + const scripted = silentScript(NODE_ON); + const rowsBefore = (await db.selectFrom("subshells").select("id").execute()).length; + try { + const res = await launch({ node: NODE_ON, destination: "proxy-box" }, ownerCookie); + expect(res.status).toBe(422); + const body = (await res.json()) as { outcome: { accepted: boolean; code: string; settings: string[] } }; + expect(body.outcome.accepted).toBe(false); + expect(body.outcome.code).toBe("unsupported_setting"); + expect(body.outcome.settings).toEqual(["ProxyCommand"]); + // Nothing was written: no row, no audit, no recency touch. + expect((await db.selectFrom("subshells").select("id").execute()).length).toBe(rowsBefore); + expect(scripted.countOf("launch")).toBe(0); + expect(await auditRows()).toHaveLength(0); + } finally { + scripted.detach(); + } + }); + + it("an unsafe destination is 400 alias_unsafe before any resolve frame", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await launch({ node: NODE_ON, destination: "-x" }, ownerCookie); + expect(res.status).toBe(400); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.ALIAS_UNSAFE); + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("normalizes an explicit own-machine key home before relay restrictions, and refuses a supplied subset", async () => { + const snap = snapshot({ identityFiles: ["/home/user/.ssh/id_ed25519"] }); + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: snap }), + launch: ok, + stat_dir: statDirEcho, + }); + try { + const refused = await launch( + { + node: NODE_ON, + keyHome: NODE_ON, + destination: "work", + fingerprints: ["SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"], + }, + ownerCookie, + ); + expect(refused.status).toBe(400); + expect(scripted.cmdTypes()).toEqual([]); + const response = await launch({ node: NODE_ON, keyHome: NODE_ON, destination: "work" }, ownerCookie); + expect(response.status).toBe(201); + const body = (await response.json()) as { subshell: { id: string } }; + createdSubshellIds.push(body.subshell.id); + expect((await new SubshellsRepository(db).findById(body.subshell.id))?.keyHomeNodeId).toBeNull(); + expect(scripted.cmdTypes()).not.toContain("ssh_relay_open"); + } finally { + scripted.detach(); + } + }); + + it("launches the ssh pane: the frame carries the config at the NODE's derived path, the option tail, the agent socket env; the row keeps the snapshot", async () => { + const snap = snapshot({ authAgentSocket: "/run/user/501/ssh-agent.sock" }); + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: snap }), + launch: ok, + stat_dir: statDirEcho, + }); + const beforeIds = new Set((await auditRows()).map((r) => r.id)); + try { + const res = await launch({ node: NODE_ON, destination: "work", name: "desk" }, ownerCookie); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: Record }; + createdSubshellIds.push(subshell.id as string); + expect(subshell.harnessId).toBe("ssh"); + expect(subshell.name).toBe("desk"); + expect(subshell.nodeOffline).toBe(false); + + const parsed = snap; // what the agent answered is what the row must hold + const row = await new SubshellsRepository(db).findById(subshell.id as string); + expect(row).toBeDefined(); + expect(JSON.parse(String(row?.ssh))).toEqual(parsed); // the snapshot column IS the kind fact + expect(row?.presetId).toBeNull(); + expect(row?.nodeId).toBe(NODE_ON); + expect(row?.restartOnExit).toBe(0); // auto-restart is forced off for ssh panes (decision 7) + + const cmd = scripted.cmdsOf("launch")[0] as Record & { + ssh?: { configPath: string; fileContent: string }; + argv?: string[]; + subshellEnv?: Record; + preset?: { flags?: string[] }; + }; + expect(cmd.ssh?.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, subshell.id as string)); // the node's dataDir, not the server's + expect(cmd.ssh?.fileContent).toBe(renderSshConfigContents(parsed as never)); + // Handoff 4's tail: every option token, then `--`, then the bare host. + const configPath = buildSshConfigPath(SCRIPTED_DATA_DIR, subshell.id as string); + expect(cmd.argv?.slice(1)).toEqual([ + ...sshOptionTokens(parsed as never, configPath), + "--", + sshDestinationToken(parsed as never), + ]); + expect(cmd.preset?.flags?.[0]).toBe("-F"); + // Decision 3: the snapshot's agent socket rides the pane env ONLY when named. + expect(cmd.subshellEnv?.SSH_AUTH_SOCK).toBe("/run/user/501/ssh-agent.sock"); + } finally { + scripted.detach(); + } + // Audit: success lands exactly one ssh.launch row with the three named facts. + const added = (await auditRows()).filter((r) => !beforeIds.has(r.id)); + expect(added).toHaveLength(1); + expect(added[0].targetId).toBe(NODE_ON); + expect(added[0].metadata).toEqual({ nodeId: NODE_ON, destination: CANON, subshellId: createdSubshellIds.at(-1) }); + }); + + it("a snapshot naming no agent socket leaves SSH_AUTH_SOCK out of the pane env", async () => { + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: ACCEPTED }), + launch: ok, + stat_dir: statDirEcho, + }); + try { + const res = await launch({ node: NODE_ON, destination: "work" }, ownerCookie); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: { id: string } }; + createdSubshellIds.push(subshell.id); + const cmd = scripted.cmdsOf("launch")[0] as { subshellEnv?: Record }; + expect(cmd.subshellEnv?.SSH_AUTH_SOCK).toBeUndefined(); + } finally { + scripted.detach(); + } + }); + + it("a successful launch records the recency row (saved_at stays null until the human saves)", async () => { + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: ACCEPTED }), + launch: ok, + stat_dir: statDirEcho, + }); + try { + const res = await launch({ node: NODE_ON, destination: "work" }, ownerCookie); + expect(res.status).toBe(201); + await res.json(); + const rows = await db + .selectFrom("sshSavedHosts") + .selectAll() + .where("destination", "=", CANON) + .where("ownerUserId", "=", ownerId) + .execute(); + expect(rows).toHaveLength(1); + expect(rows[0].savedAt).toBeNull(); + expect(rows[0].alias).toBe("work"); + expect(rows[0].nodeId).toBe(NODE_ON); + } finally { + scripted.detach(); + } + }); +}); + +describe("/api/ssh/saved-hosts", () => { + it("PUT resolves then upsert-marks-saved, and audits NOTHING (prompts precedent)", async () => { + const scripted = silentScript(NODE_ON); + const before = await db.selectFrom("auditEvents").select("id").execute(); + try { + const res = await sshFetch("/api/ssh/saved-hosts", { + method: "PUT", + body: { node: NODE_ON, destination: "work" }, + cookie: ownerCookie, + }); + expect(res.status).toBe(200); + const row = (await res.json()) as { destination: string; savedAt: string | null; alias: string | null }; + expect(row.destination).toBe(CANON); + expect(row.savedAt).not.toBeNull(); + expect(row.alias).toBe("work"); + } finally { + scripted.detach(); + } + const after = await db.selectFrom("auditEvents").select("id").execute(); + expect(after.length).toBe(before.length); // saved-host CRUD records nothing, ever + }); + + it("PUT refuses a refusal-shaped outcome with 422 {outcome} and writes no row", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await sshFetch("/api/ssh/saved-hosts", { + method: "PUT", + body: { node: NODE_ON, destination: "proxy-box" }, + cookie: ownerCookie, + }); + expect(res.status).toBe(422); + const body = (await res.json()) as { outcome: { code: string } }; + expect(body.outcome.code).toBe("unsupported_setting"); + const rows = await db.selectFrom("sshSavedHosts").selectAll().where("ownerUserId", "=", ownerId).execute(); + expect(rows.find((r) => r.destination === "proxy-box")).toBeUndefined(); + } finally { + scripted.detach(); + } + }); + + it("GET lists the caller's own saved and recent rows only — never another owner's", async () => { + // Owner A saves; owner B (a second human with their own node) must see + // none of A's rows, and vice versa. + await db + .insertInto("sshSavedHosts") + .values({ + id: crypto.randomUUID(), + ownerUserId: ownerId, + destination: "a-private.test:22", + alias: "mine", + nodeId: NODE_ON, + savedAt: "2026-10-07T10:00:00.000Z", + lastConnectAt: "2026-10-07T10:00:00.000Z", + } as never) + .execute(); + const resA = await sshFetch("/api/ssh/saved-hosts", { cookie: ownerCookie }); + expect(resA.status).toBe(200); + const viewA = (await resA.json()) as { + saved: { destination: string }[]; + recent: unknown[]; + defaultNodeId: string | null; + }; + expect(viewA.saved.map((r) => r.destination)).toContain("a-private.test:22"); + + const resB = await sshFetch("/api/ssh/saved-hosts", { cookie: editorCookie }); + const viewB = (await resB.json()) as { saved: { destination: string }[]; recent: unknown[] }; + expect(viewB.saved.map((r) => r.destination)).not.toContain("a-private.test:22"); + expect(viewB.recent).toEqual([]); + }); + + it("DELETE removes the owner's row (204) and a foreign id answers the same 404 as an absent one", async () => { + const row = await db + .insertInto("sshSavedHosts") + .values({ + id: crypto.randomUUID(), + ownerUserId: ownerId, + destination: "to-delete.test:22", + alias: null, + nodeId: NODE_ON, + savedAt: "2026-10-07T10:00:00.000Z", + lastConnectAt: "2026-10-07T10:00:00.000Z", + } as never) + .returningAll() + .executeTakeFirstOrThrow(); + const foreign = await sshFetch(`/api/ssh/saved-hosts/${row.id}`, { method: "DELETE", cookie: editorCookie }); + expect(foreign.status).toBe(404); // refused like an absent row — the id is no oracle + const own = await sshFetch(`/api/ssh/saved-hosts/${row.id}`, { method: "DELETE", cookie: ownerCookie }); + expect(own.status).toBe(204); + const again = await sshFetch(`/api/ssh/saved-hosts/${row.id}`, { method: "DELETE", cookie: ownerCookie }); + expect(again.status).toBe(404); + }); +}); + +describe("/api/ssh/preferences (default connecting machine)", () => { + async function patchPreferences(cookie: string, defaultNodeId: string | null) { + return sshFetch("/api/ssh/preferences", { method: "PATCH", body: { defaultNodeId }, cookie }); + } + + it("stores a visible node's id, reads it back on the saved-hosts view, and null clears it", async () => { + const set = await patchPreferences(ownerCookie, NODE_ON); + expect(set.status).toBe(200); + expect(((await set.json()) as { defaultNodeId: string | null }).defaultNodeId).toBe(NODE_ON); + const view = (await (await sshFetch("/api/ssh/saved-hosts", { cookie: ownerCookie })).json()) as { + defaultNodeId: string | null; + }; + expect(view.defaultNodeId).toBe(NODE_ON); + const cleared = await patchPreferences(ownerCookie, null); + expect(((await cleared.json()) as { defaultNodeId: string | null }).defaultNodeId).toBeNull(); + }); + + it("an invisible or unknown node id is 404 (validation happens at the write)", async () => { + expect((await patchPreferences(ownerCookie, "no-such-node")).status).toBe(404); + expect((await patchPreferences(editorCookie, NODE_OFF)).status).toBe(404); // the editor cannot see NODE_OFF + }); + + it("a node that vanishes after the write still reads back as the stored id (the SPA decides)", async () => { + const transient = `n-vanish-${crypto.randomUUID()}`; + createdNodeIds.push(transient); + await nodes.create({ + id: transient, + ownerUserId: ownerId, + name: `sshapi-vanish-${transient.slice(0, 8)}`, + kind: "agent", + status: "offline", + }); + await patchPreferences(ownerCookie, transient); + await nodes.deleteById(transient); + const view = (await (await sshFetch("/api/ssh/saved-hosts", { cookie: ownerCookie })).json()) as { + defaultNodeId: string | null; + }; + expect(view.defaultNodeId).toBe(transient); + }); +}); + +describe("the local node answers in-process (no agent socket exists to reach)", () => { + // NOTE on HOME: Bun's `os.homedir()` reads HOME at PROCESS START and caches + // it (measured on 1.4.2), so a test cannot swap `process.env.HOME` to feed + // the discovery engine a fixture `~`. The alias-list CONTENTS are therefore + // whatever the server account's own config holds (machine-dependent; the + // discovery engine's fixture-parsed contents are pinned in + // pane-runtime's `ssh-discover` suite). What this case owns is the plane + // half: the gate is asked BEFORE the filesystem is read, and `local` answers + // through the in-process runtime rather than an RPC. + let homeDir: string; + let previousSshPath: string | undefined; + + beforeAll(async () => { + homeDir = mkdtempSync(join(tmpdir(), "subshell-ssh-home-")); + // A stub ssh that answers `-G` like a clean OpenSSH would for the manual + // destination (no forwarding lines, one hostname, a chosen user, a + // non-default port): the resolution engine here is the SAME code the + // agent runs, fed this deterministic answer. + const stub = join(homeDir, "ssh-stub.sh"); + writeFileSync( + stub, + '#!/bin/sh\nprintf "host verify.example\\nhostname verify.example\\nport 2222\\nuser stubuser\\n"\nexit 0\n', + ); + chmodSync(stub, 0o755); + previousSshPath = process.env.SUBSHELL_SSH_PATH; + process.env.SUBSHELL_SSH_PATH = stub; // findBinary's first rung, like every harness override + }); + + afterAll(() => { + if (previousSshPath === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = previousSshPath; + rmSync(homeDir, { recursive: true, force: true }); + }); + + it("local SSH off answers 403 before the account's config is read; the admin opens it and the in-process discovery answers", async () => { + const gated = await aliases(LOCAL_NODE_ID, adminCookie); + expect(gated.status).toBe(403); // local ships OFF (spec §4.3) + + await nodes.setSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + const res = await aliases(LOCAL_NODE_ID, adminCookie); + expect(res.status).toBe(200); + const body = (await res.json()) as { aliases: string[]; includeCycle: boolean; truncated: boolean }; + // The SHAPE is the contract here (sorted names + the two cycle/truncation + // facts); contents belong to whoever's account runs the server. + expect(Array.isArray(body.aliases)).toBe(true); + expect(body.includeCycle).toBe(false); + expect(body.truncated).toBe(false); + // A member holds Everyone/edit on `local` — visibility, never SSH use. + expect((await aliases(LOCAL_NODE_ID, otherCookie)).status).toBe(200); + }); + + it("the removed grants endpoint cannot create SSH permissions", async () => { + const res = await sshFetch("/api/ssh/grants", { method: "POST", cookie: adminCookie, body: {} }); + expect(res.status).toBe(404); + }); + + it("local roster uses the server account agent; a missing service agent is a named refusal", async () => { + await nodes.setSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + const previous = process.env.SSH_AUTH_SOCK; + const agent = await startStubAgent(ROSTER, OPENSSH_10X_SCHEME); + try { + process.env.SSH_AUTH_SOCK = agent.path; + const res = await sshFetch(`/api/ssh/identities?node=${LOCAL_NODE_ID}`, { cookie: adminCookie }); + expect(res.status).toBe(200); + expect(((await res.json()) as { identities: unknown[] }).identities).toHaveLength(2); + delete process.env.SSH_AUTH_SOCK; + const missing = await sshFetch(`/api/ssh/identities?node=${LOCAL_NODE_ID}`, { cookie: adminCookie }); + expect(missing.status).toBe(502); + expect(((await missing.json()) as { message: string }).message).toContain("server service account"); + } finally { + if (previous === undefined) delete process.env.SSH_AUTH_SOCK; + else process.env.SSH_AUTH_SOCK = previous; + await agent.close(); + } + }); + + it("local resolve runs the same engine in-process: the -G answer becomes an approved snapshot", async () => { + await nodes.setSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + const res = await resolve(LOCAL_NODE_ID, "verify.example", adminCookie); + expect(res.status).toBe(200); + const body = (await res.json()) as { + accepted: boolean; + snapshot?: { host: string; port: number; user: string | null; alias: string }; + connectingAccount?: string; + }; + expect(body.accepted).toBe(true); + expect(body.snapshot?.host).toBe("verify.example"); + expect(body.snapshot?.port).toBe(2222); + expect(body.snapshot?.user).toBe("stubuser"); + expect(body.snapshot?.alias).toBe("verify.example"); + expect(body.connectingAccount).toBeDefined(); + }); +}); + +describe("SSH readiness and grantless relay authorization", () => { + it("readiness reports only visible machines, shared launch permission and separate configuration rights without an RPC", async () => { + const scripted = silentScript(NODE_ON); + try { + const response = await sshFetch("/api/ssh/readiness", { cookie: editorCookie }); + expect(response.status).toBe(200); + const { machines } = (await response.json()) as { + machines: { node: { id: string }; canConnect: boolean; canConfigure: boolean; blockers: unknown[] }[]; + }; + expect(machines.find((machine) => machine.node.id === NODE_ON)).toMatchObject({ + canConnect: true, + canConfigure: false, + blockers: [], + }); + expect(machines.some((machine) => machine.node.id === NODE_OFF)).toBe(false); + expect(scripted.cmdTypes()).toEqual([]); + await new SettingsRepository(db).set("lockdown", true); + expect((await aliases(NODE_ON, editorCookie)).status).toBe(403); + expect((await resolve(NODE_ON, "work", editorCookie)).status).toBe(403); + expect((await sshFetch(`/api/ssh/identities?node=${NODE_ON}`, { cookie: editorCookie })).status).toBe(403); + expect((await launch({ node: NODE_ON, destination: "work" }, editorCookie)).status).toBe(403); + expect(scripted.cmdTypes()).toEqual([]); + } finally { + await new SettingsRepository(db).set("lockdown", false); + scripted.detach(); + } + }); + + it("launches with an explicit live key selection and strict host pin without grant tables; revoking the node share cuts its session", async () => { + const keyNodeId = `ssh-key-source-${crypto.randomUUID()}`; + await mkNode(keyNodeId, true); + createdNodeIds.push(keyNodeId); + await new NodeSharesRepository(db).replaceForNode( + keyNodeId, + [{ granteeUserId: editorId, permission: "view" }], + ownerId, + ); + for (const id of [keyNodeId, NODE_ON]) { + const key = JSON.stringify({ kty: "EC", crv: "P-256", x: "AAA", y: "AAA" }); + await new IdentitiesRepository(db).register({ + principalId: `node:${id}`, + publicKey: key, + signingPublicKey: key, + displayName: id, + }); + } + await db + .insertInto("sshHostPins") + .values({ + id: crypto.randomUUID(), + ownerUserId: editorId, + destination: CANON, + hostKey: `example.test ssh-ed25519 ${"A".repeat(51)}`, + createdAt: "now", + updatedAt: "now", + }) + .execute(); + const a = attachScriptedNode(keyNodeId, { + ssh_agent_identities: () => ({ + identities: [ + { fingerprint: "SHA256:AAAA", comment: "Selected" }, + ...Array.from({ length: 8 }, (_, index) => ({ fingerprint: `SHA256:other${index}`, comment: "Other" })), + ], + }), + ssh_relay_open: (cmd) => (cmd.type === "ssh_relay_open" ? { role: "A", relayId: cmd.relayId } : {}), + ssh_relay_close: ok, + }); + const b = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: ACCEPTED }), + ssh_relay_open: (cmd) => + cmd.type === "ssh_relay_open" + ? { role: "B", relayId: cmd.relayId, socketPath: buildAgentSocketPath(SCRIPTED_DATA_DIR, cmd.paneId) } + : {}, + ssh_relay_close: ok, + launch: ok, + stat_dir: statDirEcho, + }); + const broker = createRelayBroker({ + sendCommand, + sendRelayFrame: () => {}, + nodeRow: (id) => nodes.findById(id), + nodeDataDir: () => SCRIPTED_DATA_DIR, + authorize: async (userId, nodeId) => (await gateSshNode(userId, nodeId)).ok, + audit, + nowMs: () => Date.now(), + schedule: () => null, + cancel: () => {}, + log: () => {}, + }); + setRelayBrokerForTests(broker); + try { + const res = await launch( + { node: NODE_ON, destination: "work", keyHome: keyNodeId, fingerprints: ["SHA256:AAAA"] }, + editorCookie, + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { subshell: { id: string } }; + createdSubshellIds.push(body.subshell.id); + const pane = await db + .selectFrom("subshells") + .select("ssh") + .where("id", "=", body.subshell.id) + .executeTakeFirstOrThrow(); + expect(JSON.parse(pane.ssh ?? "{}").relayFingerprints).toEqual(["SHA256:AAAA"]); + expect(a.cmdsOf("ssh_relay_open")[0]?.fingerprints).toEqual(["SHA256:AAAA"]); + expect(a.cmdsOf("ssh_relay_open")[0]).not.toHaveProperty("grantId"); + expect(b.cmdsOf("launch")[0]?.ssh?.fileContent).toContain("StrictHostKeyChecking yes"); + expect(broker.activeRelayCount(keyNodeId)).toBe(1); + const sharesApp = new Elysia() + .use(errorHandlerPlugin) + .use(new Elysia({ prefix: "/api/nodes" }).use(setNodeSharesRoute)); + const revoked = await sharesApp.fetch( + new Request(`http://localhost:3099/api/nodes/${keyNodeId}/shares`, { + method: "PUT", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${ownerCookie}` }, + body: JSON.stringify({ shares: [] }), + }), + ); + expect(revoked.status).toBe(200); + expect(broker.activeRelayCount(keyNodeId)).toBe(0); + expect(a.cmdsOf("ssh_relay_close")[0]?.reason).toBe("access-revoked"); + expect((await sshFetch(`/api/ssh/identities?node=${keyNodeId}`, { cookie: editorCookie })).status).toBe(404); + } finally { + broker.reset(); + setRelayBrokerForTests(null); + a.detach(); + b.detach(); + } + }); +}); diff --git a/apps/server/api/src/api/ssh/aliases.route.ts b/apps/server/api/src/api/ssh/aliases.route.ts new file mode 100644 index 000000000..9973ae43b --- /dev/null +++ b/apps/server/api/src/api/ssh/aliases.route.ts @@ -0,0 +1,58 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { apiModels } from "@/schema/index.js"; +import { sshListAliases } from "@/services/ssh-launch.service.js"; + +/** + * `GET /api/ssh/aliases?node=` — the NAMES of the machine's usable SSH + * aliases (spec 2026-10-07 §5: discovery returns names, never config file + * contents). Cookie-only; the gate (current launch access and readiness) and + * the held-machine refusal run BEFORE the machine is asked anything, so a + * machine the caller may not SSH through never sees a frame. + * + * A machine-level failure maps by kind: offline 409, an app too old for the + * ssh commands 409 naming the update, no answer 409, the machine's own + * `ok:false` 502 (its verbatim text is logged, never echoed). + */ +export const sshAliasesRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/aliases", + async ({ query, user, actor }) => { + requireCookieActor(actor, "SSH alias discovery is restricted to browser sessions"); + const answer = await sshListAliases(user.id, query.node); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return answer.value; + }, + { + query: t.Object({ + node: t.String({ + minLength: 1, + description: "Node id to read aliases from ('local' = the control-plane host)", + }), + }), + response: { + 200: t.Object({ + aliases: t.Array(t.String({ description: "Alias name (sorted, deduplicated, concrete names only)" }), { + description: "Usable alias NAMES, at most 500", + }), + includeCycle: t.Boolean({ description: "An include cycle was detected; the list is what parsed before it" }), + truncated: t.Boolean({ description: "The alias cap was hit; more exist" }), + }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "sshListAliases", + tags: ["ssh"], + description: + "Lists the connecting machine's usable SSH alias names (its own config, parsed bounded). Owner-only and off by default; a machine whose SSH is not switched on refuses the command", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/identities.route.ts b/apps/server/api/src/api/ssh/identities.route.ts new file mode 100644 index 000000000..d6ae03bfa --- /dev/null +++ b/apps/server/api/src/api/ssh/identities.route.ts @@ -0,0 +1,30 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SshAgentIdentityViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { apiModels } from "@/schema/index.js"; +import { listNodeAgentIdentities } from "@/services/ssh-launch.service.js"; + +export const sshIdentitiesRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/identities", + async ({ query, user, actor }) => { + requireCookieActor(actor, "SSH identities are restricted to browser sessions"); + const result = await listNodeAgentIdentities({ viewerId: user.id, aNodeId: query.node }); + if (!result.ok) return throwCodedRefusal(result.refusal); + return result.value; + }, + { + query: t.Object({ node: t.String({ minLength: 1 }) }), + response: { + 200: t.Object({ identities: t.Array(SshAgentIdentityViewSchema) }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/index.ts b/apps/server/api/src/api/ssh/index.ts new file mode 100644 index 000000000..0a80b9a03 --- /dev/null +++ b/apps/server/api/src/api/ssh/index.ts @@ -0,0 +1,24 @@ +import { Elysia } from "elysia"; +import { sshAliasesRoute } from "@/api/ssh/aliases.route.js"; +import { sshLaunchRoute } from "@/api/ssh/launch.route.js"; +import { sshResolveRoute } from "@/api/ssh/resolve.route.js"; +import { sshSavedHostsRoutes } from "@/api/ssh/saved-hosts.route.js"; +import { sshSetupHereRoute } from "@/api/ssh/setup-here.route.js"; +import { sshTrustRoutes } from "@/api/ssh/trust.route.js"; +import { sshIdentitiesRoute } from "./identities.route.js"; +import { sshReadinessRoute } from "./readiness.route.js"; + +/** Cookie-only SSH operations, readiness, destination ledger and trust. */ +export const sshRoutes = new Elysia({ prefix: "/api/ssh" }) + .use(sshReadinessRoute) + .use(sshIdentitiesRoute) + .use(sshAliasesRoute) + .use(sshResolveRoute) + .use(sshLaunchRoute) + .use(sshSavedHostsRoutes) + .use(sshTrustRoutes) + // The destination upgrade (spec 2026-10-08 §7, Task 14): "Set up Subshell + // here" turns an open pane's destination into an enrolled node. Mounted + // inside this group like every other ssh tier (App-type-depth rule); it is + // the launcher's own act on the launcher's own pane. + .use(sshSetupHereRoute); diff --git a/apps/server/api/src/api/ssh/launch.route.ts b/apps/server/api/src/api/ssh/launch.route.ts new file mode 100644 index 000000000..155b298f6 --- /dev/null +++ b/apps/server/api/src/api/ssh/launch.route.ts @@ -0,0 +1,92 @@ +import { normalizeLabel, SSH_ROSTER_MAX_IDENTITIES } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SubshellSchema } from "@/api/models.js"; +import { SshDestinationField, SshResolveRefusalViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { contextPlugin } from "@/plugins/context.plugin.js"; +import { apiModels } from "@/schema/index.js"; +import { sshLaunch } from "@/services/ssh-launch.service.js"; + +/** + * `POST /api/ssh/launch` `{node, destination, name?}` — open an interactive + * SSH pane to one destination (spec 2026-10-07 §5): gate (current launch access and readiness, before + * anything), resolve the destination on the machine (the SAME path a discovery + * answer takes; an unsafe token 400s first), refuse a refusal-shaped outcome + * with **422 `{outcome}`** (the endpoint that acts does not carry a refusal in + * a 200: a pane must never exist for a destination nobody approved), then + * compose the config at the machine's derived path and launch the ssh harness + * through the ordinary create path (presetless, working dir = the launch + * node's home, auto-restart off by inheritance). + * + * No `prompt` field: an ssh launch types nothing (decision 7's compose: + * reconnecting is not a task to submit). The audit `ssh.launch` lands after + * the pane exists, never before. + */ +export const sshLaunchRoute = new Elysia() + .use(contextPlugin) + .use(authGuard) + .use(apiModels) + .post( + "/launch", + async ({ body, user, actor, ctx, status }) => { + requireCookieActor(actor, "SSH launch is restricted to browser sessions"); + const answer = await sshLaunch({ + viewerId: user.id, + nodeId: body.node, + destination: body.destination, + // The same label rule every human-chosen name takes (idempotent + // through the manager's choke-point re-normalization). + name: body.name === undefined ? undefined : normalizeLabel(body.name, 120), + keyHomeNodeId: body.keyHome, + fingerprints: body.fingerprints, + subshells: ctx.services.subshells, + }); + if (!answer.ok) { + // The 422 is the ONE arm returned rather than thrown: its body is the + // resolve outcome, not an error envelope. Everything else is a coded + // refusal the handler maps with its standard status. + const r = answer.refusal; + if (r.status !== 422) return throwCodedRefusal(r); + return status(422, { outcome: r.outcome }); + } + const subshell = await ctx.services.subshells.getSubshell(user.id, answer.value.subshellId, actor); + return status(201, { subshell }); + }, + { + body: t.Object({ + node: t.String({ minLength: 1, description: "Connecting machine ('local' = the control-plane host)" }), + destination: SshDestinationField, + name: t.Optional(t.String({ minLength: 1, maxLength: 120, description: "Pane display name" })), + fingerprints: t.Optional( + t.Array(t.String({ minLength: 1, maxLength: 135 }), { maxItems: SSH_ROSTER_MAX_IDENTITIES }), + ), + keyHome: t.Optional( + t.String({ + minLength: 1, + description: + "RELAY MODE: the key home machine whose agent signs for this connection; absent uses the connecting machine's own keys", + }), + ), + }), + response: { + 201: t.Object({ subshell: SubshellSchema }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + // The resolve refused: the body is the outcome, in the shape the + // launcher screen already knows how to render from `/resolve`. + 422: t.Object({ + outcome: SshResolveRefusalViewSchema, + }), + 502: "ApiErrorResponse", + }, + detail: { + operationId: "sshLaunchPane", + tags: ["ssh"], + description: + "Opens an interactive SSH pane from the connecting machine to one approved destination. Requires machine launch access and SSH enabled; a destination whose resolution refused answers 422 carrying the outcome and launches nothing. Resolution evaluates the account's own SSH config with `ssh -G`; a `Match exec` hidden from the bounded config walk can run a local command during that evaluation, so approving a destination also approves running its resolution", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/readiness.route.ts b/apps/server/api/src/api/ssh/readiness.route.ts new file mode 100644 index 000000000..ee68ed31e --- /dev/null +++ b/apps/server/api/src/api/ssh/readiness.route.ts @@ -0,0 +1,46 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { loadNodeGate } from "@/api/nodes/node-gate.js"; +import { NodeViewSchema, toNodeView } from "@/api/nodes/node-view.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { apiModels } from "@/schema/index.js"; +import { sshReadiness } from "@/services/ssh-policy.service.js"; + +export const sshReadinessRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/readiness", + async ({ user, actor }) => { + requireCookieActor(actor, "SSH readiness is restricted to browser sessions"); + const rows = await new NodesRepository(db).findAccessible(user.id); + const machines: ({ node: Awaited> } & Awaited>)[] = + []; + for (const row of rows) { + const gate = await loadNodeGate(user.id, row.id); + if (!gate) continue; + machines.push({ + node: await toNodeView(row, gate.access, gate.isAdmin, gate.granted), + ...(await sshReadiness(gate)), + }); + } + return { machines }; + }, + { + response: { + 200: t.Object({ + machines: t.Array( + t.Object({ + node: NodeViewSchema, + canConnect: t.Boolean(), + canConfigure: t.Boolean(), + blockers: t.Array(t.Object({ code: t.String(), message: t.String() })), + }), + ), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/resolve.route.ts b/apps/server/api/src/api/ssh/resolve.route.ts new file mode 100644 index 000000000..a65abcd78 --- /dev/null +++ b/apps/server/api/src/api/ssh/resolve.route.ts @@ -0,0 +1,54 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SshDestinationField, SshResolveOutcomeViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { apiModels } from "@/schema/index.js"; +import { sshResolveDestination } from "@/services/ssh-launch.service.js"; + +/** + * `POST /api/ssh/resolve` `{node, alias}` — resolve one destination token on + * the connecting machine (`ssh -G` through the frozen engine) and answer the + * OUTCOME. + * + * **A refusal is a 200 carrying `accepted: false`** (tier-1 grammar): "this + * config needs a ProxyCommand" is a fact the human reads and goes and edits, + * which settings blocked — not a transport error. The endpoint that ACTS + * (`/launch`, saved-hosts PUT) is where a refusal-shaped outcome becomes a + * 422; here nothing is launched and nothing is stored. + * + * Same gate as every ssh surface (launch access and readiness, before any command), and the + * destination takes the wire-grammar shape check FIRST: an unsafe token is + * 400 and never reaches the machine. + */ +export const sshResolveRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .post( + "/resolve", + async ({ body, user, actor }) => { + requireCookieActor(actor, "SSH resolution is restricted to browser sessions"); + const answer = await sshResolveDestination(user.id, body.node, body.alias); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return answer.value; + }, + { + body: t.Object({ + node: t.String({ minLength: 1, description: "Node to resolve on ('local' = the control-plane host)" }), + alias: SshDestinationField, + }), + response: { + 200: SshResolveOutcomeViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "sshResolveDestination", + tags: ["ssh"], + description: + "Resolves one destination token into the approved connection snapshot (or the named refusal, which is a successful answer to read). Runs on the connecting machine; nothing is launched or stored by this call. Resolution evaluates the account's own SSH config with `ssh -G`; a `Match exec` hidden from the bounded config walk can run a local command during that evaluation, so approving a destination also approves running its resolution", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/saved-hosts.route.ts b/apps/server/api/src/api/ssh/saved-hosts.route.ts new file mode 100644 index 000000000..a814ef5a0 --- /dev/null +++ b/apps/server/api/src/api/ssh/saved-hosts.route.ts @@ -0,0 +1,182 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { + SshDestinationField, + SshResolveRefusalViewSchema, + SshSavedHostViewSchema, + throwCodedRefusal, +} from "@/api/ssh/ssh-views.js"; +import { apiErrorBody } from "@/lib/api-error.js"; +import { apiModels } from "@/schema/index.js"; +import { + sshRemoveSavedHost, + sshSavedHostsView, + sshSaveHost, + sshSetDefaultNode, +} from "@/services/ssh-launch.service.js"; + +/** + * The launcher screen's own data (spec 2026-10-07 §7): the owner's saved and + * recently-connected destinations plus the default connecting machine. + * + * The rows are per-owner like prompts: a foreign row is invisible and its id + * answers the same 404 an absent one does (the ownership axis, docs/ + * security.md §3), and CRUD here audits NOTHING — a preference row is not an + * event, the prompts precedent (the ACT the ledger records is `ssh.launch`, + * audited by the service). + * + * `PUT` resolves the destination first (the ledger is keyed by the CANONICAL + * resolved destination, so an edited alias can never silently re-point a + * saved row), which means the write carries the launch's gate: a machine the + * caller may not SSH through cannot hold their saved entry, and a + * refusal-shaped outcome is the same 422 `{outcome}` a launch answers. + */ +export const sshSavedHostsRoutes = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/saved-hosts", + async ({ user, actor }) => { + requireCookieActor(actor, "SSH saved hosts are restricted to browser sessions"); + return await sshSavedHostsView(user.id); + }, + { + response: { + 200: t.Object({ + saved: t.Array(SshSavedHostViewSchema, { description: "Rows a human saved, newest save first (max 20)" }), + recent: t.Array(SshSavedHostViewSchema, { + description: "Most-recently-connected destinations, saved or not, newest first (max 20)", + }), + defaultNodeId: t.Nullable( + t.String({ description: "Stored default connecting machine id; resolves to null when none was chosen" }), + { + description: + "The caller's default connecting machine, or null. A node deleted since reads back as the stored id; the client decides", + }, + ), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, + detail: { + operationId: "listSshSavedHosts", + tags: ["ssh"], + description: "Lists the caller's saved and recently-used SSH destinations and their default connecting machine", + }, + }, + ) + .put( + "/saved-hosts", + async ({ body, user, actor, status }) => { + requireCookieActor(actor, "SSH saved hosts are restricted to browser sessions"); + const answer = await sshSaveHost({ + viewerId: user.id, + nodeId: body.node, + destination: body.destination, + alias: body.alias, + }); + if (!answer.ok) { + // A refusal-shaped outcome refuses the SAVE with the same 422 body a + // launch answers (the saved key comes from the resolution; there is + // nothing to store without it). Everything else is a coded refusal. + const r = answer.refusal; + if (r.status !== 422) return throwCodedRefusal(r); + return status(422, { outcome: r.outcome }); + } + return answer.value; + }, + { + body: t.Object({ + node: t.String({ minLength: 1, description: "Machine to resolve the destination on (the gate owner)" }), + destination: SshDestinationField, + alias: t.Optional( + t.String({ + minLength: 1, + maxLength: 253, + description: "Display label override; absent keeps the resolved snapshot's own alias", + }), + ), + }), + response: { + 200: SshSavedHostViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 422: t.Object({ outcome: SshResolveRefusalViewSchema }), + 502: "ApiErrorResponse", + }, + detail: { + operationId: "saveSshHost", + tags: ["ssh"], + description: + "Saves one destination for the caller (resolves it first; the key is the canonical host:port, never the alias). Refusal-shaped outcomes answer 422 carrying the outcome and store nothing", + }, + }, + ) + .delete( + "/saved-hosts/:id", + async ({ params, user, actor, status, set }) => { + requireCookieActor(actor, "SSH saved hosts are restricted to browser sessions"); + const removed = await sshRemoveSavedHost(user.id, params.id); + if (!removed) { + // Foreign AND absent, one 404: the id is not an existence oracle. + return status(404, apiErrorBody({ code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Saved host not found" })); + } + // 204 with NO body at all, set on `set` and returned undefined: a + // `status(204, null)` body trips the fetch Response constructor (204 + // may not carry one) and Elysia would serialize the null anyway. + set.status = 204; + }, + { + params: t.Object({ id: t.String({ description: "Saved-host row id" }) }), + response: { + // 204 is deliberately NOT declared: a declared schema makes Elysia + // validate the (absent) body and answer 400 for a successful delete. + // The handler sets `set.status = 204` and returns nothing; OpenAPI + // reads the empty success from the detail description below. + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "deleteSshSavedHost", + tags: ["ssh"], + description: + "Deletes one of the caller's saved destinations (204 no body on success; a foreign row is the same 404 as an absent one)", + }, + }, + ) + .patch( + "/preferences", + async ({ body, user, actor }) => { + requireCookieActor(actor, "SSH preferences are restricted to browser sessions"); + const answer = await sshSetDefaultNode(user.id, body.defaultNodeId); + if (!answer.ok) return throwCodedRefusal(answer.refusal); // the one arm it can carry is the write-time 404 + return { defaultNodeId: answer.value }; + }, + { + body: t.Object({ + defaultNodeId: t.Nullable( + t.String({ minLength: 1, description: "Node id to default the launcher's machine picker to" }), + { description: "null clears the default" }, + ), + }), + response: { + 200: t.Object({ + defaultNodeId: t.Nullable(t.String({ description: "The stored default machine id (null = none)" })), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "setSshPreferences", + tags: ["ssh"], + description: + "Sets (or clears) the caller's default connecting machine; the write validates the node is one the caller can see, a later deletion leaves the stored id readable", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/setup-here.route.ts b/apps/server/api/src/api/ssh/setup-here.route.ts new file mode 100644 index 000000000..328fe7328 --- /dev/null +++ b/apps/server/api/src/api/ssh/setup-here.route.ts @@ -0,0 +1,117 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { contextPlugin } from "@/plugins/context.plugin.js"; +import { apiModels } from "@/schema/index.js"; +import { setupHere } from "@/services/ssh-setup-here.service.js"; +import { sshSetupTracker } from "@/services/ssh-setup-progress.js"; + +/** + * `POST /api/ssh/setup-here` `{paneId}` - "Set up Subshell here" (spec + * 2026-10-08 §7, Task 14): enroll the destination of an open SSH-terminal + * pane as a node by running the ordinary install over a SEPARATE + * non-interactive connection, leaving the pane itself untouched. The act is + * owner-only by construction (a foreign pane is the service's 404), cookie- + * only like every launcher door. Its answer identifies the enrolled node and + * distinguishes enrollment from a confirmed connection. The minted setup key exists nowhere in + * this response, its refusals, or the audit; the machine ran the install + * where the key is allowed to live (spec §7's accepted postures) and the + * plane kept only the parsed outcome. + */ +export const sshSetupHereRoute = new Elysia() + .use(contextPlugin) + .use(authGuard) + .use(apiModels) + .get( + "/setup-here/:paneId", + ({ user, actor, params }) => { + requireCookieActor(actor, "SSH setup status is restricted to browser sessions"); + return { operation: sshSetupTracker.read(user.id, params.paneId) }; + }, + { + params: t.Object({ + paneId: t.String({ minLength: 1, maxLength: 64, description: "Pane whose setup status to read" }), + }), + response: { + 200: t.Object({ + operation: t.Nullable( + t.Object({ + stage: t.Union( + [ + t.Literal("checking"), + t.Literal("installing"), + t.Literal("connecting"), + t.Literal("enrolled"), + t.Literal("complete"), + t.Literal("failed"), + ], + { description: "Current server-observed setup stage" }, + ), + startedAt: t.String({ description: "ISO timestamp when setup began" }), + nodeId: t.Nullable(t.String({ description: "Enrolled node, which may still be offline" })), + error: t.Nullable(t.String({ description: "Safe failure explanation, never installer output" })), + }), + ), + }), + }, + detail: { + operationId: "sshSetupHereStatus", + tags: ["ssh"], + description: + "Owner-scoped setup progress, retained for one hour after completion while this server process runs", + }, + }, + ) + .post( + "/setup-here", + async ({ body, user, actor }) => { + requireCookieActor(actor, "SSH setup acts are restricted to browser sessions"); + const answer = await sshSetupTracker.run(user.id, body.paneId, (onProgress) => + setupHere({ viewerId: user.id, paneId: body.paneId, onProgress }), + ); + if (!answer.ok) { + const r = answer.refusal; + if (r.status === 422) { + // Unreachable: no arm of this act carries a resolve outcome (the + // destination was approved when the pane opened). An unreachable + // arm is answered loudly, never swallowed - the connection's + // posture restated. + return throwCodedRefusal({ + status: 502, + code: BackendErrorCodes.SSH_NODE_REFUSED, + message: "The setup surface cannot answer that refusal shape.", + }); + } + return throwCodedRefusal(r); + } + return answer.value; + }, + { + body: t.Object({ + paneId: t.String({ + minLength: 1, + maxLength: 64, + description: "The open SSH-terminal pane whose destination is to become a node (owner-only)", + }), + }), + response: { + 200: t.Object({ + nodeId: t.String({ description: "The newly enrolled node's id" }), + connected: t.Optional(t.Boolean({ description: "False if enrolled but not confirmed connected" })), + }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "sshSetupHere", + tags: ["ssh"], + description: + "Runs the ordinary node enrollment install on an open SSH pane's destination over a separate non-interactive connection and answers the new node's id with its connection outcome; the pane keeps running and the enrollment key never appears in the pane, its log, or any returned text", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/ssh-views.ts b/apps/server/api/src/api/ssh/ssh-views.ts new file mode 100644 index 000000000..53a9f50bb --- /dev/null +++ b/apps/server/api/src/api/ssh/ssh-views.ts @@ -0,0 +1,149 @@ +import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; +import { t } from "elysia"; +import type { SshRefusal } from "@/services/ssh-launch.service.js"; + +/** + * The wire views of the `/api/ssh` surface, shared by the routes that carry + * them (the resolve outcome rides its own endpoint, the launch's 422, and the + * save's 422; the saved-host row rides GET and PUT). One definition per + * shape — the schemas are the API contract, and two copies of a contract + * drift. + */ + +/** One ProxyJump hop as the frozen snapshot spells it. */ +export const SshHopViewSchema = t.Object({ + host: t.String({ description: "Hop hostname (bracketed IPv6 literal kept as given)" }), + user: t.Nullable(t.String({ description: "Hop user; null means the connecting account's own default" })), + port: t.Number({ description: "Hop port, resolved" }), +}); + +/** The approved connection snapshot, exactly as `SshConnectionSnapshotWire` serializes. */ +export const SshSnapshotViewSchema = t.Object({ + alias: t.String({ description: "The config token the human chose (display and review context only)" }), + host: t.String({ description: "Resolved destination hostname" }), + user: t.Nullable(t.String({ description: "Destination user; null means the connecting account's own default" })), + port: t.Number({ description: "Destination port, resolved (1..65535)" }), + identityFiles: t.Array(t.String({ description: "Absolute identity-file path on the connecting machine" }), { + description: "Identity file references (paths, never key contents)", + }), + certificateFiles: t.Array(t.String({ description: "Absolute certificate-file path on the connecting machine" }), { + description: "Certificate file references (paths only)", + }), + authAgentSocket: t.Nullable(t.String({ description: "Absolute agent-socket path; null runs with no agent" }), { + description: "The authentication-agent socket to use, or null", + }), + knownHostsFiles: t.Array(t.String({ description: "Absolute known-hosts path on the connecting machine" }), { + description: "Known-hosts files to consult (paths only)", + }), + hostKeyAlias: t.Nullable(t.String({ description: "OpenSSH HostKeyAlias when the config sets one" }), { + description: "The name strict host checking looks up, or null", + }), + proxyJumps: t.Array(SshHopViewSchema, { description: "Bounded ProxyJump chain, outermost first" }), + proxyCommand: t.Null({ + description: "Always null: a resolved ProxyCommand makes a destination refusable, never renderable", + }), + forwards: t.Null({ description: "Always null: any resolved forwarding refuses the snapshot" }), + tunnels: t.Null({ description: "Always null: any resolved tunnel refuses the snapshot" }), + localCommands: t.Null({ description: "Always null: any resolved local command refuses the snapshot" }), + remoteCommand: t.Null({ description: "Always null: a resolved RemoteCommand refuses the snapshot" }), + sendEnv: t.Null({ description: "Always null: a resolved SendEnv refuses the snapshot" }), + setEnv: t.Null({ description: "Always null: a resolved SetEnv refuses the snapshot" }), + escapes: t.Null({ description: "Always null: live escape characters refuse the snapshot" }), +}); + +/** The `accepted:false` arm: a successful answer whose CONTENT is a refusal. */ +export const SshResolveRefusalViewSchema = t.Object({ + accepted: t.Literal(false, { + description: "Resolution refused: the config needs more than the approved normalization can run", + }), + code: t.String({ + description: + "The named limitation (SshErrorCode): unsupported_setting, config_missing, config_ambiguous, proxy_chain_too_long, host_key_unknown, …", + }), + settings: t.Array( + t.String({ description: "Config keyword that blocked acceptance (a ProxyCommand row, a LocalForward row, …)" }), + { + description: "What blocked; empty when the code names the whole cause", + }, + ), +}); + +/** `POST /api/ssh/resolve`'s answer: the tier-1 outcome, refusal IN THE DATA. */ +export const SshResolveOutcomeViewSchema = t.Union([ + t.Object( + { + accepted: t.Literal(true, { description: "The destination normalized cleanly into an approved snapshot" }), + snapshot: SshSnapshotViewSchema, + connectingAccount: t.Optional( + t.String({ + description: "The connecting account's OS user name, when the machine could report it (display only)", + }), + ), + }, + { description: "An approved snapshot the human reviews, then saves or launches" }, + ), + SshResolveRefusalViewSchema, +]); + +/** One saved/recency row as its owner sees it. */ +export const SshSavedHostViewSchema = t.Object({ + id: t.String({ description: "Row id (uuid)" }), + destination: t.String({ description: "Canonical host:port (or user@host:port), the resolved destination as key" }), + alias: t.Nullable(t.String({ description: "Display token (the alias typed or discovered); never a key" })), + nodeId: t.String({ description: "The connecting machine of the most recent launch to this destination" }), + savedAt: t.Nullable(t.String({ description: "ISO 8601 the human saved it; null = recency-only row" })), + lastConnectAt: t.String({ description: "ISO 8601 of the most recent launch to this destination" }), +}); + +/** The shared destination-field schema (1..253 chars; the deeper shape rule lives in the service, next to the resolve that enforces it). */ +export const SshDestinationField = t.String({ + minLength: 1, + maxLength: 253, + description: + "Destination token: an alias from the machine's config or a concrete host. Same path either way: it is resolved on the connecting machine before anything launches.", +}); + +/** + * Throw a CODED service refusal as the structured `ApiError` the global + * handler serializes (the `NODE_IN_MAINTENANCE` posture: an expected refusal + * mapped at the service boundary, doNotLog so the 4xx never fills the error + * log). The status rides the code's own definition, and every code here is + * defined with exactly the status the gate doctrine gives it (403 gate, 404 + * invisible, 409 held/offline, 400 unsafe token, 502 machine-refused) — the + * pairing is in `error-codes.ts`, not re-derived here. + * + * The 422 arm — the resolve outcome an ACTING endpoint refuses — is NOT an + * error envelope (its body is `{outcome}`) and belongs to the `status(422,…)` + * return of the launch/save routes; reaching this helper with one is a + * routing bug in this file's own callers, so it answers 500 rather than + * inventing a body. + */ +export function throwCodedRefusal(refusal: SshRefusal): never { + if (refusal.status === 422) { + throwApiError({ + code: BackendErrorCodes.INTERNAL_SERVER_ERROR, + message: "SSH refusal routed to the wrong surface", + }); + } + throwApiError({ + code: refusal.code, + message: refusal.message, + doNotLog: true, + }); +} + +/** + * One public agent identity from the key home's live roster (spec 2026-10-08 + * §5.4, Tasks 11/18): the shape BOTH roster reads answer with - the + * request-scoped one behind the approval screen and the roster-by-node read + * behind the create picker. The two fields are the whole of it, by + * construction: the key BLOBS are withheld on the machine, and this schema is + * where the wire restates that there is nowhere for one to ride. + */ +export const SshAgentIdentityViewSchema = t.Object({ + fingerprint: t.String({ + description: + "Public agent identity in the canonical SHA256: notation (the approval body sends these back verbatim)", + }), + comment: t.String({ description: "OpenSSH's label for the key, as the agent reports it (display only)" }), +}); diff --git a/apps/server/api/src/api/ssh/trust.route.ts b/apps/server/api/src/api/ssh/trust.route.ts new file mode 100644 index 000000000..2196e3141 --- /dev/null +++ b/apps/server/api/src/api/ssh/trust.route.ts @@ -0,0 +1,167 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { isSshKnownHostsPinLine } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { parseSshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { apiModels } from "@/schema/index.js"; +import { + captureHostPin, + deleteHostPin, + hostKeyFingerprints, + hostPinRefusal, + listHostPins, + SshHostPinError, +} from "@/services/ssh-host-pins.service.js"; + +/** + * `/api/ssh/host-pins` - the destination trust screen (spec 2026-10-08 §8-§9, + * Task 12): LIST the caller's host-key pins, SUPPLY an explicit pin (the §9 + * "or an explicit pin" door: the operator's key for a destination the key + * home has not connected to yet), and DELETE one - the recovery's first half, + * whose second half is the fresh connection TOFU the next capture runs. + * + * The row model is the saved-hosts posture verbatim: per-owner reads, + * a foreign id the SAME 404 an absent row is, and the cookie doctrine of + * every ssh sibling (deciding which key authenticates a destination is a + * browser-session act). What the screen NEVER sees is the key bytes: the + * serialized view carries the destination and the pinned key's `SHA256:` + * fingerprint - a public identifier - and the stored line leaves this server + * only on the signed relay-open to B. + * + * The audit trail (written by the service): `node.ssh_host_pin.create` and + * `.delete`, each naming destination + fingerprint only (docs/security.md + * §10). Supplying a pin for a destination that already stands pinned to a + * DIFFERENT key is the §9 hard block (409, nothing written) - delete the pin + * first, that is the whole recovery flow. + */ + +/** The trust screen's row shape: destination + public fingerprint, never key bytes. */ +const SshHostPinViewSchema = t.Object({ + id: t.String({ description: "Pin row id (uuid)" }), + destination: t.String({ description: "Canonical resolved destination `user@host:port`" }), + fingerprint: t.String({ description: "The pinned key's SHA256: display fingerprint (public identifier)" }), + createdAt: t.String({ description: "ISO 8601 first capture (the TOFU moment)" }), + updatedAt: t.String({ description: "ISO 8601 of the last accepted match" }), +}); + +export const sshTrustRoutes = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/host-pins", + async ({ user, actor }) => { + requireCookieActor(actor, "SSH host-key trust is restricted to browser sessions"); + return { pins: await listHostPins({ ownerUserId: user.id }) }; + }, + { + response: { + 200: t.Object({ + pins: t.Array(SshHostPinViewSchema, { description: "The caller's pinned destinations, newest first" }), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, + detail: { + operationId: "listSshHostPins", + tags: ["ssh"], + description: + "Lists the caller's destination host-key pins as destination plus SHA256: fingerprint; the pinned key line itself is display-excluded by construction", + }, + }, + ) + .post( + "/host-pins", + async ({ body, user, actor }) => { + requireCookieActor(actor, "SSH host-key trust is restricted to browser sessions"); + const destination = body.destination.trim(); + if (parseSshCanonicalDestination(destination) === null) { + return throwCodedRefusal({ + status: 400, + code: BackendErrorCodes.SSH_HOST_PIN_INVALID, + message: "That destination is not a canonical user@host:port.", + }); + } + const line = body.hostKey.trim(); + if (!isSshKnownHostsPinLine(line) || hostKeyFingerprints(line).length !== 1) { + return throwCodedRefusal({ + status: 400, + code: BackendErrorCodes.SSH_HOST_PIN_INVALID, + message: "The host key must be one known_hosts line carrying exactly one public key.", + }); + } + try { + const row = await captureHostPin({ ownerUserId: user.id, aNodeId: null, destination, hostKeyLine: line }); + return { + pin: { + id: row.id, + destination: row.destination, + fingerprint: hostKeyFingerprints(row.hostKey)[0] ?? "", + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, + }; + } catch (err) { + if (err instanceof SshHostPinError) return throwCodedRefusal(hostPinRefusal(err)); + throw err; + } + }, + { + body: t.Object({ + destination: t.String({ + minLength: 1, + maxLength: 512, + description: "Canonical resolved destination `user@host:port` (the spelling the launch keys the pin by)", + }), + hostKey: t.String({ + minLength: 1, + maxLength: 4096, + description: + "The pinned entry in OpenSSH known_hosts form (pattern, key type, base64 key material) - stored verbatim and carried verbatim to B", + }), + }), + response: { + 200: t.Object({ pin: SshHostPinViewSchema }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "createSshHostPin", + tags: ["ssh"], + description: + "Supplies an explicit host-key pin for a destination (the §9 alternative to capturing from the key home); a destination pinned to a different key is the named hard block, never an overwrite", + }, + }, + ) + .delete( + "/host-pins/:destination", + async ({ params, user, actor }) => { + requireCookieActor(actor, "SSH host-key trust is restricted to browser sessions"); + const deleted = await deleteHostPin({ ownerUserId: user.id, destination: params.destination }); + if (deleted === null) { + return throwCodedRefusal({ + status: 404, + code: BackendErrorCodes.NOT_FOUND_ERROR, + message: "No pin for that destination", + }); + } + return { deleted: true }; + }, + { + params: t.Object({ destination: t.String({ description: "Canonical destination whose pin is removed" }) }), + response: { + 200: t.Object({ deleted: t.Boolean({ description: "True once the pin row is gone" }) }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "deleteSshHostPin", + tags: ["ssh"], + description: + "Deletes the caller's pin for one destination (the TOFU recovery's first half): the next capture at a fresh key re-decides trust; the audit names the removed destination and fingerprint only", + }, + }, + ); diff --git a/apps/server/api/src/api/subshells/__tests__/restart-ssh-refusal.route.test.ts b/apps/server/api/src/api/subshells/__tests__/restart-ssh-refusal.route.test.ts new file mode 100644 index 000000000..a3aef3d1a --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/restart-ssh-refusal.route.test.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { tmpdir } from "node:os"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { subshellRoutes } from "@/api/subshells/index.js"; +import { db } from "@/db/index.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; + +/** + * An ssh pane refuses `POST /:id/restart` with a named 409 (decision 7): its + * "session" lives on the far end of a socket, so a restart would spawn a NEW + * ssh process the human never asked to reconnect — the launcher is the way + * back in. The refusal is keyed to the `subshells.ssh` column (the kind fact, + * migration 0048), asked AFTER the visibility gate (a stranger still gets the + * 404) and BEFORE anything the restart path writes. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(subshellRoutes); +const subshells = new SubshellsRepository(db); + +const pw = "ssh-nores-1"; +const ownerEmail = `sshnr-owner-${crypto.randomUUID()}@subshell.local`; +const strangerEmail = `sshnr-str-${crypto.randomUUID()}@subshell.local`; +let ownerCookie: string; +let strangerCookie: string; +let ownerId: string; +const created: string[] = []; + +function seedSshRow(id: string, ssh: string | null, nodeId = LOCAL_NODE_ID): Promise { + created.push(id); + return subshells.create({ + id, + userId: ownerId, + harnessId: "ssh", + name: "ssh pane", + workingDir: tmpdir(), + tmuxSocket: null, + status: "terminated", + alive: 0, + nodeId, + ssh, + }); +} + +function restart(id: string, cookie: string) { + return app.fetch( + new Request(`http://localhost:3099/api/subshells/${id}/restart`, { + method: "POST", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${cookie}` }, + }), + ); +} + +beforeAll(async () => { + await setupAuthTables(); + const mk = async (email: string) => + await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerId = await mk(ownerEmail); + await mk(strangerEmail); + ownerCookie = await signIn(ownerEmail, pw); + strangerCookie = await signIn(strangerEmail, pw); +}); + +afterAll(async () => { + for (const id of created) await subshells.delete(id).catch(() => {}); + for (const mail of [ownerEmail, strangerEmail]) await deleteUserByEmailOrId(mail); +}); + +describe("ssh panes refuse restart (decision 7)", () => { + it("a row whose snapshot column is set is refused with the named 409 before anything restarts", async () => { + const id = crypto.randomUUID(); + await seedSshRow(id, '{"host":"example.test"}'); + + const res = await restart(id, ownerCookie); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe(BackendErrorCodes.SSH_NO_RESTART); + expect(body.message.toLowerCase()).toContain("launch"); // the remedy: reconnect from the launcher + const row = await subshells.findById(id); + expect(row?.status).toBe("terminated"); // nothing moved + }); + + it("a stranger gets the ordinary 404 on an ssh row (the gate runs first; the refusal is no oracle)", async () => { + const id = crypto.randomUUID(); + await seedSshRow(id, '{"host":"example.test"}'); + expect((await restart(id, strangerCookie)).status).toBe(404); + }); + + it("a non-ssh row is NOT refused by this rule (the offline node still answers its own NODE_OFFLINE 409)", async () => { + const id = crypto.randomUUID(); + await seedSshRow(id, null, "node-gone-offline"); + const res = await restart(id, ownerCookie); + expect(res.status).toBe(409); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.NODE_OFFLINE); + }); +}); diff --git a/apps/server/api/src/api/subshells/__tests__/subshell-exec-ssh.route.test.ts b/apps/server/api/src/api/subshells/__tests__/subshell-exec-ssh.route.test.ts new file mode 100644 index 000000000..3b76d54bd --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/subshell-exec-ssh.route.test.ts @@ -0,0 +1,226 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; + +/** + * `POST /api/subshells/:id/exec` on SSH panes (spec 2026-10-07 §5.4): exec + * TYPES into the pane, so it carries the input rule verbatim. The kind check + * rides the same `#gate` follow-up as `sendSubshellInput`, and it precedes + * every frame AND every read: the refusal never even quiet-probes the log. + * + * The grant rules, the sentinel machinery and the scripted-log idiom are + * `subshell-exec-route.test.ts`'s, copied rather than shared so that suite + * stays byte-identical; what differs here is the row's kind. The first case + * re-pins the ordinary terminal row + edit grantee from this suite's own + * door, because the rule must not graze panes whose snapshot column is NULL. + */ +import { subshellRoutes } from "@/api/subshells/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { + attachScriptedNode, + ok, + type ScriptedHandler, + type ScriptedNode, + statDirEcho, +} from "@/test-helpers/scripted-node.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +const app = new Elysia().use(errorHandlerPlugin).use(subshellRoutes); + +const nodes = new NodesRepository(db); +const subshells = new SubshellsRepository(db); +const shares = new SubshellSharesRepository(db); + +/** What a RUNNING pane's node answers; the exec suites extend it with `log_read`. */ +const LIFECYCLE = { stat_dir: statDirEcho, launch: ok }; + +/** A minimal but truthful snapshot JSON; the exec door reads presence, not content. */ +const SNAPSHOT = JSON.stringify({ destination: "host.example", user: "theo", port: 22 }); + +const enc = new TextEncoder(); +const b64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64"); +const SENTINEL_FRAME_RE = /__xcomm_([0-9a-f]{16})_DONE/; + +/** The scripted pane's log as a function of the typed frames (the exec suite's idiom). */ +function scriptedExecLog(typed: string[]): string { + const sentinelFrame = typed[2]; + if (typed.length < 4 || sentinelFrame === undefined) return ""; + const token = SENTINEL_FRAME_RE.exec(sentinelFrame)?.[1]; + if (!token) return ""; + return `out line\n__xcomm_${token}_DONE rc=0\n$ `; +} + +function windowOver(text: string, fromByte: number, maxBytes: number) { + const full = enc.encode(text); + const end = Math.min(full.byteLength, fromByte + maxBytes); + return { bytes_b64: b64(full.subarray(fromByte, end)), next: end, size: full.byteLength }; +} + +function execLogRead(typed: string[]): ScriptedHandler { + return (cmd) => { + if (cmd.type !== "log_read") return new Error(`exec-ssh sim: wrong cmd ${cmd.type}`); + return windowOver(scriptedExecLog(typed), cmd.fromByte, cmd.maxBytes); + }; +} + +function typingInto(typed: string[]): ScriptedHandler { + return (cmd) => { + if (cmd.type === "input") typed.push(cmd.data); + return undefined; + }; +} + +const fourFramesTyped = (sim: ScriptedNode) => sim.cmdsOf("input").map((c) => c.data).length === 4; + +describe("POST /api/subshells/:id/exec on ssh panes (spec §5.4)", () => { + const ownerEmail = `exssh-owner-${crypto.randomUUID()}@subshell.local`; + const granteeEmail = `exssh-grantee-${crypto.randomUUID()}@subshell.local`; + const pw = "execssh-pass-1"; + let ownerId: string; + let ownerCookie: string; + let granteeId: string; + let granteeCookie: string; + let node: string; + const createdSubshellIds: string[] = []; + + async function exec(id: string, opts: { cookie?: string; bearer?: string; body: unknown }) { + const headers = new Headers({ "content-type": "application/json" }); + if (opts.cookie) headers.set("cookie", `better-auth.session_token=${opts.cookie}`); + if (opts.bearer) headers.set("authorization", `Bearer ${opts.bearer}`); + return app.fetch( + new Request(`http://localhost:3080/api/subshells/${id}/exec`, { + method: "POST", + headers, + body: JSON.stringify(opts.body), + }), + ); + } + + /** A RUNNING row written straight to the table (kind chosen by the caller). */ + async function directRow(overrides: { + name: string; + userId?: string; + harnessId?: string; + ssh?: string | null; + }): Promise { + const id = crypto.randomUUID(); + createdSubshellIds.push(id); + await subshells.create({ + id, + userId: overrides.userId ?? ownerId, + presetId: null, + harnessId: overrides.harnessId ?? "terminal", + name: overrides.name, + workingDir: "/tmp", + tmuxSocket: `exssh-sock-${id}`, + nodeId: node, + status: "running", + alive: 1, + ssh: overrides.ssh ?? null, + }); + return id; + } + + const errorBody = async (res: Response) => (await res.json()) as { code: string; message: string }; + + beforeAll(async () => { + await setupAuthTables(); + resetNodeRegistryForTests(); + await ensureLocalNode(db); + const users = new UsersRepository(db); + ownerId = await users.createUser({ + email: ownerEmail, + name: ownerEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + granteeId = await users.createUser({ + email: granteeEmail, + name: granteeEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerCookie = await signIn(ownerEmail, pw); + granteeCookie = await signIn(granteeEmail, pw); + + node = crypto.randomUUID(); + await nodes.create({ id: node, ownerUserId: ownerId, name: `exssh-${node.slice(0, 8)}`, kind: "agent" }); + }); + + afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await db.deleteFrom("subshells").where("id", "=", id).execute(); + await nodes.deleteById(node); + for (const email of [ownerEmail, granteeEmail]) await deleteUserByEmailOrId(email); + }); + + it("an ordinary terminal row + edit grantee is unchanged (the kind rule must not graze NULL)", async () => { + const typed: string[] = []; + const sim = attachScriptedNode(node, { ...LIFECYCLE, input: typingInto(typed), log_read: execLogRead(typed) }); + try { + const id = await directRow({ name: "exssh-ordinary" }); + await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); + const res = await exec(id, { cookie: granteeCookie, body: { command: "echo hi" } }); + expect(res.status).toBe(200); + expect(fourFramesTyped(sim)).toBe(true); + } finally { + sim.detach(); + } + }); + + it("an ssh row + the OWNER cookie 200s and types the four frames (the harness is a terminal too)", async () => { + const typed: string[] = []; + const sim = attachScriptedNode(node, { ...LIFECYCLE, input: typingInto(typed), log_read: execLogRead(typed) }); + try { + const id = await directRow({ name: "exssh-owner", harnessId: "ssh", ssh: SNAPSHOT }); + const res = await exec(id, { cookie: ownerCookie, body: { command: "echo hi" } }); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; exitCode: number | null }; + expect(body.status).toBe("completed"); + expect(body.exitCode).toBe(0); + expect(fourFramesTyped(sim)).toBe(true); + } finally { + sim.detach(); + } + }); + + it("an ssh row + an EDIT grantee 403s SSH_OWNER_INPUT_ONLY before any frame or read", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "exssh-grantee", harnessId: "ssh", ssh: SNAPSHOT }); + await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); + const res = await exec(id, { cookie: granteeCookie, body: { command: "echo hi" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + // The refusal precedes even the quiet probes: nothing reached the pane + // and nothing was read from it. + expect(sim.countOf("input")).toBe(0); + expect(sim.countOf("log_read")).toBe(0); + } finally { + sim.detach(); + } + }); + + it("an ssh row + its OWN pane key 403s (decision 5: the pane must not type into itself)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "exssh-selfkey", harnessId: "ssh", ssh: SNAPSHOT }); + const key = await issueSubshellToken(id, ownerId); + const res = await exec(id, { bearer: key, body: { command: "echo hi" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + expect(sim.countOf("input")).toBe(0); + } finally { + sim.detach(); + } + }); +}); diff --git a/apps/server/api/src/api/subshells/__tests__/subshell-input-ssh.route.test.ts b/apps/server/api/src/api/subshells/__tests__/subshell-input-ssh.route.test.ts new file mode 100644 index 000000000..864abfba5 --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/subshell-input-ssh.route.test.ts @@ -0,0 +1,205 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; + +/** + * `POST /api/subshells/:id/input` on an SSH pane (spec 2026-10-07 §5.4, + * decision 5): the pane's session lives on a remote machine under the + * OWNER's ssh identity, so only the owner's own account may type there. The + * `subshells.ssh` column is the kind fact; the refusal is the named 403 code + * `SSH_OWNER_INPUT_ONLY`, asked AFTER the ordinary gate (a stranger still + * gets the 404; a `view` grantee still gets the plain 403) and BEFORE + * anything is typed. + * + * The decisive case is the bearer one: raw REST resolves a pane's own + * subshell key as its OWNER (boost/shares off), so a bare owner comparison + * would let an ssh pane type into itself. `bearerActor` is what refuses it. + * + * The sibling suite (`subshell-input-route.test.ts`) pins the ordinary-pane + * rules; the first case here re-pins the ordinary row + edit grantee from the + * SSH suite's own door, because the rule must not graze panes whose snapshot + * column is NULL. + */ +import { subshellRoutes } from "@/api/subshells/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { attachScriptedNode, ok, type ScriptedNode, statDirEcho } from "@/test-helpers/scripted-node.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +const app = new Elysia().use(errorHandlerPlugin).use(subshellRoutes); + +const nodes = new NodesRepository(db); +const subshells = new SubshellsRepository(db); +const shares = new SubshellSharesRepository(db); + +/** What a RUNNING pane's node answers; refusals must leave it untouched. */ +const LIFECYCLE = { stat_dir: statDirEcho, launch: ok, input: ok }; + +/** A minimal but truthful snapshot JSON; the input door reads presence, not content. */ +const SNAPSHOT = JSON.stringify({ destination: "host.example", user: "theo", port: 22 }); + +describe("POST /api/subshells/:id/input on ssh panes (spec §5.4)", () => { + const ownerEmail = `inssh-owner-${crypto.randomUUID()}@subshell.local`; + const granteeEmail = `inssh-grantee-${crypto.randomUUID()}@subshell.local`; + const pw = "inputssh-pass-1"; + let ownerId: string; + let ownerCookie: string; + let granteeId: string; + let granteeCookie: string; + let node: string; // agent node with a per-test scripted agent attached + const createdSubshellIds: string[] = []; + + async function input(id: string, opts: { cookie?: string; bearer?: string; body: unknown }) { + const headers = new Headers({ "content-type": "application/json" }); + if (opts.cookie) headers.set("cookie", `better-auth.session_token=${opts.cookie}`); + if (opts.bearer) headers.set("authorization", `Bearer ${opts.bearer}`); + return app.fetch( + new Request(`http://localhost:3080/api/subshells/${id}/input`, { + method: "POST", + headers, + body: JSON.stringify(opts.body), + }), + ); + } + + /** A RUNNING row written straight to the table (kind chosen by the caller). */ + async function directRow(overrides: { + name: string; + userId?: string; + harnessId?: string; + ssh?: string | null; + }): Promise { + const id = crypto.randomUUID(); + createdSubshellIds.push(id); + await subshells.create({ + id, + userId: overrides.userId ?? ownerId, + presetId: null, + harnessId: overrides.harnessId ?? "terminal", + name: overrides.name, + workingDir: "/tmp", + tmuxSocket: `inssh-sock-${id}`, + nodeId: node, + status: "running", + alive: 1, + ssh: overrides.ssh ?? null, + }); + return id; + } + + const errorBody = async (res: Response) => (await res.json()) as { code: string; message: string }; + const inputBytes = (sim: ScriptedNode) => sim.cmdsOf("input").map((c) => c.data); + + beforeAll(async () => { + await setupAuthTables(); + resetNodeRegistryForTests(); + await ensureLocalNode(db); + const users = new UsersRepository(db); + ownerId = await users.createUser({ + email: ownerEmail, + name: ownerEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + granteeId = await users.createUser({ + email: granteeEmail, + name: granteeEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerCookie = await signIn(ownerEmail, pw); + granteeCookie = await signIn(granteeEmail, pw); + + node = crypto.randomUUID(); + await nodes.create({ id: node, ownerUserId: ownerId, name: `inssh-${node.slice(0, 8)}`, kind: "agent" }); + }); + + afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await db.deleteFrom("subshells").where("id", "=", id).execute(); + await nodes.deleteById(node); + for (const email of [ownerEmail, granteeEmail]) await deleteUserByEmailOrId(email); + }); + + it("an ordinary row + edit grantee is unchanged (the kind rule must not graze NULL)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "inssh-ordinary" }); + await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); + const res = await input(id, { cookie: granteeCookie, body: { text: "still types", submit: false } }); + expect(res.status).toBe(200); + expect(inputBytes(sim)).toEqual(["still types"]); + } finally { + sim.detach(); + } + }); + + it("an ssh row + the OWNER cookie 200s and types (the owner rule is a permission, not a lockout)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "inssh-owner", harnessId: "ssh", ssh: SNAPSHOT }); + const res = await input(id, { cookie: ownerCookie, body: { text: "owner types" } }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(inputBytes(sim)).toEqual(["owner types", "\r"]); + } finally { + sim.detach(); + } + }); + + it("an ssh row + an EDIT grantee 403s SSH_OWNER_INPUT_ONLY and types nothing", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "inssh-grantee", harnessId: "ssh", ssh: SNAPSHOT }); + await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); + const res = await input(id, { cookie: granteeCookie, body: { text: "grantee types" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + expect(sim.countOf("input")).toBe(0); + } finally { + sim.detach(); + } + }); + + it("an ssh row + its OWN pane key 403s (decision 5: the pane must not type into itself)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const id = await directRow({ name: "inssh-selfkey", harnessId: "ssh", ssh: SNAPSHOT }); + const key = await issueSubshellToken(id, ownerId); + // Raw REST resolves a pane's own key as its OWNER (boost/shares off), + // so the owner comparison alone would ADMIT this; `bearerActor` is the + // explicit second refusal decision 5 names. + const res = await input(id, { bearer: key, body: { text: "self type" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + expect(sim.countOf("input")).toBe(0); + } finally { + sim.detach(); + } + }); + + it("an ssh row + a SIBLING pane's key 403s too (the §4 owner-resolution is still a bearer act)", async () => { + const sim = attachScriptedNode(node, LIFECYCLE); + try { + const ordinaryPane = await directRow({ name: "inssh-sib-src" }); // the token's own row + const sshPane = await directRow({ name: "inssh-sib-dest", harnessId: "ssh", ssh: SNAPSHOT }); + const key = await issueSubshellToken(ordinaryPane, ownerId); + // The input route's §4 surface admits the owner's OTHER rows to a pane + // key; the ssh kind fence closes exactly that door for ssh panes. + const res = await input(sshPane, { bearer: key, body: { text: "sibling types" } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.SSH_OWNER_INPUT_ONLY); + expect(sim.countOf("input")).toBe(0); + } finally { + sim.detach(); + } + }); +}); diff --git a/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts b/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts new file mode 100644 index 000000000..edbd37d11 --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts @@ -0,0 +1,161 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { subshellRoutes } from "@/api/subshells/index.js"; +import { db } from "@/db/index.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { effectiveViewerAccess } from "@/services/subshells.service.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +/** + * The ssh view truth (spec 2026-10-07 §5.4, plan 3 decision 1): an ssh pane + * reads as `view` + `ssh: true` to EVERY non-owner in both view paths (GET + * detail and GET list, the same two doors the MCP `get_subshell`/ + * `list_subshells` read through), while enforcement itself stays where tier 2 + * put it (`paneInputAllowed` at the input doors). Ordinary panes are + * untouched: an `edit` grantee still reads `edit`. Owner and `view` pass + * through unchanged. The downgrade must never widen anything: it keys on the + * snapshot column's PRESENCE, not its content. + */ +describe("ssh panes in subshell views: the non-owner access downgrade", () => { + const pw = "ssh-view-1"; + const aliceEmail = `sv-alice-${crypto.randomUUID()}@subshell.local`; + const bobEmail = `sv-bob-${crypto.randomUUID()}@subshell.local`; + const carolEmail = `sv-carol-${crypto.randomUUID()}@subshell.local`; + const adminEmail = `sv-admin-${crypto.randomUUID()}@subshell.local`; + let aliceId: string; + let bobId: string; + let carolId: string; + let aliceCookie: string; + let bobCookie: string; + let carolCookie: string; + const created: string[] = []; + + async function mkUser(email: string, role: "user" | "admin"): Promise { + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role, + }); + } + /** Alice owns every fixture row; `ssh` rides the row exactly as launch writes it. */ + async function ownSubshell(id: string, ssh: string | null = null): Promise { + created.push(id); + await new SubshellsRepository(db).create({ + id, + userId: aliceId, + presetId: "p", + harnessId: "ssh", + name: id, + workingDir: "/tmp", + tmuxSocket: null, + ssh, + }); + } + + beforeAll(async () => { + await setupAuthTables(); + aliceId = await mkUser(aliceEmail, "user"); + bobId = await mkUser(bobEmail, "user"); + carolId = await mkUser(carolEmail, "user"); + await mkUser(adminEmail, "admin"); // signed in per test (mirrors the sharing matrix file) + aliceCookie = await signIn(aliceEmail, pw); + bobCookie = await signIn(bobEmail, pw); + carolCookie = await signIn(carolEmail, pw); + + // The snapshot content is irrelevant to the rule (presence is the kind + // fact); this one is valid JSON so the fixture also reads plausibly. + await ownSubshell("s_ssh", JSON.stringify({ destination: "dev.example.com", node: "local" })); + await ownSubshell("s_plain"); // ordinary pane: ssh NULL + const shares = new SubshellSharesRepository(db); + await shares.replaceForSubshell( + "s_ssh", + [ + { granteeUserId: bobId, permission: "edit" }, + { granteeUserId: carolId, permission: "view" }, + ], + aliceId, + ); + await shares.replaceForSubshell("s_plain", [{ granteeUserId: bobId, permission: "edit" }], aliceId); + }); + + afterAll(async () => { + for (const id of created) await db.deleteFrom("subshells").where("id", "=", id).execute(); + for (const email of [aliceEmail, bobEmail, carolEmail, adminEmail]) await deleteUserByEmailOrId(email); + }); + + function req(method: string, path: string, cookie: string) { + return subshellRoutes.fetch( + new Request(`http://localhost:3080/api/subshells${path}`, { + method, + headers: { cookie: `better-auth.session_token=${cookie}` }, + }), + ); + } + async function get(path: string, cookie: string) { + return (await (await req("GET", path, cookie)).json()) as { access: string; ssh: boolean }; + } + + it("an edit grantee GETs an ssh pane as view-only: access 'view', ssh true (detail path)", async () => { + const got = await get("/s_ssh", bobCookie); + expect(got.access).toBe("view"); + expect(got.ssh).toBe(true); + }); + + it("the owner keeps access 'owner' and sees ssh true", async () => { + const got = await get("/s_ssh", aliceCookie); + expect(got.access).toBe("owner"); + expect(got.ssh).toBe(true); + }); + + it("a view grantee is unchanged (already view), and still learns ssh", async () => { + const got = await get("/s_ssh", carolCookie); + expect(got.access).toBe("view"); + expect(got.ssh).toBe(true); + }); + + it("an ordinary pane is untouched: the edit grantee still reads access 'edit', ssh false", async () => { + const got = await get("/s_plain", bobCookie); + expect(got.access).toBe("edit"); + expect(got.ssh).toBe(false); + }); + + it("admins need a grant to see foreign SSH and ordinary panes", async () => { + const cookie = await signIn(adminEmail, pw); + expect((await req("GET", "/s_ssh", cookie)).status).toBe(404); + expect((await req("GET", "/s_plain", cookie)).status).toBe(404); + const rows = (await (await req("GET", "", cookie)).json()) as { id: string }[]; + expect(rows.some((row) => row.id === "s_ssh" || row.id === "s_plain")).toBe(false); + }); + + it("the LIST path downgrades the same way (one helper, both doors; the MCP list rides this route)", async () => { + const rows = (await (await req("GET", "", bobCookie)).json()) as { + id: string; + access: string; + ssh: boolean; + }[]; + const byId = new Map(rows.map((r) => [r.id, r])); + expect(byId.get("s_ssh")).toMatchObject({ access: "view", ssh: true }); + expect(byId.get("s_plain")).toMatchObject({ access: "edit", ssh: false }); + }); +}); + +describe("effectiveViewerAccess (the downgrade helper, exported for test)", () => { + const sshRow = { ssh: "x", userId: "owner-1" }; // presence keys the rule: content never parsed + const plainRow = { ssh: null, userId: "owner-1" }; + + it("downgrades edit to view on an ssh row", () => { + expect(effectiveViewerAccess(sshRow, "edit")).toBe("view"); + }); + it("leaves owner and view as resolved on an ssh row", () => { + expect(effectiveViewerAccess(sshRow, "owner")).toBe("owner"); + expect(effectiveViewerAccess(sshRow, "view")).toBe("view"); + }); + it("leaves every level as resolved on an ordinary row", () => { + expect(effectiveViewerAccess(plainRow, "edit")).toBe("edit"); + expect(effectiveViewerAccess(plainRow, "owner")).toBe("owner"); + expect(effectiveViewerAccess(plainRow, "view")).toBe("view"); + }); +}); diff --git a/apps/server/api/src/api/subshells/input-subshell.route.ts b/apps/server/api/src/api/subshells/input-subshell.route.ts index 9d7b5bd77..2c0b98697 100644 --- a/apps/server/api/src/api/subshells/input-subshell.route.ts +++ b/apps/server/api/src/api/subshells/input-subshell.route.ts @@ -39,10 +39,14 @@ const InputOkSchema = t.Object({ * MCP `send_to_subshell` tool rides). An `edit` act, like terminal input * on the live attach socket: a `view` grantee 403s, a foreign row 404s, and a * bearer pane key acts as its OWNER with boost and shares off (its own and its - * owner's other subshells), the same per-subshell rule restart follows. A - * non-running row is 409 SUBSHELL_NOT_RUNNING and an offline agent node 409 - * NODE_OFFLINE, both before anything is typed. `{ ok: true }` means the bytes - * were accepted by the pane's machine, not that the pane acted on them. + * owner's other subshells), the same per-subshell rule restart follows. An SSH + * pane is the exception to that bearer-as-owner resolution: its input is the + * owner's account ALONE (an `edit` grantee or the pane's own key 403s + * SSH_OWNER_INPUT_ONLY), because the far side runs under the owner's ssh + * identity (spec 2026-10-07 §5.4). A non-running row is 409 + * SUBSHELL_NOT_RUNNING and an offline agent node 409 NODE_OFFLINE, both before + * anything is typed. `{ ok: true }` means the bytes were accepted by the pane's + * machine, not that the pane acted on them. */ export const inputSubshellRoute = new Elysia() .use(contextPlugin) diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 9f1bb6dae..c74193802 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -388,18 +388,28 @@ test("real entry: available older upgrade snapshot restores and migrates before mkdirSync(backupDir, { mode: 0o700 }); const snapshot = join(backupDir, "subshell-v1.7.0-20260101-000000.db"); const db = new Database(instance.databasePath); - const migration = db.query("SELECT * FROM kysely_migration WHERE name='0046-backup-recovery'").get() as { - name: string; - timestamp: string; - }; + // "Older" must be a valid LEDGER PREFIX: the newest registered migrations + // are stripped (rows AND their DDL) so the snapshot ends where an install of + // that era would; removing one from the middle while a later row stands + // above it is exactly the non-contiguous history kysely refuses to boot. + // WHY 0047+0048+0049+0050+0051+0052 together: this era's six migrations are + // the tail of the registered ledger, so the prefix runs contiguously through + // 0046 and boot re-applies all six. Advancing LATEST_BACKUP_MIGRATION (0052) + // means the strip set moves with it in the same commit. + const migrationRows = db + .query( + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants') ORDER BY name", + ) + .all() as { name: string; timestamp: string }[]; db.exec( - "DROP TABLE backup_recovery; DELETE FROM kysely_migration WHERE name='0046-backup-recovery'; CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "CREATE TABLE backup_recovery(user_id TEXT PRIMARY KEY REFERENCES user(id)); UPDATE migration_restore_probe SET value='newer destination';", + "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; ALTER TABLE subshells ADD COLUMN key_home_node_id text; CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", ); - db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(migration.name, migration.timestamp); + for (const row of migrationRows) + db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); db.close(); const catalog = ok(await cli(["backup", "--list", "--json"], instance.configDir)); expect(catalog.backups).toHaveLength(1); @@ -407,17 +417,55 @@ test("real entry: available older upgrade snapshot restores and migrates before const restored = ok(await cli(["restore", snapshot, "--yes", "--no-start", "--json"], instance.configDir)); expect(restored).toMatchObject({ status: "pending-boot", legacyDatabaseOnly: true }); const offline = new Database(instance.databasePath, { readonly: true }); - expect(offline.query("SELECT name FROM kysely_migration WHERE name='0046-backup-recovery'").get()).toBeNull(); + expect( + offline + .query( + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants')", + ) + .all(), + ).toEqual([]); offline.close(); const running = await boot(instance.configDir, port); const migrated = new Database(instance.databasePath, { readonly: true }); - expect(migrated.query("SELECT name FROM kysely_migration WHERE name='0046-backup-recovery'").get()).toMatchObject({ - name: "0046-backup-recovery", - }); + expect( + migrated + .query( + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants') ORDER BY name", + ) + .all(), + ).toEqual([ + { name: "0047-node-ssh-enabled" }, + { name: "0048-ssh-launch-and-saved-hosts" }, + { name: "0049-ssh-relay-identity" }, + { name: "0050-ssh-grants" }, + { name: "0051-ssh-request-destination" }, + { name: "0052-subshell-key-home" }, + { name: "0053-drop-ssh-grants" }, + ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); - expect(migrated.query("SELECT name FROM sqlite_schema WHERE name='backup_recovery'").get()).toEqual({ - name: "backup_recovery", + expect( + migrated + .query("SELECT name FROM pragma_table_info('nodes') WHERE name IN ('ssh_enabled','ssh_enabled_at') ORDER BY name") + .all(), + ).toEqual([{ name: "ssh_enabled" }, { name: "ssh_enabled_at" }]); + expect(migrated.query("SELECT name FROM sqlite_schema WHERE name='ssh_saved_hosts'").get()).toEqual({ + name: "ssh_saved_hosts", }); + expect(migrated.query("SELECT name FROM pragma_table_info('subshells') WHERE name='ssh'").get()).toEqual({ + name: "ssh", + }); + expect( + migrated.query("SELECT name FROM pragma_table_info('identities') WHERE name='signing_public_key'").get(), + ).toEqual({ name: "signing_public_key" }); + expect(migrated.query("SELECT name FROM pragma_table_info('nodes') WHERE name='ssh_fingerprint'").get()).toEqual({ + name: "ssh_fingerprint", + }); + expect(migrated.query("SELECT name FROM sqlite_schema WHERE name='ssh_host_pins'").get()).toEqual({ + name: "ssh_host_pins", + }); + for (const removedTable of ["ssh_key_grants", "ssh_grant_requests"]) { + expect(migrated.query("SELECT name FROM sqlite_schema WHERE name=?").get(removedTable)).toBeNull(); + } migrated.close(); expect(readFileSync(join(instance.configDir, "config.env"), "utf8")).toBe(originalConfig); const deadline = Date.now() + 15_000; diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 9d68dc0b0..2e730c77b 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -46,6 +46,13 @@ import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cros import * as oauthCallbackBaseMigration from "@/db/migrations/0044-oauth-callback-base.js"; import * as nodeReregistrationMigration from "@/db/migrations/0045-node-reregistration.js"; import * as backupRecoveryMigration from "@/db/migrations/0046-backup-recovery.js"; +import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as sshRelayIdentityMigration from "@/db/migrations/0049-ssh-relay-identity.js"; +import * as sshGrantsMigration from "@/db/migrations/0050-ssh-grants.js"; +import * as sshRequestDestinationMigration from "@/db/migrations/0051-ssh-request-destination.js"; +import * as subshellKeyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; +import * as dropSshGrantsMigration from "./migrations/0053-drop-ssh-grants.js"; /** * Runs all pending Kysely migrations against the app database. @@ -132,6 +139,28 @@ export async function runMigrations(): Promise { "0044-oauth-callback-base": oauthCallbackBaseMigration, "0045-node-reregistration": nodeReregistrationMigration, "0046-backup-recovery": backupRecoveryMigration, + // The per-node SSH capability gate: a flag plus its changedAt stamp, + // default OFF so an upgrade never turns SSH egress on anywhere (spec 4.3). + "0047-node-ssh-enabled": sshEnabledMigration, + // The SSH launcher tier: the per-owner destination ledger and the + // pane's approved snapshot in `subshells.ssh` (spec 2026-10-07 §7). + "0048-ssh-launch-and-saved-hosts": sshLaunchMigration, + // The relay identity tier: the machine's ES256 signing slot in the + // identities record and the trust-card fingerprint mirror on the + // nodes row (spec 2026-10-08 §4, §9). + "0049-ssh-relay-identity": sshRelayIdentityMigration, + // The grant tier: standing key grants, the durable first-use approval + // queue, and the M2 host-pin store (spec 2026-10-08 §6, §9). + "0050-ssh-grants": sshGrantsMigration, + // The first-use request carries the full resolved destination so the + // approval that creates the grant can capture the host-key pin at the + // exact `user@host:port` the launch dialed (spec 2026-10-08 §9). + "0051-ssh-request-destination": sshRequestDestinationMigration, + // The relay pane's key home (spec 2026-10-08 §7): "Set up Subshell + // here" re-opens the pairing that authorized the pane, so the row + // carries the A it launched with. + "0052-subshell-key-home": subshellKeyHomeMigration, + "0053-drop-ssh-grants": dropSshGrantsMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts b/apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts new file mode 100644 index 000000000..f52bf23b9 --- /dev/null +++ b/apps/server/api/src/db/migrations/0047-node-ssh-enabled.ts @@ -0,0 +1,18 @@ +import type { Kysely } from "kysely"; + +// Adds the per-node SSH capability gate. Default 0 keeps the whole fleet (and +// the control-plane host) off after an upgrade: SSH egress and key use are +// opt-in per machine (spec 4.3; the maintenance default-0 "upgrade keeps the +// fleet as it was" doctrine). +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("nodes") + .addColumn("ssh_enabled", "integer", (col) => col.notNull().defaultTo(0)) + .execute(); + await db.schema.alterTable("nodes").addColumn("ssh_enabled_at", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("ssh_enabled_at").execute(); + await db.schema.alterTable("nodes").dropColumn("ssh_enabled").execute(); +} diff --git a/apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts b/apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts new file mode 100644 index 000000000..b2a529766 --- /dev/null +++ b/apps/server/api/src/db/migrations/0048-ssh-launch-and-saved-hosts.ts @@ -0,0 +1,46 @@ +import type { Kysely } from "kysely"; + +/** + * The SSH launcher tier (spec 2026-10-07 §7): `ssh_saved_hosts`, the per-owner + * destination ledger, and `subshells.ssh`, the pane's approved snapshot. + * + * The ledger is keyed by the RESOLVED canonical destination (the spelling + * `sshCanonicalDestination` in the db-types file produces), never by the + * alias, so an edited alias cannot silently re-point a saved row. + * `subshells.ssh` is NULL for + * every pane that is not an SSH terminal, which is exactly what the + * owner-only-input rule reads: the value's presence is the trigger. + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("ssh_saved_hosts") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull().references("user.id").onDelete("cascade")) + .addColumn("destination", "text", (col) => col.notNull()) + .addColumn("alias", "text") + .addColumn("node_id", "text", (col) => col.notNull()) + .addColumn("saved_at", "text") + .addColumn("last_connect_at", "text", (col) => col.notNull()) + .execute(); + // The key: one row per owner per canonical destination. An insert that + // arrives at a standing key is the service's refresh, not a second entry. + await db.schema + .createIndex("idx_ssh_saved_hosts_owner_destination") + .unique() + .on("ssh_saved_hosts") + .columns(["owner_user_id", "destination"]) + .execute(); + // The list read is one owner's recents, newest first: the recency column is + // `last_connect_at`, refreshed on every launch, DESC spelled in the index. + await db.schema + .createIndex("idx_ssh_saved_hosts_owner_recent") + .on("ssh_saved_hosts") + .columns(["owner_user_id", "last_connect_at desc"]) + .execute(); + await db.schema.alterTable("subshells").addColumn("ssh", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("subshells").dropColumn("ssh").execute(); + await db.schema.dropTable("ssh_saved_hosts").execute(); +} diff --git a/apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts b/apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts new file mode 100644 index 000000000..5418ccc88 --- /dev/null +++ b/apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts @@ -0,0 +1,26 @@ +import type { Kysely } from "kysely"; + +/** + * The relay identity tier (spec 2026-10-08 §4.2, §4.5, §9). + * + * `identities.signing_public_key` is the per-machine ES256 signing slot that + * rides beside the ECDH-ES encryption key in the SAME `node:` record: enroll + * fills it, deletion cascades it exactly as the encryption key already does. + * `nodes.ssh_fingerprint` is the durable mirror of the last runtime-report + * ssh-fingerprint block, so the trust card survives an offline node or a + * plane restart; it is populated by the report-handling task, not this one. + * + * Both columns are NULL for every pre-M2 row: the signing slot is filled by a + * re-enroll or the `ssh_register_identity` bootstrap (§4.3), and the mirror by + * the next runtime report (§4.5). An upgrade therefore changes nothing any + * existing behavior reads. + */ +export async function up(db: Kysely): Promise { + await db.schema.alterTable("identities").addColumn("signing_public_key", "text").execute(); + await db.schema.alterTable("nodes").addColumn("ssh_fingerprint", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("ssh_fingerprint").execute(); + await db.schema.alterTable("identities").dropColumn("signing_public_key").execute(); +} diff --git a/apps/server/api/src/db/migrations/0050-ssh-grants.ts b/apps/server/api/src/db/migrations/0050-ssh-grants.ts new file mode 100644 index 000000000..2bf1fb51d --- /dev/null +++ b/apps/server/api/src/db/migrations/0050-ssh-grants.ts @@ -0,0 +1,92 @@ +import type { Kysely } from "kysely"; + +/** + * The grant tier (spec 2026-10-08 §6, §9): the three tables the authorization + * layer stands on. + * + * - `ssh_key_grants`: the standing authorization - one owner's permission to + * reach destinations matching a selector by signing with the agent on one + * key-home machine. It carries the operator's fingerprint SELECTION (public + * `SHA256:` identifiers as a JSON array, capped at + * `SSH_MAX_GRANT_FINGERPRINTS` by the service, never by a silent truncation) + * and nothing else about the keys: no key material, ever (§6.1). + * - `ssh_grant_requests`: the DURABLE first-use queue (spec §6.2, borrowing + * the signup pending-approvals shape): the pending row survives a plane + * restart so a re-launch finds it, and the expiry sweep marks stale rows + * `expired` without writing any audit event. + * - `ssh_host_pins`: the M2 TOFU host-key store (§9), keyed by the RESOLVED + * destination, one pin per `user@host:port` per owner, independent of + * grants. The read/write/capture LOGIC lands with T12; the schema ships + * here per the plan's file map so later migrations never re-cut it. + * + * Owner is FK-cascaded like the saved-hosts ledger (0048): a removed account + * takes its grants, requests, and pins with it. Node ids carry no FK - + * matching a grant against a vanished node is refused at the gate, not at the + * schema, the same posture `ssh_saved_hosts.node_id` set. + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("ssh_key_grants") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull().references("user.id").onDelete("cascade")) + .addColumn("name", "text", (col) => col.notNull()) + .addColumn("key_home_node_id", "text", (col) => col.notNull()) + .addColumn("resolved_selector", "text", (col) => col.notNull()) + .addColumn("fingerprints", "text", (col) => col.notNull()) + .addColumn("created_via", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .execute(); + // The launch-time match reads one owner's grants for one key home; the list + // screen reads one owner's grants. Both ride this index. + await db.schema + .createIndex("idx_ssh_key_grants_owner_keyhome") + .on("ssh_key_grants") + .columns(["owner_user_id", "key_home_node_id"]) + .execute(); + + await db.schema + .createTable("ssh_grant_requests") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull().references("user.id").onDelete("cascade")) + .addColumn("key_home_node_id", "text", (col) => col.notNull()) + .addColumn("resolved_selector", "text", (col) => col.notNull()) + .addColumn("requested_fingerprints", "text") + .addColumn("pane_id", "text", (col) => col.notNull()) + .addColumn("b_node_id", "text", (col) => col.notNull()) + .addColumn("expires_at", "text", (col) => col.notNull()) + .addColumn("status", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .execute(); + // The approvals screen reads one owner's OPEN (pending) queue; the dedup + // read at request time is (owner, key home, selector) newest-first. + await db.schema + .createIndex("idx_ssh_grant_requests_owner_status") + .on("ssh_grant_requests") + .columns(["owner_user_id", "status"]) + .execute(); + + await db.schema + .createTable("ssh_host_pins") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull().references("user.id").onDelete("cascade")) + .addColumn("destination", "text", (col) => col.notNull()) + .addColumn("host_key", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .execute(); + // One pin per owner per resolved destination (§9): the TOFU record, so a + // second capture at a differing key is the §9 hard block, not a second row. + await db.schema + .createIndex("idx_ssh_host_pins_owner_destination") + .unique() + .on("ssh_host_pins") + .columns(["owner_user_id", "destination"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("ssh_host_pins").execute(); + await db.schema.dropTable("ssh_grant_requests").execute(); + await db.schema.dropTable("ssh_key_grants").execute(); +} diff --git a/apps/server/api/src/db/migrations/0051-ssh-request-destination.ts b/apps/server/api/src/db/migrations/0051-ssh-request-destination.ts new file mode 100644 index 000000000..74330cd84 --- /dev/null +++ b/apps/server/api/src/db/migrations/0051-ssh-request-destination.ts @@ -0,0 +1,20 @@ +import type { Kysely } from "kysely"; + +/** + * The first-use request carries the FULL resolved destination (spec + * 2026-10-08 §9, Task 12). The grant's selector is hostname-scoped (matched + * against the resolved host at launch); the host-key PIN is keyed per + * `user@host:port`, and the approval that creates the grant is also the + * capture moment for that pin - which means the durable pending row must + * outlive the asking launch with the exact destination the launch dialed, not + * just its host. Nullable BY DESIGN: a pending row written before this + * migration exists cannot be back-filled (the launch is gone), and the + * approval path fails closed on null rather than guessing a port or a user. + */ +export async function up(db: Kysely): Promise { + await db.schema.alterTable("ssh_grant_requests").addColumn("destination", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("ssh_grant_requests").dropColumn("destination").execute(); +} diff --git a/apps/server/api/src/db/migrations/0052-subshell-key-home.ts b/apps/server/api/src/db/migrations/0052-subshell-key-home.ts new file mode 100644 index 000000000..0c1a191ab --- /dev/null +++ b/apps/server/api/src/db/migrations/0052-subshell-key-home.ts @@ -0,0 +1,22 @@ +import type { Kysely } from "kysely"; + +/** + * The relay pane remembers its key home (spec 2026-10-08 §7, Task 14). A + * relay launch chose its key home A at the door that could name it, and the + * relay session itself is plane-memory with a 30 s lifetime; "Set up Subshell + * here" RE-OPENS that pairing minutes or hours later, and the only durable + * fact of which A authorized the pane was the launch's audit row. The act may + * not re-derive A from the grant table (several key homes can match one + * selector, and §6.3 disambiguates them by the A chosen at launch), so the + * pane row carries it: NULL on every direct (M1) pane, the A node id on every + * relay pane. Immutable by construction (it joins the omit-list of the row + * update shape), because no later act rewrites which machine authorized the + * connection it opened with. + */ +export async function up(db: Kysely): Promise { + await db.schema.alterTable("subshells").addColumn("key_home_node_id", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("subshells").dropColumn("key_home_node_id").execute(); +} diff --git a/apps/server/api/src/db/migrations/0053-drop-ssh-grants.ts b/apps/server/api/src/db/migrations/0053-drop-ssh-grants.ts new file mode 100644 index 000000000..0c2a89e4a --- /dev/null +++ b/apps/server/api/src/db/migrations/0053-drop-ssh-grants.ts @@ -0,0 +1,12 @@ +import type { Kysely } from "kysely"; + +/** SSH uses existing machine launch access; keep destination trust and history. */ +export async function up(db: Kysely): Promise { + await db.schema.dropTable("ssh_grant_requests").execute(); + await db.schema.dropTable("ssh_key_grants").execute(); +} + +/** Dropped authorizations cannot be reconstructed safely. */ +export async function down(): Promise { + throw new Error("SSH grant removal is irreversible; restore a backup to downgrade"); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts b/apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts new file mode 100644 index 000000000..bd1e322ba --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0047-node-ssh-enabled.test.ts @@ -0,0 +1,99 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; +import * as profileDefaultFlagMigration from "@/db/migrations/0010-profile-default-flag.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + nodes: Record; +} + +/** + * The SSH capability gate columns. Default 0 is the whole upgrade story: a + * node enrolled before this migration (and the seeded local row) reads OFF, + * because SSH egress and key use are opt-in per machine. + */ +describe("migration 0047-node-ssh-enabled", () => { + let dbFile: string; + let db: Kysely; + + /** Insert a node row the way the pre-0047 repository did - no ssh fields at all. */ + const legacyNode = (id: string) => + db + .insertInto("nodes") + .values({ + id, + owner_user_id: "u1", + name: id, + kind: "agent", + created_at: "2026-10-01T00:00:00.000Z", + updated_at: "2026-10-01T00:00:00.000Z", + }) + .execute(); + + beforeAll(async () => { + dbFile = `/tmp/subshell-0047-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + // Written BEFORE the columns exist - the upgrade case this test is about. + await legacyNode("pre-existing"); + await sshEnabledMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds the two columns", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((c) => c.name); + expect(names).toContain("ssh_enabled"); + expect(names).toContain("ssh_enabled_at"); + }); + + it("leaves every pre-existing node with SSH off (default-off doctrine)", async () => { + const r = await sql<{ + ssh_enabled: number; + ssh_enabled_at: string | null; + }>`SELECT ssh_enabled, ssh_enabled_at FROM nodes WHERE id = 'pre-existing'`.execute(db); + expect(r.rows[0]).toEqual({ ssh_enabled: 0, ssh_enabled_at: null }); + }); + + it("defaults a row written without the columns to 0", async () => { + await legacyNode("fresh"); + const r = await sql<{ ssh_enabled: number }>`SELECT ssh_enabled FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0].ssh_enabled).toBe(0); + }); + + it("stores the flag with its stamp", async () => { + await db + .updateTable("nodes") + .set({ ssh_enabled: 1, ssh_enabled_at: "2026-10-07T10:00:00.000Z" }) + .where("id", "=", "fresh") + .execute(); + const r = await sql<{ + ssh_enabled: number; + ssh_enabled_at: string; + }>`SELECT ssh_enabled, ssh_enabled_at FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0]).toEqual({ ssh_enabled: 1, ssh_enabled_at: "2026-10-07T10:00:00.000Z" }); + }); + + it("down removes both columns and leaves the rows", async () => { + await sshEnabledMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((c) => c.name); + expect(names).not.toContain("ssh_enabled"); + expect(names).not.toContain("ssh_enabled_at"); + const left = await sql<{ id: string }>`SELECT id FROM nodes ORDER BY id`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["fresh", "pre-existing"]); + await sshEnabledMigration.up(db); // leave the DB on the current shape + }); +}); diff --git a/apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts b/apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts new file mode 100644 index 000000000..e76257d4c --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0048-ssh-launch-and-saved-hosts.test.ts @@ -0,0 +1,187 @@ +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 subshellRenameMigration from "@/db/migrations/0019-subshell-rename.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; +import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; + +interface MigrationDatabase { + [table: string]: Record; + ssh_saved_hosts: Record; + subshells: Record; + user: Record; +} + +/** + * The SSH launcher tier's storage (spec 2026-10-07 §7): the `ssh_saved_hosts` + * destination ledger (keyed on the canonical destination, unique per owner, + * recency ordered) and `subshells.ssh`, the nullable snapshot column whose + * presence is the owner-only-input rule's trigger. + */ +describe("migration 0048-ssh-launch-and-saved-hosts", () => { + let dbFile: string; + let db: Kysely; + + /** Insert a subshell row the way the pre-0048 repository did - no ssh column at all. */ + const legacySubshell = (id: string) => + db + .insertInto("subshells") + .values({ + id, + user_id: "u1", + profile_id: "p1", + harness_id: "claude-code", + name: id, + working_dir: "/tmp", + }) + .execute(); + + /** A ledger row as the (future) service writes it: full key, live node, connect stamp. */ + const savedHost = (id: string, destination: string, ownerUserId = "u1") => + db + .insertInto("ssh_saved_hosts") + .values({ + id, + owner_user_id: ownerUserId, + destination, + alias: null, + node_id: "local", + saved_at: null, + last_connect_at: "2026-10-07T00:00:00.000Z", + }) + .execute(); + + beforeAll(async () => { + dbFile = `/tmp/subshell-0048-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + // The FK target: better-auth's user table, minimally shaped so + // `references("user.id")` resolves on this throwaway file (same posture + // as the 0040 test). + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@subshell.local')`.execute(db); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + await subshellRenameMigration.up(db); + // Written BEFORE the column exists - the upgrade case: a pre-ssh pane reads NULL. + await legacySubshell("pre-existing"); + await sshLaunchMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("creates ssh_saved_hosts with the ledger's seven columns", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('ssh_saved_hosts')`.execute(db); + expect(cols.rows.map((c) => c.name)).toEqual([ + "id", + "owner_user_id", + "destination", + "alias", + "node_id", + "saved_at", + "last_connect_at", + ]); + }); + + it("adds the nullable ssh snapshot column; a pre-existing pane reads NULL", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('subshells')`.execute(db); + expect(cols.rows.map((c) => c.name)).toContain("ssh"); + const r = await sql<{ ssh: string | null }>`SELECT ssh FROM subshells WHERE id = 'pre-existing'`.execute(db); + expect(r.rows[0].ssh).toBeNull(); + // A launch row stores its approved snapshot as JSON text and reads back. + const snapshot = JSON.stringify({ host: "example.test", port: 22, user: null }); + await sql`UPDATE subshells SET ssh = ${snapshot} WHERE id = 'pre-existing'`.execute(db); + const back = await sql<{ ssh: string }>`SELECT ssh FROM subshells WHERE id = 'pre-existing'`.execute(db); + expect(back.rows[0].ssh).toBe(snapshot); + await sql`UPDATE subshells SET ssh = NULL WHERE id = 'pre-existing'`.execute(db); + }); + + it("carries the unique (owner_user_id, destination) index as the key", async () => { + const lists = await sql<{ name: string; unique: number }>` + SELECT name, "unique" FROM pragma_index_list('ssh_saved_hosts') + `.execute(db); + const unique = lists.rows.find((i) => i.name === "idx_ssh_saved_hosts_owner_destination"); + expect(unique).toEqual({ name: "idx_ssh_saved_hosts_owner_destination", unique: 1 }); + await savedHost("h1", "dev@example.test:22"); + // The same destination under one owner is the SAME row's subject, never a second one. + await expect(savedHost("h2", "dev@example.test:22")).rejects.toThrow(); + // ...and under another owner it is a different person's ledger entry. + await sql`INSERT INTO user (id, name, email) VALUES ('u2', 'Other', 'u2@subshell.local')`.execute(db); + await savedHost("h3", "dev@example.test:22", "u2"); + const keys = await sql<{ owner_user_id: string }>` + SELECT owner_user_id FROM ssh_saved_hosts WHERE destination = 'dev@example.test:22' ORDER BY owner_user_id + `.execute(db); + expect(keys.rows.map((r) => r.owner_user_id)).toEqual(["u1", "u2"]); + }); + + it("carries the recency index, last_connect_at DESC for one owner's list read", async () => { + const lists = await sql<{ name: string }>`SELECT name FROM pragma_index_list('ssh_saved_hosts')`.execute(db); + expect(lists.rows.map((i) => i.name)).toContain("idx_ssh_saved_hosts_owner_recent"); + // The direction is the point: the list read is newest-first, pinned in the DDL. + const ddl = await sql<{ sql: string }>` + SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_ssh_saved_hosts_owner_recent' + `.execute(db); + expect(ddl.rows[0].sql).toMatch(/"last_connect_at" desc/); + }); + + it("requires the key parts and cascades the owner away", async () => { + // last_connect_at is NOT NULL: every row is a connect record, saved or not. + await expect( + db + .insertInto("ssh_saved_hosts") + .values({ + id: "h-no-stamp", + owner_user_id: "u1", + destination: "x@y.test:22", + alias: null, + node_id: "local", + saved_at: null, + } as never) + .execute(), + ).rejects.toThrow(); + // Deleting the account drops its ledger (FK cascade, like every owner column). + await savedHost("h-cascade", "gone@example.test:22", "u2"); + await sql`DELETE FROM user WHERE id = 'u2'`.execute(db); + const left = await sql<{ id: string }>`SELECT id FROM ssh_saved_hosts WHERE owner_user_id = 'u2'`.execute(db); + expect(left.rows).toEqual([]); + }); + + it("down drops the table and the snapshot column and leaves the panes", async () => { + await sshLaunchMigration.down(db); + const tables = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ssh_saved_hosts' + `.execute(db); + expect(tables.rows).toEqual([]); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('subshells')`.execute(db); + expect(cols.rows.map((c) => c.name)).not.toContain("ssh"); + const left = await sql<{ id: string }>`SELECT id FROM subshells`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["pre-existing"]); + }); +}); + +/** + * The canonical destination spelling (spec 2026-10-07 §7): `host:port` with an + * optional `user@` prefix. The key of the ledger, so its three pinned shapes + * are pinned here, at its only home. + */ +describe("sshCanonicalDestination", () => { + it("prefixes the user when the snapshot carries one", () => { + expect(sshCanonicalDestination({ host: "h", port: 22, user: "u" })).toBe("u@h:22"); + }); + + it("spells a null user as a bare host:port, never an empty prefix", () => { + expect(sshCanonicalDestination({ host: "h", port: 2222, user: null })).toBe("h:2222"); + }); + + it("passes an already-bracketed IPv6 host through untouched", () => { + expect(sshCanonicalDestination({ host: "[::1]", port: 22, user: null })).toBe("[::1]:22"); + }); +}); diff --git a/apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts b/apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts new file mode 100644 index 000000000..1c0042abf --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts @@ -0,0 +1,140 @@ +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 channelsMigration from "@/db/migrations/0009-channels.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as relayIdentityMigration from "@/db/migrations/0049-ssh-relay-identity.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + identities: Record; + nodes: Record; +} + +/** + * The relay identity tier's storage (spec 2026-10-08 §4.2, §4.5, §9): + * `identities.signing_public_key`, the machine's ES256 signing slot beside the + * ECDH-ES encryption key, and `nodes.ssh_fingerprint`, the durable mirror of + * the runtime fingerprint block so the trust card survives an offline node. + * Both are nullable by design: a pre-M2 row simply carries nothing yet. + */ +describe("migration 0049-ssh-relay-identity", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0049-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + // The FK target, minimally shaped (same posture as the 0048 test); 0009 + // additionally adds a column to the `sessions` table 0001 creates. + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await initMigration.up(db); + await channelsMigration.up(db); + await nodesMigration.up(db); + // Written BEFORE the columns exist - the upgrade case: a pre-M2 principal + // and a pre-M2 node row both read NULL for both new slots. + await db + .insertInto("identities") + .values({ + principal_id: "node:pre-m2", + public_key: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}', + display_name: "old box", + }) + .execute(); + await db + .insertInto("nodes") + .values({ + id: "node-pre-m2", + owner_user_id: "u1", + name: "old box", + kind: "agent", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }) + .execute(); + await relayIdentityMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds signing_public_key to identities; a pre-M2 registration reads NULL", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('identities')`.execute(db); + expect(cols.rows.map((c) => c.name)).toContain("signing_public_key"); + const r = await sql<{ signing_public_key: string | null }>` + SELECT signing_public_key FROM identities WHERE principal_id = 'node:pre-m2' + `.execute(db); + expect(r.rows[0].signing_public_key).toBeNull(); + // The signing slot stores the registered JWK text and reads back; the + // encryption key beside it is untouched (same record, §4.2). + const signing = '{"kty":"EC","crv":"P-256","x":"CCC","y":"DDD"}'; + await sql`UPDATE identities SET signing_public_key = ${signing} WHERE principal_id = 'node:pre-m2'`.execute(db); + const back = await sql<{ signing_public_key: string; public_key: string }>` + SELECT signing_public_key, public_key FROM identities WHERE principal_id = 'node:pre-m2' + `.execute(db); + expect(back.rows[0].signing_public_key).toBe(signing); + expect(back.rows[0].public_key).toBe('{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}'); + }); + + it("adds ssh_fingerprint to nodes; a pre-M2 row reads NULL and a mirror round-trips", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + expect(cols.rows.map((c) => c.name)).toContain("ssh_fingerprint"); + const r = await sql<{ ssh_fingerprint: string | null }>` + SELECT ssh_fingerprint FROM nodes WHERE id = 'node-pre-m2' + `.execute(db); + expect(r.rows[0].ssh_fingerprint).toBeNull(); + // The mirror block is JSON text (the §4.5 trust card data), written by a + // later task when the agent reports. + const block = JSON.stringify({ signing: "SHA256:aaa", encryption: "SHA256:bbb", reportedAt: "now" }); + await sql`UPDATE nodes SET ssh_fingerprint = ${block} WHERE id = 'node-pre-m2'`.execute(db); + const back = await sql<{ ssh_fingerprint: string }>` + SELECT ssh_fingerprint FROM nodes WHERE id = 'node-pre-m2' + `.execute(db); + expect(back.rows[0].ssh_fingerprint).toBe(block); + }); + + it("neither column is NOT NULL: a fresh insert without them succeeds", async () => { + // The identities side of the claim: a post-migration registration that + // OMITS the signing slot must land, with the column NULL, not fail NOT NULL. + await db + .insertInto("identities") + .values({ + principal_id: "node:fresh-null", + public_key: '{"kty":"EC","crv":"P-256","x":"EEE","y":"FFF"}', + display_name: "fresh box", + }) + .execute(); + const ri = await sql<{ signing_public_key: string | null }>` + SELECT signing_public_key FROM identities WHERE principal_id = 'node:fresh-null' + `.execute(db); + expect(ri.rows[0].signing_public_key).toBeNull(); + await db + .insertInto("nodes") + .values({ + id: "node-post-m2", + owner_user_id: "u1", + name: "new box", + kind: "agent", + created_at: "2026-10-08T00:00:00.000Z", + updated_at: "2026-10-08T00:00:00.000Z", + }) + .execute(); + const r = await sql<{ ssh_fingerprint: string | null }>` + SELECT ssh_fingerprint FROM nodes WHERE id = 'node-post-m2' + `.execute(db); + expect(r.rows[0].ssh_fingerprint).toBeNull(); + }); + + it("down drops both columns and leaves the rows", async () => { + await relayIdentityMigration.down(db); + const identityCols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('identities')`.execute(db); + expect(identityCols.rows.map((c) => c.name)).not.toContain("signing_public_key"); + const nodeCols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + expect(nodeCols.rows.map((c) => c.name)).not.toContain("ssh_fingerprint"); + const left = await sql<{ id: string }>`SELECT id FROM nodes ORDER BY id`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["node-post-m2", "node-pre-m2"]); + }); +}); diff --git a/apps/server/api/src/db/migrations/__tests__/0050-ssh-grants.test.ts b/apps/server/api/src/db/migrations/__tests__/0050-ssh-grants.test.ts new file mode 100644 index 000000000..37f33e938 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0050-ssh-grants.test.ts @@ -0,0 +1,238 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { CamelCasePlugin, Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as grantsMigration from "@/db/migrations/0050-ssh-grants.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + sshKeyGrants: Record; + sshGrantRequests: Record; + sshHostPins: Record; +} + +/** + * The grant tier's storage (spec 2026-10-08 §6, §9; migration 0050) creates + * ALL THREE tables in one file per the plan's file map: `ssh_key_grants` (the + * standing authorization), `ssh_grant_requests` (the durable first-use queue + * with an expiry deadline and a four-state lifecycle), and `ssh_host_pins` + * (the M2 TOFU store - one unique pin per owner per resolved `user@host:port`; + * its read/write logic is T12's, the SHAPE is this migration's). + * + * The FK target is the minimal `user` table (same posture as the 0048/0049 + * tests); node ids carry NO fk by design (a vanished node is refused at the + * gate, the saved-hosts precedent). + */ +describe("migration 0050-ssh-grants", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0050-${Math.random().toString(36).slice(2)}.db`; + // The app's own camelCase mapping (db/index.ts), so the typed inserts here + // spell names the same way every repository does. + db = new Kysely({ + dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }), + plugins: [new CamelCasePlugin()], + }); + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@test')`.execute(db); + await grantsMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("creates ssh_key_grants with the full column set and rejects a null owner", async () => { + const cols = await sql<{ + name: string; + notnull: number; + }>`SELECT name, "notnull" FROM pragma_table_info('ssh_key_grants')`.execute(db); + const byName = new Map(cols.rows.map((c) => [c.name, c.notnull])); + for (const col of [ + "id", + "owner_user_id", + "name", + "key_home_node_id", + "resolved_selector", + "fingerprints", + "created_via", + "created_at", + "updated_at", + ]) { + expect(byName.has(col)).toBe(true); + } + // EVERY grant column is NOT NULL: the service writes the full row, there is + // no partial vocabulary (a grant with no fingerprints is an empty ARRAY, + // not a NULL - "names no fingerprint serves nothing" is data, not absence). + for (const col of [ + "owner_user_id", + "name", + "key_home_node_id", + "resolved_selector", + "fingerprints", + "created_via", + "created_at", + "updated_at", + ]) { + expect(byName.get(col)).toBe(1); + } + await db + .insertInto("sshKeyGrants") + .values({ + id: "g1", + ownerUserId: "u1", + name: "work hosts", + keyHomeNodeId: "node-a", + resolvedSelector: "git.example.test", + fingerprints: JSON.stringify(["SHA256:aaa"]), + createdVia: "first-use", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(); + // The fingerprints column round-trips the JSON array exactly as stored. + const row = await sql<{ fingerprints: string }>`SELECT fingerprints FROM ssh_key_grants WHERE id = 'g1'`.execute( + db, + ); + expect(JSON.parse(row.rows[0].fingerprints)).toEqual(["SHA256:aaa"]); + // NOT NULL enforced at the door: a grant row without its owner is refused. + await expect( + db + .insertInto("sshKeyGrants") + .values({ + id: "g2", + ownerUserId: null as unknown as string, + name: "n", + keyHomeNodeId: "node-a", + resolvedSelector: "h", + fingerprints: "[]", + createdVia: "manual", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(), + ).rejects.toThrow(); + }); + + it("creates ssh_grant_requests: only requested_fingerprints is nullable, and a durable pending row round-trips", async () => { + const cols = await sql<{ + name: string; + notnull: number; + }>`SELECT name, "notnull" FROM pragma_table_info('ssh_grant_requests')`.execute(db); + const byName = new Map(cols.rows.map((c) => [c.name, c.notnull])); + // (`id` is deliberately absent: SQLite's TEXT PRIMARY KEY carries no + // NOT NULL flag, and uniqueness is the actual identity rule here.) + for (const col of [ + "owner_user_id", + "key_home_node_id", + "resolved_selector", + "pane_id", + "b_node_id", + "expires_at", + "status", + "created_at", + ]) { + expect(byName.get(col)).toBe(1); + } + // The one representable absence (spec §6.2: the approver, not the pane, + // selects the keys): a request with no pre-selection stores NULL. + expect(byName.get("requested_fingerprints")).toBe(0); + await db + .insertInto("sshGrantRequests") + .values({ + id: "r1", + ownerUserId: "u1", + keyHomeNodeId: "node-a", + resolvedSelector: "git.example.test", + requestedFingerprints: null, + paneId: "pane-1", + bNodeId: "node-b", + expiresAt: "2026-10-09T00:00:00.000Z", + status: "pending", + createdAt: "2026-10-08T00:00:00.000Z", + }) + .execute(); + // (Raw SQL identifiers stay snake - the plugin rewrites the Kysely + // builders, not raw text - but the RESULT ROW KEYS come back camel, the + // mapping the app's own queries ride everywhere.) + const back = await sql<{ status: string; requestedFingerprints: string | null; paneId: string }>` + SELECT status, requested_fingerprints, pane_id FROM ssh_grant_requests WHERE id = 'r1' + `.execute(db); + expect(back.rows[0].status).toBe("pending"); + expect(back.rows[0].requestedFingerprints).toBeNull(); + // The requester facts ride the row (the approvals screen shows WHO asked): + // the row survives because the table is durable storage, not process state. + expect(back.rows[0].paneId).toBe("pane-1"); + // A terminal status re-uses the same column vocabulary (no CHECK in this + // schema: the SERVICE validates against SSH_GRANT_REQUEST_STATUSES). + await sql`UPDATE ssh_grant_requests SET status = 'approved' WHERE id = 'r1'`.execute(db); + const after = await sql<{ status: string }>`SELECT status FROM ssh_grant_requests WHERE id = 'r1'`.execute(db); + expect(after.rows[0].status).toBe("approved"); + }); + + it("creates ssh_host_pins with the unique (owner, destination) key - the TOFU record shape", async () => { + await db + .insertInto("sshHostPins") + .values({ + id: "p1", + ownerUserId: "u1", + destination: "git@git.example.test:22", + hostKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPin", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(); + // One pin per owner per resolved destination: the second insert at the + // same key is the §9 hard block at the STORAGE level (the capture logic + // that reads this refusal is T12's). + await expect( + db + .insertInto("sshHostPins") + .values({ + id: "p2", + ownerUserId: "u1", + destination: "git@git.example.test:22", + hostKey: "ssh-ed25519 AAAAC3DIFFERENT", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(), + ).rejects.toThrow(); + // Another owner may hold their own pin for the same destination (per-owner + // scoping: one account's first use must not poison or lock another's). + await sql`INSERT INTO user (id, name, email) VALUES ('u2', 'Other', 'u2@test')`.execute(db); + await db + .insertInto("sshHostPins") + .values({ + id: "p3", + ownerUserId: "u2", + destination: "git@git.example.test:22", + hostKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPin", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(); + const count = await sql<{ n: number }>`SELECT COUNT(*) AS n FROM ssh_host_pins`.execute(db); + expect(count.rows[0].n).toBe(2); + }); + + it("owner deletion cascades every grant tier row (accounts leave no authorization behind)", async () => { + await sql`DELETE FROM user WHERE id = 'u2'`.execute(db); + const orphans = await sql<{ + n: number; + }>`SELECT COUNT(*) AS n FROM ssh_host_pins WHERE owner_user_id = 'u2'`.execute(db); + expect(orphans.rows[0].n).toBe(0); + }); + + it("down drops all three tables", async () => { + await grantsMigration.down(db); + for (const table of ["ssh_key_grants", "ssh_grant_requests", "ssh_host_pins"]) { + const left = await sql<{ n: number }>` + SELECT COUNT(*) AS n FROM sqlite_schema WHERE type = 'table' AND name = ${table} + `.execute(db); + expect(left.rows[0].n).toBe(0); + } + }); +}); diff --git a/apps/server/api/src/db/migrations/__tests__/0051-ssh-request-destination.test.ts b/apps/server/api/src/db/migrations/__tests__/0051-ssh-request-destination.test.ts new file mode 100644 index 000000000..d16e7d44c --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0051-ssh-request-destination.test.ts @@ -0,0 +1,96 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { CamelCasePlugin, Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as grantsMigration from "@/db/migrations/0050-ssh-grants.js"; +import * as destinationMigration from "@/db/migrations/0051-ssh-request-destination.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + sshGrantRequests: Record; +} + +/** + * Task 12's one storage change: `ssh_grant_requests` gains `destination`, the + * full resolved `user@host:port` the asking launch dialed. Nullable on + * purpose (a pre-T12 pending row cannot be back-filled, and approval fails + * closed on null); everything else 0050 froze stays untouched. + */ +describe("migration 0051-ssh-request-destination", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0051-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ + dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }), + plugins: [new CamelCasePlugin()], + }); + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@test')`.execute(db); + await grantsMigration.up(db); + await destinationMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds destination as a nullable column on the request row", async () => { + const cols = await sql<{ name: string; notnull: number }>` + SELECT name, "notnull" FROM pragma_table_info('ssh_grant_requests') + `.execute(db); + const names = cols.rows.map((r) => r.name); + expect(names).toContain("destination"); + const destination = cols.rows.find((r) => r.name === "destination"); + expect(destination?.notnull).toBe(0); // legacy pending rows carry null; approval refuses them by name + }); + + it("an old-shaped row still inserts (null destination) and a full one round-trips", async () => { + await db + .insertInto("sshGrantRequests") + .values({ + id: "req-legacy", + ownerUserId: "u1", + keyHomeNodeId: "n-a", + resolvedSelector: "git.example.test", + requestedFingerprints: null, + paneId: "p1", + bNodeId: "n-b", + expiresAt: "2026-10-09T00:00:00.000Z", + status: "pending", + createdAt: "2026-10-08T00:00:00.000Z", + } as never) + .execute(); + await db + .insertInto("sshGrantRequests") + .values({ + id: "req-full", + ownerUserId: "u1", + keyHomeNodeId: "n-a", + resolvedSelector: "git.example.test", + requestedFingerprints: null, + paneId: "p2", + bNodeId: "n-b", + expiresAt: "2026-10-09T00:00:00.000Z", + status: "pending", + createdAt: "2026-10-08T00:00:00.000Z", + destination: "deploy@git.example.test:2222", + } as never) + .execute(); + const rows = await db.selectFrom("sshGrantRequests").select(["id", "destination"]).execute(); + expect(rows).toEqual( + expect.arrayContaining([ + { id: "req-legacy", destination: null }, + { id: "req-full", destination: "deploy@git.example.test:2222" }, + ]), + ); + }); + + it("down() removes only the new column and keeps the 0050 tables", async () => { + await destinationMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('ssh_grant_requests')`.execute(db); + expect(cols.rows.map((r) => r.name)).not.toContain("destination"); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toEqual([]); + }); +}); diff --git a/apps/server/api/src/db/migrations/__tests__/0052-subshell-key-home.test.ts b/apps/server/api/src/db/migrations/__tests__/0052-subshell-key-home.test.ts new file mode 100644 index 000000000..2ffee7b28 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0052-subshell-key-home.test.ts @@ -0,0 +1,100 @@ +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 subshellRenameMigration from "@/db/migrations/0019-subshell-rename.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + subshells: Record; + user: Record; +} + +/** + * Task 14's one storage change: `subshells` gains `key_home_node_id`, the + * relay pane's key home A - the fact "Set up Subshell here" needs to re-open + * the pairing that authorized this pane, minutes or hours after the 30 s + * relay session itself is gone. NULL on every direct pane; the re-open act + * names A-online as its own refusal cause, so the column must exist for that + * reading, not be re-derived from the grant table. + */ +describe("migration 0052-subshell-key-home", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0052-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ + dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }), + }); + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@test')`.execute(db); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + await subshellRenameMigration.up(db); + await sshLaunchMigration.up(db); + await keyHomeMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds key_home_node_id as a nullable column on the subshell row", async () => { + const cols = await sql<{ name: string; notnull: number }>` + SELECT name, "notnull" FROM pragma_table_info('subshells') + `.execute(db); + const names = cols.rows.map((r) => r.name); + expect(names).toContain("key_home_node_id"); + expect(cols.rows.find((r) => r.name === "key_home_node_id")?.notnull).toBe(0); // direct panes carry null + }); + + it("a row written without the column reads null; a relay row round-trips the A id", async () => { + await db + .insertInto("subshells") + .values({ + id: "pane-direct", + user_id: "u1", + profile_id: "p1", + harness_id: "ssh", + name: "pane-direct", + working_dir: "/tmp", + ssh: '{"host":"d.example.test"}', + } as never) + .execute(); + await db + .insertInto("subshells") + .values({ + id: "pane-relay", + user_id: "u1", + profile_id: "p1", + harness_id: "ssh", + name: "pane-relay", + working_dir: "/tmp", + ssh: '{"host":"d.example.test"}', + key_home_node_id: "node-a", + } as never) + .execute(); + const rows = await db.selectFrom("subshells").select(["id", "key_home_node_id"]).execute(); + expect(rows).toEqual( + expect.arrayContaining([ + { id: "pane-direct", key_home_node_id: null }, + { id: "pane-relay", key_home_node_id: "node-a" }, + ]), + ); + }); + + it("down() removes only the new column", async () => { + await keyHomeMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('subshells')`.execute(db); + expect(cols.rows.map((r) => r.name)).not.toContain("key_home_node_id"); + }); +}); diff --git a/apps/server/api/src/db/migrations/__tests__/0053-drop-ssh-grants.test.ts b/apps/server/api/src/db/migrations/__tests__/0053-drop-ssh-grants.test.ts new file mode 100644 index 000000000..31ef4ada5 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0053-drop-ssh-grants.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { CamelCasePlugin, Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { db as freshDb } from "@/db/index.js"; +import * as grants from "@/db/migrations/0050-ssh-grants.js"; +import * as destinations from "@/db/migrations/0051-ssh-request-destination.js"; +import * as removal from "@/db/migrations/0053-drop-ssh-grants.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +test("SSH grant removal upgrades existing stores and preserves destination trust, saved hosts and audit", async () => { + const db = new Kysely({ + dialect: new BunSqliteDialect({ database: openSqliteDatabase(":memory:") }), + plugins: [new CamelCasePlugin()], + }); + try { + await sql`CREATE TABLE user (id TEXT PRIMARY KEY)`.execute(db); + await sql`INSERT INTO user VALUES ('u')`.execute(db); + await grants.up(db); + await destinations.up(db); + await sql`CREATE TABLE ssh_saved_hosts (id TEXT PRIMARY KEY)`.execute(db); + await sql`CREATE TABLE audit_events (id TEXT PRIMARY KEY)`.execute(db); + await sql`INSERT INTO ssh_saved_hosts VALUES ('saved')`.execute(db); + await sql`INSERT INTO audit_events VALUES ('audit')`.execute(db); + await sql`INSERT INTO ssh_host_pins VALUES ('pin','u','host:22','key','now','now')`.execute(db); + await sql`INSERT INTO ssh_key_grants VALUES ('grant','u','old','node','host','[]','manual','now','now')`.execute( + db, + ); + await sql`INSERT INTO ssh_grant_requests VALUES ('request','u','node','host',NULL,'pane','b','future','pending','now','host:22')`.execute( + db, + ); + await removal.up(db); + const names = (await sql<{ name: string }>`SELECT name FROM sqlite_master WHERE type='table'`.execute(db)).rows.map( + (row) => row.name, + ); + expect(names).not.toContain("ssh_key_grants"); + expect(names).not.toContain("ssh_grant_requests"); + for (const table of ["ssh_host_pins", "ssh_saved_hosts", "audit_events"]) { + expect((await sql.raw(`SELECT * FROM ${table}`).execute(db)).rows).toHaveLength(1); + } + await expect(removal.down()).rejects.toThrow("irreversible"); + } finally { + await db.destroy(); + } +}); + +test("fresh migration chain contains trust and no SSH permission tables", async () => { + await ensureMigratedTestDb(); + const tables = ( + await sql<{ name: string }>`SELECT name FROM sqlite_master WHERE type='table'`.execute(freshDb) + ).rows.map((row) => row.name); + expect(tables).toContain("ssh_host_pins"); + expect(tables).toContain("ssh_saved_hosts"); + expect(tables).not.toContain("ssh_key_grants"); + expect(tables).not.toContain("ssh_grant_requests"); +}); diff --git a/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts b/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts index 658c70a0e..1b2e95b42 100644 --- a/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts @@ -65,11 +65,29 @@ describe("IdentitiesRepository", () => { beforeEach(wipe); it("registers and upserts (rotate) by principal", async () => { - const first = await repo.register({ principalId: "sess:a", publicKey: "JWK-1", displayName: "Alpha" }); + const first = await repo.register({ + principalId: "sess:a", + publicKey: "JWK-1", + signingPublicKey: null, + displayName: "Alpha", + }); expect(first.publicKey).toBe("JWK-1"); - const rotated = await repo.register({ principalId: "sess:a", publicKey: "JWK-2", displayName: null }); + expect(first.signingPublicKey).toBeNull(); + const rotated = await repo.register({ + principalId: "sess:a", + publicKey: "JWK-2", + signingPublicKey: "ES-JWK", + displayName: null, + }); expect(rotated.publicKey).toBe("JWK-2"); - expect(await repo.findByPrincipal("sess:a")).toMatchObject({ publicKey: "JWK-2", displayName: null }); + // The signing slot rotates with the record in the one write (spec + // 2026-10-08 §4.2): an insert and an upsert both carry it. + expect(rotated.signingPublicKey).toBe("ES-JWK"); + expect(await repo.findByPrincipal("sess:a")).toMatchObject({ + publicKey: "JWK-2", + signingPublicKey: "ES-JWK", + displayName: null, + }); expect(await repo.findByPrincipal("sess:ghost")).toBeUndefined(); }); }); diff --git a/apps/server/api/src/db/repositories/__tests__/repositories.test.ts b/apps/server/api/src/db/repositories/__tests__/repositories.test.ts index 5f180feb7..e68417732 100644 --- a/apps/server/api/src/db/repositories/__tests__/repositories.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/repositories.test.ts @@ -15,6 +15,7 @@ import * as presetsMigration from "@/db/migrations/0027-presets.js"; import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as subshellKeyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { RecentPathsRepository } from "@/db/repositories/recent-paths.repository.js"; @@ -53,6 +54,7 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await subshellKeyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) }); beforeEach(async () => { diff --git a/apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts b/apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts new file mode 100644 index 000000000..a9814f282 --- /dev/null +++ b/apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts @@ -0,0 +1,181 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { SshSavedHostsRepository } from "@/db/repositories/ssh-saved-hosts.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; + +/** + * The per-owner SSH destination ledger (spec 2026-10-07 §7). The properties + * this suite exists to pin: + * + * - the key is (owner, canonical destination): `touch` REFRESHES recency on + * an existing row rather than adding a second one, and two owners may hold + * the same destination as two rows; + * - recency (`last_connect_at`) rides EVERY launch whether or not the row was + * ever saved; `saved_at` is only what the human gave (null = recency-only); + * - the list reads never carry another owner's rows, and `remove` refuses a + * foreign id exactly like an absent one (the ownership axis, docs/security.md §3). + */ + +const repo = new SshSavedHostsRepository(db); +const emails: string[] = []; +const inserted: string[] = []; +let ownerA = ""; +let ownerB = ""; + +const at = (iso: string) => ({ at: iso }); + +async function mkUser(tag: string): Promise { + const email = `sh-repo-${tag}-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("sh-repo-1"), + role: "user", + }); +} + +beforeAll(async () => { + await setupAuthTables(); + ownerA = await mkUser("a"); + ownerB = await mkUser("b"); +}); + +afterAll(async () => { + for (const id of inserted) await db.deleteFrom("sshSavedHosts").where("id", "=", id).execute(); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +describe("ssh-saved-hosts repository", () => { + it("touch inserts a recency-only row and REFRESHES it on the same key (one row, moved node + stamp)", async () => { + const destination = "example.test:22"; + await repo.touch({ + ownerUserId: ownerA, + destination, + nodeId: "node-1", + alias: "work", + ...at("2026-10-07T10:00:00.000Z"), + }); + const row = await db + .selectFrom("sshSavedHosts") + .selectAll() + .where("destination", "=", destination) + .executeTakeFirstOrThrow(); + inserted.push(row.id); + expect(row.ownerUserId).toBe(ownerA); + expect(row.nodeId).toBe("node-1"); + expect(row.alias).toBe("work"); + expect(row.savedAt).toBeNull(); + expect(row.lastConnectAt).toBe("2026-10-07T10:00:00.000Z"); + + await repo.touch({ ownerUserId: ownerA, destination, nodeId: "node-2", ...at("2026-10-07T12:00:00.000Z") }); + const rows = await db.selectFrom("sshSavedHosts").selectAll().where("destination", "=", destination).execute(); + expect(rows).toHaveLength(1); // refreshed, not doubled + expect(rows[0].id).toBe(row.id); // the SAME row + expect(rows[0].nodeId).toBe("node-2"); // the machine of the most recent launch + expect(rows[0].lastConnectAt).toBe("2026-10-07T12:00:00.000Z"); + // An absent alias does NOT wipe the standing one (the display token outlives a bare-host launch). + expect(rows[0].alias).toBe("work"); + }); + + it("two owners may hold the same canonical destination as two rows", async () => { + const destination = "shared.test:2222"; + await repo.touch({ ownerUserId: ownerA, destination, nodeId: "node-1", ...at("2026-10-07T10:00:00.000Z") }); + await repo.touch({ ownerUserId: ownerB, destination, nodeId: "node-1", ...at("2026-10-07T10:00:00.000Z") }); + const rows = await db.selectFrom("sshSavedHosts").selectAll().where("destination", "=", destination).execute(); + expect(rows).toHaveLength(2); + inserted.push(...rows.map((r) => r.id)); + }); + + it("markSaved stamps saved_at and returns the row (upsert on an existing recency-only key)", async () => { + const destination = "save.test:22"; + await repo.touch({ ownerUserId: ownerA, destination, nodeId: "node-1", ...at("2026-10-07T09:00:00.000Z") }); + const first = await db + .selectFrom("sshSavedHosts") + .select("id") + .where("destination", "=", destination) + .where("ownerUserId", "=", ownerA) + .executeTakeFirstOrThrow(); + inserted.push(first.id); + + const row = await repo.markSaved({ + ownerUserId: ownerA, + destination, + nodeId: "node-1", + alias: "lab", + ...at("2026-10-07T11:00:00.000Z"), + }); + expect(row.id).toBe(first.id); // saved the SAME row the launch had touched + expect(row.savedAt).toBe("2026-10-07T11:00:00.000Z"); + expect(row.alias).toBe("lab"); + expect(row.lastConnectAt).toBe("2026-10-07T11:00:00.000Z"); + }); + + it("listSaved reads only saved rows, newest-saved first; listRecent reads everything by recency", async () => { + // Destinations tagged per case: the earlier cases' rows exist too, and a + // whole-table ordering assertion would depend on THEIR stamps. + const tag = crypto.randomUUID().slice(0, 8); + await repo.markSaved({ + ownerUserId: ownerA, + destination: `${tag}-old.test:22`, + nodeId: "n", + ...at("2026-10-07T08:00:00.000Z"), + }); + await repo.markSaved({ + ownerUserId: ownerA, + destination: `${tag}-new.test:22`, + nodeId: "n", + ...at("2026-10-07T14:00:00.000Z"), + }); + await repo.touch({ + ownerUserId: ownerA, + destination: `${tag}-just.test:22`, + nodeId: "n", + ...at("2026-10-07T15:00:00.000Z"), + }); + const saved = await repo.listSaved(ownerA); + const recent = await repo.listRecent(ownerA); + const savedDests = saved.map((r) => r.destination).filter((d) => d.startsWith(tag)); + // Only rows a human gave (savedAt non-null); the recency-only row is absent. + expect(savedDests).toEqual([`${tag}-new.test:22`, `${tag}-old.test:22`]); + expect(savedDests).not.toContain(`${tag}-just.test:22`); + // Recency lists everything, newest connect first; the unsaved launch leads. + expect(recent[0]?.destination).toBe(`${tag}-just.test:22`); + const recentDests = recent.map((r) => r.destination); + expect(recentDests).toContain(`${tag}-old.test:22`); + inserted.push(...saved.map((r) => r.id), ...recent.map((r) => r.id)); + }); + + it("listRecent caps at 20 by default and honors an explicit limit", async () => { + const tag = `cap-${crypto.randomUUID().slice(0, 8)}`; + for (let i = 0; i < 25; i++) { + await repo.touch({ + ownerUserId: ownerA, + destination: `${tag}-${i}.test:22`, + nodeId: "n", + ...at(`2026-10-0${(i % 7) + 1}T00:00:0${i % 10}.000Z`), + }); + } + const rows = await db.selectFrom("sshSavedHosts").select("id").where("destination", "like", `${tag}-%`).execute(); + inserted.push(...rows.map((r) => r.id)); + expect(await repo.listRecent(ownerA)).toHaveLength(20); + expect(await repo.listRecent(ownerA, 5)).toHaveLength(5); + }); + + it("remove deletes the owner's row; a foreign id and an absent id both answer false", async () => { + const destination = "remove.test:22"; + const row = await repo.markSaved({ + ownerUserId: ownerA, + destination, + nodeId: "n", + ...at("2026-10-07T10:00:00.000Z"), + }); + expect(await repo.remove(ownerB, row.id)).toBe(false); // foreign: refused like an absent one (no existence oracle) + expect(await db.selectFrom("sshSavedHosts").select("id").where("id", "=", row.id).executeTakeFirst()).toBeDefined(); + expect(await repo.remove(ownerA, row.id)).toBe(true); + expect(await repo.remove(ownerA, row.id)).toBe(false); // already gone + expect(await repo.remove(ownerA, "never-an-id")).toBe(false); + }); +}); diff --git a/apps/server/api/src/db/repositories/__tests__/workspaces-repository.test.ts b/apps/server/api/src/db/repositories/__tests__/workspaces-repository.test.ts index 373bd7788..e99bed474 100644 --- a/apps/server/api/src/db/repositories/__tests__/workspaces-repository.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/workspaces-repository.test.ts @@ -17,6 +17,7 @@ import * as workspaceDraftsMigration from "@/db/migrations/0029-workspace-drafts import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as subshellKeyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -77,6 +78,7 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await subshellKeyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) }); beforeEach(async () => { diff --git a/apps/server/api/src/db/repositories/identities.repository.ts b/apps/server/api/src/db/repositories/identities.repository.ts index 1779f874b..8faf703f5 100644 --- a/apps/server/api/src/db/repositories/identities.repository.ts +++ b/apps/server/api/src/db/repositories/identities.repository.ts @@ -8,10 +8,17 @@ import type { IdentityTable } from "@/db/types/identities.db-types.js"; * unreadable to that principal by design (documented in the spec). */ export class IdentitiesRepository extends BaseRepository { - /** Inserts or rotates the principal's keypair registration. */ + /** + * Inserts or rotates the principal's keypair registration. The + * `signingPublicKey` slot (the machine's ES256 relay half, spec + * 2026-10-08 §4.2) rotates WITH the encryption key in the same record, so a + * caller always states it explicitly: null where the principal has no + * signing identity (panes, users) or the agent has not reported one. + */ async register(input: { principalId: string; publicKey: string; + signingPublicKey: string | null; displayName: string | null; }): Promise { await this.db @@ -19,12 +26,14 @@ export class IdentitiesRepository extends BaseRepository { .values({ principalId: input.principalId, publicKey: input.publicKey, + signingPublicKey: input.signingPublicKey, displayName: input.displayName, registeredAt: sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`, }) .onConflict((oc) => oc.column("principalId").doUpdateSet({ publicKey: input.publicKey, + signingPublicKey: input.signingPublicKey, displayName: input.displayName, registeredAt: sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`, }), @@ -33,6 +42,48 @@ export class IdentitiesRepository extends BaseRepository { return this.findByPrincipal(input.principalId) as Promise; } + /** + * Fill the principal's EMPTY signing slot (spec 2026-10-08 §4.3): a + * compare-and-write that refuses to land once the slot holds ANY value, so + * the anti-silent-rotation guard is structural in SQL rather than a + * read-then-write the caller could race. The encryption `publicKey` and + * `registeredAt` do not move - filling the signing half is not a rotation. + * @returns true when THIS call wrote the bytes (false: the slot was, or + * concurrently became, non-empty, or the row does not exist) + */ + async fillSigningPublicKey(principalId: string, signingPublicKey: string): Promise { + const res = await this.db + .updateTable("identities") + .set({ signingPublicKey }) + .where("principalId", "=", principalId) + .where("signingPublicKey", "is", null) + .executeTakeFirst(); + return Number(res.numUpdatedRows) > 0; + } + + /** Register only when absent, or fill a missing signing half while encryption still agrees. */ + async registerIfMatching(input: { + principalId: string; + publicKey: string; + signingPublicKey: string; + displayName: string | null; + }): Promise { + await this.db + .insertInto("identities") + .values({ ...input, registeredAt: sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))` }) + .onConflict((oc) => oc.column("principalId").doNothing()) + .execute(); + await this.db + .updateTable("identities") + .set({ signingPublicKey: input.signingPublicKey }) + .where("principalId", "=", input.principalId) + .where("publicKey", "=", input.publicKey) + .where("signingPublicKey", "is", null) + .execute(); + const standing = await this.findByPrincipal(input.principalId); + return standing?.publicKey === input.publicKey && standing.signingPublicKey === input.signingPublicKey; + } + /** The principal's current identity, if registered. */ async findByPrincipal(principalId: string): Promise { return this.db.selectFrom("identities").selectAll().where("principalId", "=", principalId).executeTakeFirst(); diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts index f54455f90..3fd95ca53 100644 --- a/apps/server/api/src/db/repositories/nodes.repository.ts +++ b/apps/server/api/src/db/repositories/nodes.repository.ts @@ -56,6 +56,10 @@ export class NodesRepository extends BaseRepository { maintenance: input.maintenance ?? 0, maintenanceAt: input.maintenanceAt ?? null, maintenanceSource: input.maintenanceSource ?? null, + // A new node has SSH off: `0` is the column default (migration 0047) + // and the gate is opt-in per machine (spec 4.3); mirror it the same way. + sshEnabled: input.sshEnabled ?? 0, + sshEnabledAt: input.sshEnabledAt ?? null, createdAt: input.createdAt ?? now, updatedAt: now, }) @@ -176,6 +180,26 @@ export class NodesRepository extends BaseRepository { .executeTakeFirst(); } + /** + * Mirror (or clear) this node's §4.6 SSH trust block (spec 2026-10-08 + * §4.5/§4.6): the JSON of what the machine last reported about its own and + * its pinned peers' key fingerprints, so the trust card survives an offline + * node and a plane restart alike. + * + * The `ready` ingest calls this ONLY when the report carried a well-formed + * block: a report that says nothing never clears the mirror (absence is not + * evidence the keys are gone). `null` is for callers that DO know the block + * is dead (tests today; a future machine-side identity wipe would clear it + * by re-report, not by null). + */ + async setSshFingerprint(id: string, json: string | null): Promise { + await this.db + .updateTable("nodes") + .set({ sshFingerprint: json, updatedAt: new Date().toISOString() }) + .where("id", "=", id) + .execute(); + } + /** Cache a harness inventory snapshot (spec §6.2 TTL is the caller's). */ async applyInventory(id: string, inventoryJson: string): Promise { const now = new Date().toISOString(); @@ -222,6 +246,35 @@ export class NodesRepository extends BaseRepository { .executeTakeFirst(); } + /** + * Write the SSH capability flag and its stamp (spec 4.3). + * + * Same shape as {@link setMaintenance} minus the `source` column: both + * fields move together, and the CALLER supplies `changedAt` — the stamp is + * the reconciliation protocol between the plane's row and the node's mirror + * file (Task 7), so a relay must not re-stamp the fact it is carrying. + */ + async setSshEnabled( + id: string, + state: { + /** True = this machine may be used for Subshell SSH. */ + on: boolean; + /** ISO 8601 of the write that produced `on` — never re-stamped on relay. */ + changedAt: string; + }, + ): Promise { + return await this.db + .updateTable("nodes") + .set({ + sshEnabled: state.on ? 1 : 0, + sshEnabledAt: state.changedAt, + updatedAt: new Date().toISOString(), + }) + .where("id", "=", id) + .returningAll() + .executeTakeFirst(); + } + /** * The unfinished subshells on one node, as FULL rows. * diff --git a/apps/server/api/src/db/repositories/ssh-host-pins.repository.ts b/apps/server/api/src/db/repositories/ssh-host-pins.repository.ts new file mode 100644 index 000000000..8b4830f69 --- /dev/null +++ b/apps/server/api/src/db/repositories/ssh-host-pins.repository.ts @@ -0,0 +1,78 @@ +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { SshHostPinTable } from "@/db/types/ssh-host-pins.db-types.js"; + +/** + * Repository for the M2 host-key TOFU store (spec 2026-10-08 §9; the table + * ships in migration 0050, this file is T12's logic on it). The row key is + * the UNIQUE `(owner_user_id, destination)`: one pin per owner per resolved + * `user@host:port`, independent of grants (one grant selector over many hosts + * yields many pins, and a grant's revoke never touches a pin - the pin is the + * owner's TOFU record, not the grant's property). + * + * The capture path has ONE write: {@link insertPin}. There is deliberately NO + * update-by-key write here: a differing key for a pinned destination is §9's + * hard block, not a row to overwrite - recovery is {@link deletePin} plus a + * fresh capture at the next grant creation. `host_key` is a PUBLIC known_hosts + * line, and the row is the durable record the audit names only a fingerprint + * of; nothing in this file logs or echoes the bytes. + * + * Every read and write is OWNER-SCOPED at the WHERE clause, the saved-hosts + * posture verbatim: a foreign row is absent, never "forbidden". + */ +export class SshHostPinsRepository extends BaseRepository { + /** Record a capture. The caller validated the row (line grammar, key choice). */ + async insertPin(pin: SshHostPinTable): Promise { + await this.db.insertInto("sshHostPins").values(pin).execute(); + } + + /** The owner's pin for one canonical destination; null when unpinned. */ + async getPin(ownerUserId: string, destination: string): Promise { + const row = await this.db + .selectFrom("sshHostPins") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("destination", "=", destination) + .executeTakeFirst(); + return row ?? null; + } + + /** + * Re-stamp `updated_at` after an ACCEPTED match (a capture at the same key + * agreeing with the standing pin). The row's key, id, and bytes never move + * - only the "last accepted match" stamp does, and only on a match: a + * differing key is §9's block, not an update. + */ + async touchPin(ownerUserId: string, destination: string, at: string): Promise { + await this.db + .updateTable("sshHostPins") + .set({ updatedAt: at }) + .where("ownerUserId", "=", ownerUserId) + .where("destination", "=", destination) + .execute(); + } + + /** The owner's pins for the trust screen, newest first. */ + async listPins(ownerUserId: string): Promise { + return await this.db + .selectFrom("sshHostPins") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .orderBy("createdAt", "desc") + .execute(); + } + + /** + * Delete one of the owner's pins; false for foreign AND absent (one 404 + * at the route). The §9 recovery half: TOFU re-decides after a delete, so + * the next capture at a differing key SUCCEEDS as a new first-capture + * rather than overwriting the old one. + */ + async deletePin(ownerUserId: string, destination: string): Promise { + const res = await this.db + .deleteFrom("sshHostPins") + .where("ownerUserId", "=", ownerUserId) + .where("destination", "=", destination) + .executeTakeFirst(); + return Number(res.numDeletedRows ?? 0n) > 0; + } +} diff --git a/apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts b/apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts new file mode 100644 index 000000000..afb28185b --- /dev/null +++ b/apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts @@ -0,0 +1,141 @@ +import { sql } from "kysely"; +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { SshSavedHostTable } from "@/db/types/ssh-saved-hosts.db-types.js"; + +/** + * Repository for the per-owner SSH destination ledger (spec 2026-10-07 §7, + * migration 0048). The whole design fits in two sentences: a row's key is + * (owner, canonical destination) so an edited alias can never re-point a + * saved entry, and every launch refreshes recency whether or not a human ever + * saved the row. + * + * The `New*` insert shape lives HERE, beside the repository, rather than in + * `ssh-saved-hosts.db-types.ts` (the shape task-6 recorded as missing): every + * column is NOT NULL-with-no-default except `alias`/`saved_at`, so an insert + * is the full row and nothing is defaulted — there is no partial-write + * vocabulary to type. `touch` and `markSaved` are its only callers. + */ +export type NewSshSavedHost = SshSavedHostTable; + +/** The caller-composed facts one write carries; `at` is the caller's stamp (the repository owns no clock). */ +interface LedgerWrite { + ownerUserId: string; + /** canonical `host:port` / `user@host:port` from `sshCanonicalDestination` — handoff 1: ONLY fields off a validated snapshot */ + destination: string; + /** the connecting machine of THIS write — the row's node always follows the most recent launch */ + nodeId: string; + /** display token; absent on update KEEPS the standing one (a bare-host launch does not erase an alias) */ + alias?: string | null; + /** ISO 8601 the caller stamps as the act's time */ + at: string; +} + +const RECENT_LIMIT = 20; + +export class SshSavedHostsRepository extends BaseRepository { + /** + * Record a launch: upsert on (owner, destination) and refresh recency. + * Never touches `saved_at` — that column is what the human gave and only + * {@link markSaved} sets it. + */ + async touch(write: LedgerWrite): Promise { + await this.db + .insertInto("sshSavedHosts") + .values({ + id: crypto.randomUUID(), + ownerUserId: write.ownerUserId, + destination: write.destination, + alias: write.alias ?? null, + nodeId: write.nodeId, + savedAt: null, + lastConnectAt: write.at, + }) + .onConflict((oc) => + oc.columns(["ownerUserId", "destination"]).doUpdateSet({ + nodeId: write.nodeId, + lastConnectAt: write.at, + // COALESCE, not a plain set: an omitted alias must leave the display + // token the human saw standing (the key is the destination; the + // alias is what the list labels it with). + alias: sql`COALESCE(excluded.alias, ssh_saved_hosts.alias)`, + }), + ) + .execute(); + } + + /** + * Save a destination (upsert-mark-saved): the same refresh as {@link touch} + * plus `saved_at`, returning the row so the route can answer with it. + */ + async markSaved(write: LedgerWrite): Promise { + await this.db + .insertInto("sshSavedHosts") + .values({ + id: crypto.randomUUID(), + ownerUserId: write.ownerUserId, + destination: write.destination, + alias: write.alias ?? null, + nodeId: write.nodeId, + savedAt: write.at, + lastConnectAt: write.at, + }) + .onConflict((oc) => + oc.columns(["ownerUserId", "destination"]).doUpdateSet({ + nodeId: write.nodeId, + // Re-saving re-stamps the human's own act (it is a fresh save); + // recency rides with it, same as a launch. + savedAt: write.at, + lastConnectAt: write.at, + alias: sql`COALESCE(excluded.alias, ssh_saved_hosts.alias)`, + }), + ) + .execute(); + const row = await this.db + .selectFrom("sshSavedHosts") + .selectAll() + .where("ownerUserId", "=", write.ownerUserId) + .where("destination", "=", write.destination) + .executeTakeFirst(); + // The upsert just wrote the key; a row that is not there now is a dropped + // connection, not a state. + if (!row) throw new Error(`ssh saved-host row vanished mid-write (${write.ownerUserId}/${write.destination})`); + return row; + } + + /** The owner's SAVED rows (a human stamped `saved_at`), newest save first, capped. */ + async listSaved(ownerUserId: string, limit = RECENT_LIMIT): Promise { + return await this.db + .selectFrom("sshSavedHosts") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("savedAt", "is not", null) + .orderBy("savedAt", "desc") + .limit(limit) + .execute(); + } + + /** The owner's most-recently-connected destinations (saved or not), newest first, capped. */ + async listRecent(ownerUserId: string, limit = RECENT_LIMIT): Promise { + return await this.db + .selectFrom("sshSavedHosts") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .orderBy("lastConnectAt", "desc") + .limit(limit) + .execute(); + } + + /** + * Delete one of the owner's rows. A foreign id and an absent id answer the + * same `false` — the caller renders both as the 404 the ownership axis + * demands (ids are never existence oracles, docs/security.md §3). + */ + async remove(ownerUserId: string, id: string): Promise { + const res = await this.db + .deleteFrom("sshSavedHosts") + .where("id", "=", id) + .where("ownerUserId", "=", ownerUserId) + .executeTakeFirst(); + return Number(res.numDeletedRows ?? 0n) > 0; + } +} diff --git a/apps/server/api/src/db/repositories/subshells.repository.ts b/apps/server/api/src/db/repositories/subshells.repository.ts index 7edb7f6de..9c8195d51 100644 --- a/apps/server/api/src/db/repositories/subshells.repository.ts +++ b/apps/server/api/src/db/repositories/subshells.repository.ts @@ -64,6 +64,9 @@ export class SubshellsRepository extends BaseRepository { // Node pin defaults in the DB (migration 0017); mirror it so the typed // insert is complete and the row reads back whole. nodeId: subshell.nodeId ?? LOCAL_NODE_ID, + // The relay key home (migration 0052); a direct pane's row carries the + // column's own NULL default. + keyHomeNodeId: subshell.keyHomeNodeId ?? null, // Cross-agent provenance defaults to "human" (migration 0039); mirror it. crossAgent: subshell.crossAgent ?? 0, createdAt: new Date().toISOString(), diff --git a/apps/server/api/src/db/types/identities.db-types.ts b/apps/server/api/src/db/types/identities.db-types.ts index 3c7816890..fb4ff990f 100644 --- a/apps/server/api/src/db/types/identities.db-types.ts +++ b/apps/server/api/src/db/types/identities.db-types.ts @@ -8,6 +8,14 @@ export interface IdentityTable { principalId: string; /** JSON-serialized JWK (P-256 / ECDH-ES public key) */ publicKey: string; + /** + * JSON-serialized JWK (P-256 / ES256 public key): the machine's signing + * identity for the SSH agent relay (spec 2026-10-08 §4.2). Only `node:` + * principals ever carry one - enroll fills it and node deletion cascades + * it exactly as `publicKey` does; a null reads as "never reported" and is + * what every pre-M2 row and every pane/user holds. + */ + signingPublicKey: string | null; /** Convenience label (e.g. subshell name); not authoritative */ displayName: string | null; /** ISO 8601 timestamp of (re-)registration (DB default); re-registering rotates */ diff --git a/apps/server/api/src/db/types/index.ts b/apps/server/api/src/db/types/index.ts index 075613c5c..76cf6cc09 100644 --- a/apps/server/api/src/db/types/index.ts +++ b/apps/server/api/src/db/types/index.ts @@ -17,6 +17,8 @@ import type { PromptStackItemTable, PromptStackTable } from "@/db/types/prompt-s import type { PromptTable } from "@/db/types/prompts.db-types.js"; import type { RecentPathTable } from "@/db/types/recent-paths.db-types.js"; import type { SettingTable } from "@/db/types/settings.db-types.js"; +import type { SshHostPinTable } from "@/db/types/ssh-host-pins.db-types.js"; +import type { SshSavedHostTable } from "@/db/types/ssh-saved-hosts.db-types.js"; import type { SubshellShareTable } from "@/db/types/subshell-shares.db-types.js"; import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import type { UserMetaTable } from "@/db/types/user-meta.db-types.js"; @@ -57,4 +59,6 @@ export interface Database { identities: IdentityTable; notificationsSubscriptions: NotificationSubscriptionTable; deviceTokens: DeviceTokenTable; + sshSavedHosts: SshSavedHostTable; + sshHostPins: SshHostPinTable; } diff --git a/apps/server/api/src/db/types/nodes.db-types.ts b/apps/server/api/src/db/types/nodes.db-types.ts index 98c3f6794..74400a0a8 100644 --- a/apps/server/api/src/db/types/nodes.db-types.ts +++ b/apps/server/api/src/db/types/nodes.db-types.ts @@ -87,6 +87,28 @@ export interface NodeTable { maintenanceAt: string | null; /** Which end wrote the standing value ({@link MaintenanceSource}); null when none has. */ maintenanceSource: MaintenanceSource | null; + /** + * 1 = SSH egress and key use are enabled on this machine (spec 4.3). + * Opt-in per node, never a fleet default: 0 for every row that predates + * the column, so an upgrade leaves the whole fleet (and the control-plane + * host) with SSH off until someone flips it. + */ + sshEnabled: number; + /** + * ISO 8601 of the write that last changed {@link sshEnabled} (the wire's + * `changedAt`), or null when the flag was never written. The stamp rides + * the flag on every push, the shape {@link maintenanceAt} has for + * maintenance. + */ + sshEnabledAt: string | null; + /** + * JSON mirror of the ssh-fingerprint block from this node's last runtime + * report (spec 2026-10-08 §4.5), so the trust card survives an offline node + * or a plane restart. NULL until a post-M2 agent reports. Serialized only + * under the same owner-or-`edit` gate as the live runtime field, never + * `view` and never `local`. Populated by the report-handling task. + */ + sshFingerprint: string | null; /** ISO 8601 creation time */ createdAt: string; /** ISO 8601 last update time */ diff --git a/apps/server/api/src/db/types/ssh-host-pins.db-types.ts b/apps/server/api/src/db/types/ssh-host-pins.db-types.ts new file mode 100644 index 000000000..4066e6ce6 --- /dev/null +++ b/apps/server/api/src/db/types/ssh-host-pins.db-types.ts @@ -0,0 +1,31 @@ +/** + * One TOFU host-key pin for one owner's resolved destination (spec §9): the + * `user@host:port` -> pinned public host key + timestamps record the M2 relay + * path validates against. Per-owner scoping is deliberate: a global pin would + * let another account's first connection poison (or DoS-lock) a destination + * this owner never touched. The capture/verification LOGIC is T12; this is + * the storage shape it must not re-cut. + */ +export interface SshHostPinTable { + /** Unique row id (uuid) */ + id: string; + /** Owning user id (FK to users, ON DELETE CASCADE) */ + ownerUserId: string; + /** Canonical resolved destination `user@host:port` (the `sshCanonicalDestination` spelling) */ + destination: string; + /** + * The pinned public host-key entry, one OpenSSH `known_hosts` line: the + * destination's pattern, the key type, and the base64 key material (plus + * any comment A's own file carried) - stored VERBATIM from A's recorded + * trust so B's file carries exactly the bytes A matched, never a + * reconstruction that re-spells A's entry. Public material end to end + * (a known_hosts entry CHECKS a server, it is not a secret); the audit + * rows name the destination and this line's `SHA256:` fingerprint, never + * the bytes themselves. + */ + hostKey: string; + /** ISO 8601 first capture (the TOFU moment) */ + createdAt: string; + /** ISO 8601 of the last accepted match (a differing key never writes it - that is the §9 hard block) */ + updatedAt: string; +} diff --git a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts new file mode 100644 index 000000000..43a828e32 --- /dev/null +++ b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts @@ -0,0 +1,80 @@ +/** + * One saved or recently-used SSH destination for one owner (spec 2026-10-07 + * §7). Keyed by the RESOLVED canonical destination so an edited alias can + * never silently re-point a saved row. `saved_at` is what the human gave it; + * recency is `last_connect_at`, which every launch refreshes whether or not + * the row was ever saved. + */ +export interface SshSavedHostTable { + /** Unique row id (uuid) */ + id: string; + /** Owning user id (FK to users, ON DELETE CASCADE) */ + ownerUserId: string; + /** + * The canonical key: `user@host:port` for a resolved snapshot that carries + * a user, `host:port` (no user prefix) when the snapshot's user is null, + * the latter meaning the connecting account's own default. The spelling + * rule is {@link sshCanonicalDestination}; both writers import it; the list reads back + * the stored strings. It has no other home. Unique per owner + * (idx_ssh_saved_hosts_owner_destination). + */ + destination: string; + /** display-only alias (the config token typed or discovered); never used as a key */ + alias: string | null; + /** the connecting machine used for the most recent launch to this destination */ + nodeId: string; + /** ISO 8601 the human saved this destination (null = recency-only row) */ + savedAt: string | null; + /** ISO 8601 of the most recent launch to this destination */ + lastConnectAt: string; +} + +/** + * Spell the canonical destination that keys {@link SshSavedHostTable}: + * `host:port`, prefixed with `user@` when the resolved snapshot carries a + * user. The host is passed through as given - an IPv6 literal arrives + * already bracketed (`[::1]`) and the port suffix is what disambiguates it. + */ +export function sshCanonicalDestination({ + host, + port, + user, +}: { + host: string; + port: number; + user: string | null; +}): string { + return user === null ? `${host}:${port}` : `${user}@${host}:${port}`; +} + +/** A canonical destination's three facts (the {@link sshCanonicalDestination} shape). */ +export interface SshDestinationTriple { + host: string; + port: number; + user: string | null; +} + +/** + * The exact inverse of {@link sshCanonicalDestination}: split the stored + * `user@host:port` back into its three facts (Task 12: the approval captures + * the host-key pin by ASKING A for a destination, and the ask takes the + * triple). Unambiguous because the composer built it: the user is before the + * first `@`, the port after the LAST `:` - and the host is either a plain + * name or a bracketed IPv6 literal, so a `:` inside a host never sits after + * the final one. null for anything the composer would not have written: no + * port part, a non-numeric or out-of-range port, an empty host or user. + */ +export function parseSshCanonicalDestination(text: string): SshDestinationTriple | null { + const at = text.indexOf("@"); + const user = at === -1 ? null : text.slice(0, at); + const rest = at === -1 ? text : text.slice(at + 1); + const colon = rest.lastIndexOf(":"); + if (colon < 1) return null; + const host = rest.slice(0, colon); + const portText = rest.slice(colon + 1); + if (host === "" || (user !== null && (user === "" || user.includes("@")))) return null; + if (!/^[0-9]{1,5}$/.test(portText)) return null; + const port = Number(portText); + if (port < 1 || port > 65_535) return null; + return { host, port, user }; +} diff --git a/apps/server/api/src/db/types/subshells.db-types.ts b/apps/server/api/src/db/types/subshells.db-types.ts index 4c10e155f..57085432e 100644 --- a/apps/server/api/src/db/types/subshells.db-types.ts +++ b/apps/server/api/src/db/types/subshells.db-types.ts @@ -78,6 +78,23 @@ export interface SubshellTable { * later act rewrites it. See migration 0039. */ crossAgent: number; + /** + * an SSH-terminal pane carries its approved snapshot here; the value's + * presence is the owner-only-input rule's trigger (spec §5.4). JSON-encoded + * `SshConnectionSnapshotWire` plus optional `relayFingerprints: string[]` + * recording the exact selected keys for relay panes. Never serialized to + * clients, the launch answer included. See migration 0048. + */ + ssh: string | null; + /** + * For a relay-mode ssh pane: the key home whose agent signed the + * connection, chosen at launch and IMMUTABLE after (spec 2026-10-08 §7, + * migration 0052). "Set up Subshell here" re-opens that pairing to reach + * the destination again, and the 30 s relay session itself is plane-memory + * by then - this column is the pane's only durable word on which A + * authorized it. NULL on every direct (M1) pane. + */ + keyHomeNodeId: string | null; /** * DEPRECATED (spec 2026-09-03): the terminal history cap moved per-USER * (`user_meta.terminal_replay_lines`, migration 0020). This column is @@ -113,6 +130,8 @@ export type NewSubshell = Omit< | "lastPushUrgency" | "nodeId" | "crossAgent" + | "ssh" + | "keyHomeNodeId" | "terminalReplayLines" > & { /** Preset launched with; omitted = NULL (presetless launch). Defaults on insert. */ @@ -121,6 +140,10 @@ export type NewSubshell = Omit< nodeId?: string; /** 1 = agent-launched cross-agent comms pane; omitted = DB default 0 (human) */ crossAgent?: number; + /** SSH snapshot JSON for an ssh pane; omitted = NULL = not an ssh pane (migration 0048) */ + ssh?: string | null; + /** Relay pane's key home; omitted = NULL = direct (migration 0052) */ + keyHomeNodeId?: string | null; /** Per-subshell terminal replay cap; omitted = NULL = instance default */ terminalReplayLines?: number | null; harnessSessionId?: string | null; @@ -142,6 +165,10 @@ export type NewSubshell = Omit< /** * The patch shape for row updates. `crossAgent` is excluded beside `id` and * `userId` because provenance is written exactly once, by the create path: - * "no later act un-stamps it" is a type fact, not a convention. + * "no later act un-stamps it" is a type fact, not a convention. `ssh` is + * excluded for the same reason (spec 2026-10-07 decision 5): the approved + * snapshot is written once at create, and it IS the input rule's kind fact, + * the restart refusal's trigger, and the delete sweep's pointer. No later + * act may rewrite or clear it, so no update can name it. */ -export type SubshellUpdate = Partial>; +export type SubshellUpdate = Partial>; diff --git a/apps/server/api/src/index.ts b/apps/server/api/src/index.ts index 6c12bc0f0..ff35112b9 100644 --- a/apps/server/api/src/index.ts +++ b/apps/server/api/src/index.ts @@ -14,22 +14,13 @@ // biome-ignore-all assist/source/organizeImports: entry prelude must evaluate first — see comment import "./cli-bootstrap.js"; import { join, resolve } from "node:path"; -import { serverConfigDir } from "@/config-env.js"; -import { closeAuthDatabase } from "@/auth/database.js"; -import { acquireInstanceLock } from "@/services/instance-state-lock.js"; -import { - instanceRestoreJournalPath, - assertInstanceRestoreDestination, - finalizeInstanceRestore, - rollbackInstanceRestore, -} from "@/services/backups/index.js"; -import { sweepBackupDownloads } from "@/services/backup-download-jobs.js"; -import { sweepRestoreStages } from "@/services/backup-staging.js"; -import { instanceBackupPaths } from "@/services/instance-backup-source.js"; import { getHarness, setPluginDataDir } from "@internal/pane-runtime"; +import { closeAuthDatabase } from "@/auth/database.js"; import { ensureSystemUser } from "@/auth/system-user.js"; import { setAuthPolicyDb } from "@/auth.js"; +import { banner } from "@/banner.js"; import { isCliEngaged } from "@/cli.js"; +import { serverConfigDir } from "@/config-env.js"; import { APP_BASE_URL, assertProdAuthSecret, @@ -45,33 +36,44 @@ import { runMigrations } from "@/db/migrate.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; -import { publishLive } from "@/services/live-bus.js"; import { startServer } from "@/server.js"; +import { sweepBackupDownloads } from "@/services/backup-download-jobs.js"; +import { sweepRestoreStages } from "@/services/backup-staging.js"; +import { + assertInstanceRestoreDestination, + finalizeInstanceRestore, + instanceRestoreJournalPath, + rollbackInstanceRestore, +} from "@/services/backups/index.js"; +import { instanceBackupPaths } from "@/services/instance-backup-source.js"; +import { acquireInstanceLock } from "@/services/instance-state-lock.js"; +import { publishLive } from "@/services/live-bus.js"; import { loadAndApplyDebugLogging } from "@/services/logging-preference.js"; +import { refreshNetworkOrigins, seedNetworkOrigins, startOriginRefresh } from "@/services/network/origin-refresh.js"; +import { prepareNetworkGuards, prepareNetworkProcesses } from "@/services/network/prepare.js"; +import { stopAllProcesses } from "@/services/network/supervisor.js"; import { startInventoryRefresh } from "@/services/nodes/inventory-refresh.js"; +import { prepareLocalPlugins } from "@/services/nodes/local-plugins.js"; import { reconcileMaintenance } from "@/services/nodes/maintenance.js"; import { setNodeLifecycleHooks } from "@/services/nodes/node-events.js"; import { listOnline } from "@/services/nodes/node-registry.js"; -import { prepareNetworkGuards, prepareNetworkProcesses } from "@/services/network/prepare.js"; -import { refreshNetworkOrigins, seedNetworkOrigins, startOriginRefresh } from "@/services/network/origin-refresh.js"; -import { stopAllProcesses } from "@/services/network/supervisor.js"; -import { prepareLocalPlugins } from "@/services/nodes/local-plugins.js"; import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { reconcileSshEnabled } from "@/services/nodes/ssh-enabled.js"; import { subshellLogPath } from "@/services/nodes/subshell-paths.js"; -import { completeUpdate, readPending, recordFailure, revertUpdate } from "@/services/update-transaction.js"; import { getNotifyService } from "@/services/notify.service.js"; +import { createIdleWatcher, IDLE_TICK_MS } from "@/services/notify-idle.js"; import { sweepExpiredPaneLogs, tightenPaneLogModes } from "@/services/pane-log-hygiene.js"; import { expirePendingApprovals, remarkUnmarkedArrivals } from "@/services/pending-approvals.js"; -import { createIdleWatcher, IDLE_TICK_MS } from "@/services/notify-idle.js"; import { hasAnyUser } from "@/services/registration-gate.js"; +import { shutdownRelayBroker } from "@/services/ssh-relay.service.js"; import { SubshellManagerService } from "@/services/subshell-manager.service.js"; +import { completeUpdate, readPending, recordFailure, revertUpdate } from "@/services/update-transaction.js"; import { BANNER_GROUP, getLogger } from "@/utils/logger.js"; -import { banner } from "@/banner.js"; import { SERVER_VERSION } from "@/version.js"; import { sweepWsTokens } from "@/ws/ws-token.js"; export type { App } from "@/server.js"; -export type { BackupInspection, InstanceBackupManifest, BackupAdmin } from "@/services/backups/types.js"; +export type { BackupAdmin, BackupInspection, InstanceBackupManifest } from "@/services/backups/types.js"; // Plan 2 CLI gate: `isCliEngaged()` flips synchronously inside the prelude's // `dispatchCli` call the moment a subcommand is recognised, so an async CLI @@ -323,6 +325,9 @@ async function bootServer(): Promise { // not route through the manager: the hook hands the machine's own copy to // the one module that reconciles the two (spec 2026-09-14 §5.2). onMaintenance: (nodeId, reported) => reconcileMaintenance(nodeId, reported), + // The SSH mirror's sibling (spec 2026-10-07 §4.3): same door, opposite + // direction — the row is the record, so this reconcile only pushes. + onSshEnabled: (nodeId, reported) => reconcileSshEnabled(nodeId, reported), }); // Restore alive/exit state at boot: a backend restart mid-subshell must not // leave stale alive=1 rows (tmux subshells died with the old process). @@ -462,7 +467,7 @@ function installShutdownHandlers(): void { const deadline = setTimeout(() => process.exit(0), 10_000); // Never blocks the exit on an unref'd timer of its own. deadline.unref?.(); - void stopAllProcesses() + void Promise.all([stopAllProcesses(), shutdownRelayBroker()]) .catch((err: unknown) => getLogger().withError(err).warn("could not stop every network process cleanly")) .finally(() => { clearTimeout(deadline); diff --git a/apps/server/api/src/lib/__tests__/ssh-pane-access.test.ts b/apps/server/api/src/lib/__tests__/ssh-pane-access.test.ts new file mode 100644 index 000000000..e2aee8a57 --- /dev/null +++ b/apps/server/api/src/lib/__tests__/ssh-pane-access.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import type { SubshellUpdate } from "@/db/types/subshells.db-types.js"; +import { type PaneInputRow, paneInputAllowed } from "@/lib/ssh-pane-access.js"; + +/** + * `paneInputAllowed` is the ONE ssh input predicate (spec 2026-10-07 §5.4, + * plan decision 5). The GRANT half (edit/view, the bearer switch-off) lives + * in `#gate` and the attach resolver as always; this composes with them. + * + * Compile-time pin in the last block: `SubshellUpdate` must NOT carry `ssh`, + * evaluated by `verify-types` (bun test strips types and runs the assignment + * harmlessly). The `@ts-expect-error` fails the build the moment `ssh` + * reappears in the patch shape, which is the drift being bounded: the + * snapshot is written exactly once, by the create path, like the + * `crossAgent` exclusion it mirrors. + */ + +const OWNER = "u-owner"; +const STRANGER = "u-stranger"; + +const ordinary: PaneInputRow = { ssh: null, userId: OWNER }; +const sshRow: PaneInputRow = { ssh: '{"destination":"host.example"}', userId: OWNER }; + +describe("paneInputAllowed (spec §5.4, decision 5)", () => { + it("admits every actor on an ordinary pane (the grant gate is not this predicate's question)", () => { + // The regression direction: for ssh === null the predicate is true for + // everyone, so every existing door behaves exactly as it did before. + expect(paneInputAllowed(ordinary, OWNER, false)).toBe(true); // owner, cookie + expect(paneInputAllowed(ordinary, STRANGER, false)).toBe(true); // a grantee, cookie + expect(paneInputAllowed(ordinary, OWNER, true)).toBe(true); // the pane's own key (bearer) + expect(paneInputAllowed(ordinary, STRANGER, true)).toBe(true); // a bearer resolving as another owner + }); + + it("admits the owner's human account on an ssh pane", () => { + expect(paneInputAllowed(sshRow, OWNER, false)).toBe(true); + }); + + it("refuses any other account on an ssh pane, however they got past the grant gate", () => { + // An `edit` grantee passes the grant gate; the kind rule still refuses. + expect(paneInputAllowed(sshRow, STRANGER, false)).toBe(false); + }); + + it("refuses a bearer machine credential on an ssh pane, EVEN one resolving as the owner", () => { + // The whole point of the `bearerActor` input: raw REST resolves a pane's + // own subshell key as its OWNER (boost/shares off), so a bare owner + // comparison would let an ssh pane type into itself (decision 5). + expect(paneInputAllowed(sshRow, OWNER, true)).toBe(false); + expect(paneInputAllowed(sshRow, STRANGER, true)).toBe(false); + }); + + it("is decided by the column's presence, not its content", () => { + // The kind fact is "the column is not NULL". Any non-null content is an + // ssh pane (minimal, or otherwise odd): the SAME owner/non-owner split + // holds whatever the bytes are, because snapshot validation belongs to + // the launch boundary, not the input door. + for (const ssh of ['{"destination":"host.example"}', "{}", "not-json"]) { + expect(paneInputAllowed({ ssh, userId: OWNER }, OWNER, false)).toBe(true); + expect(paneInputAllowed({ ssh, userId: OWNER }, STRANGER, false)).toBe(false); + } + // Only NULL reads as an ordinary pane. + expect(paneInputAllowed({ ssh: null, userId: OWNER }, STRANGER, false)).toBe(true); + }); +}); + +describe("SubshellUpdate excludes ssh (frozen at create, like crossAgent)", () => { + it("a patch naming `ssh` is a type error (pinned by verify-types)", () => { + const patch: SubshellUpdate = {}; + // @ts-expect-error `ssh` is excluded from the update-patch shape + patch.ssh = '{"destination":"host.example"}'; + void patch; + }); +}); diff --git a/apps/server/api/src/lib/ssh-pane-access.ts b/apps/server/api/src/lib/ssh-pane-access.ts new file mode 100644 index 000000000..64d1bddd2 --- /dev/null +++ b/apps/server/api/src/lib/ssh-pane-access.ts @@ -0,0 +1,48 @@ +import type { SubshellTable } from "@/db/types/subshells.db-types.js"; + +/** + * The two facts {@link paneInputAllowed} reads: the row's snapshot column (the + * kind fact) and the owner. `SubshellTable` is assignable to it; the narrow + * shape lets the WS path and tests hand over a partial row without pulling the + * whole record into the predicate's contract. + */ +export type PaneInputRow = Pick; + +/** + * The owner-only input rule for ssh panes (spec 2026-10-07 §5.4 / plan + * decision 5). It answers ONE question so every input door (REST input, exec, + * MCP-via-REST, the WS keystroke) asks it the same way: + * + * - An ordinary pane (`ssh === null`) is admitted unconditionally. This + * predicate is the ssh carve-out ONLY; the grant logic (`#gate` for REST, the + * attach resolver for WS) decides edit/view for ordinary panes exactly as + * before, and this predicate never narrows it. + * - An ssh pane admits input ONLY from its owner account, and NEVER from a + * bearer machine credential. The second half is why `bearerActor` is a + * separate input and not folded into the id comparison: raw REST resolves a + * pane's OWN subshell key as its owner (boost and shares off), so a bare + * `actorUserId === row.userId` would let an ssh pane type into itself — the + * exact slip decision 5 closes. A system key resolves through the human gate + * as the `system` service user (never the row's owner), so it is refused by + * the id test regardless; `bearerActor` makes the refusal explicit. + * + * The decision is keyed to the COLUMN'S PRESENCE (`subshells.ssh`, migration + * 0048), the pane's kind fact — not its content. Snapshot validation belongs + * to the launch boundary, not the input door. + * + * @param row - The subshell's kind + owner (a full `SubshellTable` fits) + * @param actorUserId - The account the caller resolves as (row's owner for a + * bearer subshell key on raw REST; the row's owner for a scoped WS token too, + * which is why `bearerActor` must carry the machine-ness) + * @param bearerActor - True for any machine credential: a `subshell-key` or + * `system-key` on REST, or a scoped WS token. False for a human (cookie + * session, or a cookie-minted unscoped WS token). + * @returns True when input may proceed on kind grounds; the caller has ALREADY + * applied its own grant/visibility gate + */ +export function paneInputAllowed(row: PaneInputRow, actorUserId: string, bearerActor: boolean): boolean { + // Ordinary pane: the ssh rule is silent, the existing grant logic governs. + if (row.ssh === null) return true; + // An ssh pane: owner account only, and never a bearer machine credential. + return !bearerActor && actorUserId === row.userId; +} diff --git a/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts b/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts new file mode 100644 index 000000000..2808bf3c0 --- /dev/null +++ b/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts @@ -0,0 +1,41 @@ +/** + * Ambient globals the agent's relay sources reference but this project's + * typecheck lib (ES2023, no DOM) does not declare. + * + * The relay integration test imports the agent's relay modules (under + * apps/node/agent) IN PLACE, so `verify-types` typechecks those sources under + * this app's `lib`. The agent's own tsconfig carries `lib: ["ES2023", "DOM"]`, + * which is where it gets the WebCrypto `JsonWebKey` global; this app + * deliberately does not add DOM (it changes `Blob`/`Response` typing across + * seven server files, measured). Adding only the one name the agent relay + * sources actually use keeps the shim to its smallest honest size. + * + * Lives under `__tests__` so `tsc -p tsconfig.build.json` (whose exclude + * list covers every `__tests__` tree under src) never sees it: the shipped + * server emit is untouched. A double-star glob cannot be quoted here: the + * comment-closing character pair occurs inside every double-star segment, + * so writing one ends this comment mid-string. That is what mangled the + * earlier wording in this file. + */ + +/** The WebCrypto JSON Web Key shape, as lib.dom declares it (agent relay only). */ +interface JsonWebKey { + alg?: string; + crv?: string; + d?: string; + dp?: string; + dq?: string; + e?: string; + ext?: boolean; + k?: string; + key_ops?: string[]; + kty?: string; + n?: string; + oth?: Array<{ d?: string; n?: string; t?: string }>; + p?: string; + q?: string; + qi?: string; + use?: string; + x?: string; + y?: string; +} diff --git a/apps/server/api/src/services/__tests__/helpers/node-fakes.ts b/apps/server/api/src/services/__tests__/helpers/node-fakes.ts index a29e788b1..06dbfd882 100644 --- a/apps/server/api/src/services/__tests__/helpers/node-fakes.ts +++ b/apps/server/api/src/services/__tests__/helpers/node-fakes.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; import type { LaunchPlan, NodeLauncher } from "@/services/nodes/node-launcher.js"; import { attachConnection, detachConnection, type NodeFacts, type NodeSocket } from "@/services/nodes/node-registry.js"; @@ -107,10 +108,15 @@ export class FakeNodeLauncher implements NodeLauncher { /** * Mirrors {@link RemoteLauncher.subshellArtifacts}: the delete-time triple, * with the MCP path composed under `nodeOnline()`'s default `/node-data` - * dataDir (the same source the manager's inline delete reads). + * dataDir (the same source the manager's inline delete reads), plus the + * ssh config at the node-derived path for an ssh pane (spec 2026-10-07 + * decision 4) — the fake takes the row's kind flag exactly the real + * launcher does, so a test can pin WHO passes it. */ - subshellArtifacts(id: string): string[] { - return [this.logPath(id), `/node-data/mcp/${id}.json`, this.metaArtifactPath(id)]; + subshellArtifacts(id: string, sshPane = false): string[] { + const artifacts = [this.logPath(id), `/node-data/mcp/${id}.json`, this.metaArtifactPath(id)]; + if (sshPane) artifacts.push(buildSshConfigPath("/node-data", id)); + return artifacts; } async readLogTail(): Promise<{ lines: string[]; truncated: boolean }> { return { lines: [], truncated: false }; diff --git a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts new file mode 100644 index 000000000..187e05b21 --- /dev/null +++ b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts @@ -0,0 +1,621 @@ +import { mkdtempSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { + base64UrlNoPad, + type NodeCommandBody, + openRelayEnvelope, + type RelayEnvelopeOpen, + type RelayFrame, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import type { AuditEventInput } from "@/services/audit.js"; +import { + createRelayBroker, + type OpenRelayResult, + type RelayBroker, + type TimerHandle, +} from "@/services/ssh-relay.service.js"; +import { + openARelaySession, + openBRelaySession, + RelaySessions, +} from "../../../../../../node/agent/src/commands/ssh-relay.js"; +import { loadOrCreateIdentity, type NodeIdentity } from "../../../../../../node/agent/src/identity.js"; +import { + type AgentScheme, + fingerprintAgentBlob, + OPENSSH_10X_SCHEME, +} from "../../../../../../node/agent/src/relay-agent-scheme.js"; + +/** + * The whole-stack fixture for `ssh-relay.integration.test.ts` (spec 2026-10-08 + * M2 Task 9): two in-process machine identities (real `loadOrCreateIdentity` + * keypairs in real temp data dirs), a REAL B-side agent proxy, a REAL A-side + * responder fed by a scheme-behavior stub agent, and the REAL plane broker + * (`createRelayBroker`) wired to both through fake transport seams. + * + * **The fake plane is blind by construction.** It is given no private key of + * any machine, no opener, and no codec call: the node-to-plane and + * plane-to-node seams serialize the frame to JSON text and re-parse it (the + * socket is what a plane actually holds), and the broker forwards `blob` by + * reference without reading it. Everything the plane can see is enumerated on + * `Stack.planeVisible()`: wire capture lines, audit rows, session views, and + * its own log lines. + * + * The agent-byte builders and the stub agent below are the T6/T7 fixture + * spellings (measured OpenSSH_10.2p1 behavior, hand-spelled byte literals), + * reused rather than re-invented; the scheme constants come from the agent's + * own `relay-agent-scheme.ts`. + */ + +/* ------------------------------------------------------------------ */ +/* the agent wire, spelled by hand (T7 fixture posture) */ +/* ------------------------------------------------------------------ */ + +/** The roster's granted key: its blob embeds the ASCII marker "KEY-IN". */ +export const KEY_IN = Buffer.concat([ + Buffer.from([0, 0, 0, 7]), + Buffer.from("ssh-ed25519"), + sshStr(Buffer.from("KEY-IN")), +]); +/** The roster's ungranted key: the scope-before-forward witness blob. */ +export const KEY_OUT = Buffer.concat([ + Buffer.from([0, 0, 0, 7]), + Buffer.from("ssh-ed25519"), + sshStr(Buffer.from("KEY-OUT")), +]); + +/** One roster entry as the stub agent carries it. */ +export interface RosterEntry { + blob: Buffer; + comment: string; +} + +/** The stub's default roster: one granted key, one ungranted key. */ +export const ROSTER: RosterEntry[] = [ + { blob: KEY_IN, comment: "granted key" }, + { blob: KEY_OUT, comment: "ungranted key" }, +]; + +/** An independent fingerprint oracle: SHA-256 over the wire blob, base64url. */ +export function fp(wire: Uint8Array): string { + return fingerprintAgentBlob(wire); +} + +/** 4-byte big-endian length. */ +export function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +/** An SSH string on the agent wire: 4-byte BE length + bytes. */ +export function sshStr(bytes: Buffer | string): Buffer { + const b = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes; + return Buffer.concat([be32(b.length), b]); +} + +/** A framed agent message: 4-byte BE length + payload (the ssh-agent wire). */ +export function framed(payload: Buffer): Buffer { + return Buffer.concat([be32(payload.length), payload]); +} + +/** An IDENTITIES_ANSWER in the stub's scheme. */ +export function buildIdentitiesAnswer(scheme: AgentScheme, entries: RosterEntry[]): Buffer { + return Buffer.concat([ + Buffer.from([scheme.answer]), + be32(entries.length), + ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), + ]); +} + +/** A classic-scheme SIGN_REQUEST (15): blob, data, flags. */ +export function buildSignRequestClassic(keyBlob: Buffer, data = Buffer.from("DATA"), flags = 0): Buffer { + return Buffer.concat([Buffer.from([15]), sshStr(keyBlob), sshStr(data), be32(flags)]); +} + +/** A 10.x-scheme SIGN_REQUEST (13): blob, data, flags, optional algorithms. */ +export function buildSignRequestTenX( + keyBlob: Buffer, + opts: { data?: Buffer; flags?: number; algorithms?: string } = {}, +): Buffer { + const parts = [Buffer.from([13]), sshStr(keyBlob), sshStr(opts.data ?? Buffer.from("DATA")), be32(opts.flags ?? 0)]; + if (opts.algorithms !== undefined) parts.push(sshStr(opts.algorithms)); + return Buffer.concat(parts); +} + +/** Decode an IDENTITIES_ANSWER independently of the production parser. */ +export function parseAnswer(answer: Buffer): { type: number; count: number; comments: string[] } { + const type = answer[0]; + const count = answer.readUInt32BE(1); + const comments: string[] = []; + let off = 5; + for (let i = 0; i < count; i += 1) { + const blobLen = answer.readUInt32BE(off); + off += 4 + blobLen; + const cLen = answer.readUInt32BE(off); + comments.push(answer.subarray(off + 4, off + 4 + cLen).toString("utf8")); + off += 4 + cLen; + } + return { type, count, comments }; +} + +/* ------------------------------------------------------------------ */ +/* the stub live agent (T7 fixture, 10.x by default) */ +/* ------------------------------------------------------------------ */ + +/** A scheme-behavior stub agent socket: identities answers, well-formed signs answer SIGN_RESPONSE("SIG"). */ +export interface StubAgent { + path: string; + /** Every request payload the stub received, in order (the open-time probes included). */ + received: Buffer[]; + close(): Promise; +} + +/** + * Start the scheme-behavior stub agent on its own socket. Exported for the + * roster-command case (Task 11): the `ssh_agent_identities` read needs A's + * live agent and NOTHING ELSE - no broker, no grant, no pairing - so it + * drives the stub without the full stack. + */ +export async function startStubAgent(roster: RosterEntry[], scheme: AgentScheme): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stack-stub-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + for (;;) { + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + const request = Buffer.from(buffer.subarray(4, 4 + len)); + buffer = buffer.subarray(4 + len); + received.push(request); + const type = request[0]; + if (request.length === 1 && type === scheme.identities) { + conn.write(framed(buildIdentitiesAnswer(scheme, roster))); + } else if (type === scheme.sign && isWellFormedSignBody(request, scheme)) { + conn.write(framed(Buffer.concat([Buffer.from([scheme.signResponse]), sshStr(Buffer.from("SIG"))]))); + } else { + conn.write(framed(Buffer.from([5]))); + } + } + }); + conn.on("error", () => { + /* the responder closes per request; the stub never throws */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { path, received, close: () => new Promise((r) => server.close(() => r())) }; +} + +/** The stub's own sign-body validity check (mirrors the real agent's parser). */ +function isWellFormedSignBody(p: Buffer, scheme: AgentScheme): boolean { + let off = 1; + const str = (): boolean => { + if (off + 4 > p.length) return false; + const n = p.readUInt32BE(off); + off += 4; + if (off + n > p.length) return false; + off += n; + return true; + }; + if (!str()) return false; // blob + if (off <= 5) return false; // empty blob length means an empty string: refuse + if (!str()) return false; // data + if (off + 4 > p.length) return false; // flags + off += 4; + if (off === p.length) return true; + if (!scheme.extendedSign) return false; + if (!str()) return false; // algorithms + return off === p.length; +} + +/* ------------------------------------------------------------------ */ +/* the stack: broker + A machine + B machine over blind transport */ +/* ------------------------------------------------------------------ */ + +/** The pane the brokered pairing serves: a path-id spelling both grammars accept. */ +export const PANE_ID = "11111111-2222-4333-8444-555555555555"; + +/** One recorded crossing of the plane: the JSON text and the node it landed on. */ +export interface WireEntry { + /** True for plane-to-node deliveries (the fake link's outbound half). */ + outbound: boolean; + /** The node this text reached (or left). */ + nodeId: "a-node" | "b-node"; + /** The JSON text exactly as the fake socket carried it. */ + raw: string; +} + +/** What {@link openStack} builds: the live stack plus everything it observed. */ +export interface Stack { + broker: RelayBroker; + /** The broker's successful open result (ref, relayId, verified socketPath). */ + result: OpenRelayResult; + /** The A machine's real identity (registered public halves live here). */ + aId: NodeIdentity; + /** The B machine's real identity. */ + bId: NodeIdentity; + /** The A machine's data dir (its identity and pin store live here). */ + aDir: string; + /** The B machine's data dir (the proxy socket was bound under here). */ + bDir: string; + /** The A-side node registry (responder under the brokered ref). */ + aRelay: RelaySessions; + /** The B-side node registry (agent proxy under the brokered ref). */ + bRelay: RelaySessions; + /** The stub agent A forwards granted requests to. */ + stub: StubAgent; + /** Requests the stub received AFTER the open-time probe. */ + forwarded(): Buffer[]; + /** Every JSON text that crossed the plane, both directions, in order. */ + wire: WireEntry[]; + /** Audit rows the broker wrote, metadata re-serialized from the JSON string. */ + audits: { action: string; metadata: Record; metadataJson: string }[]; + /** Broker log lines (plane-visible text). */ + brokerLines: string[]; + /** Commands the broker sent (plane-side composites; the open grammar's own view). */ + commands: { nodeId: string; cmd: NodeCommandBody }[]; + /** + * Plane-visible text for the byte-opacity scan: wire captures, audit rows, + * session views, broker log lines. Never the machines' innards. + */ + planeVisible(): string[]; + /** + * Simulated plane outage: while true, inbound B2A frames are captured but + * not routed (A answers nothing until `releaseB2A` runs). + */ + swallowB2A: boolean; + /** Route every swallowed B2A frame now (A's late answers flow). */ + releaseB2A(): void; + /** Deliver one frame INTO the plane as if `nodeId`'s socket carried it. */ + routeFromNode(nodeId: "a-node" | "b-node", frame: RelayFrame): void; + /** + * Run the named close on BOTH node registries WITHOUT the broker's own + * teardown: the machines end the session while the plane's blind lane for + * the ref stays up (still routing anything that re-presents it). The + * ref-reuse replay case drives exactly this resurrection. + */ + closeNodeSessions(ref: string, reason: string): void; + /** Tear everything down (registries, stub socket, broker timers). */ + cleanup(): Promise; +} + +/** Options for {@link openStack}. */ +export interface StackOptions { + /** The grant's fingerprint set (default: the one granted key). */ + fingerprints?: readonly string[]; + /** The stub's roster (default: {@link ROSTER}). */ + roster?: RosterEntry[]; + /** The numbering the stub agent speaks (default: the fleet's measured 10.x). */ + scheme?: AgentScheme; +} + +/** + * Open the whole stack: real broker, real node branches, real identities, a + * stub agent, and the blind fake plane. Resolves after the handshake + * completed (both opens acked, B's socket path byte-verified by the broker + * itself, so a returned stack means the plane's acceptance (e) check passed + * against the proxy's real bound path). + */ +export async function openStack(opts: StackOptions = {}): Promise { + const scheme = opts.scheme ?? OPENSSH_10X_SCHEME; + const fingerprints = opts.fingerprints ?? [fp(KEY_IN)]; + const aDir = mkdtempSync(join(tmpdir(), "subshell-relay-stack-a-")); + const bDir = mkdtempSync(join(tmpdir(), "subshell-relay-stack-b-")); + // The machines' REAL identities: signing + encryption keypairs as identity.ts + // generates and stores them; the broker pairs their registered public halves. + const aId = await loadOrCreateIdentity(aDir); + const bId = await loadOrCreateIdentity(bDir); + const stub = await startStubAgent(opts.roster ?? ROSTER, scheme); + + const aRelay = new RelaySessions(); + const bRelay = new RelaySessions(); + const wire: WireEntry[] = []; + const audits: Stack["audits"] = []; + const brokerLines: string[] = []; + const commands: { nodeId: string; cmd: NodeCommandBody }[] = []; + let swallowedB2A: RelayFrame[] = []; + // The outage flag lives in a plain closure variable, NOT on `stack`: + // routeFromNode already serves the open handshake (sendCommand's + // sendRelayFrame seam) while `const stack` is still in its TDZ, and a + // handshake-time B2A reading `stack.swallowB2A` would have thrown a + // ReferenceError. The open's frame silence is protocol behavior the + // fixture must not lean on to stay evaluable. + let swallowArmed = false; + + /** Plane-to-node: serialize like a socket would, re-parse, hand to the node. */ + const deliverToNode = (nodeId: "a-node" | "b-node", frame: RelayFrame): void => { + const raw = JSON.stringify(frame); + wire.push({ outbound: true, nodeId, raw }); + const parsed = JSON.parse(raw) as RelayFrame; + (nodeId === "a-node" ? aRelay : bRelay).onInboundRelayFrame(parsed); + }; + + /** Node-to-plane: the handler seam; the outage swallows B2A while armed. */ + const routeFromNode = (nodeId: "a-node" | "b-node", frame: RelayFrame): void => { + const raw = JSON.stringify(frame); + wire.push({ outbound: false, nodeId, raw }); + if (nodeId === "b-node" && frame.direction === "B2A" && swallowArmed) { + swallowedB2A.push(JSON.parse(raw) as RelayFrame); + return; + } + broker.routeRelayFrame(nodeId, frame); + }; + + const sendCommand = async (nodeId: string, cmd: NodeCommandBody): Promise => { + commands.push({ nodeId, cmd }); + if (cmd.type === "ssh_relay_open") { + if (cmd.role === "A") { + await openARelaySession({ + relay: aRelay, + dataDir: aDir, + selfNodeId: "a-node", + cmd, + sendRelayFrame: (f) => routeFromNode("a-node", f), + resolveAgentSocket: () => stub.path, + }); + return { relayId: cmd.relayId }; + } + const { socketPath } = await openBRelaySession({ + relay: bRelay, + dataDir: bDir, + selfNodeId: "b-node", + paneId: cmd.paneId, + cmd, + sendRelayFrame: (f) => routeFromNode("b-node", f), + }); + return { socketPath }; + } + if (cmd.type === "ssh_relay_close") { + (nodeId === "a-node" ? aRelay : bRelay).close(cmd.ref, cmd.reason); + return { ok: true }; + } + throw new Error(`fake node ${nodeId}: unexpected command ${String(cmd.type)}`); + }; + + const broker = createRelayBroker({ + authorize: async () => true, + sendCommand, + sendRelayFrame: deliverToNode, + async nodeRow(nodeId) { + return nodeId === "a-node" || nodeId === "b-node" ? { kind: "agent", sshEnabled: 1 } : null; + }, + nodeDataDir: (nodeId) => (nodeId === "b-node" ? bDir : null), + async audit(e: AuditEventInput) { + audits.push({ + action: e.action, + metadata: JSON.parse(e.metadataJson ?? "{}") as Record, + metadataJson: e.metadataJson ?? "", + }); + }, + nowMs: () => 1_730_000_000_000, // fixed clock: no timer in the matrix should ever fire + schedule: (): TimerHandle => { + const handle: { fn?: () => void } = {}; + return handle; // never fired; cancel is a no-op + }, + cancel: () => {}, + log: (line) => brokerLines.push(line), + }); + + const result = await broker.openRelay({ + identityGenerations: { a: broker.identityGeneration("a-node"), b: broker.identityGeneration("b-node") }, + userId: "user-1", + fingerprints, + paneId: PANE_ID, + // Task 12: the pin is REQUIRED on the open; B's branch writes it beside + // the socket, which is part of what this stack proves end to end. + hostPin: `git.example.test ssh-ed25519 ${"A".repeat(51)}`, + aNode: "a-node", + bNode: "b-node", + aPeer: { signingPublicKey: aId.signingPublicJwk, encryptionPublicJwk: aId.publicJwk }, + bPeer: { signingPublicKey: bId.signingPublicJwk, encryptionPublicJwk: bId.publicJwk }, + }); + + const probeCount = stub.received.length; // whatever the open-time numbering probe asked + + const stack: Stack = { + broker, + result, + aId, + bId, + aDir, + bDir, + aRelay, + bRelay, + stub, + forwarded: () => stub.received.slice(probeCount), + wire, + audits, + brokerLines, + commands, + planeVisible: () => [ + ...wire.map((w) => w.raw), + ...audits.map((a) => a.metadataJson), + ...brokerLines, + JSON.stringify(broker.sessionInfo(result.ref)), + ], + // Accessor over the closure flag (see swallowArmed above): the property + // stays a plain boolean on the Stack interface while routeFromNode reads + // only the closure, never the not-yet-initialized stack. + get swallowB2A() { + return swallowArmed; + }, + set swallowB2A(value: boolean) { + swallowArmed = value; + }, + releaseB2A() { + const pending = swallowedB2A; + swallowedB2A = []; + for (const f of pending) broker.routeRelayFrame("b-node", f); + }, + routeFromNode, + closeNodeSessions(ref, reason) { + // Closes ONLY the node-registry handlers for the ref (owned close, so + // no tombstone). The broker's own session for the ref is left intact: + // the plane routes by ref and knows nothing of relay ids, so a ref + // re-presented by restarted endpoints is a plane-level event the + // broker must keep routing while the ENDPOINTS refuse the old bytes. + aRelay.close(ref, reason); + bRelay.close(ref, reason); + }, + async cleanup() { + bRelay.closeAll("child-exit"); + aRelay.closeAll("child-exit"); + broker.reset(); + await stub.close(); + }, + }; + return stack; +} + +/* ------------------------------------------------------------------ */ +/* wire-side decoders: open + verify one captured frame the peer way */ +/* ------------------------------------------------------------------ */ + +/** What {@link decodeB2A}/{@link decodeA2B} return: the plaintext agent bytes + the bindings. */ +export interface DecodedFrame { + agentBytes: Buffer; + seq: number; + /** B's endpoint nonce: carried by every B2A AND echoed in every A2B (§5.6). */ + nB: string; + /** A's endpoint nonce: absent only from the session's first request. */ + nA?: string; +} + +/** + * OPEN a B2A frame at A WITHOUT verifying anything (the confidentiality half + * only): resolves iff the envelope is sealed to A's real encryption key and + * its wrapper agrees with the frame. This is the openability witness the + * refusal cases need: a downstream refusal (verification, the endpoint's gate) + * is then provably a judgment about origin or replay, not a decryption failure + * of a fixture-mis-sealed envelope - that one rejects HERE instead. + */ +export async function openB2A(s: Stack, frame: RelayFrame): Promise { + return openRelayEnvelope({ + blob: frame.blob, + own: { principalId: "node:a-node", publicJwk: s.aId.publicJwk, privateJwk: s.aId.privateJwk }, + open: mcpOpen, + expect: { ref: frame.ref, direction: frame.direction }, + }); +} + +/** + * Open + verify a B2A frame AS A did: B's signing key is the pinned origin, + * A's real encryption private half opens the envelope. Throws exactly where + * A's responder threw. The seq gate is the responder's own check, deliberately + * absent here: an honestly-sealed low-seq envelope DECODES fine, which is what + * makes the responder's refusal nameable as the gate's. + */ +export async function decodeB2A(s: Stack, frame: RelayFrame): Promise { + const opened = await openB2A(s, frame); + const message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: JSON.parse(s.bId.signingPublicJwk) as JsonWebKey, + expect: { routingRef: frame.ref, direction: "B2A", seq: opened.seq, relaySessionId: s.result.relayId }, + }); + return { + agentBytes: Buffer.from(message.agentBytesB64, "base64url"), + seq: opened.seq, + nB: message.nB ?? "", + ...(message.nA === undefined ? {} : { nA: message.nA }), + }; +} + +/** + * Open + verify an A2B frame AS B did: A's signing key is the pinned origin, + * B's real encryption private half opens the envelope. The witness for the + * matrix's "B can open it with A's pinned encryption key" assertions. + */ +export async function decodeA2B(s: Stack, frame: RelayFrame): Promise { + const opened = await openRelayEnvelope({ + blob: frame.blob, + own: { principalId: "node:b-node", publicJwk: s.bId.publicJwk, privateJwk: s.bId.privateJwk }, + open: mcpOpen, + expect: { ref: frame.ref, direction: frame.direction }, + }); + const message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: JSON.parse(s.aId.signingPublicJwk) as JsonWebKey, + expect: { routingRef: frame.ref, direction: "A2B", seq: opened.seq, relaySessionId: s.result.relayId }, + }); + return { + agentBytes: Buffer.from(message.agentBytesB64, "base64url"), + seq: opened.seq, + nB: message.nB ?? "", + ...(message.nA === undefined ? {} : { nA: message.nA }), + }; +} + +/** Seal one B2A request with the given signer (evil signer = the wrong-origin attack). */ +export async function sealB2A( + s: Stack, + opts: { agentBytes: Buffer; seq: number; nB: string; nA?: string; signerPrivateJwk: string }, +): Promise { + const blob = await sealRelayEnvelope({ + message: { + relaySessionId: s.result.relayId, + routingRef: s.result.ref, + direction: "B2A", + seq: opts.seq, + nB: opts.nB, + ...(opts.nA === undefined ? {} : { nA: opts.nA }), + agentBytesB64: base64UrlNoPad(new Uint8Array(opts.agentBytes)), + }, + privateJwk: JSON.parse(opts.signerPrivateJwk) as JsonWebKey, + recipient: { principalId: "node:a-node", publicJwk: s.aId.publicJwk }, + seal: mcpSeal, + }); + return { type: "relay", ref: s.result.ref, seq: opts.seq, direction: "B2A", blob }; +} + +/** Seal one A2B reply with the given signer (evil signer = the forged-reply attack). */ +export async function sealA2B( + s: Stack, + opts: { agentBytes: Buffer; seq: number; nB: string; nA?: string; signerPrivateJwk: string }, +): Promise { + const blob = await sealRelayEnvelope({ + message: { + relaySessionId: s.result.relayId, + routingRef: s.result.ref, + direction: "A2B", + seq: opts.seq, + nB: opts.nB, + ...(opts.nA === undefined ? {} : { nA: opts.nA }), + agentBytesB64: base64UrlNoPad(new Uint8Array(opts.agentBytes)), + }, + privateJwk: JSON.parse(opts.signerPrivateJwk) as JsonWebKey, + recipient: { principalId: "node:b-node", publicJwk: s.bId.publicJwk }, + seal: mcpSeal, + }); + return { type: "relay", ref: s.result.ref, seq: opts.seq, direction: "A2B", blob }; +} + +/* ------------------------------------------------------------------ */ +/* tiny async helpers */ +/* ------------------------------------------------------------------ */ + +/** Sleep. */ +export async function sleep(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)); +} + +/** Wait until `cond` holds or throw with a named timeout. */ +export async function waitUntil(cond: () => boolean, what: string, timeoutMs = 3000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error(`timed out waiting for ${what}`); + await sleep(5); + } +} diff --git a/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts b/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts new file mode 100644 index 000000000..ab1e50470 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts @@ -0,0 +1,390 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import type { NodeSshHostKeyResult } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { SshHostPinsRepository } from "@/db/repositories/ssh-host-pins.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import type { SshHostPinTable } from "@/db/types/ssh-host-pins.db-types.js"; +import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; +import { + captureHostPin, + deleteHostPin, + hostKeyFingerprints, + hostPinFor, + hostPinRefusal, + listHostPins, + SshHostPinError, + setSshHostPinsDepsForTests, +} from "@/services/ssh-host-pins.service.js"; + +/** + * The host-key pin store (spec 2026-10-08 §9, Task 12). The properties this + * suite exists to pin, one per §9 sentence: + * + * - A CAPTURE writes the destination's pin from A's `known_hosts` answer and + * audits `node.ssh_host_pin.create` naming destination + fingerprint ONLY: + * the audit row and every log path must never carry the line's bytes, and + * the fingerprint is OpenSSH's own `SHA256:` over the decoded blob. + * - The EMPTY answer is the named `no-pin` failure (mapped to + * SSH_HOST_PIN_MISSING): a relay grant never stands without its pin. + * - MULTIPLE distinct keys are `ambiguous`, refused rather than picked. + * - A capture at a key DIFFERENT from the stored pin is `changed`: nothing + * is written, nothing is overwritten, the row stands verbatim. + * - The SAME key again is idempotent (no second create row; updatedAt moves). + * - DELETE + re-capture is the TOFU recovery: the differing key becomes the + * new pin only AFTER the delete, and the delete audits destination + + * fingerprint. + * - Per-owner scoping: a foreign owner's pin is absent, never forbidden. + * - The explicit line (trust-screen supply) is grammar-checked and audits + * `via: explicit`. + * + * The A fetch is the service's own seam; no test here touches a socket, an + * ssh-keygen, or a developer's `~/.ssh`. + */ + +const HOST = "git.example.test"; +const DEST = `${HOST}:22`; +/** A real ed25519 public blob shape: the wire encoding, base64'd as known_hosts spells it. */ +const KEY_BYTES = Buffer.concat([ + Buffer.from([0, 0, 0, 11]), + Buffer.from("ssh-ed25519"), + Buffer.from([0, 0, 0, 32]), + Buffer.alloc(32, 7), +]); +const LINE = `${HOST} ssh-ed25519 ${KEY_BYTES.toString("base64")}`; +const OTHER_BYTES = Buffer.concat([Buffer.from([0, 0, 0, 11]), Buffer.from("ssh-ed25519"), Buffer.alloc(32, 9)]); +const OTHER_LINE = `${HOST} ssh-ed25519 ${OTHER_BYTES.toString("base64")}`; +const EXPECTED_FP = `SHA256:${createHash("sha256").update(KEY_BYTES).digest("base64url")}`; +const OTHER_FP = `SHA256:${createHash("sha256").update(OTHER_BYTES).digest("base64url")}`; + +/** A clock the suite moves: stamps assert the touch, the row, and the audit. */ +let clockMs = Date.parse("2026-10-08T00:00:00.000Z"); + +/** Scripted A: what the fetch answers, or the RPC error it throws. */ +let fetchAnswer: () => NodeSshHostKeyResult = () => ({ lines: [LINE] }); +let fetchCalls: { nodeId: string; triple: unknown }[] = []; + +function installDeps() { + fetchCalls = []; + setSshHostPinsDepsForTests({ + nowIso: () => new Date(clockMs).toISOString(), + fetchHostKey: async (nodeId, triple) => { + fetchCalls.push({ nodeId, triple }); + return fetchAnswer(); + }, + }); +} + +async function expectPinError(fn: () => Promise, code: SshHostPinError["code"]): Promise { + try { + await fn(); + } catch (err) { + expect(err).toBeInstanceOf(SshHostPinError); + const e = err as SshHostPinError; + expect(e.code).toBe(code); + return e; + } + throw new Error(`expected SshHostPinError(${code}); nothing threw`); +} + +let owner: string; +let stranger: string; +const emails: string[] = []; + +async function mkUser(tag: string): Promise { + const email = `hostpins-${tag}-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("hostpins-1"), + role: "user", + }); +} + +beforeAll(async () => { + await setupAuthTables(); + await ensureMigratedTestDb(); + owner = await mkUser("owner"); + stranger = await mkUser("stranger"); +}); + +afterAll(async () => { + setSshHostPinsDepsForTests(null); + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("auditEvents").execute(); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +beforeEach(async () => { + clockMs = Date.parse("2026-10-08T00:00:00.000Z"); + fetchAnswer = () => ({ lines: [LINE] }); + installDeps(); + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("auditEvents").execute(); +}); + +async function latestAudit(action: string): Promise> { + const rows = await db + .selectFrom("auditEvents") + .select(["metadataJson"]) + .where("action", "=", action) + .orderBy("createdAt", "desc") + .limit(1) + .execute(); + return rows[0] ? (JSON.parse(rows[0].metadataJson ?? "{}") as Record) : {}; +} + +describe("capture", () => { + it("writes the pin from A's answer and audits destination + fingerprint, never the bytes", async () => { + const row = await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(row.hostKey).toBe(LINE); + expect(row.destination).toBe(DEST); + // The ask carried the PARSED triple (not the raw text): host git.example.test, port 22, user null. + expect(fetchCalls).toEqual([{ nodeId: "node-a", triple: { host: HOST, port: 22, user: null } }]); + const audit = await latestAudit("node.ssh_host_pin.create"); + expect(audit.destination).toBe(DEST); + expect(audit.fingerprint).toBe(EXPECTED_FP); + expect(audit.via).toBe("captured"); + // The bytes are nowhere on the trail: the base64 key material is not in + // the audit metadata (the fingerprint is its digest, not its spelling). + const meta = JSON.stringify(audit); + expect(meta).not.toContain(KEY_BYTES.toString("base64")); + expect(hostKeyFingerprints(LINE)).toEqual([EXPECTED_FP]); + }); + + it("an empty answer is the named no-pin failure and writes NOTHING (a relay grant never lacks its pin)", async () => { + fetchAnswer = () => ({ lines: [] }); + const err = await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "no-pin", + ); + expect(hostPinRefusal(err)).toMatchObject({ status: 409, code: BackendErrorCodes.SSH_HOST_PIN_MISSING }); + expect(await hostPinFor({ ownerUserId: owner, destination: DEST })).toBeNull(); + const rows = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.create") + .execute(); + expect(rows).toHaveLength(0); + }); + + it("two distinct keys from A are refused (ambiguous), not picked", async () => { + fetchAnswer = () => ({ lines: [LINE, OTHER_LINE] }); + await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "ambiguous", + ); + expect(await hostPinFor({ ownerUserId: owner, destination: DEST })).toBeNull(); + }); + + it("a differing capture is the changed hard block: nothing written, the row stands verbatim", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + fetchAnswer = () => ({ lines: [OTHER_LINE] }); + const err = await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "changed", + ); + expect(hostPinRefusal(err).code).toBe(BackendErrorCodes.SSH_HOST_PIN_CHANGED); + const standing = await hostPinFor({ ownerUserId: owner, destination: DEST }); + expect(standing?.hostKey).toBe(LINE); // never overwritten + const rows = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.create") + .execute(); + expect(rows).toHaveLength(1); // the original capture is the only create row + }); + + it("the same key again is idempotent: no second create row, the updatedAt stamp moves", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + clockMs += 60_000; + const again = await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(again.updatedAt).toBe(new Date(clockMs).toISOString()); + const creates = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.create") + .execute(); + expect(creates).toHaveLength(1); + }); + + it("an explicit line (the trust-screen door) pins without asking A, audited via explicit", async () => { + const row = await captureHostPin({ ownerUserId: owner, aNodeId: null, destination: DEST, hostKeyLine: LINE }); + expect(row.hostKey).toBe(LINE); + expect(fetchCalls).toHaveLength(0); // supplied is never fetched + const audit = await latestAudit("node.ssh_host_pin.create"); + expect(audit.via).toBe("explicit"); + }); + + it("an explicit line that is not ONE grammar-valid key entry is refused", async () => { + await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: null, destination: DEST, hostKeyLine: "just-a-host" }), + "invalid-line", + ); + await expectPinError( + () => + captureHostPin({ + ownerUserId: owner, + aNodeId: null, + destination: DEST, + hostKeyLine: "a b\nsecond ssh-ed25519 A", + }), + "invalid-line", + ); + }); + + it("the machine's RPC failures leave as the named capture causes (offline, refused)", async () => { + fetchAnswer = () => { + throw new SshRpcError("offline", "no live socket", "node-a"); + }; + const offline = await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "offline", + ); + expect(hostPinRefusal(offline)).toMatchObject({ status: 409, code: BackendErrorCodes.NODE_OFFLINE }); + fetchAnswer = () => { + throw new SshRpcError("refused", "ssh disabled on this node", "node-a"); + }; + const refused = await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "refused", + ); + expect(hostPinRefusal(refused)).toMatchObject({ status: 502, code: BackendErrorCodes.SSH_NODE_REFUSED }); + expect(await hostPinFor({ ownerUserId: owner, destination: DEST })).toBeNull(); + }); +}); + +describe("the concurrent-first-capture race (the UNIQUE index decides, never a raw error)", () => { + /** + * Simulate the interleaving deterministically without a second connection: + * blind the service's FIRST getPin (the read every serialized capture made + * before its rival committed) while the real UNIQUE(owner_user_id, + * destination) index stays live, so the loser's insert collides for real + * and the re-read sees the winner. Every later read answers truthfully. + */ + async function withBlindedFirstRead(fn: () => Promise): Promise { + const proto = SshHostPinsRepository.prototype as { + getPin: (ownerUserId: string, destination: string) => Promise; + }; + const realGetPin = proto.getPin; + let blinded = false; + proto.getPin = async function (this: SshHostPinsRepository, o: string, d: string) { + if (!blinded) { + blinded = true; + return null; + } + return await realGetPin.call(this, o, d); + }; + try { + return await fn(); + } finally { + proto.getPin = realGetPin; + } + } + + async function pinRowCount(): Promise { + const rows = await db.selectFrom("sshHostPins").select("id").execute(); + return rows.length; + } + + async function createAuditCount(): Promise { + const rows = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.create") + .execute(); + return rows.length; + } + + it("two concurrent FIRST captures at the SAME key: no raw error, one row, one create row, both answer the winner's pin", async () => { + // This is what the unguarded get-then-insert surfaced as a raw + // SqliteError (a 500 on the grant path): the collision must resolve to + // the serialized path's outcome - idempotent success, touched stamp. + const [a, b] = await withBlindedFirstRead(async () => { + return await Promise.all([ + captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + ]); + }); + expect(a.hostKey).toBe(LINE); + expect(b.hostKey).toBe(LINE); + expect(a.id).toBe(b.id); // both answered the SAME standing row + expect(await pinRowCount()).toBe(1); + expect(await createAuditCount()).toBe(1); // the winner created; the loser only touched + }); + + it("a FIRST capture colliding with a DIFFERENT key is the changed block through the race path too, the winner's row standing", async () => { + // The winner's LINE stands; the loser (blinded initial read) races in + // with OTHER_LINE, hits the index, and must read back and refuse exactly + // as the serialized path does - not crash, not overwrite. + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); // the winner + fetchAnswer = () => ({ lines: [OTHER_LINE] }); + const err = await withBlindedFirstRead(async () => { + return await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "changed", + ); + }); + expect(hostPinRefusal(err).code).toBe(BackendErrorCodes.SSH_HOST_PIN_CHANGED); + const standing = await hostPinFor({ ownerUserId: owner, destination: DEST }); + expect(standing?.hostKey).toBe(LINE); + expect(await pinRowCount()).toBe(1); + expect(await createAuditCount()).toBe(1); + }); +}); + +describe("delete and re-capture (the TOFU recovery)", () => { + it("delete removes the row and audits destination + fingerprint; the re-capture at the NEW key succeeds as a fresh TOFU", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + const removed = await deleteHostPin({ ownerUserId: owner, destination: DEST }); + expect(removed?.hostKey).toBe(LINE); + const audit = await latestAudit("node.ssh_host_pin.delete"); + expect(audit.destination).toBe(DEST); + expect(audit.fingerprint).toBe(EXPECTED_FP); + // The §9 recovery: only AFTER the delete does the differing key become the pin. + fetchAnswer = () => ({ lines: [OTHER_LINE] }); + const re = await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(re.hostKey).toBe(OTHER_LINE); + expect(re.createdAt).toBe(new Date(clockMs).toISOString()); + // The recovered pin fingerprints as the NEW key. + expect(hostKeyFingerprints(re.hostKey)).toEqual([OTHER_FP]); + }); + + it("deleting an absent or foreign destination answers null and audits nothing", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(await deleteHostPin({ ownerUserId: stranger, destination: DEST })).toBeNull(); + expect(await deleteHostPin({ ownerUserId: owner, destination: "other.test:22" })).toBeNull(); + expect(await hostPinFor({ ownerUserId: owner, destination: DEST })).not.toBeNull(); // stranger deleted nothing + const dels = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.delete") + .execute(); + expect(dels).toHaveLength(0); + }); + + it("pins are per-owner and per-destination: one owner's pin is absent for the other, and ports/users are distinct keys", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(await hostPinFor({ ownerUserId: stranger, destination: DEST })).toBeNull(); + expect(await listHostPins({ ownerUserId: stranger })).toHaveLength(0); + const withUser = `deploy@${HOST}:22`; + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: withUser, hostKeyLine: LINE }); + expect(await listHostPins({ ownerUserId: owner })).toHaveLength(2); + // The screens list never carries key bytes - only the fingerprint view. + const views = await listHostPins({ ownerUserId: owner }); + expect(JSON.stringify(views)).not.toContain(KEY_BYTES.toString("base64")); + expect(views.every((v) => v.fingerprint === EXPECTED_FP)).toBe(true); + }); +}); + +describe("the user@host:port triple", () => { + it("a user and a non-default port parse to the ask the capture sends", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: "deploy@git.example.test:2222" }); + expect(fetchCalls).toEqual([{ nodeId: "node-a", triple: { host: HOST, port: 2222, user: "deploy" } }]); + }); +}); diff --git a/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts b/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts new file mode 100644 index 000000000..7ff5fd972 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "bun:test"; +import { buildSshConfigPath, buildSshKnownHostsPath, renderSshConfigContents } from "@internal/pane-runtime"; +import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; +import { composeSshLaunch, isWireSafeSshDestination, relaySnapshotRefusal } from "@/services/ssh-launch.service.js"; + +/** + * The compose half of the ssh launch service (spec 2026-10-07 §5.2, decisions + * 3/4, handoff 4): the pure function that turns an APPROVED snapshot plus the + * target machine's dataDir into everything the launch needs. The route matrix + * (`api/ssh/__tests__/ssh-routes.test.ts`) pins when it runs; this file pins + * WHAT it returns, byte-exactly, because the agent re-derives the path and + * refuses a single-byte mismatch. + */ + +const SNAP = (over: Partial = {}): SshConnectionSnapshotWire => ({ + alias: "work", + host: "example.test", + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...over, +}); + +const ID = "11111111-2222-4333-8444-555555555555"; + +describe("isWireSafeSshDestination", () => { + it("accepts aliases and bare hosts; refuses the option-like, the blank, the oversized and anything with whitespace or control characters", () => { + for (const ok of ["work", "example.test", "dev@10.0.0.7", "a".repeat(253), "10.0.0.7", "[::1]", "box_1"]) { + expect(isWireSafeSshDestination(ok)).toBe(true); + } + for (const bad of [ + "", // blank answers 400 at the wire schema, and false here all the same + "-x", + "-oProxyCommand=evil", + "has space", + "tab\tsep", + "esc\x07ape", + "a".repeat(254), // past SSH_NAME_MAX_CHARS: the wire grammar's own cap + "new\nline", + ]) { + expect(isWireSafeSshDestination(bad)).toBe(false); + } + }); +}); + +describe("composeSshLaunch in relay mode (Task 12, spec 2026-10-08 §9)", () => { + it("the relay render forces yes on the PINNED file and replaces the snapshot's trust refs", () => { + const snapshot = SNAP({ knownHostsFiles: ["/home/scripted/.ssh/known_hosts"] }); + const pinPath = buildSshKnownHostsPath("/home/scripted/.subshell", ID); + const composed = composeSshLaunch({ + snapshot, + targetDataDir: "/home/scripted/.subshell", + subshellId: ID, + hostPinPath: pinPath, + }); + expect(composed.fileContent).toContain("StrictHostKeyChecking yes"); + expect(composed.fileContent).not.toContain("accept-new"); + expect(composed.fileContent).toContain(` UserKnownHostsFile ${pinPath}`); + expect(composed.fileContent).not.toContain("/home/scripted/.ssh/known_hosts"); // B's ambient file is not consulted + // Same path derivation as the config: the pane's dir holds config + known_hosts + agent.sock. + expect(pinPath).toBe(`/home/scripted/.subshell/ssh/${ID}/known_hosts`); + }); + + it("absent the pin option, the bytes are the M1 compose byte-for-byte (the accept-new branch is untouched)", () => { + const snapshot = SNAP(); + const plain = composeSshLaunch({ snapshot, targetDataDir: "/home/n/.subshell", subshellId: ID }); + expect(plain.fileContent).toBe(renderSshConfigContents(snapshot)); + expect(plain.fileContent).toContain("accept-new"); + }); +}); + +describe("composeSshLaunch", () => { + it("composes the config at the target machine's derived path, with the rendered bytes and the full option tail", () => { + const snapshot = SNAP(); + const composed = composeSshLaunch({ snapshot, targetDataDir: "/home/scripted/.subshell", subshellId: ID }); + const configPath = buildSshConfigPath("/home/scripted/.subshell", ID); + expect(composed.configPath).toBe(configPath); + expect(composed.fileContent).toBe(renderSshConfigContents(snapshot)); + // Handoff 4, verbatim: option tokens (with `-F` first) then `--` then the host. + expect(composed.presetFlags).toEqual(["-F", configPath, "-p", "22", "--", "example.test"]); + // No agent socket named means no SSH_AUTH_SOCK added — decision 3's scope + // rule: non-ssh panes never carry it, and neither does an ssh pane that + // resolved without one. + expect(composed.extraPaneEnv).toBeUndefined(); + }); + + it("the LOCAL machine's dataDir is the server's own, and the byte-derived path is the one LocalLauncher enforces", () => { + const composed = composeSshLaunch({ snapshot: SNAP(), targetDataDir: SUBSHELL_SERVER_DATA_DIR, subshellId: ID }); + expect(composed.configPath).toBe(subshellSshConfigPath(ID)); + }); + + it("a snapshot naming an agent socket carries it as SSH_AUTH_SOCK; a user, non-default port, alias and jump chain ride the option tokens", () => { + const snapshot = SNAP({ + user: "dev", + port: 2222, + hostKeyAlias: "bastion", + authAgentSocket: "/run/user/501/ssh-agent.sock", + proxyJumps: [{ host: "jump.example", user: "j", port: 22 }], + }); + const composed = composeSshLaunch({ snapshot, targetDataDir: "/home/n/.subshell", subshellId: ID }); + expect(composed.extraPaneEnv).toEqual({ SSH_AUTH_SOCK: "/run/user/501/ssh-agent.sock" }); + const flags = composed.presetFlags; + expect(flags.slice(0, 2)).toEqual(["-F", composed.configPath]); + expect(flags).toContain("-p"); + expect(flags[flags.indexOf("-p") + 1]).toBe("2222"); + expect(flags).toContain("-l"); + expect(flags[flags.indexOf("-l") + 1]).toBe("dev"); + expect(flags).toContain("HostKeyAlias=bastion"); + expect(flags.some((f) => f.startsWith("ProxyJump=") && f.includes("j@jump.example"))).toBe(true); + expect(flags.slice(-2)).toEqual(["--", "example.test"]); + }); + + it("refuses a snapshot the frozen grammar cannot express, rather than render it (the belt beside the RPC validator)", () => { + const forged = { ...SNAP(), host: "-oProxyCommand=evil" } as unknown as SshConnectionSnapshotWire; + expect(() => composeSshLaunch({ snapshot: forged, targetDataDir: "/home/n/.subshell", subshellId: ID })).toThrow(); + }); +}); + +describe("relaySnapshotRefusal", () => { + it("accepts canonical destinations and refuses unsupported relay trust settings before opening a session", () => { + expect(relaySnapshotRefusal(SNAP())).toBeNull(); + for (const snapshot of [ + SNAP({ hostKeyAlias: "alias" }), + SNAP({ proxyJumps: [{ host: "jump.example", user: null, port: 22 }] }), + ]) { + const result = relaySnapshotRefusal(snapshot); + expect(result?.ok).toBe(false); + if (result && !result.ok) { + expect(result.refusal.status).toBe(409); + if (result.refusal.status !== 422) + expect(result.refusal.message).toContain("Use keys on the connecting machine"); + } + } + }); +}); diff --git a/apps/server/api/src/services/__tests__/ssh-local-identity.test.ts b/apps/server/api/src/services/__tests__/ssh-local-identity.test.ts new file mode 100644 index 000000000..50e888aff --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-local-identity.test.ts @@ -0,0 +1,75 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { createLocalRelayIdentityProvider } from "../ssh-local-identity.js"; + +const dirs: string[] = []; +async function directory() { + const dir = await mkdtemp(join(tmpdir(), "ssh-local-id-")); + dirs.push(dir); + return dir; +} +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +test("concurrent first use registers one identity and restart preserves exact public bytes", async () => { + const dir = await directory(); + let registrations = 0; + const provider = createLocalRelayIdentityProvider(dir, async () => { + registrations++; + }); + const identities = await Promise.all(Array.from({ length: 20 }, () => provider())); + expect(new Set(identities.map((identity) => identity.publicJwk)).size).toBe(1); + expect(registrations).toBe(1); + const restart = createLocalRelayIdentityProvider(dir, async () => {}); + expect(await restart()).toEqual(identities[0]); +}); + +test("corrupt disk is preserved and remains refused after repeated calls and restart", async () => { + const dir = await directory(); + await writeFile(join(dir, "identity.json"), "corrupt"); + const provider = createLocalRelayIdentityProvider(dir, async () => { + throw new Error("must not register"); + }); + await expect(provider()).rejects.toThrow("unreadable"); + await expect(provider()).rejects.toThrow("unreadable"); + await expect(createLocalRelayIdentityProvider(dir, async () => {})()).rejects.toThrow("unreadable"); + expect(await readFile(join(dir, "identity.json"), "utf8")).toBe("corrupt"); +}); + +test("registration mismatch stays refused without regenerating the disk identity", async () => { + const dir = await directory(); + let registrations = 0; + const provider = createLocalRelayIdentityProvider(dir, async () => { + registrations++; + throw new Error("registered identity differs"); + }); + await expect(provider()).rejects.toThrow("differs"); + const bytes = await readFile(join(dir, "identity.json"), "utf8"); + await expect(provider()).rejects.toThrow("differs"); + expect(registrations).toBe(1); + expect(await readFile(join(dir, "identity.json"), "utf8")).toBe(bytes); +}); + +test("database first-registration race cannot replace either public half; an empty signing slot only fills for matching encryption", async () => { + await ensureMigratedTestDb(); + const repo = new IdentitiesRepository(db); + const principalId = `node:local-test-${crypto.randomUUID()}`; + const one = { principalId, publicKey: "encryption-one", signingPublicKey: "signing-one", displayName: null }; + const two = { ...one, publicKey: "encryption-two", signingPublicKey: "signing-two" }; + const outcomes = await Promise.all([repo.registerIfMatching(one), repo.registerIfMatching(two)]); + expect(outcomes.filter(Boolean)).toHaveLength(1); + const standing = await repo.findByPrincipal(principalId); + expect(await repo.registerIfMatching(outcomes[0] ? two : one)).toBe(false); + expect(await repo.findByPrincipal(principalId)).toEqual(standing); + await db.updateTable("identities").set({ signingPublicKey: null }).where("principalId", "=", principalId).execute(); + expect(await repo.registerIfMatching(outcomes[0] ? two : one)).toBe(false); + expect((await repo.findByPrincipal(principalId))?.signingPublicKey).toBeNull(); + expect(await repo.registerIfMatching(outcomes[0] ? one : two)).toBe(true); + await db.deleteFrom("identities").where("principalId", "=", principalId).execute(); +}); diff --git a/apps/server/api/src/services/__tests__/ssh-local-operations.test.ts b/apps/server/api/src/services/__tests__/ssh-local-operations.test.ts new file mode 100644 index 000000000..2d950d35a --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-local-operations.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { sshExec, sshExecStatus, sshHostKey } from "@/services/nodes/ssh-rpc.js"; + +test("server setup execution runs in-process, validates its path, redacts secrets and removes temporary config", async () => { + const directory = await mkdtemp(join(tmpdir(), "local-ssh-exec-")); + const previous = process.env.SUBSHELL_SSH_PATH; + const executable = join(directory, "ssh"); + await writeFile(executable, "#!/bin/sh\nprintf 'visible output\\nsecret nsk_do-not-retain\\n'\nexit 23\n"); + await chmod(executable, 0o700); + process.env.SUBSHELL_SSH_PATH = executable; + const execId = crypto.randomUUID(); + const configPath = buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, execId); + const args = { + execId, + configPath, + fileContent: "Host host\n HostName example.test\n", + presetFlags: ["-F", configPath, "host"], + command: "setup nsk_do-not-retain", + relay: false, + agentSocketPath: null, + timeoutMs: 1000, + }; + try { + await expect(sshExec("local", { ...args, configPath: join(directory, "escape") })).rejects.toMatchObject({ + kind: "refused", + }); + expect(existsSync(join(directory, "escape"))).toBe(false); + await sshExec("local", args); + let result = await sshExecStatus("local", execId); + for (let count = 0; result.state === "running" && count < 100; count++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + result = await sshExecStatus("local", execId); + } + expect(result.state).toBe("done"); + if (result.state !== "done") throw new Error("setup exec did not finish"); + expect(result.code).toBe(23); + expect(result.stdout).toContain("visible output"); + expect(JSON.stringify(result)).not.toContain("nsk_"); + for (let count = 0; existsSync(configPath) && count < 100; count++) + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(existsSync(configPath)).toBe(false); + } finally { + if (previous === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = previous; + 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 }); + } + }, +); diff --git a/apps/server/api/src/services/__tests__/ssh-local-relay.test.ts b/apps/server/api/src/services/__tests__/ssh-local-relay.test.ts new file mode 100644 index 000000000..4833eaf8b --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-local-relay.test.ts @@ -0,0 +1,143 @@ +import { expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { open, seal } from "@internal/mcp-core"; +import { + MachinePinStore, + OPENSSH_10X_SCHEME, + openARelaySession, + openBRelaySession, + RelaySessions, + requestLiveAgent, +} from "@internal/pane-runtime"; +import type { RelayFrame } from "@internal/subshell-protocol"; +import { createLocalRelayIdentityProvider } from "../ssh-local-identity.js"; +import { createLocalRelayParticipant } from "../ssh-local-participant.js"; +import { createRelayBroker } from "../ssh-relay.service.js"; +import { + buildSignRequestTenX, + fp, + KEY_IN, + KEY_OUT, + parseAnswer, + ROSTER, + startStubAgent, +} from "./helpers/ssh-relay-stack.js"; + +for (const localRole of ["A", "B"] as const) + test(`sealed relay with server as ${localRole}: selection, pins, routing and teardown`, async () => { + const root = await mkdtemp(join(tmpdir(), "local-relay-")); + const localDir = join(root, "server"); + const remoteDir = join(root, "node"); + const localIdentity = createLocalRelayIdentityProvider(join(localDir, "ssh-relay"), async () => {}); + const remoteIdentity = createLocalRelayIdentityProvider(remoteDir, async () => {}); + const [localKeys, remoteKeys] = await Promise.all([localIdentity(), remoteIdentity()]); + const agent = await startStubAgent(ROSTER, OPENSSH_10X_SCHEME); + const remoteSessions = new RelaySessions(); + const frames: RelayFrame[] = []; + const aNode = localRole === "A" ? "local" : "remote"; + const bNode = localRole === "B" ? "local" : "remote"; + const participant = createLocalRelayParticipant({ + dataDir: localDir, + identityDir: join(localDir, "ssh-relay"), + identity: localIdentity, + resolveAgentSocket: () => agent.path, + log: () => {}, + sendRelayFrame: (frame) => { + frames.push(frame); + broker.routeRelayFrame("local", frame); + }, + }); + const broker = createRelayBroker({ + nodeRow: async (id) => ({ kind: id === "local" ? "local" : "agent", sshEnabled: 1 }), + nodeDataDir: (id) => (id === "local" ? localDir : remoteDir), + authorize: async () => true, + nowMs: Date.now, + audit: async () => {}, + log: () => {}, + schedule: (fn, ms) => setTimeout(fn, ms), + cancel: (timer) => clearTimeout(timer as ReturnType), + sendRelayFrame: (id, frame) => + id === "local" ? participant.inbound(frame) : remoteSessions.onInboundRelayFrame(frame), + sendCommand: async (id, cmd) => { + if (id === "local") return participant.command(cmd); + if (cmd.type === "ssh_relay_close") return { closed: remoteSessions.close(cmd.ref, cmd.reason) }; + if (cmd.type !== "ssh_relay_open") throw new Error("unexpected command"); + const args = { + cmd, + relay: remoteSessions, + dataDir: remoteDir, + selfNodeId: "remote", + identity: remoteIdentity, + seal, + open, + resolveAgentSocket: () => agent.path, + sendRelayFrame: (frame: RelayFrame) => { + frames.push(frame); + broker.routeRelayFrame("remote", frame); + }, + }; + if (cmd.role === "A") { + // The node acknowledges before its asynchronous open finishes. The shared + // pending handler must keep B's first request until identity/probe is ready. + void openARelaySession({ + ...args, + identity: async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + return remoteIdentity(); + }, + }); + return { role: "A", relayId: cmd.relayId, pending: true }; + } + return openBRelaySession({ ...args, paneId: cmd.paneId }); + }, + }); + try { + const peer = (keys: typeof localKeys) => ({ + signingPublicKey: keys.signingPublicJwk, + encryptionPublicJwk: keys.publicJwk, + }); + const opened = await broker.openRelay({ + identityGenerations: { a: broker.identityGeneration(aNode), b: broker.identityGeneration(bNode) }, + userId: "test-user", + aNode, + bNode, + aPeer: peer(localRole === "A" ? localKeys : remoteKeys), + bPeer: peer(localRole === "B" ? localKeys : remoteKeys), + fingerprints: [fp(KEY_IN)], + paneId: crypto.randomUUID(), + hostPin: "host ssh-ed25519 AAAA", + }); + expect(new MachinePinStore(join(localDir, "ssh-relay")).get("remote")).toEqual({ + signing: remoteKeys.signingPublicJwk, + encryption: remoteKeys.publicJwk, + }); + const answer = await requestLiveAgent(opened.socketPath, Buffer.from([11])); + expect(parseAnswer(answer).count).toBe(1); + const before = agent.received.length; + expect(await requestLiveAgent(opened.socketPath, buildSignRequestTenX(KEY_OUT))).toEqual(Buffer.from([5])); + expect(agent.received.length).toBe(before); + expect((await requestLiveAgent(opened.socketPath, buildSignRequestTenX(KEY_IN)))[0]).toBe(14); + const request = frames.find((frame) => frame.direction === "B2A"); + if (!request) throw new Error("missing sealed agent request"); + broker.routeRelayFrame("outsider", request); + broker.routeRelayFrame(bNode, { ...request, direction: "A2B" }); + broker.routeRelayFrame(bNode, request); // valid envelope replay is refused by the receiver sequence gate + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(agent.received.length).toBe(before + 1); + expect(JSON.stringify(frames)).not.toContain("KEY-IN"); + await broker.shutdown(); + participant.closeAll(); + expect(participant.sessions.size).toBe(0); + expect(remoteSessions.size).toBe(0); + expect(existsSync(opened.socketPath)).toBe(false); + } finally { + participant.closeAll(); + remoteSessions.closeAll("test"); + broker.reset(); + await agent.close(); + await rm(root, { recursive: true, force: true }); + } + }); diff --git a/apps/server/api/src/services/__tests__/ssh-machine-pins.service.test.ts b/apps/server/api/src/services/__tests__/ssh-machine-pins.service.test.ts new file mode 100644 index 000000000..e4b9e35a3 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-machine-pins.service.test.ts @@ -0,0 +1,269 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID, type NodeKind } from "@/db/types/nodes.db-types.js"; +import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; +import { + repairMachinePin, + SshMachinePinRepairError, + type SshMachinePinRepairFailure, + setSshMachinePinsDepsForTests, +} from "@/services/ssh-machine-pins.service.js"; + +/** + * The §4.5 re-pair service (spec 2026-10-08 §4.5, Task 17). The properties + * this suite exists to pin: + * + * - The delivered pair is the peer's REGISTERED public half from the + * identities store (signing + encryption), sent in the relay-open's own + * base64 carriage - never from the caller. + * - OWNER, exactly: a non-owner actor (an admin included) refuses BEFORE any + * command leaves, and nothing is audited. + * - A offline is the named `offline` refusal with NO audit row - the audit + * lands only after A's ack confirms the write (no success-as-audit). + * - A ack mismatch (or a machine refusal) refuses and audits nothing. + * - `local` refuses as A and as peer; `same-node` refuses; an absent peer or + * a peer with no registered signing half refuses by name before any send. + * - The audit row is `node.ssh_machine_pin.repair` naming the TWO NODE IDS + * ONLY - the serialized row is asserted free of every key sentinel. + */ + +const pw = "mp-repair-1"; +const OWNER_EMAIL = `mp-owner-${crypto.randomUUID()}@subshell.local`; +const ADMIN_EMAIL = `mp-admin-${crypto.randomUUID()}@subshell.local`; +let ownerId: string; +let adminId: string; + +const NODE_A = `n-a-${crypto.randomUUID()}`; +const PEER_B = `n-b-${crypto.randomUUID()}`; + +/** Sentinel-bearing public JWK text: if these bytes reach an audit row, the sentinel assert names them. */ +const PEER_SIGNING = '{"kty":"EC","crv":"P-256","x":"SIGN-SENTINEL-aaaa","y":"SIGN-SENTINEL-bbbb"}'; +const PEER_ENCRYPT = '{"kty":"EC","crv":"P-256","x":"ENCL-SENTINEL-cccc","y":"ENCL-SENTINEL-dddd"}'; +const PEER_ENCRYPT_B64 = Buffer.from(PEER_ENCRYPT, "utf8").toString("base64"); + +type SentCmd = { nodeId: string; cmd: unknown }; +let sent: SentCmd[] = []; +let sendBehavior: (nodeId: string, cmd: unknown) => Promise<{ repaired: true; peerNodeId: string }> = async ( + _n, + cmd, +) => { + const c = cmd as { peerNodeId: string }; + return { repaired: true, peerNodeId: c.peerNodeId }; +}; + +function installDeps() { + sent = []; + setSshMachinePinsDepsForTests({ + sendRepair: async (nodeId, cmd) => { + sent.push({ nodeId, cmd }); + return sendBehavior(nodeId, cmd); + }, + }); +} + +async function repairRows(): Promise< + { id: string; actorUserId: string | null; targetId: string | null; metadataJson: string | null }[] +> { + return await db + .selectFrom("auditEvents") + .select(["id", "actorUserId", "targetId", "metadataJson"]) + .where("action", "=", "node.ssh_machine_pin.repair") + .where("targetId", "=", NODE_A) + .execute(); +} + +async function mkNode(id: string, ownerUserId: string, kind: NodeKind): Promise { + const now = new Date().toISOString(); + await db + .insertInto("nodes") + .values({ id, ownerUserId, name: id, kind, status: "offline", createdAt: now, updatedAt: now } as never) + .execute(); +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + ownerId = await new UsersRepository(db).createUser({ + email: OWNER_EMAIL, + name: OWNER_EMAIL, + passwordHash: await hashPassword(pw), + role: "user", + }); + adminId = await new UsersRepository(db).createUser({ + email: ADMIN_EMAIL, + name: ADMIN_EMAIL, + passwordHash: await hashPassword(pw), + role: "admin", + }); + await mkNode(NODE_A, ownerId, "agent"); + await mkNode(PEER_B, ownerId, "agent"); + await new IdentitiesRepository(db).register({ + principalId: `node:${PEER_B}`, + publicKey: PEER_ENCRYPT, + signingPublicKey: PEER_SIGNING, + displayName: null, + }); +}); + +afterAll(async () => { + setSshMachinePinsDepsForTests(null); + await db.deleteFrom("nodes").where("id", "in", [NODE_A, PEER_B]).execute(); + await db.deleteFrom("identities").where("principalId", "=", `node:${PEER_B}`).execute(); + for (const email of [OWNER_EMAIL, ADMIN_EMAIL]) await deleteUserByEmailOrId(email); +}); + +beforeEach(() => installDeps()); + +describe("the happy path: registered pair out, ids-only audit in", () => { + it("sends the peer's REGISTERED public pair in the relay-open carriage and audits ids only", async () => { + await repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: PEER_B }); + expect(sent).toHaveLength(1); + expect(sent[0].nodeId).toBe(NODE_A); + expect(sent[0].cmd).toEqual({ + type: "ssh_machine_pin_repair", + peerNodeId: PEER_B, + peerSigningPublicKey: PEER_SIGNING, // byte-identical to the store (§4.4's byte-equality depends on it) + peerEncryptPublicKey: PEER_ENCRYPT_B64, // acceptance (h): base64 of the UTF-8 JSON + }); + const rows = await repairRows(); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBe(ownerId); // the acting human, not null + expect(rows[0].metadataJson).toBe(JSON.stringify({ nodeIdOfA: NODE_A, peerNodeId: PEER_B })); + // The §10 rule, asserted against the SERIALIZED row, not the intent: + // no key bytes, no base64 carriage, no sentinel coordinates - ids only. + const serialized = JSON.stringify(rows[0]); + for (const secret of [PEER_SIGNING, PEER_ENCRYPT, PEER_ENCRYPT_B64, "SIGN-SENTINEL", "ENCL-SENTINEL"]) { + expect(serialized).not.toContain(secret); + } + }); +}); + +describe("the owner door (exact, not manage, not admin)", () => { + async function expectRefusal(code: SshMachinePinRepairFailure, run: () => Promise): Promise { + const before = (await repairRows()).length; + const err = await run().then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(SshMachinePinRepairError); + expect((err as SshMachinePinRepairError).code).toBe(code); + expect(sent).toHaveLength(0); // refused BEFORE any command + expect((await repairRows()).length).toBe(before); // and nothing audited + } + + it("a non-owner actor refuses not-owner - an admin included", async () => { + await expectRefusal("not-owner", () => + repairMachinePin({ actorUserId: adminId, nodeId: NODE_A, peerNodeId: PEER_B }), + ); + }); + + it("the owner's OWN re-pair still answers when the peer is owned by someone else", async () => { + // The peer's ownership gates NOTHING: the act re-delivers what the + // peer's own enrollment registered, into a store A's owner commands. + const foreign = `n-f-${crypto.randomUUID()}`; + await mkNode(foreign, adminId, "agent"); + await new IdentitiesRepository(db).register({ + principalId: `node:${foreign}`, + publicKey: PEER_ENCRYPT, + signingPublicKey: PEER_SIGNING, + displayName: null, + }); + await repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: foreign }); + expect(sent).toHaveLength(1); + expect(sent[0].cmd).toMatchObject({ peerNodeId: foreign }); + await db.deleteFrom("nodes").where("id", "=", foreign).execute(); + await db.deleteFrom("identities").where("principalId", "=", `node:${foreign}`).execute(); + }); + + it("same-node refuses: a machine is never its own relay peer", async () => { + await expectRefusal("same-node", () => + repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: NODE_A }), + ); + }); + + it("only an administrator can repair server pins, while a remote owner may repair its server peer", async () => { + await expectRefusal("not-owner", () => + repairMachinePin({ actorUserId: ownerId, nodeId: LOCAL_NODE_ID, peerNodeId: PEER_B }), + ); + await repairMachinePin({ actorUserId: adminId, nodeId: LOCAL_NODE_ID, peerNodeId: PEER_B }); + await repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: LOCAL_NODE_ID }); + expect(sent).toHaveLength(2); + }); + + it("an absent A refuses no-node; an absent peer refuses no-peer", async () => { + const ghost = `n-ghost-${crypto.randomUUID()}`; + await expectRefusal("no-node", () => repairMachinePin({ actorUserId: ownerId, nodeId: ghost, peerNodeId: PEER_B })); + await expectRefusal("no-peer", () => repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: ghost })); + }); +}); + +describe("the peer-identity door", () => { + it("a peer with no registered signing half refuses peer-identity-missing, without asking A", async () => { + const keyless = `n-k-${crypto.randomUUID()}`; + await mkNode(keyless, ownerId, "agent"); + const before = (await repairRows()).length; + const err = await repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: keyless }).then( + () => null, + (e: unknown) => e, + ); + expect((err as SshMachinePinRepairError).code).toBe("peer-identity-missing"); + expect(sent).toHaveLength(0); + expect((await repairRows()).length).toBe(before); + await db.deleteFrom("nodes").where("id", "=", keyless).execute(); + }); +}); + +describe("A online by command: every delivery failure audits NOTHING (no success-as-audit)", () => { + async function expectSendFailure(code: SshMachinePinRepairFailure, boom: Error): Promise { + sendBehavior = async () => { + throw boom; + }; + const before = (await repairRows()).length; + const err = await repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: PEER_B }).then( + () => null, + (e: unknown) => e, + ); + expect((err as SshMachinePinRepairError).code).toBe(code); + expect((await repairRows()).length).toBe(before); + } + + it("offline refuses by name and audits nothing", async () => { + await expectSendFailure("offline", new SshRpcError("offline", "no live socket", NODE_A)); + }); + it("unsupported (old agent) refuses and audits nothing", async () => { + await expectSendFailure("unsupported", new SshRpcError("unsupported", "unknown command", NODE_A)); + }); + it("timeout refuses and audits nothing", async () => { + await expectSendFailure("timeout", new SshRpcError("timeout", "deadline", NODE_A)); + }); + it("a machine refusal refuses and audits nothing", async () => { + await expectSendFailure("refused", new SshRpcError("refused", "peer key rejected", NODE_A, "peer key rejected")); + }); + + it("an ack about the WRONG peer is malformed, loudly, with no audit", async () => { + sendBehavior = async () => ({ repaired: true, peerNodeId: "someone-else" }); + const before = (await repairRows()).length; + const err = await repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: PEER_B }).then( + () => null, + (e: unknown) => e, + ); + expect((err as SshMachinePinRepairError).code).toBe("malformed"); + expect((await repairRows()).length).toBe(before); + }); + + it("a thrown non-RPC error is not swallowed (it leaves as itself)", async () => { + sendBehavior = async () => { + throw new Error("db exploded"); + }; + const before = (await repairRows()).length; + await expect(repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: PEER_B })).rejects.toThrow( + "db exploded", + ); + expect((await repairRows()).length).toBe(before); // and it audited nothing either + }); +}); diff --git a/apps/server/api/src/services/__tests__/ssh-policy.service.test.ts b/apps/server/api/src/services/__tests__/ssh-policy.service.test.ts new file mode 100644 index 000000000..1f6f27587 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-policy.service.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import type { NodeGate } from "@/api/nodes/node-gate.js"; +import { sshPolicy } from "@/services/ssh-policy.service.js"; +import { selectRelayFingerprints } from "@/services/ssh-relay-launch.service.js"; + +const ready = { lockdown: false, serverAccountEnabled: true, held: false, online: true, ready: true }; +const gate = (over: Partial = {}): NodeGate => ({ + row: { id: "agent", kind: "agent", sshEnabled: 1, maintenance: 0 } as NodeGate["row"], + access: "view", + granted: "view", + isAdmin: false, + shares: [], + canManage: false, + canRetire: false, + ...over, +}); + +describe("SSH policy follows existing launch permission", () => { + test("view and edit launch shares connect without gaining SSH configuration rights", () => { + for (const access of ["view", "edit"] as const) { + expect(sshPolicy(gate({ access, granted: access }), ready)).toEqual({ + canConnect: true, + canConfigure: false, + blockers: [], + }); + } + }); + test("owner and local admin configuration rights survive blocked runtime", () => { + expect(sshPolicy(gate({ canManage: true }), { ...ready, online: false }).canConfigure).toBe(true); + }); + test("local admin boost does not substitute for launch shares", () => { + const local = gate({ + row: { kind: "local", sshEnabled: 1, maintenance: 0 } as NodeGate["row"], + access: "edit", + granted: "none", + isAdmin: true, + canManage: true, + }); + expect(sshPolicy(local, ready).canConnect).toBe(false); + expect(sshPolicy({ ...local, granted: "view" }, ready).canConnect).toBe(true); + expect(sshPolicy({ ...local, granted: "view" }, { ...ready, serverAccountEnabled: false }).canConnect).toBe(false); + }); + test.each([ + [{ ...ready, lockdown: true }, BackendErrorCodes.SSH_GATE_OFF], + [{ ...ready, held: true }, BackendErrorCodes.NODE_PROTOCOL_HELD], + [{ ...ready, online: false }, BackendErrorCodes.NODE_OFFLINE], + [{ ...ready, ready: false }, BackendErrorCodes.NODE_OFFLINE], + ])("runtime and global launch blockers are structured", (state, code) => { + const result = sshPolicy(gate(), state); + expect(result.canConnect).toBe(false); + expect(result.blockers[0]?.code).toBe(code); + expect(result.blockers[0]?.message.length).toBeGreaterThan(10); + }); + test("maintenance and disabled SSH refuse even owners", () => { + const owner = gate({ access: "owner", granted: "owner", canManage: true }); + expect(sshPolicy({ ...owner, row: { ...owner.row, maintenance: 1 } }, ready).blockers[0]?.code).toBe( + BackendErrorCodes.NODE_IN_MAINTENANCE, + ); + expect(sshPolicy({ ...owner, row: { ...owner.row, sshEnabled: 0 } }, ready).canConnect).toBe(false); + }); +}); + +describe("SSH relay key selection", () => { + const roster = Array.from({ length: 9 }, (_, i) => ({ fingerprint: `SHA256:key${i}` })); + test("omitted selection uses the whole current roster within the cap", () => { + expect(selectRelayFingerprints(roster.slice(0, 8))).toEqual({ + ok: true, + value: roster.slice(0, 8).map((k) => k.fingerprint), + }); + const result = selectRelayFingerprints(roster); + expect(result.ok).toBe(false); + if (!result.ok && result.refusal.status !== 422) + expect(result.refusal.code).toBe(BackendErrorCodes.SSH_KEYS_OVER_LIMIT); + }); + test("explicit choice is deduplicated and must belong to the live roster", () => { + expect(selectRelayFingerprints(roster, ["SHA256:key1", "SHA256:key1"])).toEqual({ + ok: true, + value: ["SHA256:key1"], + }); + expect(selectRelayFingerprints(roster, ["SHA256:missing"]).ok).toBe(false); + expect(selectRelayFingerprints(roster, ["not-a-fingerprint"]).ok).toBe(false); + expect(selectRelayFingerprints(roster, null as unknown as string[]).ok).toBe(false); + }); +}); diff --git a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts new file mode 100644 index 000000000..e778b30c1 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts @@ -0,0 +1,663 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { createConnection, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildAgentSocketPath } from "@internal/pane-runtime"; +import { + base64UrlNoPad, + newNonce, + parseNodeSshAgentIdentities, + parseRelayFrame, + type RelayFrame, + SSH_RELAY_LIFETIME_MS, + type SshRelayOpenCommand, +} from "@internal/subshell-protocol"; +import { exportJWK, generateKeyPair } from "jose"; +import type { CommandContext } from "../../../../../node/agent/src/commands/context.js"; +import { execSshAgentIdentities } from "../../../../../node/agent/src/commands/ssh-identity.js"; +import { openARelaySession, openBRelaySession } from "../../../../../node/agent/src/commands/ssh-relay.js"; +import { OPENSSH_10X_SCHEME } from "../../../../../node/agent/src/relay-agent-scheme.js"; +import { writeSshEnabled } from "../../../../../node/agent/src/ssh-enabled.js"; +import { + buildSignRequestTenX, + decodeA2B, + decodeB2A, + fp, + framed, + KEY_IN, + KEY_OUT, + openB2A, + openStack, + PANE_ID, + parseAnswer, + ROSTER, + type Stack, + sealA2B, + sealB2A, + sleep, + sshStr, + startStubAgent, + waitUntil, +} from "./helpers/ssh-relay-stack.js"; + +/** + * The M2 relay integration matrix (spec 2026-10-08, Task 9): the whole stack + * at once. Two REAL machine identities (`loadOrCreateIdentity` in temp data + * dirs), the REAL B proxy socket, the REAL A responder over a stub agent, and + * the REAL plane broker, wired through a fake plane that is blind by + * construction: it holds no private key, imports no opener, and touches only + * JSON text ({type, ref, seq, direction, blob}) - the envelope is base64 + * ciphertext to it, as it is to the shipped broker. + * + * What each case asserts is observable bytes and side effects: the stub + * agent's received list (what A's agent was actually asked), the pane + * socket's received frames (what the pane's ssh actually got), the plane's + * wire capture, and the serialized audit rows. Case 6 (replay inertness), + * case 3 (wrong origin) and case 4 (scope-before-forward) are the crown + * jewels: a replayed envelope must be inert, a foreign signature must not + * reach the key home, and an ungranted blob must never be put in front of + * A's agent, roster answer first or not. + * + * Matrix case 10 (the `ssh_agent_identities` roster command accepted with no + * grant) shipped with Task 11, which built the command; it is the one case + * that runs WITHOUT the stack, because the roster read consults nothing but + * A's live agent and A's own gate mirror. + */ + +/** An ES256 pair pinned by nobody: signatures from it are forgeries. */ +const evilReady: Promise<{ privateJwk: string; publicJwk: string }> = generateKeyPair("ES256", { + extractable: true, +}).then(async ({ publicKey, privateKey }) => ({ + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), +})); + +/** The b64-of-UTF8 spelling the open command carries the peer encryption key in. */ +function b64Jwk(jwkJson: string): string { + return Buffer.from(jwkJson, "utf8").toString("base64"); +} + +/** The pane's ssh end: connect, collect framed replies, observe death. */ +function paneAt(socketPath: string): { + conn: Socket; + closed: () => boolean; + payloads: () => Buffer[]; + destroy(): void; +} { + const conn = createConnection({ path: socketPath }); + const chunks: Buffer[] = []; + let closed = false; + conn.on("data", (d) => chunks.push(Buffer.from(d))); + conn.on("close", () => { + closed = true; + }); + conn.on("error", () => { + /* failures are read through the close/data events, never thrown */ + }); + return { + conn, + closed: () => closed, + payloads: (): Buffer[] => { + const buf = Buffer.concat(chunks); + const out: Buffer[] = []; + let off = 0; + while (off + 4 <= buf.length) { + const len = buf.readUInt32BE(off); + if (off + 4 + len > buf.length) break; + out.push(buf.subarray(off + 4, off + 4 + len)); + off += 4 + len; + } + return out; + }, + destroy: () => conn.destroy(), + }; +} + +/** The frames the plane RECEIVED (node-to-plane legs), decoded, in order. */ +function ingress(s: Stack): RelayFrame[] { + return s.wire.filter((w) => !w.outbound).map((w) => JSON.parse(w.raw) as RelayFrame); +} + +/** A2B envelopes A actually PRODUCED (the plane saw them leave A's socket). */ +function producedByA(s: Stack): number { + return s.wire.filter((w) => !w.outbound && w.nodeId === "a-node").length; +} + +/** Change the first base64 character: same length, valid alphabet, broken first byte. */ +function garbleBlob(blob: string): string { + const replacement = blob[0] === "A" ? "B" : "A"; + return replacement + blob.slice(1); +} + +/* ------------------------------------------------------------------ */ +/* 1. the happy path: a granted sign round trips sealed */ +/* ------------------------------------------------------------------ */ + +test("1. a granted SIGN_REQUEST from the pane reaches A's agent and the signature comes back sealed: B opens it with A's pinned encryption key", async () => { + const s = await openStack(); + try { + // The broker's byte-check (acceptance (e)) already ran inside openRelay: + // the returned path IS the proxy's real bound path, equal to the plane's + // own derivation from B's dataDir and the command's pane. + expect(s.result.socketPath).toBe(buildAgentSocketPath(s.bDir, PANE_ID)); + const p = paneAt(s.result.socketPath); + const request = buildSignRequestTenX(KEY_IN, { algorithms: "ssh-ed25519" }); + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 1, "the SIGN_RESPONSE on the pane socket"); + expect([...p.payloads()[0]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); // measured 10.x bytes + expect(s.forwarded()).toEqual([request]); // A's agent saw EXACTLY the scoped request, byte-identical + + const inF = ingress(s); + expect(inF.length).toBe(2); // one B2A, one A2B; nothing else crossed + expect(parseRelayFrame(JSON.stringify(inF[0]))).not.toBeNull(); + expect(inF[0].ref).toBe(s.result.ref); + expect(inF[0].direction).toBe("B2A"); + expect(inF[1].direction).toBe("A2B"); + + // Decode the pair independently, the way each endpoint's checklist does. + const asA = await decodeB2A(s, inF[0]); + expect(asA.agentBytes).toEqual(request); + expect(asA.nB).toMatch(/^[A-Za-z0-9_-]{22}$/); // B minted its endpoint nonce + expect(asA.nA).toBeUndefined(); // B spoke first; A's nonce is not in a request yet + const asB = await decodeA2B(s, inF[1]); // opens with B's own pinned encryption half + expect(asB.agentBytes).toEqual(p.payloads()[0]); // what B can OPEN is what B's pane receives + expect(asB.nA).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(asB.nB).toBe(asA.nB); // the SAME session nonces ride both directions + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 2. the method allow-list end to end */ +/* ------------------------------------------------------------------ */ + +test("2. a non-allow-listed agent method (EXTENSION) is refused at A and never forwarded, over the real socket", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + p.conn.write(framed(Buffer.concat([Buffer.from([27]), sshStr("ssh-agent-extension-op"), Buffer.from([1])]))); + await waitUntil(() => p.payloads().length === 1, "the clean refusal"); + expect([...p.payloads()[0]]).toEqual([5]); // SSH2_AGENT_FAILURE, never a hang + await sleep(30); + expect(s.forwarded()).toEqual([]); // A's agent was never asked + expect(producedByA(s)).toBe(1); // the [5] is A's own synthesized refusal: one A2B left A's socket + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 3. wrong origin: an unpinned machine key must not reach the key home */ +/* ------------------------------------------------------------------ */ + +test("3. a B2A envelope signed by a machine key that is not B's pin opens at A but verifies nowhere and reaches nothing", async () => { + const s = await openStack(); + const evil = await evilReady; + try { + // The plane (or anyone) holds A's PUBLIC encryption key and can seal at + // will (spec §5.6's first line): the envelope opens. Only the signature + // can name the origin, and the evil key is not B's pin. + const forged = await sealB2A(s, { + agentBytes: Buffer.from([11]), + seq: 0, + nB: newNonce(), + signerPrivateJwk: evil.privateJwk, + }); + // Openability witness: the SAME envelope decrypts at A (openB2A resolves), + // so the refusal below is the SIGNATURE stage, not a decryption failure. + // Had sealB2A mis-sealed this to the wrong recipient, openB2A would reject + // here and the case would fail loudly instead of passing vacuously. + await openB2A(s, forged); + // ...and decodeB2A (open THEN verify against B's pin) rejects naming the + // signature, which a wrong-recipient seal would instead die a DecryptError. + await expect(decodeB2A(s, forged)).rejects.toThrow(/signature is invalid/); + s.routeFromNode("b-node", forged); + await sleep(60); + expect(s.forwarded()).toEqual([]); // the key home never saw the request + expect(producedByA(s)).toBe(0); // and a forgery costs no answer: no signature oracle + expect(ingress(s).length).toBe(1); // the only thing the plane routed was the forged B2A + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 4. scope-before-forward, after a correctly filtered roster */ +/* ------------------------------------------------------------------ */ + +test("4. after the filtered roster answer, a SIGN_REQUEST for the UNGRANTED blob is refused at A: A's agent is never asked to sign it", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + p.conn.write(framed(Buffer.from([11]))); + await waitUntil(() => p.payloads().length === 1, "the roster answer"); + const roster = parseAnswer(p.payloads()[0]); + expect(roster.type).toBe(12); + expect(roster.count).toBe(1); // two keys on A's agent, one key for the pane + expect(roster.comments).toEqual(["granted key"]); + + p.conn.write(framed(buildSignRequestTenX(KEY_OUT))); // the blob the roster never showed + await waitUntil(() => p.payloads().length === 2, "the refusal"); + expect([...p.payloads()[1]]).toEqual([5]); + + const fwd = s.forwarded(); + expect(fwd.length).toBe(1); // ONLY the identities request went to A's agent + expect(fwd[0]).toEqual(Buffer.from([11])); + for (const seen of s.stub.received) { + if (seen[0] === 13) expect(seen.length).toBe(1); // byte 13 in the stub's view is only the probe + } + expect(producedByA(s)).toBe(2); // both answers came back, but the second is a refusal, not a signature + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 5. the relayed blob is byte-opaque AT THE PLANE */ +/* ------------------------------------------------------------------ */ + +test("5. a full exchange leaves no plaintext anywhere the plane can see: wire capture, audits, session view, broker log", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + const signRequest = buildSignRequestTenX(KEY_IN, { data: Buffer.from("DATA-SECRET") }); + p.conn.write(framed(Buffer.from([11]))); + await waitUntil(() => p.payloads().length === 1, "the roster"); + p.conn.write(framed(signRequest)); + await waitUntil(() => p.payloads().length === 2, "the signature"); + + const signResponse = p.payloads()[1]; + // Only full multi-field payloads ride the marker list: a one-byte + // request's two-character base64 would "collide" inside random + // ciphertext by chance and tell us nothing. + const payloadB64 = [signRequest, signResponse].flatMap((b) => [ + b.toString("base64"), + base64UrlNoPad(new Uint8Array(b)), + ]); + const markers = [ + "KEY-IN", + "KEY-OUT", + "ssh-ed25519", + "granted key", + "ungranted key", + "DATA-SECRET", // markers containing a hyphen or a space cannot occur inside base64 by chance + ]; + + // Scan every string the plane holds BEFORE the close, then force the + // close audit and scan again (the lifetime audit rides the close). + const sessionView = JSON.stringify(s.broker.sessionInfo(s.result.ref)); + const beforeClose = [...s.planeVisible(), sessionView]; + expect(await s.broker.closeRelay(s.result.ref, "child-exit")).toBe(true); + const afterClose = s.planeVisible(); + + const blobs = ingress(s).map((f) => f.blob); + expect(blobs.length).toBeGreaterThanOrEqual(4); // two requests and two answers crossed + for (const text of [...beforeClose, ...afterClose]) { + for (const marker of markers) expect(text).not.toContain(marker); + for (const form of payloadB64) expect(text).not.toContain(form); + } + for (const blob of blobs) { + // The opaque envelope itself never leaks into a durable structure: + // audits and the session view carry ids and paths, never relay bytes. + for (const a of s.audits) expect(a.metadataJson).not.toContain(blob); + expect(sessionView).not.toContain(blob); + } + + // The audit census is the shipped posture: routing ids, hosts, the + // reason, and the fingerprint COUNT (never the set) - and no fingerprints. + const open = s.audits.find((a) => a.action === "node.ssh_relay.open"); + const close = s.audits.find((a) => a.action === "node.ssh_relay.close"); + expect(open).toBeDefined(); + expect(close).toBeDefined(); + expect(open?.metadata.fingerprintCount).toBe(1); + expect(Object.keys(open?.metadata ?? {}).sort()).toEqual( + ["aNodeId", "bNodeId", "fingerprintCount", "userId", "lifetimeMs", "paneId", "ref", "relayId"].sort(), + ); + expect(close?.metadata.reason).toBe("child-exit"); + for (const a of s.audits) { + expect(a.metadataJson).not.toContain(fp(KEY_IN)); + expect(a.metadataJson).not.toContain(fp(KEY_OUT)); + } + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 6. replay inertness: inside the window, and into a ref-reuse */ +/* ------------------------------------------------------------------ */ + +test("6. a captured envelope is INERT replayed inside the lifetime and inert replayed into a later session on the reused routing ref", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + const request = buildSignRequestTenX(KEY_IN); + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 1, "the genuine signature"); + const inF = ingress(s); + const b2a = inF[0]; + const a2b = inF[1]; + const decReq = await decodeB2A(s, b2a); + const decRep = await decodeA2B(s, a2b); + + // (a) inside the lifetime window. + // (a1) the byte-identical B2A resend (the plane routes what it already + // routed): refused by the binding flip (the recorded nA must ride) and, + // whatever path refuses it, A asks its agent nothing and answers nothing. + s.routeFromNode("b-node", JSON.parse(JSON.stringify(b2a)) as RelayFrame); + await sleep(60); + expect(s.forwarded().length).toBe(1); + expect(producedByA(s)).toBe(1); + expect(p.payloads().length).toBe(1); + + // (a2) a freshly signed B2A that passes EVERY binding except seq: the + // signed anti-replay value goes backwards, so the SeqGate alone refuses. + const rewind = await sealB2A(s, { + agentBytes: request, + seq: 0, + nB: decReq.nB, + ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), + signerPrivateJwk: s.bId.signingPrivateJwk, + }); + // Openability witness: the refused envelope opens AND verifies end to end + // (decodeB2A runs A's open+verify checklist with NO gate - the gate is the + // responder's own next step), so the live refusal below can only be the + // SeqGate's. A sealB2A that sealed to the wrong recipient would throw + // right here, failing this case instead of refusing for the wrong reason. + expect((await decodeB2A(s, rewind)).agentBytes).toEqual(request); + s.routeFromNode("b-node", rewind); + await sleep(60); + expect(s.forwarded().length).toBe(1); + expect(producedByA(s)).toBe(1); + + // (a3) the captured A2B replayed toward B: refused by B's gate; the pane + // stream gains nothing and the connection is not punished for it. + s.routeFromNode("a-node", JSON.parse(JSON.stringify(a2b)) as RelayFrame); + await sleep(60); + expect(p.payloads().length).toBe(1); + expect(p.closed()).toBe(false); + + // (b) the crown jewel: both machines end this pairing and a LATER + // session re-presents the SAME routing ref with a fresh relay id and + // fresh endpoint state. The ref is `crypto.randomUUID()` per pairing, so + // the broker itself would never re-issue it; what the test forces is the + // state the plane is actually in when a ref somehow comes back: it routes + // the ref blindly (it never sees relay ids or nonces), so only the + // ENDPOINTS' bindings can make the old bytes inert. + s.closeNodeSessions(s.result.ref, "child-exit"); + expect(s.aRelay.has(s.result.ref)).toBe(false); + expect(s.bRelay.has(s.result.ref)).toBe(false); + expect(s.broker.sessionInfo(s.result.ref)).not.toBeNull(); // the plane's lane for the ref is still open + const cmd2 = (role: "A" | "B"): SshRelayOpenCommand => ({ + type: "ssh_relay_open", + relayId: "relay-second", + ref: s.result.ref, + role, + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: role === "A" ? s.bId.signingPublicJwk : s.aId.signingPublicJwk, + peerEncryptPublicKey: b64Jwk(role === "A" ? s.bId.publicJwk : s.aId.publicJwk), + fingerprints: [fp(KEY_IN)], + lifetimeMs: SSH_RELAY_LIFETIME_MS, + paneId: PANE_ID, + hostPin: `git.example.test ssh-ed25519 ${"A".repeat(51)}`, + }); + await openARelaySession({ + relay: s.aRelay, + dataDir: s.aDir, + selfNodeId: "a-node", + cmd: cmd2("A"), + sendRelayFrame: (f) => s.routeFromNode("a-node", f), + resolveAgentSocket: () => s.stub.path, + }); + await openBRelaySession({ + relay: s.bRelay, + dataDir: s.bDir, + selfNodeId: "b-node", + paneId: PANE_ID, + cmd: cmd2("B"), + sendRelayFrame: (f) => s.routeFromNode("b-node", f), + }); + const fwdBase = s.forwarded().length; // session 2's open re-probed; baseline moves + const aProducedBase = producedByA(s); + + // (b1) the old relayId, old-nonce, old-seq B2A envelope, replayed into + // the session that shares its ref: A's session-2 responder opens it (the + // key home never moved) and EXACTLY one binding refuses it: the signed + // relaySessionId claim (the bytes carry session 1's id, this responder was + // opened under relay-second). The endpoint nonces refuse nothing at this + // point: session 2 has recorded no nB and has not flipped bindNA, so its + // checklist omits both nonce members; and the fresh gate would have + // ACCEPTED seq 0, had the verification ever passed. + s.routeFromNode("b-node", JSON.parse(JSON.stringify(b2a)) as RelayFrame); + await sleep(60); + expect(s.forwarded().length).toBe(fwdBase); + expect(producedByA(s)).toBe(aProducedBase); + + // (b2) the old A2B envelope replayed at session 2's idle proxy: nothing + // is written to any pane socket by it. + const p2 = paneAt(s.result.socketPath); + s.routeFromNode("a-node", JSON.parse(JSON.stringify(a2b)) as RelayFrame); + await sleep(60); + expect(p2.payloads().length).toBe(0); + + // (b3) and session 2 itself is healthy: a genuine sign completes on it, + // proving the refusals above refused the FORGERY, not the session. + p2.conn.write(framed(buildSignRequestTenX(KEY_IN))); + await waitUntil(() => p2.payloads().length === 1, "session 2's own signature"); + expect([...p2.payloads()[0]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); + p2.destroy(); + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 7. multi-key session: several granted keys, strictly increasing seq */ +/* ------------------------------------------------------------------ */ + +test("7. a multi-key session: SIGN_REQUESTs for two granted keys at strictly increasing seqs both complete", async () => { + const s = await openStack({ fingerprints: [fp(KEY_IN), fp(KEY_OUT)] }); + try { + const p = paneAt(s.result.socketPath); + const req1 = buildSignRequestTenX(KEY_IN, { data: Buffer.from("DATA-IN") }); + const req2 = buildSignRequestTenX(KEY_OUT, { data: Buffer.from("DATA-OUT") }); + p.conn.write(framed(req1)); + await waitUntil(() => p.payloads().length === 1, "the first signature"); + p.conn.write(framed(req2)); + await waitUntil(() => p.payloads().length === 2, "the second signature"); + expect([...p.payloads()[0]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); + expect([...p.payloads()[1]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); + expect(s.forwarded()).toEqual([req1, req2]); + + const inF = ingress(s); + const b2a = inF.filter((f) => f.direction === "B2A"); + const a2b = inF.filter((f) => f.direction === "A2B"); + expect(b2a.length).toBe(2); + expect(a2b.length).toBe(2); + const signedB2A = await Promise.all(b2a.map((f) => decodeB2A(s, f))); + const signedA2B = await Promise.all(a2b.map((f) => decodeA2B(s, f))); + expect(signedB2A.map((d) => d.seq)).toEqual([0, 1]); // the SIGNED seqs are strictly increasing + expect(signedA2B.map((d) => d.seq)).toEqual([0, 1]); + expect(new Set(signedB2A.map((d) => d.nB)).size).toBe(1); // one session, one pair of nonces + expect(new Set(signedA2B.map((d) => d.nA)).size).toBe(1); + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 8. a resent or non-increasing seq is refused, both directions */ +/* ------------------------------------------------------------------ */ + +test("8. a resent envelope and a non-increasing signed seq are refused in BOTH directions, and the session still serves the next request", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + const request = buildSignRequestTenX(KEY_IN); + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 1, "the genuine signature"); + const inF = ingress(s); + + // B2A direction, byte-identical resend (the plane can resend any envelope). + s.routeFromNode("b-node", JSON.parse(JSON.stringify(inF[0])) as RelayFrame); + await sleep(50); + expect(s.forwarded().length).toBe(1); + expect(producedByA(s)).toBe(1); + + // B2A direction, a freshly signed envelope whose seq does not advance + // (every other binding correct): the gate alone refuses it. + const decReq = await decodeB2A(s, inF[0]); + const decRep = await decodeA2B(s, inF[1]); + const sameSeq = await sealB2A(s, { + agentBytes: request, + seq: 0, + nB: decReq.nB, + ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), + signerPrivateJwk: s.bId.signingPrivateJwk, + }); + // Openability witness (case 6(a2)'s same guard): every check before the + // gate passes for this exact envelope, so the refusal below is the gate's + // alone. A mis-sealed fixture would throw here, not pass vacuously. + expect((await decodeB2A(s, sameSeq)).agentBytes).toEqual(request); + s.routeFromNode("b-node", sameSeq); + await sleep(50); + expect(s.forwarded().length).toBe(1); + expect(producedByA(s)).toBe(1); + + // A2B direction: the captured answer resent toward B; the pane's stream + // gains no duplicate and the connection lives. + s.routeFromNode("a-node", JSON.parse(JSON.stringify(inF[1])) as RelayFrame); + await sleep(50); + expect(p.payloads().length).toBe(1); + expect(p.closed()).toBe(false); + + // Both gates moved forward, so the session continues: request two answers + // at signed seq 1 in each direction. + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 2, "the next genuine signature"); + const secondA2B = ingress(s) + .filter((f) => f.direction === "A2B") + .at(-1); + expect((await decodeA2B(s, secondA2B as RelayFrame)).seq).toBe(1); + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 9. B rejects a forged or garbled A2B: nothing reaches the socket */ +/* ------------------------------------------------------------------ */ + +test("9. a forged A2B signature fails the pane's connection without a byte written, and a garbled envelope is noise that costs the session nothing", async () => { + const s = await openStack(); + const evil = await evilReady; + try { + const p = paneAt(s.result.socketPath); + const request = buildSignRequestTenX(KEY_IN); + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 1, "the genuine first signature"); + const inF = ingress(s); + const decReq = await decodeB2A(s, inF[0]); + const decRep = await decodeA2B(s, inF[1]); + + // Request two, with the plane withholding A's answer so the slot stays + // open: the forged reply races nothing. + s.swallowB2A = true; + p.conn.write(framed(request)); + await waitUntil( + () => ingress(s).filter((f) => f.direction === "B2A").length === 2, + "the second request reaches the plane", + ); + const forged = await sealA2B(s, { + agentBytes: Buffer.concat([Buffer.from([14]), sshStr(Buffer.from("EVIL-SIG"))]), + seq: 1, + nB: decReq.nB, + ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), + signerPrivateJwk: evil.privateJwk, // opens (sealed to B's public half), verifies against nobody's pin + }); + s.routeFromNode("a-node", forged); + await waitUntil(() => p.closed(), "the forged reply fails the waiting connection"); + expect(p.payloads().length).toBe(1); // the refused frame wrote nothing... + for (const pl of p.payloads()) expect(pl.includes(Buffer.from("EVIL-SIG"))).toBe(false); // ...and it was never the forged bytes + + // A's late answer to the dead request 2 is absorbed by the burned slot. + s.swallowB2A = false; + s.releaseB2A(); + await sleep(30); + + // The garble, against a fresh connection: valid base64, broken JWE bytes. + const p3 = paneAt(s.result.socketPath); + s.swallowB2A = true; + p3.conn.write(framed(request)); + await waitUntil( + () => ingress(s).filter((f) => f.direction === "B2A").length === 3, + "the third request reaches the plane", + ); + const garbled: RelayFrame = { ...inF[1], blob: garbleBlob(inF[1].blob) }; // the genuine answer's envelope, one byte broken + s.routeFromNode("a-node", garbled); + await sleep(50); + expect(p3.payloads().length).toBe(0); // an unopenable envelope writes nothing + expect(p3.closed()).toBe(false); // ...and is noise, not a forgery: the session lives + s.swallowB2A = false; + s.releaseB2A(); // A's real answer to request 3 lands: the garble cost the session nothing + await waitUntil(() => p3.payloads().length === 1, "the real answer after the garbled noise"); + expect([...p3.payloads()[0]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); + p3.destroy(); + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 10. the roster command: answered with no grant, no relay session */ +/* ------------------------------------------------------------------ */ + +// Matrix case 10, flipped from the Task 9 todo now that the command exists +// (spec 2026-10-08 §5.4, Task 11). The whole matrix stack is deliberately +// ABSENT: there is no broker, no grant, no pairing, and no B. The point of +// the case is that the roster read needs none of that - A's live agent and +// A's own gate mirror are its entire world. +test("10. ssh_agent_identities is accepted with no grant and no relay session: the WHOLE roster answers, blobs withheld", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-roster-")); + const stub = await startStubAgent(ROSTER, OPENSSH_10X_SCHEME); + writeSshEnabled(dir, { on: true, changedAt: "2026-10-08T10:00:00.000Z" }); + const savedSock = process.env.SSH_AUTH_SOCK; + process.env.SSH_AUTH_SOCK = stub.path; // the handler's production socket lookup, over a real round trip + try { + // The arm dispatch would run, on the same data-dir-only context the node + // suite builds; routing of the type is the node suite's own pin. + const result = await execSshAgentIdentities({ config: { dataDir: dir } } as unknown as CommandContext); + expect(result.ok).toBe(true); + const roster = parseNodeSshAgentIdentities(result.ok ? result.data : null); + expect(roster).not.toBeNull(); + // The WHOLE roster, not a grant-filtered one: with nothing granted there + // is no set to scope by, and the approval screen must show both keys. + expect(roster?.identities).toEqual([ + { fingerprint: fp(KEY_IN), comment: "granted key" }, + { fingerprint: fp(KEY_OUT), comment: "ungranted key" }, + ]); + // The blobs are withheld: neither wire encoding of either blob appears + // anywhere in the serialized result frame. + const wire = JSON.stringify(result); + for (const blob of [KEY_IN, KEY_OUT]) { + expect(wire).not.toContain(blob.toString("base64")); + expect(wire).not.toContain(blob.toString("base64url")); + } + // Only one-byte requests ever reached the agent: the classic candidate + // (13) confirmed FAILURE, the 10.x candidate (11) resolved the scheme + // positively, and the roster was asked in the RESOLVED numbering. No + // sign body, no session byte: nothing the responder's rules exclude. + expect(stub.received).toEqual([Buffer.from([13]), Buffer.from([11]), Buffer.from([11])]); + // Nothing was written anywhere the read could not already see: the data + // dir holds exactly the gate mirror, no session state, no audit artifact. + expect(readdirSync(dir)).toEqual(["ssh-enabled.json"]); + } finally { + if (savedSock === undefined) delete process.env.SSH_AUTH_SOCK; + else process.env.SSH_AUTH_SOCK = savedSock; + await stub.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts new file mode 100644 index 000000000..bc3c1d666 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts @@ -0,0 +1,794 @@ +import { describe, expect, it } from "bun:test"; +import { buildAgentSocketPath } from "@internal/pane-runtime"; +import type { + NodeCommandBody, + RelayFrame, + SshRelayCloseReason, + SshRelayOpenCommand, +} from "@internal/subshell-protocol"; +import { + NODE_PROTOCOL_VERSION, + SSH_RELAY_LIFETIME_MS, + SSH_RELAY_MAX_PER_NODE, + SSH_RELAY_TEARDOWN_GRACE_MS, +} from "@internal/subshell-protocol"; +import type { NodeSocket } from "@/services/nodes/node-registry.js"; +import { attachConnection, getLive, resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { + closeRelayForPaneExit, + createRelayBroker, + type RelayBroker, + RelaySendError, + sendRelayFrameOverNodeSocket, + setRelayBrokerForTests, + sshRelayPaneEnv, + sweepRelayForPane, +} from "@/services/ssh-relay.service.js"; + +/** + * The plane relay broker (spec 2026-10-08 §5.1/§5.3/§5.5/§5.6, Task 8). The + * whole file runs against injected seams: no DB, no socket, no real clock. + * The acceptance gates get their own named test: + * (a) both opens are delivered as signed commands with the role branches and + * the close reaches both sides; (b) paneId carriage + refusal; + * (c) the per-node send glue throws on a dropped send and a frame routed to + * a dead peer tears the session down; (d) `ssh_enabled` and `local` refuse + * before anything is brokered; (e) SSH_AUTH_SOCK leaves ONLY as the scoped + * one-key pane env, byte-matching the B answer's socket path; (f) every + * §5.6 cut fires its named reason through injectable timers; (g) the quota + * is a loud refusal touching nothing; (h) the peer encryption key is carried + * as base64 of the UTF-8 JSON public JWK, and the plane is BLIND (§5.5): + * routing is a byte-for-byte copy and no session record ever holds a blob. + */ + +const PIN_LINE = `git.example.test ssh-ed25519 ${"A".repeat(51)}`; +const PANE = "11111111-2222-4333-8444-555555555555"; +const A_SIGNING = '{"kty":"EC","crv":"P-256","x":"AX","y":"AY","use":"sig"}'; +const A_ENCRYPT = '{"kty":"EC","crv":"P-256","x":"EX","y":"EY","use":"enc"}'; +const B_SIGNING = '{"kty":"EC","crv":"P-256","x":"BX","y":"BY","use":"sig"}'; +const B_ENCRYPT = '{"kty":"EC","crv":"P-256","x":"FX","y":"FY","use":"enc"}'; +const FINGERPRINTS = ["SHA256:AAAA", "SHA256:BBBB"]; + +const b64 = (s: string): string => Buffer.from(s, "utf8").toString("base64"); + +const frame = (over: Partial = {}): RelayFrame => ({ + type: "relay", + ref: "r-unknown", + seq: 0, + direction: "B2A", + blob: "QUJDk5+ToQ==", // opaque; the broker must never decode it + ...over, +}); + +interface FakeTimer { + id: number; + at: number; + fn: () => void; + cancelled: boolean; +} + +function makeHarness( + opts: { + rows?: Record; + dataDir?: string | null; + ack?: (nodeId: string, cmd: NodeCommandBody) => unknown | Promise; + authorize?: (userId: string, nodeId: string) => Promise; + relaySend?: (nodeId: string, frame: RelayFrame) => void; + } = {}, +) { + const clock = { now: 1_730_000_000_000 }; + const timers: FakeTimer[] = []; + let nextTimer = 1; + const commands: { nodeId: string; cmd: NodeCommandBody }[] = []; + const relayFrames: { nodeId: string; frame: RelayFrame }[] = []; + const audits: { + action: string; + actorUserId: string | null; + targetType: string | null; + targetId: string | null; + meta: Record; + }[] = []; + const lines: string[] = []; + const rows = opts.rows ?? { a: { kind: "agent", sshEnabled: 1 }, b: { kind: "agent", sshEnabled: 1 } }; + const defaultAck = (_nodeId: string, cmd: NodeCommandBody): unknown => + cmd.type === "ssh_relay_open" && cmd.role === "B" + ? { role: "B", relayId: cmd.relayId, socketPath: buildAgentSocketPath(opts.dataDir ?? "/data/b", cmd.paneId) } + : cmd.type === "ssh_relay_open" + ? { role: "A", relayId: cmd.relayId } + : { ok: true }; + + const broker: RelayBroker = createRelayBroker({ + authorize: opts.authorize ?? (async () => true), + async sendCommand(nodeId, cmd) { + commands.push({ nodeId, cmd }); + return (opts.ack ?? defaultAck)(nodeId, cmd); + }, + sendRelayFrame(nodeId, f) { + relayFrames.push({ nodeId, frame: f }); + if (opts.relaySend) return opts.relaySend(nodeId, f); + throw new RelaySendError(nodeId, "fake pump always drops"); + }, + async nodeRow(nodeId) { + const r = rows[nodeId]; + return r === undefined ? null : r; + }, + nodeDataDir: () => (opts.dataDir === undefined ? "/data/b" : opts.dataDir), + async audit(e) { + audits.push({ ...e, meta: JSON.parse(e.metadataJson ?? "{}") as Record }); + }, + nowMs: () => clock.now, + schedule: (fn, ms) => { + const t: FakeTimer = { id: nextTimer, at: clock.now + ms, fn, cancelled: false }; + nextTimer += 1; + timers.push(t); + return t; + }, + cancel: (t) => { + (t as FakeTimer).cancelled = true; + }, + log: (line) => lines.push(line), + }); + + const fireDue = (): number => { + let fired = 0; + for (;;) { + const due = timers.filter((t) => !t.cancelled && t.at <= clock.now).sort((x, y) => x.at - y.at)[0]; + if (!due) return fired; + due.cancelled = true; + due.fn(); + fired += 1; + } + }; + + return { + broker, + commands, + relayFrames, + audits, + lines, + timers, + clock, + advance: async (ms: number) => { + clock.now += ms; + // A macrotask turn drains every microtask chain the fake scheduler's + // callbacks started (allSettleds, awaits in closeRelay). + await new Promise((r) => setTimeout(r, 0)); + fireDue(); + await new Promise((r) => setTimeout(r, 0)); + }, + open: (over: Partial[0]> = {}) => + broker.openRelay({ + identityGenerations: { + a: broker.identityGeneration(over.aNode ?? "a"), + b: broker.identityGeneration(over.bNode ?? "b"), + }, + userId: "grant-1", + fingerprints: FINGERPRINTS, + paneId: PANE, + aNode: "a", + bNode: "b", + aPeer: { signingPublicKey: A_SIGNING, encryptionPublicJwk: A_ENCRYPT }, + bPeer: { signingPublicKey: B_SIGNING, encryptionPublicJwk: B_ENCRYPT }, + // Task 12: the destination's pin is REQUIRED at the broker; the base + // carries a well-formed line and one refusal test drops it. + hostPin: PIN_LINE, + ...over, + }), + }; +} + +/* ------------------------------------------------------------------ */ +/* openRelay: pairing, carriage, gates, quota */ +/* ------------------------------------------------------------------ */ + +describe("openRelay (spec §5.1/§5.3, Task 8 (a)(b)(d)(g)(h))", () => { + it("pairs A and B with an opaque ref and delivers the whole pairing to BOTH sides", async () => { + const h = makeHarness(); + const r = await h.open(); + // Opaque ids, minted by the plane; the ref is the blind pairing key. + expect(r.ref).toMatch(/^[0-9a-f-]{36}$/); + expect(r.relayId).toMatch(/^[0-9a-f-]{36}$/); + expect(r.ref).not.toBe(r.relayId); + expect(new Date(r.expiresAt).getTime()).toBe(h.clock.now + SSH_RELAY_LIFETIME_MS); + + const toA = h.commands.find((c) => c.nodeId === "a")?.cmd as SshRelayOpenCommand | undefined; + const toB = h.commands.find((c) => c.nodeId === "b")?.cmd as SshRelayOpenCommand | undefined; + expect(toA?.type).toBe("ssh_relay_open"); + expect(toB?.type).toBe("ssh_relay_open"); + // (a) the role branches name each machine what it is; one ref pairs them. + expect(toA?.role).toBe("A"); + expect(toB?.role).toBe("B"); + expect(toA?.ref).toBe(r.ref); + expect(toB?.ref).toBe(r.ref); + expect(toA?.relayId).toBe(r.relayId); + expect(toA?.aNodeId).toBe("a"); + expect(toA?.bNodeId).toBe("b"); + expect(toA?.lifetimeMs).toBe(SSH_RELAY_LIFETIME_MS); + // Each side is told the PEER's keys to pin, never its own. + expect(toA?.peerSigningPublicKey).toBe(B_SIGNING); + expect(toB?.peerSigningPublicKey).toBe(A_SIGNING); + // (h) the encryption half is base64 of the UTF-8 JSON public JWK - the + // exact spelling the node decodes and the pin store holds. + expect(toA?.peerEncryptPublicKey).toBe(b64(B_ENCRYPT)); + expect(Buffer.from(String(toA?.peerEncryptPublicKey), "base64").toString("utf8")).toBe(B_ENCRYPT); + expect(toB?.peerEncryptPublicKey).toBe(b64(A_ENCRYPT)); + // (b) the pane rides the command (the B proxy socket is named by it). + expect(toA?.paneId).toBe(PANE); + expect(toB?.paneId).toBe(PANE); + // (a) the grant's selected fingerprints are delivered for A's responder. + expect(toA?.fingerprints).toEqual(FINGERPRINTS); + expect(toA).not.toHaveProperty("userId"); + // Task 12 (spec §9): the destination's pinned host-key line rides the + // signed open to BOTH sides (B writes it beside the socket it binds); the + // audit still names only ids, never the pin bytes. + expect(toA?.hostPin).toBe(PIN_LINE); + expect(toB?.hostPin).toBe(PIN_LINE); + expect(JSON.stringify(h.audits[0]?.meta ?? {})).not.toContain("ssh-ed25519"); + // The open is audited once, ids only. + expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open"]); + expect(h.audits[0]?.meta).toEqual({ + relayId: r.relayId, + ref: r.ref, + userId: "grant-1", + paneId: PANE, + aNodeId: "a", + bNodeId: "b", + fingerprintCount: FINGERPRINTS.length, + lifetimeMs: SSH_RELAY_LIFETIME_MS, + }); + expect(h.audits[0]?.actorUserId).toBeNull(); + // And the plane records both sides as opened once the acks land. + const info = h.broker.sessionInfo(r.ref); + expect(info).not.toBeNull(); + expect(info?.aOpened).toBe(true); + expect(info?.bOpened).toBe(true); + }); + + it("answers the verified B socket path, byte-matching the shared derivation", async () => { + const h = makeHarness(); + const r = await h.open(); + expect(r.socketPath).toBe(buildAgentSocketPath("/data/b", PANE)); + }); + + it("refuses a B answer whose socket path is not THIS pane's derived path (never trusts a machine claim)", async () => { + const h = makeHarness({ + ack: (_nodeId, cmd) => + cmd.type === "ssh_relay_open" && cmd.role === "B" + ? { role: "B", relayId: cmd.relayId, socketPath: "/attacker/agent.sock" } + : { role: "A" }, + }); + await expect(h.open()).rejects.toMatchObject({ code: "bad-socket-path" }); + // The session was torn down and BOTH sides were told, under a named cut. + expect(h.broker.sessionInfo(h.audits[0]?.meta.ref as string)).toBeNull(); + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes.map((c) => c.nodeId).sort()).toEqual(["a", "b"]); + expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open", "node.ssh_relay.close"]); + }); + + it("refuses a missing or non-string B socket answer the same way", async () => { + const h = makeHarness({ + ack: (_nodeId, cmd) => + cmd.type === "ssh_relay_open" && cmd.role === "B" ? { role: "B", relayId: cmd.relayId } : { role: "A" }, + }); + await expect(h.open()).rejects.toMatchObject({ code: "bad-socket-path" }); + }); + + it("refuses a node whose ssh gate is off or has no row - before any command or audit (acceptance (d))", async () => { + for (const [rows, code] of [ + [{ a: { kind: "agent" as const, sshEnabled: 0 }, b: { kind: "agent" as const, sshEnabled: 1 } }, "node-off"], + [{ a: null, b: { kind: "agent" as const, sshEnabled: 1 } }, "no-node"], + [{ a: { kind: "agent" as const, sshEnabled: null }, b: { kind: "agent" as const, sshEnabled: 1 } }, "node-off"], + ] as const) { + const h = makeHarness({ rows: rows as never }); + await expect(h.open()).rejects.toMatchObject({ code }); + expect(h.commands).toHaveLength(0); + expect(h.audits).toHaveLength(0); + expect(h.broker.activeRelayCount("a")).toBe(0); + } + }); + + it("refuses the same machine as both A and B, and a paneId outside the path-composition shape (acceptance (b))", async () => { + const h = makeHarness(); + await expect(h.open({ aNode: "a", bNode: "a" })).rejects.toMatchObject({ code: "same-node" }); + await expect(h.open({ paneId: "../escape" })).rejects.toMatchObject({ code: "bad-pane-id" }); + await expect(h.open({ paneId: "" })).rejects.toMatchObject({ code: "bad-pane-id" }); + await expect(h.open({ paneId: "x".repeat(65) })).rejects.toMatchObject({ code: "bad-pane-id" }); + await expect(h.open({ fingerprints: ["not-a-fingerprint"] })).rejects.toMatchObject({ code: "bad-fingerprints" }); + // Task 12 (spec §9): the pin door at the mint - a session without the + // destination's pinned line does not exist, so a missing, empty, or + // multi-line hostPin is refused BEFORE any command leaves the plane. + await expect(h.open({ hostPin: "" })).rejects.toMatchObject({ code: "bad-host-pin" }); + await expect(h.open({ hostPin: undefined })).rejects.toMatchObject({ code: "bad-host-pin" }); + await expect(h.open({ hostPin: "host ssh-rsa AAA\nsecond ssh-rsa BBB" })).rejects.toMatchObject({ + code: "bad-host-pin", + }); + expect(h.commands).toHaveLength(0); + expect(h.audits).toHaveLength(0); + }); + + it("enforces SSH_RELAY_MAX_PER_NODE as a loud refusal that touches nothing (acceptance (g))", async () => { + const h = makeHarness({ + rows: { + a: { kind: "agent", sshEnabled: 1 }, + b: { kind: "agent", sshEnabled: 1 }, + "fresh-b": { kind: "agent", sshEnabled: 1 }, + }, + }); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) { + await h.open({ paneId: `pane-${i}`, userId: `grant-${i}` }); + } + expect(h.broker.activeRelayCount("b")).toBe(SSH_RELAY_MAX_PER_NODE); + const auditsBefore = h.audits.length; + const commandsBefore = h.commands.length; + await expect(h.open({ paneId: "pane-over", userId: "grant-over" })).rejects.toMatchObject({ + code: "quota", + nodeId: "a", + }); + expect(h.audits).toHaveLength(auditsBefore); // no half-done audit + expect(h.commands).toHaveLength(commandsBefore); // no half-delivered command + // The cap counts each of A and B: a node at the cap cannot join even as A. + await expect( + h.open({ paneId: "pane-as-a", userId: "grant-as-a", aNode: "b", bNode: "fresh-b" }), + ).rejects.toMatchObject({ code: "quota", nodeId: "b" }); + // A closed slot frees room (the count is LIVE sessions, not history). + const first = h.broker.sessionInfo(h.audits[0]?.meta.ref as string); + expect(first).not.toBeNull(); + await h.broker.closeRelay(first?.ref as string, "lifetime-expiry"); + await expect(h.open({ paneId: "pane-over", userId: "grant-over" })).resolves.toMatchObject({}); + }); + + it("arms the lifetime and the grace on the INJECTED timers only (no real 30 s waits)", async () => { + const h = makeHarness(); + await h.open(); + const armed = h.timers.map((t) => t.at - 1_730_000_000_000).sort((a, b) => a - b); + expect(armed).toEqual([SSH_RELAY_TEARDOWN_GRACE_MS, SSH_RELAY_LIFETIME_MS]); + }); +}); + +/* ------------------------------------------------------------------ */ +/* routing: blind, by ref, throw-on-drop teardown */ +/* ------------------------------------------------------------------ */ + +describe("routeRelayFrame (spec §5.5, Task 8 (c))", () => { + it("forwards the blob byte-for-byte to the peer and holds nothing itself", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + const f = frame({ ref: r.ref, direction: "B2A", blob: b64(JSON.stringify({ secret: "agent bytes" })) }); + h.broker.routeRelayFrame("b", f); + expect(h.relayFrames).toHaveLength(1); + expect(h.relayFrames[0]?.nodeId).toBe("a"); + // The exact same frame, same strings, same objects: an unopened copy. + expect(h.relayFrames[0]?.frame).toBe(f); + // The plane read the ref and nothing else: the session record has no + // blob slot at any point in its life (§5.5), and never gained one. + const info = h.broker.sessionInfo(r.ref); + expect(Object.keys(info ?? {}).sort()).toEqual( + ["aNode", "aOpened", "bNode", "bOpened", "expiresAt", "userId", "paneId", "ref", "relayId", "socketPath"].sort(), + ); + // ...and the session view never holds the blob, encoded or decoded. + expect(JSON.stringify(info)).not.toContain(f.blob); + expect(JSON.stringify(info)).not.toContain("agent bytes"); + // Both directions route; A's reply rides to B untouched. + h.broker.routeRelayFrame("a", frame({ ref: r.ref, direction: "A2B", seq: 0 })); + expect(h.relayFrames[1]?.nodeId).toBe("b"); + }); + + it("drops a frame for an unbrokered ref and a frame from a node that is not a party", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + h.broker.routeRelayFrame("b", frame({ ref: "r-not-brokered" })); + h.broker.routeRelayFrame("stranger", frame({ ref: r.ref })); + expect(h.relayFrames).toHaveLength(0); + expect(h.broker.sessionInfo(r.ref)).not.toBeNull(); // a drop is not a teardown + }); + + it("a frame routed to a dead A peer throws through the glue and the session is torn down, not left spinning (acceptance (c))", async () => { + const h = makeHarness(); // the fake pump DROPS (throws) by default + const r = await h.open(); + h.broker.routeRelayFrame("b", frame({ ref: r.ref })); + // The teardown it triggers is async (the close sends, then the audit). + await new Promise((resolve) => setTimeout(resolve, 0)); + // The phantom-hang direction (B's request cannot reach A): the plane cuts + // now, with the named reason, and both sides are told. + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes).toHaveLength(2); + expect(closes[0]?.cmd).toEqual({ type: "ssh_relay_close", ref: r.ref, reason: "a-dropped" }); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open", "node.ssh_relay.close"]); + expect(h.audits[1]?.meta.reason).toBe("a-dropped"); + }); + + it("an undeliverable reply to a dead B loses the frame but not the session (no b-dropped word exists; the caps own it)", async () => { + const h = makeHarness(); + const r = await h.open(); + h.broker.routeRelayFrame("a", frame({ ref: r.ref, direction: "A2B" })); + expect(h.broker.sessionInfo(r.ref)).not.toBeNull(); // A-side cuts must not masquerade as a-dropped + expect(h.commands.filter((c) => c.cmd.type === "ssh_relay_close")).toHaveLength(0); + // The lifetime cap still ends it. + await h.advance(SSH_RELAY_LIFETIME_MS); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + expect(h.audits[1]?.meta.reason).toBe("lifetime-expiry"); + }); +}); + +/* ------------------------------------------------------------------ */ +/* §5.6 teardown cuts, each with its named reason */ +/* ------------------------------------------------------------------ */ + +describe("teardown (spec §5.6, Task 8 (f))", () => { + it("lifetime expiry cuts with lifetime-expiry, tells both sides, audits once", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + await h.advance(SSH_RELAY_LIFETIME_MS); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes.map((c) => c.nodeId).sort()).toEqual(["a", "b"]); + for (const c of closes) expect(c.cmd).toEqual({ type: "ssh_relay_close", ref: r.ref, reason: "lifetime-expiry" }); + expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open", "node.ssh_relay.close"]); + expect(h.audits[1]?.meta).toMatchObject({ ref: r.ref, relayId: r.relayId, reason: "lifetime-expiry" }); + }); + + it("the grace window elapsed without both sides cuts with handshake-grace", async () => { + let releaseB: ((v: unknown) => void) | undefined; + const h = makeHarness({ + ack: (nodeId, cmd) => { + if (nodeId === "b" && cmd.type === "ssh_relay_open") { + return new Promise((resolve) => { + releaseB = resolve; + }); + } + return cmd.type === "ssh_relay_open" ? { role: "A", relayId: cmd.relayId } : { ok: true }; + }, + }); + const opening = h.open(); + await new Promise((r) => setTimeout(r, 0)); + // A answered; B has not, inside the grace: the session survives. + const opened = h.commands.find((c) => c.cmd.type === "ssh_relay_open"); + const ref = opened?.cmd.type === "ssh_relay_open" ? opened.cmd.ref : ""; + expect(ref).not.toBe(""); + await h.advance(SSH_RELAY_TEARDOWN_GRACE_MS); + expect(h.broker.sessionInfo(ref)).toBeNull(); + expect( + h.commands.some( + (c) => c.cmd.type === "ssh_relay_close" && (c.cmd as { reason: string }).reason === "handshake-grace", + ), + ).toBe(true); + // B's late ack lands on a session that no longer exists: it must not + // resurrect it and openRelay refuses loudly (the handshake never completed). + releaseB?.({ role: "B", socketPath: buildAgentSocketPath("/data/b", PANE) }); + await expect(opening).rejects.toMatchObject({ code: "handshake" }); + expect(h.broker.sessionInfo(ref)).toBeNull(); + }); + + it("a refusing node cuts the session under handshake-grace and openRelay throws the named reason", async () => { + const h = makeHarness({ + ack: (_nodeId, cmd) => { + if (cmd.type === "ssh_relay_open" && cmd.role === "B") throw new Error("machine pin for a has MOVED"); + return { role: "A", relayId: cmd.type === "ssh_relay_open" ? cmd.relayId : "" }; + }, + }); + await expect(h.open()).rejects.toMatchObject({ code: "handshake" }); + expect(h.broker.activeRelayCount("a")).toBe(0); + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes).toHaveLength(2); + const firstClose = closes[0]; + expect(firstClose?.cmd.type).toBe("ssh_relay_close"); + if (firstClose?.cmd.type === "ssh_relay_close") expect(firstClose.cmd.reason).toBe("handshake-grace"); + }); + + it("share revocation closes only sessions whose user loses launch access", async () => { + let revoked = false; + const h = makeHarness({ relaySend: () => {}, authorize: async (userId) => !revoked || userId !== "g-target" }); + const r1 = await h.open({ paneId: "p1", userId: "g-target" }); + const r2 = await h.open({ paneId: "p2", userId: "g-keep" }); + revoked = true; + const n = await h.broker.closeUnauthorizedForNode("a"); + expect(n).toBe(1); + expect(h.broker.sessionInfo(r1.ref)).toBeNull(); + expect(h.broker.sessionInfo(r2.ref)).not.toBeNull(); + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes).toHaveLength(2); + for (const c of closes) expect(c.cmd).toEqual({ type: "ssh_relay_close", ref: r1.ref, reason: "access-revoked" }); + expect(h.audits.filter((a) => a.action === "node.ssh_relay.close")).toHaveLength(1); + }); + + it("A's authenticated socket dropping cuts A's sessions with a-dropped and leaves B-side sessions alone", async () => { + const h = makeHarness({ + relaySend: () => {}, + rows: { + a: { kind: "agent", sshEnabled: 1 }, + b: { kind: "agent", sshEnabled: 1 }, + c: { kind: "agent", sshEnabled: 1 }, + }, + }); + const asA = await h.open({ paneId: "p1" }); + // Same node as B of another pairing: its socket close is NOT a cut + // (§5.6 names only A-dropping; B's loss cuts via child-exit or the caps). + const asB = await h.open({ paneId: "p2", aNode: "c", bNode: "a" }); + const closed = await h.broker.onNodeSocketClosed("a"); + expect(closed).toBe(1); + expect(h.broker.sessionInfo(asA.ref)).toBeNull(); + expect(h.broker.sessionInfo(asB.ref)).not.toBeNull(); + expect( + h.commands.some( + (c) => + c.cmd.type === "ssh_relay_close" && c.nodeId === "b" && (c.cmd as { reason: string }).reason === "a-dropped", + ), + ).toBe(true); + }); + + it("child exit cuts by paneId; the sweep helper is best-effort and takes the row's id", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + const n = await h.broker.closeForPane(PANE, "child-exit"); + expect(n).toBe(1); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + expect( + h.commands.some((c) => c.cmd.type === "ssh_relay_close" && (c.cmd as { reason: string }).reason === "child-exit"), + ).toBe(true); + // An unknown pane is a no-op (the sweep runs on every death transition). + expect(await h.broker.closeForPane("pane-nothing", "child-exit")).toBe(0); + // The row-shaped sweep fires through the module singleton without throwing. + setRelayBrokerForTests(h.broker); + try { + sweepRelayForPane({ id: "pane-nothing" }); + closeRelayForPaneExit("pane-nothing"); + closeRelayForPaneExit(null); + } finally { + setRelayBrokerForTests(null); + } + }); + + it("an over-cap frame closes the session it names, by name, and the blob never comes along", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + expect(await h.broker.refuseOverCap(r.ref)).toBe(true); + expect( + h.commands.some((c) => c.cmd.type === "ssh_relay_close" && (c.cmd as { reason: string }).reason === "over-cap"), + ).toBe(true); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + expect(JSON.stringify(h.audits)).not.toContain("over-cap-blob-bytes"); + expect(await h.broker.refuseOverCap("r-unknown")).toBe(false); + }); + + it("closeRelay is idempotent: the second cut answers false and fires no second audit", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + expect(await h.broker.closeRelay(r.ref, "lifetime-expiry")).toBe(true); + expect(await h.broker.closeRelay(r.ref, "lifetime-expiry")).toBe(false); + expect(h.audits.filter((a) => a.action === "node.ssh_relay.close")).toHaveLength(1); + // An unknown reason never leaves the plane either. + expect(await h.broker.closeRelay("r-none", "not-a-reason" as SshRelayCloseReason)).toBe(false); + }); + + it("teardown keeps the close command's own failure from poisoning anything", async () => { + const h = makeHarness({ + relaySend: () => {}, + ack: (_nodeId, cmd) => { + if (cmd.type === "ssh_relay_close") throw new Error("node offline"); + return cmd.type === "ssh_relay_open" && cmd.role === "B" + ? { socketPath: buildAgentSocketPath("/data/b", cmd.paneId) } + : { ok: true }; + }, + }); + const r = await h.open(); + await expect(h.broker.closeRelay(r.ref, "access-revoked")).resolves.toBe(true); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* audit hygiene, pane env, and the real per-node send glue */ +/* ------------------------------------------------------------------ */ + +describe("audit hygiene (Global Constraints)", () => { + it("open/close rows carry ids, hosts and the reason ONLY - never keys, fingerprints, or payloads", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + h.broker.routeRelayFrame("b", frame({ ref: r.ref })); + await h.advance(SSH_RELAY_LIFETIME_MS); + const serialized = JSON.stringify(h.audits); + for (const secret of [A_SIGNING, B_SIGNING, A_ENCRYPT, B_ENCRYPT, "SHA256:AAAA", "QUJDk5", r.socketPath]) { + expect(serialized).not.toContain(secret); + } + // fingerprint COUNT, never the set. + expect(h.audits[0]?.meta.fingerprintCount).toBe(2); + expect(h.audits[0]?.meta.fingerprints).toBeUndefined(); + }); +}); + +describe("SSH_AUTH_SOCK scoping (acceptance (e))", () => { + it("the relay pane env is EXACTLY the one-key scoped exception, nothing else", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + expect(sshRelayPaneEnv(r.socketPath)).toEqual({ SSH_AUTH_SOCK: `/data/b/ssh/${PANE}/agent.sock` }); + // A caller cannot smuggle a second variable through the seam. + expect(Object.keys(sshRelayPaneEnv("x"))).toEqual(["SSH_AUTH_SOCK"]); + }); +}); + +describe("sendRelayFrameOverNodeSocket (the plane's throw-on-drop glue, acceptance (c))", () => { + it("throws when the node has no live connection", () => { + resetNodeRegistryForTests(); + expect(() => sendRelayFrameOverNodeSocket("gone", frame())).toThrow(RelaySendError); + }); + + it("throws on a closing or link-less connection and seals onto a live one", () => { + resetNodeRegistryForTests(); + const sent: (string | Buffer)[] = []; + const ws: NodeSocket = { + send: (d) => { + sent.push(d); + }, + close: () => {}, + }; + const conn = attachConnection("n1", ws); + // No link yet (the handshake window, or a hand-built conn): a relay + // frame is NEVER written plaintext. + expect(() => sendRelayFrameOverNodeSocket("n1", frame({ ref: "r-1" }))).toThrow(RelaySendError); + conn.link = { + sealFrame: (s: string) => new TextEncoder().encode(`SEALED:${s}`), + } as never; + sendRelayFrameOverNodeSocket("n1", frame({ ref: "r-1" })); + expect(sent).toHaveLength(1); + expect(Buffer.isBuffer(sent[0])).toBe(true); + // The Buffer carries the SEALED envelope, not the plaintext frame. + expect((sent[0] as Buffer).toString("utf8")).toContain("SEALED:"); + // Closing / superseded conns throw too. + conn.closing = true; + expect(() => sendRelayFrameOverNodeSocket("n1", frame({ ref: "r-1" }))).toThrow(RelaySendError); + resetNodeRegistryForTests(); + expect(getLive("n1")).toBeUndefined(); + }); + + it("a throwing socket send surfaces as the named RelaySendError (never silence)", () => { + resetNodeRegistryForTests(); + const ws: NodeSocket = { + send: () => { + throw new Error("socket write failed"); + }, + close: () => {}, + }; + const conn = attachConnection("n2", ws); + conn.link = { sealFrame: () => new Uint8Array([1]) } as never; + expect(() => sendRelayFrameOverNodeSocket("n2", frame())).toThrow(RelaySendError); + resetNodeRegistryForTests(); + }); +}); + +describe("grammar/version posture", () => { + it("relays a protocol-19-only world: the constant is pinned here as the broker's precondition", () => { + // The handler refuses every relay frame from a pre-18 agent upstream; + // this tripwire documents the dependency at the broker. + expect(NODE_PROTOCOL_VERSION).toBe(19); + }); +}); + +describe("identity repair excludes concurrent and stale opens", () => { + it("holds the barrier through deferred closes and registration/cache replacement", async () => { + const closing = Promise.withResolvers(); + const closeAck = Promise.withResolvers(); + const replacing = Promise.withResolvers(); + const replacement = Promise.withResolvers(); + const h = makeHarness({ + ack: async (_nodeId, cmd) => { + if (cmd.type === "ssh_relay_close") { + closing.resolve(); + await closeAck.promise; + } + return cmd.type === "ssh_relay_open" && cmd.role === "B" + ? { socketPath: buildAgentSocketPath("/data/b", cmd.paneId) } + : { ok: true }; + }, + }); + const stale = { a: h.broker.identityGeneration("a"), b: h.broker.identityGeneration("b") }; + const session = await h.open(); + const repair = h.broker.withNodeIdentityRepair("a", async () => { + replacing.resolve(); + await replacement.promise; + }); + await closing.promise; + await expect(h.open()).rejects.toMatchObject({ code: "identity-repair" }); + await expect(h.broker.withNodeIdentityRepair("a", async () => {})).rejects.toMatchObject({ + code: "identity-repair", + }); + closeAck.resolve(); + await replacing.promise; + const during = { a: h.broker.identityGeneration("a"), b: h.broker.identityGeneration("b") }; + await expect(h.open()).rejects.toMatchObject({ code: "identity-repair" }); + replacement.resolve(); + await repair; + expect(h.broker.sessionInfo(session.ref)).toBeNull(); + expect(h.broker.activeRelayCount("a")).toBe(0); + await expect(h.open({ identityGenerations: stale })).rejects.toMatchObject({ code: "identity-repair" }); + await expect(h.open({ identityGenerations: during })).rejects.toMatchObject({ code: "identity-repair" }); + await h.open(); + expect(h.broker.activeRelayCount("a")).toBe(1); + await h.broker.shutdown(); + }); + + it("waits for a close already draining outside the routable session map", async () => { + const closing = Promise.withResolvers(); + const closeAck = Promise.withResolvers(); + const h = makeHarness({ + ack: async (_nodeId, cmd) => { + if (cmd.type === "ssh_relay_close") { + closing.resolve(); + await closeAck.promise; + } + return cmd.type === "ssh_relay_open" && cmd.role === "B" + ? { socketPath: buildAgentSocketPath("/data/b", cmd.paneId) } + : { ok: true }; + }, + }); + const opened = await h.open(); + const close = h.broker.closeRelay(opened.ref, "child-exit"); + await closing.promise; + let replaced = false; + const repair = h.broker.withNodeIdentityRepair("a", async () => { + replaced = true; + }); + await expect(h.open()).rejects.toMatchObject({ code: "identity-repair" }); + expect(replaced).toBe(false); + closeAck.resolve(); + await Promise.all([close, repair]); + expect(replaced).toBe(true); + expect(h.broker.activeRelayCount("a")).toBe(0); + }); + + it("invalidates an open still awaiting authorization before it creates a session", async () => { + const entered = Promise.withResolvers(); + const gate = Promise.withResolvers(); + const h = makeHarness({ + authorize: async () => { + entered.resolve(); + return gate.promise; + }, + }); + const opening = h.open(); + await entered.promise; + await h.broker.withNodeIdentityRepair("b", async () => {}); + gate.resolve(true); + await expect(opening).rejects.toMatchObject({ code: "identity-repair" }); + expect(h.commands).toEqual([]); + expect(h.broker.activeRelayCount("a")).toBe(0); + }); + + it("cuts pending handshakes and refuses their late open acknowledgments", async () => { + const entered = Promise.withResolvers(); + const openAck = Promise.withResolvers(); + const participants = new Set(); + const h = makeHarness({ + ack: async (nodeId, cmd) => { + if (cmd.type === "ssh_relay_open") { + participants.add(nodeId); + entered.resolve(); + await openAck.promise; + return cmd.role === "B" ? { socketPath: buildAgentSocketPath("/data/b", cmd.paneId) } : { ok: true }; + } + if (cmd.type === "ssh_relay_close") participants.delete(nodeId); + return { ok: true }; + }, + }); + const opening = h.open(); + await entered.promise; + await h.broker.withNodeIdentityRepair("a", async () => {}); + expect(participants.size).toBe(0); + openAck.resolve(); + await expect(opening).rejects.toMatchObject({ code: "identity-repair" }); + expect(h.broker.activeRelayCount("a")).toBe(0); + }); + + it("releases the barrier after a failed replacement while invalidating prepared keys", async () => { + const h = makeHarness(); + const stale = { a: h.broker.identityGeneration("a"), b: h.broker.identityGeneration("b") }; + await expect( + h.broker.withNodeIdentityRepair("a", async () => { + throw new Error("registration failed"); + }), + ).rejects.toThrow("registration failed"); + await expect(h.open({ identityGenerations: stale })).rejects.toMatchObject({ code: "identity-repair" }); + await h.open(); + await h.broker.shutdown(); + }); +}); diff --git a/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts new file mode 100644 index 000000000..f4237a54e --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts @@ -0,0 +1,807 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { buildAgentSocketPath, buildSshConfigPath } from "@internal/pane-runtime"; +import type { SshConnectionSnapshotWire, SshExecCommand } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { APP_BASE_URL } from "@/constants.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import type { NodeTable } from "@/db/types/nodes.db-types.js"; +import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import type { SshRefusal } from "@/services/ssh-launch.service.js"; +import { type OpenRelayInput, type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; +import { setSshSetupHereDepsForTests, setupHere } from "@/services/ssh-setup-here.service.js"; +import { attachScriptedNode, SCRIPTED_DATA_DIR, type ScriptedNode } from "@/test-helpers/scripted-node.js"; + +/** + * The "Set up Subshell here" service (spec 2026-10-08 §7, Task 14). What + * this suite exists to pin, the brief's five arms first: + * + * (b) a non-egressing D answers the NAMED egress cause (its own code) and + * the working pane row is untouched - this act never writes one; + * (c) relay mode RE-OPENS the relay (the broker sees a fresh pairing keyed + * to the exec's ephemeral id, not the pane's), and an A that is offline + * refuses naming the key home's online-ness before any key or session + * exists; + * (d) success is the new node's `ready` (the registry fact) observed inside + * the bounded wait, not the installer's cheer line; + * (e) a captured line containing `nsk_` never reaches the returned answer, + * any refusal copy, or the `node.ssh_upgrade.run` audit row. + * + * B answers over the REAL sendCommand chain through the scripted node (the + * emitted frames pass the real wire grammar); the relay broker is the fake + * this suite records; every wait runs on injected seams. The key the test + * recovers from the exec command string is the mint's plaintext - which is + * what makes the leak assertions mean something. + */ + +const HOST = "d.example.test"; +const PIN_LINE = "d.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; +const DEST = sshCanonicalDestination({ host: HOST, port: 22, user: null }); +const SNAP = (over: Partial = {}): SshConnectionSnapshotWire => ({ + alias: HOST, + host: HOST, + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...over, +}); + +const emails: string[] = []; +const createdPanes: string[] = []; +const createdNodes: string[] = []; +const nodes = new NodesRepository(db); +const keys = new NodeSetupKeysRepository(db); +let owner = ""; +let stranger = ""; +const NODE_A = "setup-node-a"; +const NODE_B = "setup-node-b"; + +/** The relay broker fake: opens recorded, closes recorded, socket answer derived. */ +let openCalls: OpenRelayInput[] = []; +let closeCalls: { paneId: string; reason: string }[] = []; +let fakeBroker: RelayBroker; + +function makeFakeBroker(): RelayBroker { + return { + identityGeneration: () => 0, + openRelay: async (input: OpenRelayInput) => { + openCalls.push(input); + return { + relayId: `relay-${openCalls.length}`, + ref: `ref-${openCalls.length}`, + expiresAt: new Date(Date.now() + 30_000).toISOString(), + // In-contract answer: the plane byte-checks this against the same + // derivation the node's relay-open bound. + socketPath: buildAgentSocketPath(SCRIPTED_DATA_DIR, input.paneId), + }; + }, + closeForPane: async (paneId: string, reason: string) => { + closeCalls.push({ paneId, reason }); + return 1; + }, + } as unknown as RelayBroker; +} + +function installDeps(): void { + setSshSetupHereDepsForTests({ + nowMs: () => Date.now(), + sleepMs: () => new Promise((r) => setTimeout(r, 1)), + execTimeoutMs: 600_000, + execPollMarginMs: 50, + readyBudgetMs: 1_000, + pollMs: 1, + broker: () => fakeBroker, + }); +} + +async function mkNode(id: string): Promise { + await nodes.create({ id, ownerUserId: owner, name: id, kind: "agent", status: "offline" }); + createdNodes.push(id); + await nodes.setSshEnabled(id, { on: true, changedAt: "2026-10-08T09:00:00.000Z" }); + await db + .updateTable("nodes") + .set({ + inventoryJson: JSON.stringify([{ harnessId: "ssh", installed: true, binaryPath: "/usr/bin/ssh", version: "1" }]), + inventoryAt: new Date().toISOString(), + } as never) + .where("id", "=", id) + .execute(); + return (await nodes.findById(id)) as NodeTable; +} + +async function mkPane(opts: { + keyHome: boolean; + ssh?: string | null; + fingerprints?: string[] | null; +}): Promise { + const id = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id, + userId: owner, + harnessId: "ssh", + name: `pane-${id.slice(0, 8)}`, + workingDir: "/home/scripted", + tmuxSocket: null, + nodeId: NODE_B, + // `??` would read a deliberate null as absent; the test's null IS the value. + ssh: + opts.ssh === undefined + ? JSON.stringify({ + ...SNAP(), + ...(opts.keyHome && opts.fingerprints !== null + ? { relayFingerprints: opts.fingerprints ?? ["SHA256:AAAA"] } + : {}), + }) + : opts.ssh, + keyHomeNodeId: opts.keyHome ? NODE_A : null, + }); + createdPanes.push(id); + return id; +} + +/** The destination pin and identities every relay leg reads (fixture, once). */ +async function authorizeRelay(): Promise { + const now = new Date().toISOString(); + await db + .insertInto("sshHostPins") + .values({ + id: crypto.randomUUID(), + ownerUserId: owner, + destination: DEST, + hostKey: PIN_LINE, + createdAt: now, + updatedAt: now, + }) + .execute(); + const identities = new IdentitiesRepository(db); + for (const [n, half] of [ + [NODE_A, "AAA"], + [NODE_B, "EEE"], + ] as const) { + await identities.register({ + principalId: `node:${n}`, + publicKey: `{"kty":"EC","crv":"P-256","x":"${half}","y":"${half}"}`, + signingPublicKey: `{"kty":"EC","crv":"P-256","x":"${half}","y":"${half}"}`, + displayName: n, + }); + } +} + +/** The key the act minted, recovered from the exec command the scripted machine saw. */ +function mintedKey(cmd: SshExecCommand | undefined): string { + const m = cmd?.command.match(/setup_key=(nsk_[A-Za-z0-9_-]{32})/); + if (!m) throw new Error("the exec command carried no minted key"); + return m[1]; +} + +/** The refusal's coded facts; the 422 outcome arm is not an answer this act ever gives. */ +function coded(refusal: SshRefusal): { status: number; code: BackendErrorCodes; message: string } { + if (refusal.status === 422) throw new Error(`unexpected outcome refusal: ${JSON.stringify(refusal)}`); + return refusal; +} + +async function upgradeRows(): Promise<{ action: string; metadataJson: string | null }[]> { + return await db + .selectFrom("auditEvents") + .select(["action", "metadataJson"]) + .where("action", "=", "node.ssh_upgrade.run") + .execute(); +} + +/** + * Attach the scripted machines: B answers the two exec arms from the caller's + * controller (kick recorded for the status arm to read back). A is attached + * silently ONLY when the test wants a live key home. + */ +function attachPair(impl: { + onKick?: (cmd: SshExecCommand) => void | Promise; + status: (kick: SshExecCommand | undefined) => unknown; + withA: boolean; + fingerprints?: string[]; +}): { b: ScriptedNode; a: ScriptedNode | null; kick: () => SshExecCommand | undefined } { + let seen: SshExecCommand | undefined; + const a = impl.withA + ? attachScriptedNode(NODE_A, { + ssh_agent_identities: () => ({ + identities: (impl.fingerprints ?? ["SHA256:AAAA"]).map((fingerprint) => ({ + fingerprint, + comment: "test key", + })), + }), + }) + : null; + const b = attachScriptedNode(NODE_B, { + ssh_exec: async (cmd) => { + if (cmd.type !== "ssh_exec") throw new Error("wrong arm"); + seen = cmd; + await impl.onKick?.(cmd); + return { started: true, execId: cmd.execId }; + }, + ssh_exec_status: (cmd) => { + if (cmd.type !== "ssh_exec_status") throw new Error("wrong arm"); + return impl.status(seen); + }, + }); + return { b, a, kick: () => seen }; +} + +/** The install's success answer as the scripted machine states it. */ +const DONE_OK = { state: "done", code: 0, timedOut: false, stdout: "==> done.\n", stderr: "" } as const; + +/** Stand-in for D's enrollment completing: spend the minted key, bring D's `ready` up. */ +async function enrollD(key: string): Promise<{ id: string; handle: ScriptedNode }> { + const id = crypto.randomUUID(); + await nodes.create({ id, ownerUserId: owner, name: `d-${id.slice(0, 8)}`, kind: "agent", status: "offline" }); + const spent = await keys.consume(key, id); + if (!spent) throw new Error("the fixture could not consume the minted key"); + return { id, handle: attachScriptedNode(id, {}) }; +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + const mk = async (email: string): Promise => { + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("setup-here-1"), + role: "user", + }); + }; + owner = await mk(`setup-owner-${crypto.randomUUID()}@subshell.local`); + stranger = await mk(`setup-other-${crypto.randomUUID()}@subshell.local`); + await mkNode(NODE_A); + await mkNode(NODE_B); + await authorizeRelay(); +}); + +afterAll(async () => { + setSshSetupHereDepsForTests(null); + setRelayBrokerForTests(null); + resetNodeRegistryForTests(); + for (const id of createdPanes) await new SubshellsRepository(db).delete(id).catch(() => {}); + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("identities").execute(); + await db.deleteFrom("nodeSetupKeys").execute(); + await db.deleteFrom("auditEvents").where("action", "=", "node.ssh_upgrade.run").execute(); + for (const id of createdNodes) await nodes.deleteById(id).catch(() => {}); + for (const e of emails) await deleteUserByEmailOrId(e).catch(() => {}); +}); + +beforeEach(() => { + openCalls = []; + closeCalls = []; + fakeBroker = makeFakeBroker(); + setRelayBrokerForTests(fakeBroker); + installDeps(); +}); + +/** Each act's trail row is consumed by the test that wrote it; start clean. */ +async function clearUpgradeRows(): Promise { + await db.deleteFrom("auditEvents").where("action", "=", "node.ssh_upgrade.run").execute(); +} + +describe("setupHere door order (pane first, gates before any key)", () => { + it("a foreign or absent pane is the 404 the ownership axis demands", async () => { + const paneId = await mkPane({ keyHome: true }); + const foreign = await setupHere({ viewerId: stranger, paneId }); + expect(foreign.ok).toBe(false); + if (foreign.ok) return; + expect(foreign.refusal.status).toBe(404); + const absent = await setupHere({ viewerId: owner, paneId: crypto.randomUUID() }); + expect(absent.ok).toBe(false); + if (absent.ok) return; + expect(absent.refusal.status).toBe(404); + }); + + it("a non-ssh pane refuses by name", async () => { + const paneId = await mkPane({ keyHome: false, ssh: null }); + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(coded(res.refusal).message).toContain("Open an SSH subshell"); + }); +}); + +describe("setupHere relay mode", () => { + for (const rosterSize of [2, 9]) { + it(`reuses only the saved selected key from a current roster of ${rosterSize} keys`, async () => { + const selected = "SHA256:AAAA"; + const paneId = await mkPane({ keyHome: true, fingerprints: [selected] }); + let enrolled: { id: string; handle: ScriptedNode } | null = null; + const pair = attachPair({ + withA: true, + fingerprints: [selected, ...Array.from({ length: rosterSize - 1 }, (_, i) => `SHA256:extra${i}`)], + onKick: async (cmd) => { + enrolled = await enrollD(mintedKey(cmd)); + }, + status: () => DONE_OK, + }); + try { + const result = await setupHere({ viewerId: owner, paneId }); + expect(result.ok).toBe(true); + expect(openCalls).toHaveLength(1); + expect(openCalls[0]?.fingerprints).toEqual([selected]); + const stored = await db + .selectFrom("subshells") + .select("ssh") + .where("id", "=", paneId) + .executeTakeFirstOrThrow(); + expect(JSON.parse(stored.ssh ?? "{}").relayFingerprints).toEqual([selected]); + } finally { + pair.a?.detach(); + pair.b.detach(); + const destination = enrolled as { id: string; handle: ScriptedNode } | null; + destination?.handle.detach(); + if (destination) await nodes.deleteById(destination.id); + } + }); + } + + it("refuses when a saved selected key disappears without replacing it with another live key", async () => { + const paneId = await mkPane({ keyHome: true, fingerprints: ["SHA256:AAAA"] }); + const pair = attachPair({ withA: true, fingerprints: ["SHA256:BBBB"], status: () => DONE_OK }); + const keysBefore = (await keys.listByUser(owner)).length; + try { + const result = await setupHere({ viewerId: owner, paneId }); + expect(result.ok).toBe(false); + if (!result.ok) expect(coded(result.refusal).code).toBe(BackendErrorCodes.SSH_KEYS_INVALID); + expect(openCalls).toHaveLength(0); + expect(pair.b.cmdTypes()).toEqual([]); + expect((await keys.listByUser(owner)).length).toBe(keysBefore); + } finally { + pair.a?.detach(); + pair.b.detach(); + } + }); + + it("requires relaunching legacy relay panes without a saved key selection", async () => { + const paneId = await mkPane({ keyHome: true, fingerprints: null }); + const pair = attachPair({ withA: true, status: () => DONE_OK }); + const keysBefore = (await keys.listByUser(owner)).length; + try { + const result = await setupHere({ viewerId: owner, paneId }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(coded(result.refusal).code).toBe(BackendErrorCodes.SSH_UPGRADE_FAILED); + expect(coded(result.refusal).message).toMatch(/relaunch.*choose.*keys/i); + } + expect(openCalls).toHaveLength(0); + expect(pair.a?.cmdTypes()).toEqual([]); + expect(pair.b.cmdTypes()).toEqual([]); + expect((await keys.listByUser(owner)).length).toBe(keysBefore); + } finally { + pair.a?.detach(); + pair.b.detach(); + } + }); + + it("refuses jump or aliased host trust before creating a relay or setup key", async () => { + const pair = attachPair({ withA: true, status: () => DONE_OK }); + const keysBefore = (await keys.listByUser(owner)).length; + try { + for (const snapshot of [ + SNAP({ hostKeyAlias: "host-trust-alias" }), + SNAP({ proxyJumps: [{ host: "jump.example.test", user: null, port: 22 }] }), + ]) { + const paneId = await mkPane({ keyHome: true, ssh: JSON.stringify(snapshot) }); + const answer = await setupHere({ viewerId: owner, paneId }); + expect(answer.ok).toBe(false); + if (!answer.ok) expect(coded(answer.refusal).code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); + } + expect(openCalls).toHaveLength(0); + expect(pair.b.cmdTypes()).toEqual([]); + expect((await keys.listByUser(owner)).length).toBe(keysBefore); + } finally { + pair.a?.detach(); + pair.b.detach(); + } + }); + + it("a non-egressing D answers the NAMED egress cause and leaves the pane untouched", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + const keysBefore = (await keys.listByUser(owner)).length; + const pair = attachPair({ + withA: true, + status: () => ({ + state: "done", + code: 1, + timedOut: false, + stdout: "", + stderr: + "subshell: could not reach http://plane.invalid; nothing was installed (/home/u/.local/bin/subshell untouched).", + }), + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + const refusal = coded(res.refusal); + expect(refusal.status).toBe(409); + expect(refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + 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); + expect(row?.alive).toBe(1); + expect(row?.status).toBe("running"); + expect(pair.b.cmdTypes()).toEqual(["ssh_exec", "ssh_exec_status"]); + // The relay was opened once and closed with the named child-exit word. + const exec = pair.kick(); + if (!exec) throw new Error("the act never kicked an exec"); + expect(openCalls).toHaveLength(1); + expect(closeCalls).toEqual([{ paneId: exec.execId, reason: "child-exit" }]); + // The unspent key is revoked on the way out; the trail names the cause. + expect((await keys.listByUser(owner)).length).toBe(keysBefore); + const audits = await upgradeRows(); + expect(audits).toHaveLength(1); + expect(audits[0]?.metadataJson).toContain('"cause":"egress"'); + expect(audits[0]?.metadataJson).not.toContain("nsk_"); + } finally { + pair.a?.detach(); + pair.b.detach(); + } + }); + + it("an offline A refuses naming the key home online, before any key or relay exists", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + const keysBefore = (await keys.listByUser(owner)).length; + // A has NO scripted connection: getLive(A) is null, the offline class. + const pair = attachPair({ withA: false, status: () => DONE_OK }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + const refusal = coded(res.refusal); + expect(refusal.code).toBe(BackendErrorCodes.NODE_OFFLINE); + expect(refusal.message).toMatch(/online/i); + expect(openCalls).toHaveLength(0); // never re-opened + expect(pair.b.cmdTypes()).toEqual([]); // B was never even asked to exec + expect((await keys.listByUser(owner)).length).toBe(keysBefore); // no key minted + } finally { + pair.b.detach(); + } + }); + + it("success is the new node's ready: the relay re-opens under the exec id, the bounded wait observes the fact", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + let polls = 0; + let d: { id: string; handle: ScriptedNode } | null = null; + const pair = attachPair({ + withA: true, + onKick: (cmd) => { + // D's enrollment lands a beat AFTER the kick, while the act is + // already polling: the wait observes it, no test-side sleep races. + void enrollD(mintedKey(cmd)).then((r) => { + d = r; + }); + }, + status: () => { + polls += 1; + // Keep the exec "running" for two polls; the ready wait then finds + // the spend + the live `ready` facts through the service's own loop. + if (polls <= 2) return { state: "running" }; + return DONE_OK; + }, + }); + // `d` is filled by the onKick closure; the accessor re-widens what flow + // analysis narrows at the declaration. + const seenD = () => d as { id: string; handle: ScriptedNode } | null; + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(true); + if (!res.ok) return; + const machine = seenD(); + const nodeId = machine?.id ?? ""; + expect(nodeId).not.toBe(""); + expect(res.value).toEqual({ nodeId }); + // The relay was RE-OPENED for this act, keyed to the exec's ephemeral + // id (never the pane's), carrying the destination's pin; closed with + // the named word once the exec's child was gone. + const kick = pair.kick(); + if (!kick) throw new Error("the act never kicked an exec"); + const execId = kick.execId; + expect(execId).not.toBe(paneId); + expect(openCalls).toHaveLength(1); + expect(openCalls[0]?.paneId).toBe(execId); + expect(openCalls[0]?.aNode).toBe(NODE_A); + expect(openCalls[0]?.bNode).toBe(NODE_B); + expect(openCalls[0]?.hostPin).toBe(PIN_LINE); + expect(closeCalls).toEqual([{ paneId: execId, reason: "child-exit" }]); + // The composed run: the exec's OWN config dir, BatchMode first, the + // installer one-liner with the destination host as the scripted name. + expect(kick.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, execId)); + expect(kick.presetFlags.slice(0, 2)).toEqual(["-o", "BatchMode=yes"]); + expect(kick.presetFlags).toContain("--"); + expect(kick.relay).toBe(true); + expect(kick.agentSocketPath).toBeNull(); + expect(kick.command).toContain(`install.sh?setup_key=${mintedKey(kick)}`); + expect(kick.command).toContain("SUBSHELL_NODE_NAME="); + expect(kick.command).toContain(HOST); + expect(polls).toBeGreaterThanOrEqual(3); + // Audit: exactly one enrolled row naming ids, never the key. + const audits = await upgradeRows(); + expect(audits).toHaveLength(1); + const meta = JSON.parse(audits[0]?.metadataJson ?? "{}") as Record; + expect(meta).toMatchObject({ + paneId, + bNodeId: NODE_B, + aNodeId: NODE_A, + destination: DEST, + outcome: "enrolled", + cause: "ready", + newNodeId: nodeId, + }); + expect(audits[0]?.metadataJson).not.toContain("nsk_"); + expect(JSON.stringify(res)).not.toContain("nsk_"); + seenD()?.handle.detach(); + await nodes.deleteById(nodeId).catch(() => {}); + } finally { + seenD()?.handle.detach(); + pair.a?.detach(); + pair.b.detach(); + } + }); + + it("a status answer carrying the minted key leaks NOTHING: belt redaction covers refusal, response, and trail", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + const pair = attachPair({ + withA: true, + status: (kick) => ({ + state: "done", + code: 1, + timedOut: false, + // The machine ANSWERS with its own hygiene failing: the plane's belt + // must hold anyway - no `nsk_` byte is kept, returned, or repeated. + stdout: `KEY="${mintedKey(kick)}" here it is\nsubshell: the install exploded`, + stderr: "", + }), + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + const key = mintedKey(pair.kick()); + const serialized = JSON.stringify(res); + expect(serialized).not.toContain("nsk_"); + expect(serialized).not.toContain(key); + const audits = await upgradeRows(); + expect(audits).toHaveLength(1); + expect(audits[0]?.metadataJson).not.toContain("nsk_"); + expect(audits[0]?.metadataJson).not.toContain(key); + expect(audits[0]?.metadataJson).toContain("install-exit-1"); + } finally { + pair.a?.detach(); + pair.b.detach(); + } + }); + + it("install finished but the key never enrolled: named failure, key revoked", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + const keysBefore = (await keys.listByUser(owner)).length; + const pair = attachPair({ withA: true, status: () => DONE_OK }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + const refusal = coded(res.refusal); + expect(refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_FAILED); + expect(refusal.message).toContain("no machine enrolled"); + expect((await keys.listByUser(owner)).length).toBe(keysBefore); + } finally { + await clearUpgradeRows(); + pair.a?.detach(); + pair.b.detach(); + } + }); +}); + +describe("setupHere direct mode (B's own keys, no relay)", () => { + it("a direct pane's exec carries no relay, and the snapshot's socket is the one scoped key", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ + keyHome: false, + ssh: JSON.stringify(SNAP({ authAgentSocket: "/run/user/700/agent.sock" })), + }); + let d: { id: string; handle: ScriptedNode } | null = null; + const pair = attachPair({ + withA: false, // direct: A must NEVER be part of this act + onKick: (cmd) => { + void enrollD(mintedKey(cmd)).then((r) => { + d = r; + }); + }, + status: () => DONE_OK, + }); + // Same closure-filled `d`: the accessor re-widens the declaration's flow type. + const seenD = () => d as { id: string; handle: ScriptedNode } | null; + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(true); + if (!res.ok) return; + const kick = pair.kick(); + if (!kick) throw new Error("the act never kicked an exec"); + expect(kick.relay).toBe(false); + expect(kick.agentSocketPath).toBe("/run/user/700/agent.sock"); + expect(kick.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, kick.execId)); + // The direct pane's exec never touched the relay broker, and A's live + // absence was irrelevant to it (no key-home gate was walked). + expect(openCalls).toHaveLength(0); + expect(closeCalls).toHaveLength(0); + seenD()?.handle.detach(); + await nodes.deleteById(seenD()?.id ?? "").catch(() => {}); + } finally { + await clearUpgradeRows(); + pair.b.detach(); + } + }); + + // The dominant no-egress shape (spec §7/§13): a DARK destination never + // runs install.sh, so none of the script's sentences exist in the answer; + // the act's FIRST curl is what fails, and the named cause must come from + // curl's own words/exit codes, not the generic install-exit stage. + const darkD = (code: number, stderr: string) => ({ + state: "done" as const, + code, + timedOut: false, + stdout: "", + stderr, + }); + + it("a dark destination (DNS failure on the first fetch) answers the NAMED egress cause, not install-exit", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: false }); + const keysBefore = (await keys.listByUser(owner)).length; + const pair = attachPair({ + withA: false, + status: () => darkD(6, "curl: (6) Could not resolve host: plane.invalid\n"), + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + const refusal = coded(res.refusal); + expect(refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + expect(refusal.message).toContain("couldn't reach"); + expect(refusal.message).toContain(APP_BASE_URL); + const audits = await upgradeRows(); + expect(audits).toHaveLength(1); + expect(audits[0]?.metadataJson).toContain('"cause":"egress"'); + expect(audits[0]?.metadataJson).not.toContain("install-exit"); + expect((await keys.listByUser(owner)).length).toBe(keysBefore); + expect(pair.b.cmdTypes()).toEqual(["ssh_exec", "ssh_exec_status"]); + } finally { + await clearUpgradeRows(); + pair.b.detach(); + } + }); + + it("a dark destination (refused TCP connect) answers the egress cause", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: false }); + const pair = attachPair({ + withA: false, + status: () => darkD(7, "curl: (7) Failed to connect to 10.9.9.9 port 80 after 3004 ms: Connection refused\n"), + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(coded(res.refusal).code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + const audits = await upgradeRows(); + expect(audits[0]?.metadataJson).toContain('"cause":"egress"'); + expect(audits[0]?.metadataJson).not.toContain("install-exit"); + } finally { + await clearUpgradeRows(); + pair.b.detach(); + } + }); + + it("curl's connect-failure exit codes classify without curl's sentence (belt: no installer banner)", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: false }); + // The belt arm: an older or stripped curl that exits 6/7 but words + // nothing. No `==>` banner means install.sh never ran, so the only + // network actor that failed is the act's first curl. + const pair = attachPair({ withA: false, status: () => darkD(7, "") }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(coded(res.refusal).code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + } finally { + await clearUpgradeRows(); + pair.b.detach(); + } + }); +}); + +describe("setupHere preserves completed enrollment after later failures", () => { + for (const failure of [ + "kick", + "lost-status", + "deadline", + "installer", + "installer-timeout", + "ready-timeout", + ] as const) { + it(`returns the enrolled machine when ${failure} happens after the key was consumed`, async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: false }); + let enrolled: Awaited> | undefined; + let clock = 0; + setSshSetupHereDepsForTests({ + nowMs: () => clock++, + sleepMs: async () => {}, + execTimeoutMs: 1, + execPollMarginMs: 0, + readyBudgetMs: 1, + pollMs: 1, + broker: () => fakeBroker, + }); + const pair = attachPair({ + withA: false, + onKick: async (kick) => { + enrolled = await enrollD(mintedKey(kick)); + enrolled.handle.detach(); + if (failure === "kick") throw new Error("reply lost after installation"); + }, + status: () => { + if (failure === "lost-status") return new Error("connection lost"); + if (failure === "deadline") return { state: "running" }; + if (failure === "ready-timeout") return DONE_OK; + return { + ...DONE_OK, + code: 1, + timedOut: failure === "installer-timeout", + stderr: "service installation failed", + }; + }, + }); + try { + const answer = await setupHere({ viewerId: owner, paneId }); + if (!enrolled) throw new Error("the installer did not consume its key"); + expect(answer).toEqual({ ok: true, value: { nodeId: enrolled.id, connected: false } }); + const key = await keys.peekByKey(mintedKey(pair.kick())); + expect(typeof key?.usedAt).toBe("string"); + const events = await upgradeRows(); + expect(events).toHaveLength(1); + expect(events[0]?.metadataJson).toContain('"outcome":"enrolled"'); + expect(events[0]?.metadataJson).toContain(enrolled?.id ?? "missing"); + expect(events[0]?.metadataJson).not.toContain("nsk_"); + } finally { + pair.b.detach(); + if (enrolled) await nodes.deleteById(enrolled.id); + installDeps(); + } + }); + } +}); diff --git a/apps/server/api/src/services/__tests__/ssh-setup-progress.test.ts b/apps/server/api/src/services/__tests__/ssh-setup-progress.test.ts new file mode 100644 index 000000000..fc6f7bbff --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-setup-progress.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import type { SshAnswer } from "@/services/ssh-launch.service.js"; +import { createSshSetupTracker } from "@/services/ssh-setup-progress.js"; + +test("reopening or retrying an ongoing setup joins one operation, with owner-scoped stage and result", async () => { + const tracker = createSshSetupTracker(); + let finish!: (answer: SshAnswer<{ nodeId: string }>) => void; + let calls = 0; + const run = () => + tracker.run("owner", "pane", (stage) => { + calls++; + stage("installing"); + return new Promise((resolve) => { + finish = resolve; + }); + }); + const first = run(); + await Promise.resolve(); + expect(tracker.read("owner", "pane")?.stage).toBe("installing"); + expect(tracker.read("other", "pane")).toBeNull(); + const second = run(); + expect(calls).toBe(1); + finish({ ok: true, value: { nodeId: "new-node" } }); + await Promise.all([first, second]); + expect(tracker.read("owner", "pane")).toMatchObject({ stage: "complete", nodeId: "new-node", error: null }); + await run(); + expect(calls).toBe(1); +}); + +test("failures may be retried; unexpected errors never disclose installer secrets", async () => { + const tracker = createSshSetupTracker(); + await tracker.run("o", "p", async () => { + throw new Error("secret setup key"); + }); + expect(JSON.stringify(tracker.read("o", "p"))).not.toContain("secret setup key"); + expect(tracker.read("o", "p")?.stage).toBe("failed"); + await tracker.run("o", "p", async () => ({ + ok: false, + refusal: { status: 409, code: BackendErrorCodes.SSH_UPGRADE_FAILED, message: "Install tmux first." }, + })); + expect(tracker.read("o", "p")?.error).toBe("Install tmux first."); + await tracker.run("o", "p", async () => ({ ok: true, value: { nodeId: "n" } })); + expect(tracker.read("o", "p")?.stage).toBe("complete"); +}); + +test("completed status expires after an hour", async () => { + let now = 0; + const tracker = createSshSetupTracker(() => now); + await tracker.run("o", "p", async () => ({ ok: true, value: { nodeId: "n" } })); + now = 3_600_001; + expect(tracker.read("o", "p")).toBeNull(); +}); + +test("enrolled but disconnected is terminal and reopening does not reinstall", async () => { + const tracker = createSshSetupTracker(); + let calls = 0; + const run = () => + tracker.run("o", "p", async () => { + calls++; + return { ok: true, value: { nodeId: "n", connected: false } }; + }); + await run(); + expect(tracker.read("o", "p")).toMatchObject({ stage: "enrolled", nodeId: "n", error: null }); + await run(); + expect(calls).toBe(1); +}); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts index 498d18be6..8aa336e65 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts @@ -24,6 +24,8 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; @@ -185,6 +187,8 @@ beforeAll(async () => { await crossAgentMigration.up(dbHandle); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(dbHandle); // presets launch trio (0042) - repository create enumerates it await presetCrossCommOptInMigration.up(dbHandle); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(dbHandle); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(dbHandle); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presetsRepo = new PresetsRepository(dbHandle); subshellsRepo = new SubshellsRepository(dbHandle); presetId = await seedPreset(presetsRepo); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts index 779ac50cf..df4fdfed5 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts @@ -22,6 +22,8 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -145,6 +147,8 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(db); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presets = new PresetsRepository(db); subshells = new SubshellsRepository(db); presetId = await seedPreset(presets); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts index 57afc7703..0ff6b6c89 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts @@ -20,6 +20,8 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -87,6 +89,8 @@ beforeAll(async () => { await crossAgentMigration.up(dbHandle); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(dbHandle); // presets launch trio (0042) - repository create enumerates it await presetCrossCommOptInMigration.up(dbHandle); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(dbHandle); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(dbHandle); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presetsRepo = new PresetsRepository(dbHandle); subshellsRepo = new SubshellsRepository(dbHandle); presetId = await seedPreset(presetsRepo); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts new file mode 100644 index 000000000..ed35602ea --- /dev/null +++ b/apps/server/api/src/services/__tests__/subshell-manager-ssh.test.ts @@ -0,0 +1,368 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { db } from "@/db/index.js"; +import { runMigrations } from "@/db/migrate.js"; +import { PresetsRepository } from "@/db/repositories/presets.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { FakeNodeLauncher, nodeOnline } from "@/services/__tests__/helpers/node-fakes.js"; +import { prepareLocalPlugins } from "@/services/nodes/local-plugins.js"; +import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; +import { EMPTY_PRESET } from "@/services/preset-definition.js"; +import { SubshellManagerService } from "@/services/subshell-manager.service.js"; + +/** + * The manager's ssh launch plumbing (Task 7): what `createSubshell` does with + * the ssh service's composed facts, and which row fact the delete sweep reads. + * + * The composition itself (`composeSshLaunch`) and its call from the HTTP + * surface are pinned elsewhere (`ssh-launch.service.test.ts`, + * `api/ssh/__tests__/ssh-routes.test.ts`). This file pins the three manager + * promises: the `presetFlags` launch preset is a FRESH object (EMPTY_PRESET + * is shared and frozen — a mutation there would leak ssh flags into every + * later presetless launch), the composed ssh member and the merged pane env + * reach the {@link LaunchPlan} untouched, and `subshells.ssh` (migration + * 0048's JSON column) is the snapshot's home. + * + * The delete-path half pins the kind fact: `row.ssh !== null`, NOT + * `row.harnessId === "ssh"` — a hand-built row that names the ssh harness + * without a snapshot gets no config cleanup, and a snapshot on any harness + * row gets it. + * + * The third describe pins the DEATH-transition sweep: a local ssh pane going + * dead through the shared `#applyDeath` removes its config dir even when the + * exit report's own sweep never fired (the lost-`pane-died`-hook case), and an + * agent row going dead there sweeps nothing on this host. + */ + +const OWNER = "u-ssh-plumb"; +const testDir = mkdtempSync(join(tmpdir(), "subshell-mgr-ssh-")); +let subshellsRepo: SubshellsRepository; +const created: string[] = []; + +const SNAPSHOT: SshConnectionSnapshotWire = { + alias: "work", + host: "example.test", + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, +}; + +/** A live socket so the AGENT branch of `#planMcp` has `ready` facts to read. */ +function plumbOnline(): () => void { + return nodeOnline("node-plumb"); +} + +function mkManager(): { manager: SubshellManagerService; launcher: FakeNodeLauncher } { + const launcher = new FakeNodeLauncher(testDir); + const manager = new SubshellManagerService({ + subshells: subshellsRepo, + presets: new PresetsRepository(db), + launcher, + tokens: { issue: async () => "subshell_stub", revoke: async () => {} }, + audit: async () => {}, + }); + return { manager, launcher }; +} + +beforeAll(async () => { + await runMigrations(); + await prepareLocalPlugins(); + subshellsRepo = new SubshellsRepository(db); +}); + +afterAll(async () => { + for (const id of created) await subshellsRepo.delete(id).catch(() => {}); + rmSync(testDir, { recursive: true, force: true }); +}); + +describe("createSubshell ssh plumbing", () => { + it("threads the composed ssh member, the flag preset and the extra pane env into the LaunchPlan, and writes the snapshot column", async () => { + const id = crypto.randomUUID(); + created.push(id); + const { manager, launcher } = mkManager(); + const configPath = buildSshConfigPath("/home/plumb/.subshell", id); + const flags = ["-F", configPath, "-p", "22", "--", "example.test"]; + const off = plumbOnline(); + const created_ = await manager.createSubshell({ + subshellId: id, + userId: OWNER, + harnessId: "ssh", + presetId: null, + workingDir: tmpdir(), + nodeId: "node-plumb", + ssh: { configPath, fileContent: "Host *\n", snapshot: SNAPSHOT }, + presetFlags: flags, + extraPaneEnv: { SSH_AUTH_SOCK: "/run/user/501/sock" }, + }); + off(); + expect(created_.id).toBe(id); // the caller's pre-assigned id IS the row id + + const plan = launcher.plans[0]; + expect(plan?.ssh).toEqual({ configPath, fileContent: "Host *\n" }); // the snapshot itself stays off the wire member + expect(plan?.preset.flags).toEqual(flags); + expect(plan?.subshellEnv.SSH_AUTH_SOCK).toBe("/run/user/501/sock"); + // The SUBSHELL_* infrastructure env is still there — the ssh pane is a + // token-holding row (decision 10), just with no MCP registration. + expect(plan?.subshellEnv.SUBSHELL_ID).toBe(id); + expect(plan?.mcp).toBeUndefined(); + + const row = await subshellsRepo.findById(id); + expect(JSON.parse(String(row?.ssh))).toEqual(SNAPSHOT); + expect(row?.harnessId).toBe("ssh"); + }); + + it("the flag preset is a fresh object — EMPTY_PRESET is shared and must stay flagless", async () => { + expect(EMPTY_PRESET.flags).toEqual([]); // frozen, and still empty + const id = crypto.randomUUID(); + created.push(id); + const { manager, launcher } = mkManager(); + const off = plumbOnline(); + await manager.createSubshell({ + subshellId: id, + userId: OWNER, + harnessId: "ssh", + presetId: null, + workingDir: tmpdir(), + nodeId: "node-plumb", + ssh: { configPath: buildSshConfigPath("/d", id), fileContent: "x\n", snapshot: SNAPSHOT }, + presetFlags: ["-F", "/d/ssh/x/config", "--", "h"], + }); + off(); + expect(EMPTY_PRESET.flags).toEqual([]); // untouched by the compose above + expect(launcher.plans[0]?.preset).not.toBe(EMPTY_PRESET); + }); + + it("an ordinary launch grows nothing: no ssh member, no SSH_AUTH_SOCK, NULL snapshot", async () => { + const id = crypto.randomUUID(); + created.push(id); + const { manager, launcher } = mkManager(); + await manager.createSubshell({ + subshellId: id, + userId: OWNER, + harnessId: "terminal", + presetId: null, + workingDir: tmpdir(), + }); + const plan = launcher.plans[0]; + expect(plan?.ssh).toBeUndefined(); + expect(plan?.subshellEnv.SSH_AUTH_SOCK).toBeUndefined(); + const row = await subshellsRepo.findById(id); + expect(row?.ssh).toBeNull(); // the column is the kind fact: NULL = not an ssh pane + }); + + it("a null agent socket adds no SSH_AUTH_SOCK (only a named socket rides)", async () => { + const id = crypto.randomUUID(); + created.push(id); + const { manager, launcher } = mkManager(); + const off = plumbOnline(); + await manager.createSubshell({ + subshellId: id, + userId: OWNER, + harnessId: "ssh", + presetId: null, + workingDir: tmpdir(), + nodeId: "node-plumb", + ssh: { configPath: buildSshConfigPath("/d", id), fileContent: "x\n", snapshot: SNAPSHOT }, + presetFlags: ["--", "h"], + }); + off(); + expect(launcher.plans[0]?.subshellEnv.SSH_AUTH_SOCK).toBeUndefined(); + }); +}); + +describe("a death transition sweeps the LOCAL ssh config dir (#applyDeath)", () => { + // The exit REPORT path (service.reportExit) sweeps on its own; this covers the + // gap it cannot: a `pane-died` hook LOST entirely, so no report ever fires and + // only the 60 s reconcile learns the process is gone. The reconcile drives the + // SAME shared `#applyDeath` that `applySelfReportedExit` flows into, so calling + // the manager directly (bypassing the service's sweep) proves the transition + // itself removes the dir. CI e2e spec 22 flaked when this was the only missing + // sweep: natural death whose hook was lost left /ssh/ on disk. + function localDir(id: string): string { + return join(SUBSHELL_SERVER_DATA_DIR, "ssh", id); + } + + it("a running LOCAL ssh pane going dead removes its config dir", async () => { + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + harnessId: "ssh", + name: "lost-hook row", + workingDir: tmpdir(), + tmuxSocket: `subshell-${id.slice(0, 8)}`, + status: "running", + alive: 1, + nodeId: LOCAL_NODE_ID, + ssh: JSON.stringify(SNAPSHOT), + }); + mkdirSync(localDir(id), { recursive: true }); + writeFileSync(subshellSshConfigPath(id), "Host *\n"); + const { manager } = mkManager(); + await manager.applySelfReportedExit(id, 0, new Date().toISOString()); + expect(existsSync(localDir(id))).toBe(false); // #applyDeath, not the service, removed it + const row = await subshellsRepo.findById(id); + expect(row?.alive).toBe(0); + }); + + it("an AGENT ssh pane going dead sweeps NOTHING on this host (the node cleans its own disk)", async () => { + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + harnessId: "ssh", + name: "remote lost-hook row", + workingDir: tmpdir(), + tmuxSocket: `subshell-${id.slice(0, 8)}`, + status: "running", + alive: 1, + nodeId: "node-remote-death", + ssh: JSON.stringify(SNAPSHOT), + }); + // A dir carrying the pane's id on THIS host's disk must survive: it is not + // this plane's to remove. #applyDeath's sweep guard is what declines it. + mkdirSync(localDir(id), { recursive: true }); + const { manager } = mkManager(); + await manager.applySelfReportedExit(id, 1, new Date().toISOString()); + // The death DID land through #applyDeath (proving the sweep's decline was + // exercised, not skipped because the transition never ran) yet the agent + // row's dir stands on this host. + expect((await subshellsRepo.findById(id))?.alive).toBe(0); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); + + it("the reconcile no-socket path sweeps too (it stamps alive->dead without #applyDeath)", async () => { + // The one alive->dead stamp that does NOT funnel through #applyDeath: a row + // with no socket cannot be alive, so the sweep fast-path retires it directly. + // It must still remove a local ssh pane's config dir, or the "whichever hand + // first notices removes it" guarantee has a hole. A dedicated user keeps + // reconcile() from touching any other test's (socketed, real-tmux) rows. + const id = crypto.randomUUID(); + created.push(id); + const user = "u-ssh-nosocket"; + await subshellsRepo.create({ + id, + userId: user, + harnessId: "ssh", + name: "no-socket local ssh", + workingDir: tmpdir(), + tmuxSocket: null, // -> the no-socket branch, before any tmux probe + status: "running", + alive: 1, + nodeId: LOCAL_NODE_ID, + ssh: JSON.stringify(SNAPSHOT), + }); + mkdirSync(localDir(id), { recursive: true }); + const { manager } = mkManager(); + await manager.reconcile(user); + expect(existsSync(localDir(id))).toBe(false); + expect((await subshellsRepo.findById(id))?.alive).toBe(0); + }); +}); + +describe("deleteSubshell reads the row's ssh COLUMN as the kind fact", () => { + it("a LOCAL row with a snapshot gets its config DIR swept — whatever its harness id claims", async () => { + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + // NOT "ssh": the column, not the id, says the pane owned a config. + harnessId: "terminal", + name: "snapshot row", + workingDir: tmpdir(), + tmuxSocket: null, + status: "terminated", + alive: 0, + nodeId: LOCAL_NODE_ID, + ssh: JSON.stringify(SNAPSHOT), + }); + // The dir the LocalLauncher would have written, on the SERVER's own disk + // (the sweep is byte-derived, not launcher-provided). + mkdirSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id), { recursive: true }); + writeFileSync(subshellSshConfigPath(id), "Host *\n"); + const { manager, launcher } = mkManager(); + expect(await manager.deleteSubshell(OWNER, id)).toBe(true); + // The config path is NO LONGER in the launcher's unlink list (a file unlink + // left the DIR); the sweep function took the whole directory instead. + expect(launcher.removedPaths.at(-1)).toEqual([launcher.logPath(id)]); + expect(existsSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id))).toBe(false); + }); + + it("an AGENT row with a snapshot passes the kind flag through subshellArtifacts", async () => { + const nodeId = "node-ssh-del"; + const off = nodeOnline(nodeId, []); + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + harnessId: "ssh", + name: "remote ssh pane", + workingDir: tmpdir(), + tmuxSocket: "subshell-x", + status: "terminated", + alive: 0, + nodeId, + ssh: JSON.stringify(SNAPSHOT), + }); + const { manager, launcher } = mkManager(); + expect(await manager.deleteSubshell(OWNER, id)).toBe(true); + // The ssh config rides the triple at the node-derived path (fake mirrors + // RemoteLauncher's composition from its `/node-data` facts). + expect(launcher.removedPaths.at(-1)).toEqual([ + launcher.logPath(id), + `/node-data/mcp/${id}.json`, + `/node-data/subshells/${id}.meta.json`, + buildSshConfigPath("/node-data", id), + ]); + off(); + }); + + it("a row that merely NAMES the ssh harness but holds no snapshot is not an ssh pane (the re-point's negative arm)", async () => { + const id = crypto.randomUUID(); + created.push(id); + await subshellsRepo.create({ + id, + userId: OWNER, + harnessId: "ssh", + name: "harness-only row", + workingDir: tmpdir(), + tmuxSocket: null, + status: "terminated", + alive: 0, + nodeId: LOCAL_NODE_ID, + ssh: null, + }); + // A dir carrying the pane's NAME exists, but the row owns no config: the + // sweep's guard, not the launcher list, is what keeps it standing. + mkdirSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id), { recursive: true }); + const { manager, launcher } = mkManager(); + expect(await manager.deleteSubshell(OWNER, id)).toBe(true); + expect(launcher.removedPaths.at(-1)).toEqual([launcher.logPath(id)]); // no config path + expect(existsSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id))).toBe(true); // sweep refused + rmSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id), { recursive: true, force: true }); + }); +}); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts index 3c656bf01..3e627248c 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts @@ -22,6 +22,8 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -114,6 +116,8 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(db); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presets = new PresetsRepository(db); subshells = new SubshellsRepository(db); presetId = await seedPreset(presets); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-view.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-view.test.ts index 13c7f0b01..f173845b0 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-view.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-view.test.ts @@ -31,6 +31,7 @@ function row(overrides: Partial[0]> = {}) { waitingSince: null, lastPushUrgency: null, crossAgent: 0, + ssh: null, ...overrides, } satisfies Parameters[0]; } @@ -51,6 +52,16 @@ describe("toSubshellView notification fields", () => { expect(toSubshellView(row({ crossAgent: 0 }), "running").crossAgent).toBe(false); }); + it("maps the ssh snapshot column to a presence boolean and never echoes the snapshot (spec 2026-10-07)", () => { + // The rule keys on PRESENCE, not parseability: an unparseable string is + // still an ssh pane (validation belongs to the launch boundary). + expect(toSubshellView(row({ ssh: "x" }), "running").ssh).toBe(true); + const mapped = toSubshellView(row({ ssh: '{"destination":"dev.example.com"}' }), "running"); + expect(mapped.ssh).toBe(true); + expect(JSON.stringify(mapped)).not.toContain("dev.example.com"); + expect(toSubshellView(row(), "running").ssh).toBe(false); + }); + it("defaults access to 'owner' and honours an explicit override (sharing, spec 2026-08-31)", () => { // A returned view is always visible to someone, so it never carries "none". expect(toSubshellView(row(), "running").access).toBe("owner"); diff --git a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts index ca3082d61..a518b7020 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts @@ -23,6 +23,8 @@ import * as pushUrgencyMigration from "@/db/migrations/0035-subshell-push-urgenc import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -154,6 +156,8 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await sshLaunchMigration.up(db); // 0048 subshells.ssh — SubshellsRepository.create writes it on EVERY create now (null for a non-ssh pane), and the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presetsRepo = new PresetsRepository(db); subshellsRepo = new SubshellsRepository(db); subshellManager = new SubshellManagerService({ diff --git a/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts b/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts new file mode 100644 index 000000000..b3eac7ef2 --- /dev/null +++ b/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts @@ -0,0 +1,157 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { runMigrations } from "@/db/migrate.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; +import { SubshellsService } from "@/services/subshells.service.js"; +import { getLogger } from "@/utils/logger.js"; + +/** + * The LOCAL half of the ssh config cleanup (spec 2026-10-07 decision 4; + * plan-2 handoff 2): the exit-report path removes + * `/ssh/` when the row is a LOCAL pane whose + * snapshot column is set. Remote panes sweep on the agent (their own disk); + * this host must NEVER sweep an agent row's directory name off its own disk. + * + * It fires even when the row is already retired — a terminate kills the pane, + * the tmux `pane-died` hook races the retire, and whichever order they land + * in, the config's removal must be a sure thing (that is what Task 9's e2e + * asserts after terminate). + */ + +const OWNER = "u-ssh-exit"; +const subshellsRepo = new SubshellsRepository(getRequestlessContext().db); +const created: string[] = []; +let service: SubshellsService; + +const localDir = (id: string): string => join(SUBSHELL_SERVER_DATA_DIR, "ssh", id); + +async function seedPaneRow(over: { + id: string; + nodeId: string; + ssh: string | null; + status?: "running" | "terminated"; + alive?: number; +}): Promise { + created.push(over.id); + await subshellsRepo.create({ + id: over.id, + userId: OWNER, + harnessId: "ssh", + name: "ssh exit row", + workingDir: tmpdir(), + tmuxSocket: `subshell-${over.id.slice(0, 8)}`, + status: over.status ?? "running", + alive: over.alive ?? 1, + nodeId: over.nodeId, + ssh: over.ssh, + }); +} + +function makeConfigDir(id: string): void { + const dir = localDir(id); + mkdirSync(dir, { recursive: true }); + writeFileSync(buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id), "Host *\n"); +} + +beforeAll(async () => { + await runMigrations(); + const ctx = getRequestlessContext(); + service = new SubshellsService({ db: ctx.db, log: getLogger(), repos: ctx.repos }); +}); + +afterAll(async () => { + for (const id of created) { + rmSync(localDir(id), { recursive: true, force: true }); + await subshellsRepo.delete(id).catch(() => {}); + } +}); + +describe("the exit report sweeps the LOCAL ssh config dir", () => { + it("a running local ssh pane's death removes /ssh/ best-effort", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}' }); + makeConfigDir(id); + expect(existsSync(localDir(id))).toBe(true); + await service.reportExit(id, 0); + expect(existsSync(localDir(id))).toBe(false); // handoff 2: the DIR (dirname of the config path), not just the file + const row = await subshellsRepo.findById(id); + expect(row?.alive).toBe(0); // the manager's death transition ran as before + }); + + it("a row the terminate already retired still sweeps (the hook-vs-retire race is the e2e's case)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}', status: "terminated", alive: 0 }); + makeConfigDir(id); + await service.reportExit(id, null); + expect(existsSync(localDir(id))).toBe(false); + }); + + it("an AGENT row sweeps nothing on this host — the node cleans its own disk", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-ssh", ssh: '{"host":"example.test"}' }); + makeConfigDir(id); // a directory with the same NAME must survive: it is not ours + await service.reportExit(id, 1); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); + + it("a local row without a snapshot sweeps nothing (non-ssh panes have no config to clean)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: null }); + await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); + expect(existsSync(localDir(id))).toBe(false); + }); + + it("an already-dead row's report still converges and the sweep is idempotent on a missing dir", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}' }); + // No dir made at all: the sweep must not throw on a missing path. + await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); + await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); + }); + + it("(M2 §5.6 child-exit) the report also witnesses the pane's death to the relay broker", async () => { + // The relay cut is keyed by the PANE, so it fires for every row flavor + // (local or agent, ssh or not - the broker's map lookup no-ops the rows + // that never brokered anything). A spy broker observes the witness. + const cuts: { paneId: string; reason: string }[] = []; + const spy = { + openRelay: async () => { + throw new Error("unused"); + }, + routeRelayFrame: () => {}, + closeRelay: async () => false, + closeUnauthorizedForNode: async () => 0, + closeForPane: async (paneId: string, reason: string) => { + cuts.push({ paneId, reason }); + return 0; + }, + refuseOverCap: async () => false, + onNodeSocketClosed: async () => 0, + activeRelayCount: () => 0, + sessionInfo: () => null, + reset: () => {}, + }; + setRelayBrokerForTests(spy as unknown as RelayBroker); + try { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-ssh", ssh: '{"host":"example.test"}' }); + await service.reportExit(id, 0); + await new Promise((resolve) => setTimeout(resolve, 0)); // the sweep is fire-and-forget + // The report's own witness AND the manager's shared death transition + // both speak the same word (the dir sweep's redundancy, restated): + // closeForPane is idempotent, and at least one sure thing is the bar. + expect(cuts.length).toBeGreaterThanOrEqual(1); + for (const cut of cuts) expect(cut).toEqual({ paneId: id, reason: "child-exit" }); + } finally { + setRelayBrokerForTests(null); + } + }); +}); diff --git a/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts b/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts new file mode 100644 index 000000000..23a7eb9b8 --- /dev/null +++ b/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts @@ -0,0 +1,179 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { runMigrations } from "@/db/migrate.js"; +import { PresetsRepository } from "@/db/repositories/presets.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; +import { SubshellManagerService } from "@/services/subshell-manager.service.js"; +import { SubshellsService } from "@/services/subshells.service.js"; +import { getLogger } from "@/utils/logger.js"; + +/** + * The TERMINATE half of the ssh config sweep (spec 2026-10-07 decision 4; the + * e2e spec-22 finding): terminate revokes the pane's token synchronously with + * the kill, so the tmux `pane-died` hook's report can arrive 401 and its sweep + * never runs. The terminate verb therefore sweeps itself, through the SAME + * {@link sweepLocalSshDir} the exit report calls (one function, no drift) — + * and so does the maintenance kill, {@link SubshellManagerService.terminateForMaintenance}, + * which reaches the same teardown through the manager (no REST verb involved) + * and raced the identical way: a maintenance window used to leave every local + * ssh config dir behind. + * + * The rows here carry no tmux socket: the manager's kill is skipped, which is + * the point — the sweep is the killer's own act after the teardown, not the + * pane's last breath. + */ + +const OWNER = "u-ssh-term"; +const subshellsRepo = new SubshellsRepository(getRequestlessContext().db); +const created: string[] = []; +let service: SubshellsService; + +const localDir = (id: string): string => join(SUBSHELL_SERVER_DATA_DIR, "ssh", id); + +async function seedPaneRow(over: { id: string; nodeId: string; ssh: string | null }): Promise { + created.push(over.id); + await subshellsRepo.create({ + id: over.id, + userId: OWNER, + harnessId: "ssh", + name: "ssh terminate row", + workingDir: tmpdir(), + tmuxSocket: null, // no pane to kill: the test isolates the verb's sweep step + status: "running", + alive: 1, + nodeId: over.nodeId, + ssh: over.ssh, + }); +} + +function makeConfigDir(id: string): void { + const dir = localDir(id); + mkdirSync(dir, { recursive: true }); + writeFileSync(buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id), "Host *\n"); +} + +beforeAll(async () => { + await runMigrations(); + const ctx = getRequestlessContext(); + service = new SubshellsService({ db: ctx.db, log: getLogger(), repos: ctx.repos }); +}); + +afterAll(async () => { + for (const id of created) { + rmSync(localDir(id), { recursive: true, force: true }); + await subshellsRepo.delete(id).catch(() => {}); + } +}); + +describe("the terminate verb sweeps the LOCAL ssh config dir", () => { + it("terminating a local ssh row removes /ssh/ (the dying hook is not relied on)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}' }); + makeConfigDir(id); + expect(existsSync(localDir(id))).toBe(true); + expect(await service.terminateSubshell(OWNER, id, "cookie")).toEqual({ ok: true }); + expect(existsSync(localDir(id))).toBe(false); // file AND dir: the sweep is an rm -rf + const row = await subshellsRepo.findById(id); + expect(row?.status).toBe("terminated"); // the ordinary terminate still ran + }); + + it("terminating a local ORDINARY row touches nothing ssh-shaped (the guard, not luck)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: null }); + makeConfigDir(id); // a dir with the pane's NAME but no snapshot column: not its to remove + expect(await service.terminateSubshell(OWNER, id, "cookie")).toEqual({ ok: true }); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); + + it("terminating an AGENT ssh row sweeps nothing on this host (the node cleans its own disk)", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-term", ssh: '{"host":"example.test"}' }); + makeConfigDir(id); // same NAME, not ours: the agent's watcher owns that disk + expect(await service.terminateSubshell(OWNER, id, "cookie")).toEqual({ ok: true }); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); + + it("(M2 §5.6 child-exit) terminate also cuts any relay session brokered for the pane", async () => { + // The kill hand is the sure thing: a brokered B-side relay must die with + // the pane even when the node's dying report never lands. The broker's + // pane lookup no-ops rows that never relayed, so this witness fires for + // every terminate. + const cuts: string[] = []; + const spy = { + openRelay: async () => { + throw new Error("unused"); + }, + routeRelayFrame: () => {}, + closeRelay: async () => false, + closeUnauthorizedForNode: async () => 0, + closeForPane: async (paneId: string, reason: string) => { + cuts.push(`${paneId}:${reason}`); + return 0; + }, + refuseOverCap: async () => false, + onNodeSocketClosed: async () => 0, + activeRelayCount: () => 0, + sessionInfo: () => null, + reset: () => {}, + }; + setRelayBrokerForTests(spy as unknown as RelayBroker); + try { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-term", ssh: '{"host":"example.test"}' }); + expect(await service.terminateSubshell(OWNER, id, "cookie")).toEqual({ ok: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); // the sweep is fire-and-forget + expect(cuts).toEqual([`${id}:child-exit`]); + } finally { + setRelayBrokerForTests(null); + } + }); +}); + +/** + * The MAINTENANCE kill reaches the same teardown through the manager with no + * REST verb in the loop (the maintenance window and lockdown both call + * `terminateForMaintenance` per row), so the verb's sweep never runs for it. + * Same harness as the verb cases above: no tmux socket, so the kill is + * skipped and the sweep is the act under test. + */ +describe("the maintenance kill sweeps the LOCAL ssh config dir", () => { + const manager = new SubshellManagerService({ + subshells: subshellsRepo, + presets: new PresetsRepository(getRequestlessContext().db), + audit: async () => {}, + notify: async () => {}, + }); + + it("terminateForMaintenance on a local ssh row removes /ssh/", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: LOCAL_NODE_ID, ssh: '{"host":"example.test"}' }); + makeConfigDir(id); + expect(existsSync(localDir(id))).toBe(true); + const row = await subshellsRepo.findById(id); + if (!row) throw new Error("seeded row vanished"); // the arg type is the full row + await manager.terminateForMaintenance(row); + expect(existsSync(localDir(id))).toBe(false); // file AND dir: the sweep is an rm -rf + const after = await subshellsRepo.findById(id); + expect(after?.status).toBe("terminated"); // the ordinary terminate still ran + }); + + it("terminateForMaintenance on an AGENT ssh row sweeps nothing on this host", async () => { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-maint", ssh: '{"host":"example.test"}' }); + makeConfigDir(id); // same NAME, not ours: the agent's watcher owns that disk + const row = await subshellsRepo.findById(id); + if (!row) throw new Error("seeded row vanished"); // the arg type is the full row + await manager.terminateForMaintenance(row); + expect(existsSync(localDir(id))).toBe(true); + rmSync(localDir(id), { recursive: true, force: true }); + }); +}); diff --git a/apps/server/api/src/services/backups/__tests__/backups.test.ts b/apps/server/api/src/services/backups/__tests__/backups.test.ts index 1674250ca..9a13d95ff 100644 --- a/apps/server/api/src/services/backups/__tests__/backups.test.ts +++ b/apps/server/api/src/services/backups/__tests__/backups.test.ts @@ -163,6 +163,27 @@ describe("instance archive", () => { await expect(backup(source)).rejects.toThrow("required identity: data/identities/sess-one.json"); }); + it("roundtrips server relay identity and pins, and refuses a registered identity without its file", async () => { + const source = fixture("relay-source"); + const db = new Database(source.databasePath); + db.exec("CREATE TABLE identities(principal_id TEXT)"); + db.query("INSERT INTO identities VALUES (?)").run("node:local"); + db.close(); + await expect(backup(source)).rejects.toThrow("required identity: data/ssh-relay/identity.json"); + put(join(source.dataDir, "ssh-relay", "identity.json"), "relay-private-identity"); + put(join(source.dataDir, "ssh-relay", "ssh-machine-pins.json"), "relay-trust-pins"); + const staged = await stage(await backup(source)); + const destination = fixture("relay-destination"); + const transaction = await restoreInstanceBackup(staged, { destination }); + expect(readFileSync(join(destination.dataDir, "ssh-relay", "identity.json"), "utf8")).toBe( + "relay-private-identity", + ); + expect(readFileSync(join(destination.dataDir, "ssh-relay", "ssh-machine-pins.json"), "utf8")).toBe( + "relay-trust-pins", + ); + await finalizeInstanceRestore(transaction.journalPath); + }); + it("rejects wrong password, truncated ciphertext and authenticated-header tampering", async () => { const path = await backup(fixture("source"), "unique archive password"); await expect(stage(path)).rejects.toThrow("requires a password"); diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 2d0ac1be5..b2ff1452a 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0046-backup-recovery"; +export const LATEST_BACKUP_MIGRATION = "0053-drop-ssh-grants"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { @@ -85,6 +85,8 @@ export function requiredIdentityPaths(path: string): string[] { required.push("data/node-encryption.json"); } } + if (tables.has("identities") && db.query("SELECT 1 FROM identities WHERE principal_id = 'node:local'").get()) + required.push("data/ssh-relay/identity.json"); const panes = tables.has("subshells") ? "subshells" : tables.has("sessions") ? "sessions" : null; if (tables.has("identities") && panes) { requireMetadataColumns(schema, "identities", ["principal_id"]); diff --git a/apps/server/api/src/services/backups/paths.ts b/apps/server/api/src/services/backups/paths.ts index 52d06e5d3..7f5776962 100644 --- a/apps/server/api/src/services/backups/paths.ts +++ b/apps/server/api/src/services/backups/paths.ts @@ -16,6 +16,7 @@ export const DATA_COMPONENTS = [ "vapid.json", "peers.json", "identities", + "ssh-relay", "plugins", "plugins-state", "subshells", diff --git a/apps/server/api/src/services/channels.service.ts b/apps/server/api/src/services/channels.service.ts index 3133c505e..80f725275 100644 --- a/apps/server/api/src/services/channels.service.ts +++ b/apps/server/api/src/services/channels.service.ts @@ -219,12 +219,15 @@ export class ChannelsService extends BaseService { // waiting at its prompt is WOKEN — the line names the tool and is // submitted, so the agent reads the post without being told to poll. // A mid-turn pane gets the old Enter-less cue (submitting into a busy - // harness corrupts the turn). Never peer content in either line. + // harness corrupts the turn). Never peer content in either line. An + // SSH pane is NEVER nudged: it is a raw shell under the owner's ssh + // identity that takes input from the owner alone (spec §5.4), and no + // agent sits behind it to read the cue. const subshells = this.repos.subshells; for (const recipientId of new Set(recipientIds)) { if (!recipientId.startsWith("sess:") || recipientId === principal) continue; const row = await subshells.findById(recipientId.slice("sess:".length)); - if (row?.alive !== 1 || !row.tmuxSocket) continue; + if (row?.alive !== 1 || !row.tmuxSocket || row.ssh !== null) continue; const waiting = row.waitingSince != null; const line = waiting ? `[#${channel.name}] subshell peer post, read it: call read_channel("${channel.name}")` diff --git a/apps/server/api/src/services/expo-push.ts b/apps/server/api/src/services/expo-push.ts index 260ed22f0..4b35d5828 100644 --- a/apps/server/api/src/services/expo-push.ts +++ b/apps/server/api/src/services/expo-push.ts @@ -121,8 +121,10 @@ export function buildExpoMessages( threadId: subshellId, tag: subshellId, collapseId: subshellId, - // Names registered in apps/client/mobile/src/native/push.ts — keep the three in - // sync or the lock-screen actions silently vanish on real devices. + // Category and channel NAMES as registered in + // apps/client/mobile/src/native/push.ts (`subshell` and the + // `subshell-subshells` channel); keep these strings in sync with what that + // file registers or the lock-screen actions silently vanish on real devices. categoryId: "subshell", channelId: "subshell-subshells", _channelId: "subshell-subshells", diff --git a/apps/server/api/src/services/nodes/__tests__/argv-parity.test.ts b/apps/server/api/src/services/nodes/__tests__/argv-parity.test.ts index 1d3ba57d8..a0c55a9b4 100644 --- a/apps/server/api/src/services/nodes/__tests__/argv-parity.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/argv-parity.test.ts @@ -73,11 +73,15 @@ const SETTINGS_FIXTURES: Record> = { // that the matrix holds for inputs the plugin ignores, and a shell ignores // all of them. terminal: {}, + // SSH is the terminal launch pointed at ssh; like a shell it stores no + // settings keys, and the ssh option tokens ride `preset.flags`. Its + // "settings set" row is the empty object for the same reason terminal's is. + ssh: {}, }; /** * Derived, never hand-listed: a hand-synced list is itself a bypass, because - * a sixth built-in added to pane-runtime would simply not appear and the + * a new built-in added to pane-runtime would simply not appear and the * gate would silently shrink. What the registry ANSWERS is what the matrix * runs; the completeness test below fails loudly if a built-in failed to * CONSTRUCT (that case removes it from this list), and the per-id fixture @@ -257,11 +261,13 @@ for (const id of BUILTIN_IDS) { } /** - * Sanity: every built-in under test really is one of the six, and the + * Sanity: every built-in under test really is one of the seven, and the * registry is what production reads (`getHarness`, the same accessor * `subshell-manager` resolves launches through) — no copies. */ -test("argv parity: the matrix covers all six built-ins through the production registry", () => { +test("argv parity: the matrix covers all seven built-ins through the production registry", () => { const registryIds = BUILTIN_IDS.map((id) => getHarness(id)?.id); - expect(new Set(registryIds)).toEqual(new Set(["claude-code", "codex", "opencode", "hermes", "pi", "terminal"])); + expect(new Set(registryIds)).toEqual( + new Set(["claude-code", "codex", "opencode", "hermes", "pi", "ssh", "terminal"]), + ); }); diff --git a/apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts b/apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts new file mode 100644 index 000000000..b9aeec01f --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/local-launcher-ssh.test.ts @@ -0,0 +1,166 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { existsSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSshConfigPath, getHarness, TmuxRunner, tmuxSocketFor } from "@internal/pane-runtime"; +import { TRUE_BINARY } from "@/__tests__/helpers/true-binary.js"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { LocalLauncher } from "../local-launcher.js"; +import type { LaunchPlan } from "../node-launcher.js"; +import { subshellLogPath, subshellSshConfigPath } from "../subshell-paths.js"; + +/** + * Task 5 (spec 2026-10-07 decision 4): the LOCAL twin of the agent's ssh + * config write. `LocalLauncher.launch` materializes the rendered config under + * the SERVER's own dataDir, at the same derived path the ssh service composed, + * BEFORE the pane spawns — and `subshellArtifacts` names it for the delete + * sweep only when the caller says the pane was an ssh pane. + * + * Scripted tmux (no real spawn): the write is pure fs, and the spawn-side + * contract is exactly "the file was there when tmux was dialed". + */ + +const SSH_CONTENT = "Host *\n StrictHostKeyChecking yes\n"; +const pid = process.pid; + +/** Counts the spawn and records whether the config existed at spawn time. */ +class ScriptedTmux extends TmuxRunner { + spawns = 0; + pipes = 0; + configAtSpawn: boolean | null = null; + readonly #watchPath: () => string; + constructor(watchPath: () => string) { + super(); + this.#watchPath = watchPath; + } + override newSubshell(): void { + this.spawns++; + this.configAtSpawn = existsSync(this.#watchPath()); + } + override pipePane(): void { + this.pipes++; + } +} + +const sshHarness = getHarness("ssh"); +if (!sshHarness) throw new Error("ssh harness plugin missing from the registry"); + +// An arrow const (not a hoisted function declaration): the module-level +// narrowing of `sshHarness` survives into it (the pattern +// local-launcher.test.ts uses for the pi harness). +const sshPlan = (id: string, over: Partial = {}): LaunchPlan => ({ + id, + socket: tmuxSocketFor(id), + harness: sshHarness, + binary: TRUE_BINARY, + cwd: tmpdir(), + preset: { + name: "p", + description: null, + env: {}, + flags: ["-F", buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id), "--"], + settings: null, + configIsolation: false, + restartOnExit: false, + }, + subshellName: "s", + subshellEnv: {}, + ...over, +}); + +function cleanup(id: string): void { + rmSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id), { recursive: true, force: true }); + void Bun.file(subshellLogPath(id)) + .unlink() + .catch(() => {}); +} + +describe("LocalLauncher ssh config write (spec 2026-10-07 decision 4)", () => { + it("launch(plan with ssh): config at the derived path, 0600 in a 0700 dir, content byte-equal, written BEFORE the spawn", async () => { + const id = `launcher-ssh-${pid}`; + const configPath = buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id); + const scripted = new ScriptedTmux(() => configPath); + const launcher = new LocalLauncher({ tmux: scripted }); + try { + await launcher.launch(sshPlan(id, { ssh: { configPath, fileContent: SSH_CONTENT } })); + expect(scripted.spawns).toBe(1); + expect(scripted.configAtSpawn).toBe(true); // the file existed when tmux was dialed + expect(await Bun.file(configPath).text()).toBe(SSH_CONTENT); + expect(statSync(configPath).mode & 0o777).toBe(0o600); + const parent = configPath.slice(0, configPath.lastIndexOf("/")); + expect(statSync(parent).mode & 0o777).toBe(0o700); + } finally { + cleanup(id); + } + }); + + it("the derived path is the single composition: subshellSshConfigPath == buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id)", () => { + const id = "11111111-2222-4333-8444-555555555555"; + expect(subshellSshConfigPath(id)).toBe(buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id)); + }); + + it("configPath mismatch: launch throws, nothing written, nothing spawned", async () => { + const id = `launcher-ssh-mismatch-${pid}`; + const scripted = new ScriptedTmux(() => buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id)); + const launcher = new LocalLauncher({ tmux: scripted }); + try { + await expect( + launcher.launch( + sshPlan(id, { + ssh: { + configPath: buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, `${id}-elsewhere`), + fileContent: SSH_CONTENT, + }, + }), + ), + ).rejects.toThrow(/ssh config path/i); + expect(scripted.spawns).toBe(0); // refused before the spawn + expect(existsSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id))).toBe(false); + } finally { + cleanup(id); + } + }); + + it("no ssh member: the launch creates no ssh dir for an ordinary pane", async () => { + const id = `launcher-ssh-absent-${pid}`; + const scripted = new ScriptedTmux(() => ""); + const launcher = new LocalLauncher({ tmux: scripted }); + try { + await launcher.launch(sshPlan(id)); + expect(scripted.spawns).toBe(1); + expect(existsSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh", id))).toBe(false); + } finally { + cleanup(id); + } + }); + + it("subshellArtifacts: the ssh config path rides the list for ssh panes only", () => { + const id = `launcher-ssh-artifacts-${pid}`; + const launcher = new LocalLauncher({ tmux: new ScriptedTmux(() => "") }); + expect(launcher.subshellArtifacts(id)).toEqual([subshellLogPath(id)]); + expect(launcher.subshellArtifacts(id, true)).toEqual([ + subshellLogPath(id), + buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id), + ]); + }); + + it("delete round-trip: launch writes it, removeArtifacts(subshellArtifacts(id, true)) unlinks it", async () => { + const id = `launcher-ssh-delete-${pid}`; + const configPath = buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id); + const launcher = new LocalLauncher({ tmux: new ScriptedTmux(() => configPath) }); + try { + await launcher.launch(sshPlan(id, { ssh: { configPath, fileContent: SSH_CONTENT } })); + expect(existsSync(configPath)).toBe(true); + await launcher.removeArtifacts(launcher.subshellArtifacts(id, true)); + expect(existsSync(configPath)).toBe(false); + } finally { + cleanup(id); + } + }); +}); + +afterAll(() => { + // Defensive: the per-case cleanups own the paths; this sweeps any leftover + // from a mid-test throw so the shared test dataDir stays tidy. + rmSync(join(SUBSHELL_SERVER_DATA_DIR, "ssh"), { recursive: true, force: true }); +}); diff --git a/apps/server/api/src/services/nodes/__tests__/local-plugins.test.ts b/apps/server/api/src/services/nodes/__tests__/local-plugins.test.ts index 2f8055d65..50db36aa0 100644 --- a/apps/server/api/src/services/nodes/__tests__/local-plugins.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/local-plugins.test.ts @@ -57,6 +57,7 @@ describe("the control-plane host's plugins", () => { "netbird", "opencode", "pi", + "ssh", "tailscale", "terminal", ]); diff --git a/apps/server/api/src/services/nodes/__tests__/node-events.test.ts b/apps/server/api/src/services/nodes/__tests__/node-events.test.ts index 88804099a..2e901edef 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-events.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-events.test.ts @@ -255,6 +255,7 @@ describe("exit / subshells_report → lifecycle hooks (spec §3.3)", () => { }, onSubshellsReport: () => {}, onMaintenance: () => {}, + onSshEnabled: () => {}, }); const h = makeHarness(); const ws = fakeSocket("n1"); @@ -280,6 +281,7 @@ describe("exit / subshells_report → lifecycle hooks (spec §3.3)", () => { setNodeLifecycleHooks({ onExit: () => {}, onMaintenance: () => {}, + onSshEnabled: () => {}, onSubshellsReport: (nodeId, report) => { seen.push({ nodeId, report }); }, diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index e55561d1d..157c3b224 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -7,6 +7,8 @@ import { NODE_MAX_FRAME_BYTES, NODE_PROTOCOL_VERSION, type NodeEvent, + type RelayFrame, + SSH_RELAY_FRAME_MAX_BYTES, } from "@internal/subshell-protocol"; import { createClientSession, @@ -20,6 +22,7 @@ import { type NodeReadyReport, NodesRepository } from "@/db/repositories/nodes.r import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import type { NodeKind, NodeTable } from "@/db/types/nodes.db-types.js"; import { subscribeLive } from "@/services/live-bus.js"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { resetInputHoldsForTests } from "@/ws/input-hold.js"; import { resetInputWindowsForTests } from "@/ws/input-window.js"; import { handleSubshellMessage } from "@/ws/subshell-ws.js"; @@ -48,6 +51,7 @@ import { type NodeWsDeps, type NodeWsSocket, } from "../node-ws-handler.js"; +import { relayFramesSeenFor, relayOverCapsSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; import { beginUpdate, readView, resetForTests, updateSwapped } from "../update-tracker.js"; /* ---------------------------- fakes ----------------------------- */ @@ -98,6 +102,8 @@ interface Harness { disabledOwners: Set; ready: { id: string; report: NodeReadyReport }[]; inventories: { id: string; json: string }[]; + /** JSON trust blocks `ready` mirror writes landed (spec 2026-10-08 §4.6) */ + sshFingerprints: { id: string; json: string | null }[]; touched: string[]; statuses: { id: string; status: string }[]; /** what deps.resolveResult saw: the connection handed to it + the event */ @@ -121,6 +127,7 @@ function makeHarness(): Harness { disabledOwners: new Set(), ready: [], inventories: [], + sshFingerprints: [], touched: [], statuses: [], results: [], @@ -160,6 +167,9 @@ function makeHarness(): Harness { setStatus: async (id, status) => { h.statuses.push({ id, status }); }, + setSshFingerprint: async (id, json) => { + h.sshFingerprints.push({ id, json }); + }, } as unknown as NodeWsDeps["nodes"], resolveResult: (conn, event) => { h.results.push({ conn, event }); @@ -619,6 +629,64 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => expect(ws.closed).toHaveLength(0); }); + /** + * A complete runtime report the strict validator accepts, so the frames + * below exercise the MIRROR rules rather than the parser's floor. + */ + const runtimeWith = (extra: Record) => ({ + startedAt: "2026-10-08T00:00:00.000Z", + supervised: false, + service: { + manager: "systemd", + installed: false, + definitionPath: null, + state: "running", + pid: null, + enabled: null, + linger: null, + paneSafety: "unknown", + }, + configPath: "/c", + agentLogPath: "/c/agent.log", + logPath: null, + logHint: null, + tmuxPath: null, + binaryPath: "/b", + logging: { debug: false, source: "default" }, + ...extra, + }); + + /** The canonical §4.6 trust block shapes; one good, one the grammar refuses. */ + const TRUST_BLOCK = { + own: { signing: `SHA256:${"A".repeat(43)}`, encryption: `SHA256:${"B".repeat(43)}` }, + peers: [], + }; + + it("ready mirrors the ssh trust block onto the row; no block, no write, no clear", async () => { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ runtime: runtimeWith({ sshFingerprint: TRUST_BLOCK }) })), + ); + expect(h.sshFingerprints).toEqual([{ id: "n1", json: JSON.stringify(TRUST_BLOCK) }]); + // A later ready WITHOUT a block (the agent predates M2, or the read + // failed): the mirror must survive it. Absence is not evidence the + // machine's keys are gone; only a fresh true report moves the copy. + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame())); + expect(h.sshFingerprints).toHaveLength(1); + // And a malformed block is dropped by the frame parser BEFORE the switch, + // so it reaches no write at all (the same frame still comes online: the + // ready itself is kept, `runtime` with it, minus the junk block). + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ runtime: runtimeWith({ sshFingerprint: { own: "junk", peers: [] } }) })), + ); + expect(h.sshFingerprints).toHaveLength(1); + expect(h.ready).toHaveLength(3); + }); + /** * Harness detection when a node comes ONLINE — the half of freshness a * person cannot supply (spec 2026-09-10 §4 as extended; the periodic other @@ -694,6 +762,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => onMaintenance: (nodeId, reported) => { seen.push([nodeId, reported]); }, + onSshEnabled: () => {}, }); try { const ws = fakeSocket("n1"); @@ -750,6 +819,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => onMaintenance: (nodeId, reported) => { seen.push([nodeId, reported]); }, + onSshEnabled: () => {}, }); return { seen, off: () => resetNodeEventsForTests() }; } @@ -813,6 +883,87 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => }); }); + /** + * The SSH mirror (spec 2026-10-07 §4.3) walks the same two frames into the + * same hook as maintenance — one reconciler for connect-time state and a + * mid-session report — and the tests below pin that the DIRECTION is not + * shared: what the hook does with what it hears lives in + * `ssh-enabled.test.ts`, where "never adopt" is asserted. Here it is only + * the door: both carriers, the socket's nodeId, the lenient field. + */ + describe("ssh_enabled → the lifecycle hook (spec 2026-10-07 §4.3)", () => { + /** Install a recording SSH hook and return both the log and the uninstaller. */ + function recordSshEnabled(): { seen: [string, unknown][]; off: () => void } { + const seen: [string, unknown][] = []; + setNodeLifecycleHooks({ + onExit: () => {}, + onSubshellsReport: () => {}, + onMaintenance: () => {}, + onSshEnabled: (nodeId, reported) => { + seen.push([nodeId, reported]); + }, + }); + return { seen, off: () => resetNodeEventsForTests() }; + } + + it("a ready CARRYING sshEnabled reconciles it", async () => { + const { seen, off } = recordSshEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ sshEnabled: { on: true, changedAt: "2026-10-07T10:00:00.000Z" } })), + ); + expect(seen).toEqual([["n1", { on: true, changedAt: "2026-10-07T10:00:00.000Z" }]]); + } finally { + off(); + } + }); + + it("a ready WITHOUT it still reconciles — silence is the fail-closed answer, not a skipped beat", async () => { + // A node with no mirror file reads REFUSED, which is precisely when the + // plane's row (say, ON from a flip during the outage) must push. + const { seen, off } = recordSshEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame())); + expect(seen).toEqual([["n1", undefined]]); + } finally { + off(); + } + }); + + it("the ssh_enabled EVENT reaches the same hook, under the SOCKET's nodeId", async () => { + const { seen, off } = recordSshEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "ssh_enabled", on: false, changedAt: "2026-10-07T11:00:00.000Z" }), + ); + expect(seen).toEqual([["n1", { on: false, changedAt: "2026-10-07T11:00:00.000Z" }]]); + } finally { + off(); + } + }); + + it("a ready whose sshEnabled field is malformed keeps the ready and drops the field", async () => { + // Lenient exactly like `maintenance`: the plane learns the drop as + // silence, and silence reconciles toward the row. + const { seen, off } = recordSshEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame({ sshEnabled: { on: "yes" } }))); + expect(h.ready).toHaveLength(1); + expect(seen).toEqual([["n1", undefined]]); + } finally { + off(); + } + }); + }); + it("ready stashes homeDir and selfInvoke on the live facts; env is NOT a ready field", async () => { // Spec 2026-09-10 §5 as amended by the final review: `ready` reports the // home (resume defaults hang off it) and the agent's `selfInvoke` @@ -1342,6 +1493,52 @@ describe("handleNodeClose (superseded-close hygiene, spec §5.3)", () => { await handleNodeClose(h.deps, fakeSocket("n1")); expect(h.statuses).toEqual([]); }); + + it("(Task 8, spec 2026-10-08 §5.6) the LIVE socket's death is witnessed by the relay broker; a superseded one is not", async () => { + // The `a-dropped` cut's ONLY trigger is this witness: the handler owns + // "the authenticated link is gone", the broker owns "which sessions that + // kills". A superseded close means the node is NOT gone - its newer + // socket still answers - so the witness must fire once, for the real + // death only. + const seen: string[] = []; + const broker: RelayBroker = { + openRelay: async () => { + throw new Error("unused"); + }, + routeRelayFrame: () => {}, + closeRelay: async () => false, + closeUnauthorizedForNode: async () => 0, + closeForPane: async () => 0, + refuseOverCap: async () => false, + onNodeSocketClosed: async (nodeId) => { + seen.push(nodeId); + return 0; + }, + activeRelayCount: () => 0, + sessionInfo: () => null, + shutdown: async () => {}, + identityGeneration: () => 0, + withNodeIdentityRepair: async (_nodeId, repair) => { + await repair(); + }, + reset: () => {}, + }; + setRelayBrokerForTests(broker); + try { + const h = makeHarness(); + const first = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, first); + const second = fakeSocket("n1"); // newest-wins: the first is superseded + handleNodeOpen(OPEN_DEPS, second); + await handleNodeClose(h.deps, first); + expect(seen).toEqual([]); // a superseded close tells the broker NOTHING + await handleNodeClose(h.deps, second); + expect(seen).toEqual(["n1"]); // the real death is reported exactly once + await new Promise((resolve) => setTimeout(resolve, 0)); // drain the fire-and-forget + } finally { + setRelayBrokerForTests(null); + } + }); }); /** @@ -1699,6 +1896,104 @@ describe("handleNodeMessage through the link machine (spec 2026-09-24 §4/§5/§ expect(big.closed[0]?.code).toBe(NODE_CLOSE_TOO_BIG); }); + it("(g2) a tier-17 binding is refused, and no relay frame EVER reaches the seam on that socket (spec 2026-10-08 §11)", async () => { + // THE bump gate: relay frames are a new link frame kind, so an agent + // that ships no relay grammar must not pair at all. The exact-match + // check in the binding is what refuses it - BEFORE establishment, which + // is the only door a relay frame has (the machine hands plaintext only + // after `established`, and the switch never runs on an unestablished + // socket). The relay frame here is a VALID shape; it fails on the + // version, which is the point. + resetRelayFramesForTests(); + const f = await handshakeFixture(); + const { h, ws, client } = f; + await handleNodeMessage(h.deps, ws, kxText(f)); + await handleNodeMessage( + h.deps, + ws, + client.session.sealFrame( + JSON.stringify({ nodeId: "n1", nodeKey: LIVE, protocolVersion: NODE_PROTOCOL_VERSION - 1 }), + ), + ); + expect(ws.closed[0]?.code).toBe(NODE_CLOSE_HANDSHAKE_REQUIRED); // 4410 + expect(ws.data.linkPhase).not.toBe("established"); + // What the pre-18 agent tries anyway - first as plaintext junk, then as + // a WELL-FORMED sealed relay frame its own session can still encrypt. + const relayFrame = { type: "relay", ref: "r-1", seq: 0, direction: "B2A", blob: "QUJD" }; + await handleNodeMessage(h.deps, ws, JSON.stringify(relayFrame)); + await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify(relayFrame))); + expect(relayFramesSeenFor("n1")).toBeUndefined(); // refused at the version match, never routed + }); + + it("(g3) an established tier-18 link routes a sealed relay frame verbatim to the seam; over-cap is refused by name", async () => { + resetRelayFramesForTests(); + const f = await handshakeFixture(); + const { h, ws, client } = f; + await handleNodeMessage(h.deps, ws, kxText(f)); + await handleNodeMessage(h.deps, ws, bindingBytes(f)); // established at 18 + const relayFrame: RelayFrame = { type: "relay", ref: "r-1", seq: 3, direction: "A2B", blob: "QUJD" }; + await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify(relayFrame))); + // The frame reached the broker (Task 8's route: pair by ref, copy the + // blob unopened). The seam's own record is a routing SUMMARY - the plane + // is a blind router (§5.5) and the summary has no slot to hold a blob. + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, lastRef: "r-1", lastDirection: "A2B" }); + expect(JSON.stringify(relayFramesSeenFor("n1"))).not.toContain("QUJD"); + // The over-cap blob is refused by the grammar upstream of the ROUTER seam + // (§5.1: cap is law); the socket survives, the frame is not forwarded. + // But §5.1's named-reason close needs the refusal to be OBSERVABLE, and + // the handler's over-cap probe is what routes it - ref only, no blob. + const over = "A".repeat(Math.ceil(SSH_RELAY_FRAME_MAX_BYTES / 3) * 4 + 4); + await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify({ ...relayFrame, blob: over }))); + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, lastRef: "r-1", lastDirection: "A2B" }); // router seam: NOT the normal route + expect(relayOverCapsSeenFor("n1")).toEqual({ overCaps: 1, lastRef: "r-1" }); // over-cap seam, ref carried + // The blob never crossed: the record has no byte slot, and the only + // bytes the seam could know are the ref (§5.5 blindness end to end). + expect(JSON.stringify(relayOverCapsSeenFor("n1"))).not.toContain("AAAA"); + expect(ws.closed).toHaveLength(0); // a refused frame is not fatal (spec §3.1 posture) + }); + + it("(g3b) an over-cap relay frame lands on the over-cap seam, NOT the router seam; junk lands on neither (Task 4 review)", async () => { + // The plain-path twin of g3's encrypted route (the fake socket skips the + // link machine), pinning the branch the handler added to its + // parseNodeEvent-null path: shape probe first, then the drop. + resetRelayFramesForTests(); + const h = makeHarness(); + const ws = fakeSocket("n1"); + const over = "A".repeat(Math.ceil(SSH_RELAY_FRAME_MAX_BYTES / 3) * 4 + 4); + const overFrame = { type: "relay", ref: "r-9", seq: 1, direction: "B2A", blob: over }; + await handleNodeMessage(h.deps, ws, JSON.stringify(overFrame)); + expect(relayOverCapsSeenFor("n1")).toEqual({ overCaps: 1, lastRef: "r-9" }); + expect(relayFramesSeenFor("n1")).toBeUndefined(); // never routed, blob never forwarded + // A second refusal increments the same node's count with the newer ref. + await handleNodeMessage(h.deps, ws, JSON.stringify({ ...overFrame, ref: "r-10" })); + expect(relayOverCapsSeenFor("n1")).toEqual({ overCaps: 2, lastRef: "r-10" }); + // A MALFORMED over-cap frame is junk, not an over-cap: it stays on the + // silent-drop path the probe deliberately does not widen. + await handleNodeMessage(h.deps, ws, JSON.stringify({ ...overFrame, seq: -5 })); + expect(relayOverCapsSeenFor("n1")).toMatchObject({ overCaps: 2 }); + // And a well-formed UNDER-cap frame goes the other way: routed, not refused. + const goodFrame: RelayFrame = { type: "relay", ref: "r-11", seq: 0, direction: "A2B", blob: "QUJD" }; + await handleNodeMessage(h.deps, ws, JSON.stringify(goodFrame)); + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, lastRef: "r-11", lastDirection: "A2B" }); + expect(relayOverCapsSeenFor("n1")).toMatchObject({ overCaps: 2 }); // still just the two + expect(ws.closed).toHaveLength(0); + }); + + it("(g4) a HELD socket's relay frame is dropped by the held-gate, not routed", async () => { + resetRelayFramesForTests(); + const h = makeHarness(); + const ws = fakeSocket("n1"); // unclassified fake: straight to the gates/switch + handleNodeOpen(OPEN_DEPS, ws); // the hold needs the connection it attaches + await handleNodeMessage(h.deps, ws, JSON.stringify(readyFrame({ protocolVersion: 999 }))); + expect(getHeld("n1")).toBeDefined(); + await handleNodeMessage( + h.deps, + ws, + JSON.stringify({ type: "relay", ref: "r-1", seq: 0, direction: "B2A", blob: "QUJD" }), + ); + expect(relayFramesSeenFor("n1")).toBeUndefined(); // held speaks exactly one thing: `result` + }); + it("(h) the held `update` path traverses the machine untouched: plaintext out, plaintext result in, RPC completes", async () => { // spec 2026-09-24 §5 regression lock ("held update path unregressed"). A // LEGACY below-floor agent is held by the EXISTING floor gate — the diff --git a/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts new file mode 100644 index 000000000..a04ee660d --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { RelayFrame } from "@internal/subshell-protocol"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; +import { + onNodeSocketClosed, + onRelayFrame, + onRelayFrameOverCap, + relayFramesSeenFor, + relayOverCapsSeenFor, + resetRelayFramesForTests, +} from "../relay-frames.js"; + +/** + * The Task-8 seam (spec 2026-10-08 §5.1/§5.3/§5.5): validation happened + * upstream (parseRelayFrame via the handler's parse), the held/supersede + * guards upstream of that, and the version gate upstream of everything. This + * file pins the SEAM's own contract now that the broker is real: every entry + * point reaches the broker, the per-node record is a routing SUMMARY with no + * blob slot anywhere (§5.5), a throwing broker never escapes the seam, and + * the test reset keeps suites isolated under `bun test --parallel`. + */ + +const frame: RelayFrame = { + type: "relay", + ref: "r-4f2a", + seq: 0, + direction: "B2A", + blob: "QUJDk5+ToQ==", +}; + +/** A recording broker: the seam's three verbs, counted, with an optional throw. */ +function spyBroker(overrides: Partial = {}) { + const calls: { route: [string, RelayFrame][]; overCap: string[]; socketClosed: string[] } = { + route: [], + overCap: [], + socketClosed: [], + }; + const noop = (): RelayBroker["closeRelay"] => async () => false; + const broker: RelayBroker = { + openRelay: async () => { + throw new Error("seam test must not open"); + }, + routeRelayFrame: (nodeId, f) => { + calls.route.push([nodeId, f]); + }, + closeRelay: noop(), + closeUnauthorizedForNode: async () => 0, + closeForPane: async () => 0, + refuseOverCap: async (ref) => { + calls.overCap.push(ref); + return false; + }, + onNodeSocketClosed: async (nodeId) => { + calls.socketClosed.push(nodeId); + return 0; + }, + activeRelayCount: () => 0, + sessionInfo: () => null, + shutdown: async () => {}, + identityGeneration: () => 0, + withNodeIdentityRepair: async (_nodeId, repair) => { + await repair(); + }, + reset: () => {}, + ...overrides, + }; + return { broker, calls }; +} + +afterEach(() => { + setRelayBrokerForTests(null); + resetRelayFramesForTests(); +}); + +describe("onRelayFrame (the brokered seam, Task 8)", () => { + it("hands the broker the EXACT frame object and records a blob-free summary", () => { + const { broker, calls } = spyBroker(); + setRelayBrokerForTests(broker); + resetRelayFramesForTests(); + onRelayFrame("n-a", frame); + expect(calls.route).toHaveLength(1); + expect(calls.route[0]?.[0]).toBe("n-a"); + expect(calls.route[0]?.[1]).toBe(frame); // by reference: the blind hand-off + const seen = relayFramesSeenFor("n-a"); + expect(seen).toEqual({ frames: 1, lastRef: "r-4f2a", lastDirection: "B2A" }); + // §5.5, structurally: the record has no blob slot at all - the seam + // could not hold the bytes even if a test asked it to. + expect(Object.keys(seen ?? {})).toEqual(["frames", "lastRef", "lastDirection"]); + expect(JSON.stringify(seen)).not.toContain(frame.blob); + expect(JSON.stringify(seen)).not.toContain("QUJD"); + }); + + it("counts per node and keeps only the newest routing facts", () => { + const { broker } = spyBroker(); + setRelayBrokerForTests(broker); + resetRelayFramesForTests(); + onRelayFrame("n-b", frame); + onRelayFrame("n-b", { ...frame, ref: "r-5", seq: 1, direction: "A2B" }); + expect(relayFramesSeenFor("n-b")).toEqual({ frames: 2, lastRef: "r-5", lastDirection: "A2B" }); + // Per-node isolation: A's traffic is not B's record. + expect(relayFramesSeenFor("n-a")).toBeUndefined(); + }); + + it("a throwing broker is CONTAINED (the handler's frame chain survives)", () => { + const { broker } = spyBroker({ + routeRelayFrame: () => { + throw new Error("broker exploded"); + }, + }); + setRelayBrokerForTests(broker); + resetRelayFramesForTests(); + expect(() => onRelayFrame("n-c", frame)).not.toThrow(); + expect(relayFramesSeenFor("n-c")).toEqual({ frames: 1, lastRef: "r-4f2a", lastDirection: "B2A" }); + }); +}); + +describe("onRelayFrameOverCap (the named refusal, §5.1)", () => { + it("refuses BY NAME through the broker and records ONLY the ref", async () => { + const { broker, calls } = spyBroker(); + setRelayBrokerForTests(broker); + resetRelayFramesForTests(); + onRelayFrameOverCap("n-d", "r-1"); + onRelayFrameOverCap("n-d", "r-2"); + await Promise.resolve(); + expect(calls.overCap).toEqual(["r-1", "r-2"]); + expect(relayOverCapsSeenFor("n-d")).toEqual({ overCaps: 2, lastRef: "r-2" }); + // Per-node isolation, same posture as the routed summary. + expect(relayOverCapsSeenFor("n-e")).toBeUndefined(); + expect(Object.keys(relayOverCapsSeenFor("n-d") ?? {})).toEqual(["overCaps", "lastRef"]); + }); + + it("the seams are independent, and reset clears them", () => { + const { broker } = spyBroker(); + setRelayBrokerForTests(broker); + resetRelayFramesForTests(); + onRelayFrameOverCap("n-f", "r-9"); + expect(relayFramesSeenFor("n-f")).toBeUndefined(); // a refusal is not a routed frame + onRelayFrame("n-f", frame); + expect(relayOverCapsSeenFor("n-f")).toEqual({ overCaps: 1, lastRef: "r-9" }); // a route is not a refusal + resetRelayFramesForTests(); + expect(relayOverCapsSeenFor("n-f")).toBeUndefined(); + expect(relayFramesSeenFor("n-f")).toBeUndefined(); + }); +}); + +describe("onNodeSocketClosed (the a-dropped witness, §5.6)", () => { + it("reports the dead node to the broker and never throws", async () => { + const { broker, calls } = spyBroker(); + setRelayBrokerForTests(broker); + onNodeSocketClosed("n-g"); + await Promise.resolve(); + expect(calls.socketClosed).toEqual(["n-g"]); + const { broker: bad } = spyBroker({ + onNodeSocketClosed: () => Promise.reject(new Error("broker rejected the witness")), + }); + setRelayBrokerForTests(bad); + expect(() => onNodeSocketClosed("n-h")).not.toThrow(); + }); +}); diff --git a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts index cf2083078..a8cb3413a 100644 --- a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts @@ -173,6 +173,10 @@ function nodeRow(over: Partial = {}): NodeTable { maintenance: 0, maintenanceAt: null, maintenanceSource: null, + sshEnabled: 0, + sshEnabledAt: null, + // The 0049 mirror slot: NULL until the runtime-report task fills it. + sshFingerprint: null, createdAt: "2026-09-01T00:00:00.000Z", updatedAt: "2026-09-01T00:00:00.000Z", ...over, @@ -350,6 +354,28 @@ describe("launch", () => { expect(cmd.bestEffortLog).toBe(true); }); + it("an ssh plan ships the frame's ssh member verbatim; a plain plan carries no ssh key at all", async () => { + // spec 2026-10-07 decision 4: the launcher is a CARRIER. The path inside + // the member was composed by the launch service from this node's + // `ready.dataDir`; the agent re-derives it and refuses a byte-mismatch, + // so nothing here recomputes or rewrites it. + const h = makeHarness(); + const ssh = { configPath: `/home/u/.subshell/ssh/${SUB}/config`, fileContent: "Host *\n" }; + await h.launcher.launch({ ...planBase(), id: SUB, ssh }); + const cmd = h.calls[0]?.cmd as Extract; + expect(cmd.ssh).toEqual(ssh); + // The member is protocol-17 wire shape: it must survive the JSON round + // trip through the frame parser the node runs before dispatch. + expect(parseNodeCommandBody(JSON.parse(JSON.stringify(cmd)))).not.toBeNull(); + + // The mcp lesson, applied to ssh: an absent block stays ABSENT on the + // object (a conditional spread), never an explicit-undefined key. + const h2 = makeHarness(); + await h2.launcher.launch(planBase()); + const plain = h2.calls[0]?.cmd as Extract; + expect("ssh" in plain).toBe(false); + }); + it("a remote launch carries an argv whose binary slot is the placeholder", async () => { // The REAL pi plugin, not the stub: argv is the plugin's dialect, and the // resolve rule must be the pi manifest's detect block passed straight @@ -984,6 +1010,31 @@ describe("removeArtifacts", () => { }); }); +describe("subshellArtifacts sshPane (spec 2026-10-07 decision 4)", () => { + const NODE_SUB = "11111111-2222-4333-8444-555555555555"; + const triple = [ + `/home/u/.subshell/subshells/${NODE_SUB}.log`, + `/home/u/.subshell/mcp/${NODE_SUB}.json`, + `/home/u/.subshell/subshells/${NODE_SUB}.meta.json`, + ]; + + it("names the derived ssh config path only when the caller says the pane was an ssh pane", () => { + const h = makeHarness(); + expect(h.launcher.subshellArtifacts(NODE_SUB)).toEqual(triple); + // The path template is the SAME pane-runtime composition the launch-side + // configPath used; on the node it is composed from `ready.dataDir`. + expect(h.launcher.subshellArtifacts(NODE_SUB, true)).toEqual([ + ...triple, + `/home/u/.subshell/ssh/${NODE_SUB}/config`, + ]); + }); + + it("offline stays [] even for an ssh pane (no facts, no layout to name paths from)", () => { + const h = makeHarness(null); + expect(h.launcher.subshellArtifacts(NODE_SUB, true)).toEqual([]); + }); +}); + describe("offline short-circuit (no facts ⇒ no send)", () => { it("facts-dependent members fail before the wire; never-throws members stay soft", async () => { const h = makeHarness(null); diff --git a/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts b/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts new file mode 100644 index 000000000..e395f127d --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts @@ -0,0 +1,293 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { + decideSshEnabled, + pushSetSshEnabled, + reconcileSshEnabled, + setNodeSshEnabled, +} from "@/services/nodes/ssh-enabled.js"; +import { attachScriptedNode, ok } from "@/test-helpers/scripted-node.js"; + +/** + * The SSH gate's plane half (spec 2026-10-07 §4.3): the decide table — pure, + * no DB, no socket (the `maintenance-decide.test.ts` pattern) — and the hook + * body with one real row and one scripted node (the `maintenance-apply.test.ts` + * pattern, and its seam: no RPC mocks, the push travels the real + * `sendCommand` chain and lands in the scripted wire, which is what proves the + * `set_ssh_enabled` frame parses against the shipped parser). + * + * The property this suite exists to pin is the DIRECTION: the plane is the + * sole writer, so a reconcile NEVER adopts. Every disagreement ends with the + * row's value pushed down and the row itself unmoved. + */ + +const nodes = new NodesRepository(db); +const nodeIds: string[] = []; +const emails: string[] = []; +let ownerId: string; + +async function mkUser(): Promise { + const email = `ssh-on-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("ssh-pass-1"), + role: "user", + }); +} + +/** An enrolled agent node with no live socket — every command on it fails offline. */ +async function mkNode(): Promise { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ id, ownerUserId: ownerId, name: `ssh-${id.slice(0, 8)}`, kind: "agent", status: "online" }); + return id; +} + +/** Audit rows for one target, newest last. */ +async function auditFor(targetId: string): Promise<{ actorUserId: string | null; meta: unknown }[]> { + const rows = await db + .selectFrom("auditEvents") + .select(["actorUserId", "metadataJson"]) + .where("targetId", "=", targetId) + .where("action", "=", "node.ssh_enabled.update") + .orderBy("createdAt", "asc") + .execute(); + return rows.map((r) => ({ actorUserId: r.actorUserId, meta: JSON.parse(r.metadataJson ?? "null") })); +} + +/** Let the void-fired pushes land (the maintenance-apply posture). */ +const settle = () => new Promise((r) => setTimeout(r, 25)); + +beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(); +}); + +afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of nodeIds) await nodes.deleteById(id); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +/* ------------------ the pure table -------------------------- */ + +describe("decideSshEnabled — the plane is authoritative, so stamps are never read", () => { + const row = (sshEnabled: 0 | 1, sshEnabledAt: string | null) => ({ sshEnabled, sshEnabledAt }); + + it("agreement is a noop — reported matches the row, either value", () => { + expect( + decideSshEnabled(row(1, "2026-10-07T10:00:00.000Z"), { on: true, changedAt: "2026-10-07T10:00:00.000Z" }), + ).toBe("noop"); + expect( + decideSshEnabled(row(0, "2026-10-07T10:00:00.000Z"), { on: false, changedAt: "2026-10-07T09:00:00.000Z" }), + ).toBe("noop"); + // Differing stamps under an agreeing value are a relayed fact, not a + // fight — maintenance's tie-breaker has no job when nothing writes from + // the node. + expect( + decideSshEnabled(row(1, "2026-10-07T12:00:00.000Z"), { on: true, changedAt: "2026-10-07T09:00:00.000Z" }), + ).toBe("noop"); + }); + + it("silence means OFF here: row-off agrees (noop), row-on pushes", () => { + // An absent mirror reads REFUSED on the machine, so a never-written or + // off row and a silent node already answer alike; the row saying ON faces + // a machine that refuses, and the row wins — that push is the repair. + expect(decideSshEnabled(row(0, null), undefined)).toBe("noop"); + expect(decideSshEnabled(row(0, "2026-10-07T10:00:00.000Z"), undefined)).toBe("noop"); + expect(decideSshEnabled(row(1, "2026-10-07T10:00:00.000Z"), undefined)).toBe("push-plane"); + }); + + it("disagreement pushes the row, in both directions — and never adopts, not even a NEWER report", () => { + // maintenance would adopt a newer node stamp; there is no node stamp here + // that anyone wrote, so even a future-dated report is just bytes the row + // overwrites. + expect( + decideSshEnabled(row(0, "2026-10-07T10:00:00.000Z"), { on: true, changedAt: "2099-01-01T00:00:00.000Z" }), + ).toBe("push-plane"); + expect( + decideSshEnabled(row(1, "2026-10-07T10:00:00.000Z"), { on: false, changedAt: "2026-10-07T09:00:00.000Z" }), + ).toBe("push-plane"); + }); + + it("the one disagreement with nothing to send is a noop, not an invented stamp", () => { + // Row never written + a node mirror saying ON (data dir carried across + // from another plane, say): the row IS off, every plane-side gate reads + // the row, so the disagreement is inert — and a push would have to mint a + // `changedAt` for a write that never happened. + expect(decideSshEnabled(row(0, null), { on: true, changedAt: "2026-10-07T09:00:00.000Z" })).toBe("noop"); + }); +}); + +/* ------------------ the act, on a real row -------------------------- */ + +describe("setNodeSshEnabled — write, audit, push, one function", () => { + it("writes the row, audits EXACTLY ONE row naming the actor, and pushes the stamped value", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + const changedAt = "2026-10-07T10:00:00.000Z"; + try { + await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); + const row = await nodes.findById(nodeId); + expect(row?.sshEnabled).toBe(1); + expect(row?.sshEnabledAt).toBe(changedAt); + await settle(); + // Exactly one audit row — the push adds nothing, and a second writer of + // this act is precisely what the one-function shape exists to prevent. + const rows = await auditFor(nodeId); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBe(ownerId); + expect(rows[0].meta).toEqual({ on: true }); + // The push is the row's OWN bytes — the wire frame the shipped parser + // accepted, not a re-stamped copy. + expect(scripted.cmdsOf("set_ssh_enabled")).toEqual([{ type: "set_ssh_enabled", on: true, changedAt }]); + } finally { + scripted.detach(); + } + }); + + it("an OFF flip lands too — the mirror is written as a value, not deleted", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + try { + await setNodeSshEnabled({ nodeId, on: false, changedAt: "2026-10-07T11:00:00.000Z", actorUserId: ownerId }); + const row = await nodes.findById(nodeId); + expect(row?.sshEnabled).toBe(0); + expect(row?.sshEnabledAt).toBe("2026-10-07T11:00:00.000Z"); + await settle(); + expect(scripted.cmdsOf("set_ssh_enabled")).toEqual([ + { type: "set_ssh_enabled", on: false, changedAt: "2026-10-07T11:00:00.000Z" }, + ]); + } finally { + scripted.detach(); + } + }); + + it("survives an offline node — the push is best-effort; the row already refuses", async () => { + const nodeId = await mkNode(); + await expect( + setNodeSshEnabled({ nodeId, on: true, changedAt: "2026-10-07T12:00:00.000Z", actorUserId: ownerId }), + ).resolves.toBeUndefined(); + expect((await nodes.findById(nodeId))?.sshEnabled).toBe(1); + }); +}); + +/* ------------------ the hook body -------------------------- */ + +describe("reconcileSshEnabled — decide, push the row, write nothing", () => { + it("agreement pushes NOTHING — the steady state must not echo a command per beat", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + const changedAt = "2026-10-07T10:00:00.000Z"; + await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); + await settle(); + try { + // The `ready` that follows the push carries exactly what was written. + await reconcileSshEnabled(nodeId, { on: true, changedAt }); + await settle(); + expect(scripted.countOf("set_ssh_enabled")).toBe(1); // only the flip's own push + } finally { + scripted.detach(); + } + }); + + it("disagreement pushes the ROW's value verbatim, and the row never moves", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + const changedAt = "2026-10-07T10:00:00.000Z"; + await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); + await settle(); + try { + // The machine reports REFUSED (its mirror corrupted, say) under an ON + // row: the answer is the row pushed down — never the report pulled up. + await reconcileSshEnabled(nodeId, { on: false, changedAt: "2026-10-07T13:00:00.000Z" }); + await settle(); + const pushes = scripted.cmdsOf("set_ssh_enabled"); + expect(pushes).toHaveLength(2); + expect(pushes[1]).toEqual({ type: "set_ssh_enabled", on: true, changedAt }); // the row's bytes, stamp included + expect((await nodes.findById(nodeId))?.sshEnabledAt).toBe(changedAt); // unmoved: never adopted, never re-stamped + } finally { + scripted.detach(); + } + }); + + it("row ON + node silent → push; and the reconcile itself writes no row and no audit", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); + const changedAt = "2026-10-07T10:00:00.000Z"; + await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); + await settle(); + const auditsBefore = (await auditFor(nodeId)).length; + try { + // A wiped data dir: no mirror, no stamp to offer, the row's value is + // re-armed by the push (the maintenance-decide table's same cell). + await reconcileSshEnabled(nodeId, undefined); + await settle(); + expect(scripted.cmdsOf("set_ssh_enabled")).toEqual([ + { type: "set_ssh_enabled", on: true, changedAt }, + { type: "set_ssh_enabled", on: true, changedAt }, + ]); + expect((await auditFor(nodeId)).length).toBe(auditsBefore); // pushes are not acts; only flips audit + } finally { + scripted.detach(); + } + }); + + it("a future-dated report does not outrank anything — there is nothing to outrank", async () => { + // The maintenance suite's clamp test has no SSH twin because SSH has + // nothing to clamp: an adopted stamp is the failure mode clamps guard + // against, and SSH never adopts one to begin with. + const nodeId = await mkNode(); + await nodes.setSshEnabled(nodeId, { on: false, changedAt: "2026-10-07T10:00:00.000Z" }); + await reconcileSshEnabled(nodeId, { on: true, changedAt: "2099-01-01T00:00:00.000Z" }); + await settle(); + const row = await nodes.findById(nodeId); + expect(row?.sshEnabled).toBe(0); + expect(row?.sshEnabledAt).toBe("2026-10-07T10:00:00.000Z"); + expect(await auditFor(nodeId)).toHaveLength(0); // reconcile records nothing, ever + }); + + it("does nothing for a node that is not enrolled here", async () => { + await expect(reconcileSshEnabled("not-a-node", { on: true, changedAt: "2026-10-07T10:00:00.000Z" })).resolves.toBe( + undefined, + ); + }); +}); + +/* ------------------ `local` -------------------------- */ + +describe("`local` has no mirror to push to", () => { + it("pushSetSshEnabled is a NO-OP for local — resolves without a socket, where an agent would fail offline", async () => { + // The proof is the contrast: `sendCommand` to a node with no live socket + // rejects (the offline case above swallows exactly that). Local must + // RESOLVE — its answer to the gate is the DB row, and there is no second + // copy to tell. + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); + await expect( + pushSetSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T10:00:00.000Z" }), + ).resolves.toBeUndefined(); + }); + + it("setNodeSshEnabled works on local: row + audit, and the flip completes", async () => { + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); + const changedAt = "2026-10-07T10:00:00.000Z"; + await setNodeSshEnabled({ nodeId: LOCAL_NODE_ID, on: true, changedAt, actorUserId: ownerId }); + expect((await nodes.findById(LOCAL_NODE_ID))?.sshEnabled).toBe(1); + // Restore whatever the shared test DB had, so no later suite inherits a + // server account with SSH open (spec §4.3: local ships OFF). The restore + // goes through the service, so this test leaves two node.ssh_enabled.update + // audit rows on LOCAL_NODE_ID in THIS file's DB - harmless by design: each + // test file gets its own database, and audit-counting tests filter by + // targetId. + await setNodeSshEnabled({ nodeId: LOCAL_NODE_ID, on: false, changedAt, actorUserId: ownerId }); + expect((await nodes.findById(LOCAL_NODE_ID))?.sshEnabled).toBe(0); + }); +}); diff --git a/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts b/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts new file mode 100644 index 000000000..fe41ffba1 --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts @@ -0,0 +1,245 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { exportJWK, generateKeyPair } from "jose"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { bootstrapSshIdentityOnReady, deliverSigningKey } from "@/services/nodes/ssh-identity.js"; +import { attachScriptedNode, type ScriptedNode } from "@/test-helpers/scripted-node.js"; + +/** + * `deliverSigningKey` + the `ready`-time bootstrap (spec 2026-10-08 §4.3): + * the §4.3 delivery stores a first report, is idempotent on a re-report of + * the SAME bytes, and REFUSES to overwrite a slot holding a DIFFERENT key + * (the own-registration guard: a silent rotation must never be repairable + * by the machine that reported it). The property this suite exists to pin + * is that refusal, and that nothing the store writes is ever a private key: + * a JWK carrying `d` is refused exactly like junk. + * + * Posture copied from ssh-enabled.test.ts: real migrated DB, real rows, + * scripted node on the real registry for the RPC leg - no RPC mocks, the + * `ssh_register_identity` frame travels the real `sendCommand` chain and is + * gated by the real `parseNodeCommandBody`. + */ + +const nodes = new NodesRepository(db); +const identities = new IdentitiesRepository(db); +const nodeIds: string[] = []; +const emails: string[] = []; +const scripted: ScriptedNode[] = []; +let ownerId: string; +let KEY_A: string; +let KEY_B: string; +/** A real ES256 PRIVATE JWK: the store must refuse it exactly like garbage. */ +let PRIVATE_A: string; +/** A plausible ECDH-ES public JWK for the identity row's encryption half. */ +const ENC_JWK = + '{"kty":"EC","crv":"P-256","x":"BBBBBBBBBBBBBBBBBBBBBBBB-BBBBBBBBBBBBBBBBBBB","y":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}'; + +async function mkUser(): Promise { + const email = `ssh-id-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("ssh-id-pass-1"), + role: "user", + }); +} + +/** An enrolled agent node whose identity record carries an EMPTY signing slot. */ +async function mkNodeWithEmptySlot(): Promise { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ id, ownerUserId: ownerId, name: `ssh-id-${id.slice(0, 8)}`, kind: "agent", status: "online" }); + await identities.register({ + principalId: `node:${id}`, + publicKey: ENC_JWK, + signingPublicKey: null, + displayName: null, + }); + return id; +} + +async function slotOf(nodeId: string): Promise { + const row = await identities.findByPrincipal(`node:${nodeId}`); + return row?.signingPublicKey ?? null; +} + +/** Audit rows for one target's registration, newest last. */ +async function auditFor(targetId: string): Promise<{ actorUserId: string | null; meta: string | null }[]> { + const rows = await db + .selectFrom("auditEvents") + .select(["actorUserId", "metadataJson"]) + .where("targetId", "=", targetId) + .where("action", "=", "node.ssh_identity.register") + .orderBy("createdAt", "asc") + .execute(); + return rows.map((r) => ({ actorUserId: r.actorUserId, meta: r.metadataJson })); +} + +beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(); + const a = await generateKeyPair("ES256", { crv: "P-256", extractable: true }); + KEY_A = JSON.stringify(await exportJWK(a.publicKey)); + PRIVATE_A = JSON.stringify(await exportJWK(a.privateKey)); + const b = await generateKeyPair("ES256", { crv: "P-256", extractable: true }); + KEY_B = JSON.stringify(await exportJWK(b.publicKey)); +}); + +afterAll(async () => { + for (const s of scripted) s.detach(); + resetNodeRegistryForTests(); + for (const id of nodeIds) { + await db.deleteFrom("identities").where("principalId", "=", `node:${id}`).execute(); + await nodes.deleteById(id); + } + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +describe("deliverSigningKey (spec 2026-10-08 §4.3)", () => { + it("stores a FIRST report into the empty slot and audits it (ids and via only)", async () => { + const id = await mkNodeWithEmptySlot(); + expect(await deliverSigningKey(id, KEY_A)).toBe(KEY_A); + expect(await slotOf(id)).toBe(KEY_A); + const rows = await auditFor(id); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBeNull(); + expect(JSON.parse(rows[0].meta ?? "null")).toEqual({ via: "link" }); + // The key VALUE never enters the audit row (Global Constraint: no key + // material in any audit row; the metadata names the delivery, nothing else). + expect(rows[0].meta).not.toContain(KEY_A); + expect(rows[0].meta).not.toContain("-----"); + }); + + it("is idempotent on a re-report of the SAME bytes: success, no second write", async () => { + const id = await mkNodeWithEmptySlot(); + expect(await deliverSigningKey(id, KEY_A)).toBe(KEY_A); + expect(await deliverSigningKey(id, KEY_A)).toBe(KEY_A); + expect(await slotOf(id)).toBe(KEY_A); + expect(await auditFor(id)).toHaveLength(1); // the re-report wrote nothing new + }); + + it("REFUSES a DIFFERENT key: returns the existing, overwrites nothing, audits nothing", async () => { + const id = await mkNodeWithEmptySlot(); + await deliverSigningKey(id, KEY_A); + // The §4.3 own-registration guard: a machine that reports a key other + // than the one on file gets the EXISTING answer back and changes nothing. + expect(await deliverSigningKey(id, KEY_B)).toBe(KEY_A); + expect(await slotOf(id)).toBe(KEY_A); + expect(await auditFor(id)).toHaveLength(1); + }); + + it("refuses malformed input WITHOUT storing it (and never a private JWK)", async () => { + const id = await mkNodeWithEmptySlot(); + expect(await deliverSigningKey(id, "not json at all")).toBeNull(); + expect(await deliverSigningKey(id, '"just a string"')).toBeNull(); + expect(await deliverSigningKey(id, PRIVATE_A)).toBeNull(); // has `d` + expect(await slotOf(id)).toBeNull(); + expect(await auditFor(id)).toHaveLength(0); + }); + + it("refuses a node with NO identity row: nothing to fill, no row invented", async () => { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ + id, + ownerUserId: ownerId, + name: `ssh-orphan-${id.slice(0, 8)}`, + kind: "agent", + status: "online", + }); + expect(await deliverSigningKey(id, KEY_A)).toBeNull(); + expect(await identities.findByPrincipal(`node:${id}`)).toBeUndefined(); + expect(await auditFor(id)).toHaveLength(0); + }); + + it("a raced double-report lands exactly once (the CAS-loser branch)", async () => { + const id = await mkNodeWithEmptySlot(); + // Two concurrent deliveries against the EMPTY slot: their reads both see + // null (each chain yields at the same awaits, and bun:sqlite runs the + // query synchronously), so both reach the CAS. Exactly one UPDATE flips + // the slot; the loser re-reads and returns the winner's bytes instead of + // auditing a second row. + const results = await Promise.all([deliverSigningKey(id, KEY_B), deliverSigningKey(id, KEY_B)]); + expect(results).toEqual([KEY_B, KEY_B]); + expect(await slotOf(id)).toBe(KEY_B); + expect(await auditFor(id)).toHaveLength(1); // the loser wrote no audit row + }); +}); + +describe("IdentitiesRepository.fillSigningPublicKey (the CAS in SQL)", () => { + it("refuses every write once the slot holds a value, same bytes or different", async () => { + const id = await mkNodeWithEmptySlot(); + const principalId = `node:${id}`; + // The first fill lands: this is the write the empty slot exists for. + expect(await identities.fillSigningPublicKey(principalId, KEY_A)).toBe(true); + // Same bytes: the UPDATE still matches nothing (the `signingPublicKey is + // null` predicate fails), so even re-reporting the stored key answers + // false. Different bytes: refused the same way. + expect(await identities.fillSigningPublicKey(principalId, KEY_A)).toBe(false); + expect(await identities.fillSigningPublicKey(principalId, KEY_B)).toBe(false); + // The stored bytes never moved. + expect(await slotOf(id)).toBe(KEY_A); + }); +}); + +describe("bootstrapSshIdentityOnReady (the ready-time wire-up)", () => { + it("sends ssh_register_identity ONCE on an empty slot and files the answer", async () => { + const id = await mkNodeWithEmptySlot(); + const node = attachScriptedNode(id, { ssh_register_identity: () => ({ signingPublicKey: KEY_A }) }); + scripted.push(node); + await bootstrapSshIdentityOnReady(id); + expect(node.countOf("ssh_register_identity")).toBe(1); + expect(await slotOf(id)).toBe(KEY_A); + expect(await auditFor(id)).toHaveLength(1); + }); + + it("sends NOTHING when the slot is already filled (the no-spam gate)", async () => { + const id = await mkNodeWithEmptySlot(); + await deliverSigningKey(id, KEY_A); + const node = attachScriptedNode(id, { ssh_register_identity: () => ({ signingPublicKey: KEY_A }) }); + scripted.push(node); + await bootstrapSshIdentityOnReady(id); + expect(node.countOf("ssh_register_identity")).toBe(0); + expect(await slotOf(id)).toBe(KEY_A); + expect(await auditFor(id)).toHaveLength(1); // only the direct delivery above + }); + + it("sends NOTHING for a node with no identity row", async () => { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ + id, + ownerUserId: ownerId, + name: `ssh-none-${id.slice(0, 8)}`, + kind: "agent", + status: "online", + }); + const node = attachScriptedNode(id, { ssh_register_identity: () => ({ signingPublicKey: KEY_A }) }); + scripted.push(node); + await bootstrapSshIdentityOnReady(id); + expect(node.countOf("ssh_register_identity")).toBe(0); + expect(await identities.findByPrincipal(`node:${id}`)).toBeUndefined(); + }); + + it("survives a malformed machine answer: no throw, nothing stored", async () => { + const id = await mkNodeWithEmptySlot(); + const node = attachScriptedNode(id, { ssh_register_identity: () => ({ signingPublicKey: "garbage{" }) }); + scripted.push(node); + await bootstrapSshIdentityOnReady(id); + expect(node.countOf("ssh_register_identity")).toBe(1); // the ask did go out + expect(await slotOf(id)).toBeNull(); + expect(await auditFor(id)).toHaveLength(0); + }); + + it("swallows an unreachable machine quietly (offline is routine at ready)", async () => { + const id = await mkNodeWithEmptySlot(); // no scripted node attached: sendCommand answers offline + await expect(bootstrapSshIdentityOnReady(id)).resolves.toBeUndefined(); + expect(await slotOf(id)).toBeNull(); + }); +}); diff --git a/apps/server/api/src/services/nodes/local-launcher.ts b/apps/server/api/src/services/nodes/local-launcher.ts index 4df5513db..6dd9d0b0c 100644 --- a/apps/server/api/src/services/nodes/local-launcher.ts +++ b/apps/server/api/src/services/nodes/local-launcher.ts @@ -1,8 +1,10 @@ -import { chmodSync, existsSync, type FSWatcher, mkdirSync, unlinkSync, watch } from "node:fs"; +import { chmodSync, existsSync, type FSWatcher, mkdirSync, unlinkSync, watch, writeFileSync } from "node:fs"; import { homedir } from "node:os"; +import { dirname } from "node:path"; import { stripAnsi } from "@internal/backend-errors"; import { buildHarnessCommand, + enforceMode, exitHookFor, type HarnessPlugin, paneEnvFor, @@ -13,7 +15,7 @@ import { probePaneLogLaunch } from "@/services/mcp-resolve.js"; import { logger } from "@/utils/logger.js"; import { readLogTailFrom, TAIL_POLL_MS } from "./log-tail.js"; import type { LaunchPlan, NodeLauncher } from "./node-launcher.js"; -import { subshellLogDir, subshellLogPath } from "./subshell-paths.js"; +import { subshellLogDir, subshellLogPath, subshellSshConfigPath } from "./subshell-paths.js"; /** Today's exact tmux/fs behavior behind the launcher seam (spec §6.3). */ export class LocalLauncher implements NodeLauncher { @@ -52,6 +54,16 @@ export class LocalLauncher implements NodeLauncher { * logged (debug) and swallowed, exactly like the pre-seam revive did. */ async launch(plan: LaunchPlan): Promise { + // SSH pane config (spec 2026-10-07 decision 4): whoever spawns the pane + // writes the file, and for a local row that is THIS process — under the + // SERVER's own dataDir, before the pane exists. The path is DERIVED, not + // trusted: a plan whose `configPath` is not byte-equal to + // `subshellSshConfigPath(id)` is a caller bug and throws before anything + // is composed or spawned (the same posture the agent's launch block + // refuses with). `SSH_AUTH_SOCK` needs no handling here: the pane's + // command IS the ssh process, so the service's merged `plan.subshellEnv` + // rides `buildHarnessCommand`'s env layers unchanged. + if (plan.ssh) await this.#writeSshConfig(plan.id, plan.ssh); const cmd = buildHarnessCommand( plan.harness, plan.binary, @@ -107,6 +119,31 @@ export class LocalLauncher implements NodeLauncher { } } + /** + * Materialize one ssh pane's rendered config on the SERVER's own disk: + * 0700 dir, 0600 file, {@link enforceMode} on both (mkdir's/writeFile's + * `mode` is clamped by the umask and applies only to segments actually + * created — the same re-tightening `ensureLogDirMode` and the agent's MCP + * block exist for). The path is this machine's OWN derivation; a plan + * naming anything else throws before any byte is written. + */ + async #writeSshConfig(id: string, ssh: { configPath: string; fileContent: string }): Promise { + const sshPath = subshellSshConfigPath(id); + if (ssh.configPath !== sshPath) { + // Byte-equality, same posture as the agent's launch block: the plane + // may name nothing else, and a mismatch here is a composition bug in + // the caller, not a user-reachable state. + throw new Error( + `ssh config path mismatch for ${id}: plan names ${JSON.stringify(ssh.configPath)}, the derived path is ${JSON.stringify(sshPath)}`, + ); + } + const dir = dirname(sshPath); + mkdirSync(dir, { recursive: true }); + await enforceMode(dir, 0o700); + writeFileSync(sshPath, ssh.fileContent, { mode: 0o600 }); + await enforceMode(sshPath, 0o600); + } + /** * Ensure the pane log's parent dir exists before pipe-pane: tmux runs * `cat >> ` once, and a missing parent dir makes it die silently — @@ -368,12 +405,25 @@ export class LocalLauncher implements NodeLauncher { } /** - * The local machine owns exactly one per-subshell file: the pipe-pane replay - * log (the MCP config under `subshellMcpConfigPath` is a control-plane file — - * `deleteSubshell` unlinks it directly for local and remote rows alike). + * The local machine owns the pipe-pane replay log, and — for an ssh pane + * only — the rendered config `launch` wrote under the server's dataDir + * (spec 2026-10-07 decision 4). The MCP config under `subshellMcpConfigPath` + * is a control-plane file — `deleteSubshell` unlinks it directly for local + * and remote rows alike. + * + * `sshPane` is the ROW's kind fact: only the caller that holds the row can + * say whether this pane owns a config. Every non-ssh caller gets the + * pre-ssh list unchanged. The ssh arm serves the launcher seam: on an + * AGENT row the node launcher's twin arm adds the config FILE path to the + * machine's `remove_paths` list, and the dir itself is the machine's to + * take (the agent's exit sweep, then the orphan sweep in + * `ssh-dir-retention.ts`). A LOCAL ssh row never reaches this list: the + * manager's local branch names `logPath` directly and lets + * `sweepLocalSshDir` rm-rf the whole per-pane dir on every control-plane + * teardown path (unlinking the file and leaving the dir is not a removal). */ - subshellArtifacts(id: string): string[] { - return [this.logPath(id)]; + subshellArtifacts(id: string, sshPane = false): string[] { + return sshPane ? [this.logPath(id), subshellSshConfigPath(id)] : [this.logPath(id)]; } /** Best-effort unlink of artifact paths (absent files are not errors). */ diff --git a/apps/server/api/src/services/nodes/node-events.ts b/apps/server/api/src/services/nodes/node-events.ts index dae255544..45aae4d33 100644 --- a/apps/server/api/src/services/nodes/node-events.ts +++ b/apps/server/api/src/services/nodes/node-events.ts @@ -1,4 +1,4 @@ -import type { NodeEvent, NodeMaintenanceWire } from "@internal/subshell-protocol"; +import type { NodeEvent, NodeMaintenanceWire, NodeSshEnabledWire } from "@internal/subshell-protocol"; /** * The backend event plane for `/ws/node` (spec 2026-08-31 §3.3, phase 2). @@ -97,6 +97,21 @@ export interface NodeLifecycleHooks { * (no file there, or a malformed `ready` field the lenient parser dropped) */ onMaintenance(nodeId: string, reported: NodeMaintenanceWire | undefined): Promise | void; + /** + * The machine's own copy of its SSH capability (spec 2026-10-07 §4.3) — + * from `ready.sshEnabled` at connect, or from the `ssh_enabled` event when + * the mirror moved underneath the connection (in practice: corruption). + * + * The same hook-not-repository shape as {@link onMaintenance}, and the same + * one reconciler for both frames; what differs is the DIRECTION — the plane + * is this flag's only writer, so reconciliation never adopts, it pushes. + * + * @param nodeId - the SOCKET's authenticated identity, never a frame's claim + * @param reported - the machine's mirror answer, or undefined when it + * reported none (no file, a file that says off, or a malformed `ready` + * field the lenient parser dropped — all of which mean REFUSED there) + */ + onSshEnabled(nodeId: string, reported: NodeSshEnabledWire | undefined): Promise | void; } let lifecycleHooks: NodeLifecycleHooks | undefined; diff --git a/apps/server/api/src/services/nodes/node-launcher.ts b/apps/server/api/src/services/nodes/node-launcher.ts index 978aa7fa6..6eaf41d60 100644 --- a/apps/server/api/src/services/nodes/node-launcher.ts +++ b/apps/server/api/src/services/nodes/node-launcher.ts @@ -20,6 +20,17 @@ export interface LaunchPlan { subshellEnv: Record; /** MCP registration (dialect computed control-side) */ mcp?: McpRegistration; + /** + * The ssh pane's rendered config (spec 2026-10-07 decision 4): content the + * ssh service composed from the approved snapshot, plus the path + * `buildSshConfigPath(, id)` — derived, never chosen. + * `RemoteLauncher` ships the member verbatim on the launch frame; the agent + * re-derives the path from its OWN dataDir and refuses a byte-mismatch. + * `LocalLauncher` re-derives from the server's own dataDir, throws on a + * mismatch (a caller bug, the same posture), and writes the file (0600 in a + * 0700 dir) before spawning. Absent on every non-ssh pane. + */ + ssh?: { configPath: string; fileContent: string }; /** * Absolute path ON THE TARGET machine where `RemoteLauncher` ships * `mcp.fileContent` — composed by the caller from the node's `ready.dataDir` @@ -189,8 +200,14 @@ export interface NodeLauncher { * The node-side files a subshell owns — what delete removes. `[]` when the * machine cannot answer: an agent with no live `ready` facts has no readable * layout to name paths from (its artifacts age out with the node, §5.6). + * + * `sshPane` is the row's kind fact (spec 2026-10-07 decision 4): the + * rendered ssh config lives at the machine-derived path + * `/ssh//config`, and ONLY a caller holding the row can say + * the pane owns one. Callers pass the row's truth; `false` (the default) + * yields exactly the pre-ssh list, so every existing caller is unchanged. */ - subshellArtifacts(id: string): string[]; + subshellArtifacts(id: string, sshPane?: boolean): string[]; /** Best-effort deletion of artifact paths from {@link subshellArtifacts}. */ removeArtifacts(paths: string[]): Promise; } diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index a1296b8bf..5e5189350 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -7,6 +7,7 @@ import { type NodeEvent, nodeVersionSupported, parseNodeEvent, + relayFrameRefIfOverCap, } from "@internal/subshell-protocol"; import type { LinkSession } from "@internal/subshell-protocol/node-link-crypto"; import { HttpError } from "@/api/auth-guard.js"; @@ -29,6 +30,7 @@ import { } from "@/services/nodes/link-session.js"; import { loadNodeEncryptionKeys, nodeEncryptionPublicKey } from "@/services/nodes/node-encryption-keys.js"; import { announceNodePresence, projectNodeOffline } from "@/services/nodes/node-presence-announce.js"; +import { bootstrapSshIdentityOnReady } from "@/services/nodes/ssh-identity.js"; import { logger } from "@/utils/logger.js"; import { refireInputHoldsForNode } from "@/ws/input-hold.js"; import { dispatchOutput, getNodeLifecycleHooks } from "./node-events.js"; @@ -46,6 +48,7 @@ import { releaseHeld, } from "./node-registry.js"; import { binaryPayload, failConnPendings, resolveResult } from "./node-rpc.js"; +import { onNodeSocketClosed, onRelayFrame, onRelayFrameOverCap } from "./relay-frames.js"; import { nodeRouteTickets } from "./route-tickets.js"; import { recordNodeDisconnect, recordNodeReady } from "./update-tracker.js"; @@ -183,7 +186,7 @@ export interface NodeVerifiedKey { /** Repository slice the socket touches (full `NodesRepository` satisfies it). */ export type NodeWsNodesRepo = Pick< NodesRepository, - "findById" | "applyReady" | "applyInventory" | "touch" | "setStatus" + "findById" | "applyReady" | "applyInventory" | "touch" | "setStatus" | "setSshFingerprint" >; /** Everything the handler reaches outside its own module. */ @@ -751,6 +754,18 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: const event = parseNodeEvent(resolved); if (!event) { + // A WELL-FORMED relay frame whose blob exceeds SSH_RELAY_FRAME_MAX_BYTES + // is refused by the strict grammar (parseRelayFrame stays strict so the + // NodeEvent union cannot carry an unlawful frame), but §5.1 refuses to + // answer it with silence: "closed with a named reason" needs the refusal + // to be OBSERVABLE. Probe the shape before logging "unrecognized" - the + // probe reads shape and the blob's raw length only, never the bytes + // (§5.5) - and route the named refusal to the relay seam. + const overCapRef = relayFrameRefIfOverCap(resolved); + if (overCapRef !== null) { + onRelayFrameOverCap(nodeId, overCapRef); + return; + } logger.debug(`node ws: dropped unrecognized frame from ${nodeId}`); return; } @@ -871,6 +886,17 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: ...(event.runtime ? { runtime: event.runtime } : {}), }; } + // Mirror the §4.6 trust block the report carried onto the row (spec + // 2026-10-08 §4.5/§4.6) — the durable copy behind the offline, + // STALE-marked card, written on the same "record FIRST" posture as the + // facts stash above and on the same surviving-socket rule (the stillLive + // probe already returned for a disowned one). A ready WITHOUT a block + // — an agent that predates M2, or one whose block the grammar refused — + // writes NOTHING: absence is not evidence the machine's keys are gone, + // and only a fresh true report may move the plane's copy. + if (event.runtime?.sshFingerprint) { + await deps.nodes.setSshFingerprint(nodeId, JSON.stringify(event.runtime.sshFingerprint)); + } // The floor FIRST, because its refusal is the one a person can act on: // it names the version to install and the version found, where a bare // protocol number names neither. Identity is already persisted above, @@ -980,6 +1006,21 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: const hooks = getNodeLifecycleHooks(); if (hooks) await hooks.onMaintenance(nodeId, event.maintenance); else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile maintenance`); + // The SSH mirror travels the same door (spec 2026-10-07 §4.3) with the + // simpler decision behind it: the row is the record and never adopts, + // so the hook's whole cost is one read and at most one fire-and-forget + // push of the row's own value. Awaited only because it is trivially + // cheap; everything slow lives in the push's own best-effort swallow. + if (hooks) await hooks.onSshEnabled(nodeId, event.sshEnabled); + else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile ssh_enabled`); + // The §4.3 signing bootstrap (spec 2026-10-08): an agent whose identity + // record has no signing key is ASKED ONCE, and the answer files itself. + // Fire-and-forget for the same reason the detect kick above is: the + // machine's answer arrives as a LATER `result` frame on this socket's + // own queue, and awaiting it inside this frame would deadlock the + // chain. The no-spam gate (filled slot / absent record = silent skip) + // lives in the call itself. + void bootstrapSshIdentityOnReady(nodeId); return; } case "heartbeat": { @@ -1041,6 +1082,19 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: else logger.warn(`node ws: maintenance from ${nodeId} with no lifecycle hook`); return; } + case "ssh_enabled": { + const hooks = getNodeLifecycleHooks(); + // The machine's SSH mirror moved mid-session. With no node-side writer + // there is no keyboard flip to carry — what arrives here is a file that + // changed by other means (corruption, chiefly: it flips the node to + // REFUSED silently, and this frame is how the plane learns to push the + // repair now rather than at the next reconnect). Same hook as `ready`: + // a mid-session report and a connect-time state are the same + // disagreement, and one reconciler keeps them answering alike. + if (hooks) await hooks.onSshEnabled(nodeId, { on: event.on, changedAt: event.changedAt }); + else logger.warn(`node ws: ssh_enabled from ${nodeId} with no lifecycle hook`); + return; + } case "result": { // Connection-scoped settle: the frame can only resolve pendings on the // socket it arrived on (same record the close path drains). Fall back to @@ -1054,6 +1108,17 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: case "error": logger.withMetadata({ nodeId, code: event.code }).warn(`node reported error: ${event.message}`); return; + case "relay": { + // The sealed agent-relay leg (spec 2026-10-08 §5.1). Everything a + // router must know about this frame is already decided upstream: the + // grammar validated shape and the SSH_RELAY_FRAME_MAX_BYTES cap, the + // exact-match handshake refused any pre-18 agent BEFORE this socket + // could carry a frame, and the held/supersede guards above ran. The + // blob stays opaque right through here (§5.5) - Task 8's broker pairs + // refs and shuttles; this is the routing seam, not the broker. + onRelayFrame(nodeId, event); + return; + } } } @@ -1122,6 +1187,14 @@ export async function handleNodeClose(deps: NodeWsDeps, ws: NodeWsSocket): Promi // touched any of them — so without this the dashboard keeps rendering // them as healthy until the viewer reconnects. announceNodePresence(nodeId); + // The relay broker's `a-dropped` witness (spec 2026-10-08 §5.6): this is + // the honest "this node's authenticated link is gone" moment - the + // broker cuts sessions where this machine was A and leaves its B-side + // sessions for the redial (or a child-exit / cap cut). The superseded + // branch below deliberately calls nothing: a replacement socket means + // the node is NOT gone. Fire-and-forget by contract (relay-frames owns + // the swallow). + onNodeSocketClosed(nodeId); logger.debug(`node ws: ${nodeId} disconnected → offline`); return; } diff --git a/apps/server/api/src/services/nodes/relay-frames.ts b/apps/server/api/src/services/nodes/relay-frames.ts new file mode 100644 index 000000000..edfd398dc --- /dev/null +++ b/apps/server/api/src/services/nodes/relay-frames.ts @@ -0,0 +1,155 @@ +import type { RelayFrame } from "@internal/subshell-protocol"; +import { getRelayBroker } from "@/services/ssh-relay.service.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The plane-side recognition seam for `relay` link frames (spec 2026-10-08 + * §5.1/§5.3) - the single named point between "a decrypted, grammar-validated + * relay frame arrived on an authenticated node socket" and the broker that + * pairs the sockets. `node-ws-handler.ts` routes here from its event switch + * and from its close teardown; nothing else calls in. + * + * What was ALREADY decided before a frame reaches this file, and is not + * re-decided here: the socket is authenticated (the upgrade chain), the link + * is encrypted and version-matched at NODE_PROTOCOL_VERSION (the handshake - + * which is why a pre-18 agent never gets a relay frame routed at all), the + * frame is well-formed and within SSH_RELAY_FRAME_MAX_BYTES (`parseRelayFrame`, + * via `parseNodeEvent`), the node is not held, and the socket is not + * superseded (all guards run upstream in the handler). + * + * The Task-8 broker (`services/ssh-relay.service.ts`) owns everything past + * this door: it pairs the two live sockets by the frame's routing `ref`, + * forwards `blob` to the peer BLIND (the plane reads the ref and nothing + * else, §5.5), enforces the quota at open, and cuts on every §5.6 event with + * the named-reason close and the `node.ssh_relay.close` audit. This module + * adds only the per-node routing SUMMARY - frame count, last ref, last + * direction - so the handler's wiring tests can see that a routed frame + * reached the broker without this seam itself holding a byte of any blob + * (§5.5: the summary's record deliberately has no blob slot). + * + * {@link onRelayFrameOverCap} is the second entry point: the refusal the + * grammar performed (an over-cap blob answers `parseRelayFrame` with null) + * but cannot express in a `NodeEvent`; the handler's shape probe routes the + * named case here and the broker closes the session it names with reason + * `over-cap`. The blob never comes along - the probe reads its raw length + * and nothing else. + * + * {@link onNodeSocketClosed} is the third: when an authenticated live socket + * dies, the broker cuts the sessions where that node was A (§5.6's + * `a-dropped`); a node's B-side link redialing is not a cut, so the broker + * decides by role itself and this seam just reports the witness. + * + * Routing is FIRE-AND-FORGET by contract: the handler's frame chain must not + * die because a broker send refused - the broker already handled the drop + * (its teardown IS the response to a refused send), and a throw here would + * cost the socket every frame behind this one. + */ + +/** What the seam records for one node's routed frames: counters and routing ids, never payloads. */ +export interface RelayFrameRouteRecord { + /** How many relay frames have been routed from this node. */ + frames: number; + /** The routing ref of the most recent routed frame (an opaque id, not a secret). */ + lastRef: string; + /** The direction of the most recent routed frame. */ + lastDirection: RelayFrame["direction"]; +} + +/** What the seam holds for one node's over-cap refusals. */ +export interface RelayOverCapRecord { + /** How many over-cap relay frames have been refused from this node. */ + overCaps: number; + /** The routing ref of the most recent refusal. NEVER the blob: an over-cap frame is refused unread (§5.1, §5.5). */ + lastRef: string; +} + +const routedByNode = new Map(); +const overCapByNode = new Map(); + +/** + * Route one validated relay frame from `nodeId` to the broker. Records the + * per-node routing summary first (the handler tests' observability), then + * hands the frame object to the broker unchanged. + * @param nodeId - the node whose authenticated socket produced the frame + * @param frame - the grammar-validated frame (`parseRelayFrame` narrowed it) + */ +export function onRelayFrame(nodeId: string, frame: RelayFrame): void { + const record = routedByNode.get(nodeId); + if (record) { + record.frames += 1; + record.lastRef = frame.ref; + record.lastDirection = frame.direction; + } else { + routedByNode.set(nodeId, { frames: 1, lastRef: frame.ref, lastDirection: frame.direction }); + } + try { + // Blind hand-off: the broker pairs by `ref` and copies `blob` to the + // peer's socket unopened (§5.5). A refused send (peer socket gone) is + // the broker's named cut to perform, not an exception to propagate. + getRelayBroker().routeRelayFrame(nodeId, frame); + } catch (err: unknown) { + logger.withError(err).warn(`relay routing for node ${nodeId} threw (contained): ${String(err)}`); + } +} + +/** + * Refuse an over-cap relay frame BY NAME (spec 2026-10-08 §5.1): the broker + * closes the session named by `ref` with reason `over-cap`, to both sides, + * audited. An unbrokered ref has no session to close and costs a log line. + * @param nodeId - the node whose authenticated socket produced the frame + * @param ref - the routing ref the well-formed candidate carried + */ +export function onRelayFrameOverCap(nodeId: string, ref: string): void { + const record = overCapByNode.get(nodeId); + if (record) { + record.overCaps += 1; + record.lastRef = ref; + } else { + overCapByNode.set(nodeId, { overCaps: 1, lastRef: ref }); + } + void getRelayBroker() + .refuseOverCap(ref) + .catch((err: unknown) => logger.withError(err).warn(`relay over-cap close for ${ref} threw: ${String(err)}`)); +} + +/** + * An authenticated LIVE node socket died. Tell the broker: sessions where + * this node was A are cut with `a-dropped` (§5.6); its B-side sessions are + * deliberately not (a B link that redials keeps its registry, and B's real + * end is child-exit or a cap). A superseded socket's close never calls this + * (the handler's per-connection identity owns the distinction) - the newer + * socket's node is not gone. + * @param nodeId - the node whose live connection just went away + */ +export function onNodeSocketClosed(nodeId: string): void { + void getRelayBroker() + .onNodeSocketClosed(nodeId) + .catch((err: unknown) => logger.withError(err).warn(`relay socket-drop cut for ${nodeId} threw: ${String(err)}`)); +} + +/** + * What the seam recorded for one node (the routed-frame summary: count, last + * ref, last direction - never a blob). + * @internal test-only: the handler's routing tests + */ +export function relayFramesSeenFor(nodeId: string): RelayFrameRouteRecord | undefined { + return routedByNode.get(nodeId); +} + +/** + * What the seam recorded for one node's over-cap refusals (count and last + * ref; never a blob). + * @internal test-only + */ +export function relayOverCapsSeenFor(nodeId: string): RelayOverCapRecord | undefined { + return overCapByNode.get(nodeId); +} + +/** + * Drop every seam record (routed summaries and over-cap refusals alike). + * @internal test-only; per-file isolation under `bun test --parallel` + */ +export function resetRelayFramesForTests(): void { + routedByNode.clear(); + overCapByNode.clear(); +} diff --git a/apps/server/api/src/services/nodes/remote-launcher.ts b/apps/server/api/src/services/nodes/remote-launcher.ts index 1ba130d57..45fe90e89 100644 --- a/apps/server/api/src/services/nodes/remote-launcher.ts +++ b/apps/server/api/src/services/nodes/remote-launcher.ts @@ -1,4 +1,4 @@ -import type { HarnessPlugin } from "@internal/pane-runtime"; +import { buildSshConfigPath, type HarnessPlugin } from "@internal/pane-runtime"; import { HARNESS_BINARY_PLACEHOLDER, type NodeCommandBody, @@ -300,6 +300,14 @@ export class RemoteLauncher implements NodeLauncher { // Narrowed by the gate above: required since protocol 3, and always // present here. resolve: plan.harness.detectSpec, + // The ssh pane's rendered config (spec 2026-10-07 decision 4): carried + // VERBATIM — `configPath` was composed by the launch service from this + // node's `ready.dataDir` with the same `buildSshConfigPath` the agent + // runs, and the AGENT re-derives and refuses a byte-mismatch, so this + // launcher neither recomputes nor rewrites. Conditional spread, never + // `ssh: undefined` on a plain plan (the frame's parser refuses an + // explicit-undefined optional; the mcp comment above is the precedent). + ...(plan.ssh ? { ssh: { configPath: plan.ssh.configPath, fileContent: plan.ssh.fileContent } } : {}), }; try { await this.#send(cmd, LAUNCH_TIMEOUT_MS); @@ -536,18 +544,42 @@ export class RemoteLauncher implements NodeLauncher { return factsPath(this.#requireFacts(), `mcp/${id}.json`); } + /** + * The ssh pane's rendered config ON THE NODE: `/ssh//config` + * — the delete-side twin of the `launch.ssh.configPath` the launcher ships + * and the agent's byte-derived write target. Composed from the same + * `ready.dataDir` as the MCP/meta paths; it is the SAME pane-runtime + * {@link buildSshConfigPath} the node runs at write time, so the unlink + * lands exactly where the write landed (spec 2026-10-07 decision 4). The + * delete-time `remove_paths` unlinks the config FILE; the whole per-pane ssh + * DIR is swept by the agent's exit watcher on natural death (report.ts). + * Class-local (not on {@link NodeLauncher}) like {@link metaArtifactPath} — + * the concept is agent-side layout. Throws {@link NoLiveConnectionError} + * offline (sync member, same shape as {@link logPath}). + */ + sshArtifactPath(id: string): string { + assertNodePathId(id); + return buildSshConfigPath(this.#requireFacts().dataDir, id); + } + /** * The three files a subshell leaves on the node — log + MCP config + the * agent's own meta record, in the order the pre-seam `deleteSubshell` block * pushed them (spec §6.4). Empty when the node has no live `ready` facts: * no facts, no layout to name paths from, and the artifacts age out with * the node — the same offline skip every pre-seam path made individually. - * The id guard rides through the three composed paths, so there is no - * fourth interpolation to forget. + * The id guard rides through the composed paths, so there is no extra + * interpolation to forget. + * + * `sshPane` (the row's kind fact, spec 2026-10-07 decision 4) adds the + * rendered config as a fourth path; every non-ssh caller gets the triple + * unchanged. */ - subshellArtifacts(id: string): string[] { + subshellArtifacts(id: string, sshPane = false): string[] { if (!this.#facts()) return []; - return [this.logPath(id), this.mcpArtifactPath(id), this.metaArtifactPath(id)]; + const artifacts = [this.logPath(id), this.mcpArtifactPath(id), this.metaArtifactPath(id)]; + if (sshPane) artifacts.push(this.sshArtifactPath(id)); + return artifacts; } /** diff --git a/apps/server/api/src/services/nodes/reregister-node.ts b/apps/server/api/src/services/nodes/reregister-node.ts index e4fb95fa1..d09a82c6f 100644 --- a/apps/server/api/src/services/nodes/reregister-node.ts +++ b/apps/server/api/src/services/nodes/reregister-node.ts @@ -21,6 +21,14 @@ export async function reregisterNode( agentVersion: string; }, encryptPublicKey: string | null, + /** + * The ES256 relay signing half (spec 2026-10-08 §4.2): re-registration is a + * fresh enrollment-grade trust event, so it REPLACES the slot rather than + * preserving it. The machine's on-disk keypairs normally survive the + * re-registration, so the replacement is normally the same bytes; null is + * the pre-M2 posture until the §4.3 bootstrap reports. + */ + signingPublicKey: string | null, ): Promise<{ nodeId: string; nodeKey: string; name: string }> { const nodeId = key.targetNodeId; if (!nodeId) throw new Error("Re-registration needs an existing node"); @@ -59,9 +67,12 @@ export async function reregisterNode( .executeTakeFirst(); if (!updated) throw new Error("Node credentials changed; issue a new re-registration key and retry"); name = updated.name; + // Both identity halves rotate together, inside the same transaction that + // swaps the credentials (§4.2). await new IdentitiesRepository(tx).register({ principalId: `node:${nodeId}`, publicKey: machine.publicKey, + signingPublicKey, displayName: name, }); // Auth uses a separate handle to the SAME SQLite file. Revoke through diff --git a/apps/server/api/src/services/nodes/ssh-enabled.ts b/apps/server/api/src/services/nodes/ssh-enabled.ts new file mode 100644 index 000000000..d461513a3 --- /dev/null +++ b/apps/server/api/src/services/nodes/ssh-enabled.ts @@ -0,0 +1,157 @@ +import type { NodeSshEnabledWire } from "@internal/subshell-protocol"; +import { LOCAL_NODE_ID, type NodeTable } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { audit } from "@/services/audit.js"; +import { sendCommand } from "@/services/nodes/node-rpc.js"; +import { getRelayBroker } from "@/services/ssh-relay.service.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The node SSH capability flag (spec 2026-10-07 §4.3), written in exactly one + * place — the posture `maintenance.ts` established and for the same reason: + * two implementations of the change act is two implementations that drift, + * and the half that drifts is whichever one nobody exercised. + * + * **The differences from maintenance are all in one fact: the plane is the + * SOLE writer.** There is no `subshell ssh-enabled` verb, so there is no + * second stamp to race — reconciliation never compares dates, never adopts, + * and answers every disagreement the same way: push the row. That is also + * why {@link SshEnabledWrite} has no `source` field and its `actorUserId` + * cannot be null: the only caller that reaches the row is a human's request, + * and the reconcile path performs no write at all. + */ + +export interface SshEnabledWrite { + nodeId: string; + on: boolean; + changedAt: string; + actorUserId: string; +} + +/** Flip one node's SSH capability: row, audit, and the best-effort push — + * all in this one function, so no caller can perform half the act. */ +export async function setNodeSshEnabled(write: SshEnabledWrite): Promise { + const { repos } = getRequestlessContext(); + await repos.nodes.setSshEnabled(write.nodeId, { on: write.on, changedAt: write.changedAt }); + await audit({ + actorUserId: write.actorUserId, + action: "node.ssh_enabled.update", + targetType: "node", + targetId: write.nodeId, + metadataJson: JSON.stringify({ on: write.on }), + }); + if (!write.on) await getRelayBroker().closeUnauthorizedForNode(write.nodeId); + // The row is written and audited BEFORE the push, so a node that answers the + // command cannot be looking at a mirror the record has not caught up to — + // and a push that fails (the node is offline) costs nothing: the value + // re-lands at the node's next `ready` through {@link reconcileSshEnabled}. + pushSetSshEnabledBestEffort(write.nodeId, { on: write.on, changedAt: write.changedAt }); +} + +/** The node-row fields {@link decideSshEnabled} reads — nothing else is relevant. */ +type SshRow = Pick; + +/** What reconciliation decided about one node's two copies of the flag. */ +export type SshDecision = "push-plane" | "noop"; + +/** + * Decide what to do when the plane's row and the node's report disagree. + * PURE — it reads two values and answers; the caller performs. + * + * The table, and why each row is what it is (spec §4.3 — no node writer, so + * this is maintenance's `decideMaintenance` with the stamp comparison struck + * out; there is no "node wrote newer" case to price): + * + * | node reports | plane row | → | + * |---|---|---| + * | nothing | off / never written | `noop` — silence means refusal HERE, and the row already refuses | + * | nothing | on | `push-plane` — the node has no file (or a lost one); give it ours | + * | the row's value | — | `noop` — agreement, the steady state; a push here would echo forever | + * | the other value | with a stamp | `push-plane` — the row is the record; the machine is told, never asked | + * | the other value | never written | `noop` — nothing to send (no stamp exists), and the disagreement is INERT: the plane gates every SSH act on the row, so a stale mirror-on permits nothing the row permits | + * + * Stamps are never compared: with no node-side writer, a differing + * `changedAt` under an agreeing `on` is just a value relayed through the + * link — pushing on it would be a command per heartbeat for a difference that + * is only latency. And NOTHING adopts: an old mirror, a corrupted mirror + * reporting the refusal it produces, a future one — none of them move the + * row. That is the direction this whole task exists to pin. + * + * @param row - the plane's node row (the two SSH fields only) + * @param reported - what the machine says its mirror answers, or undefined + * when it reported nothing (no file, a file that says off, or a malformed + * `ready` field the lenient parser dropped) + */ +export function decideSshEnabled(row: SshRow, reported: NodeSshEnabledWire | undefined): SshDecision { + const rowOn = row.sshEnabled === 1; + // Silence reads as OFF — the node's own fail-closed default — which is the + // only reading under which "node lost its file while the row says off" is + // the quiet agreement it is. + if ((reported?.on ?? false) === rowOn) return "noop"; + // The one disagreement with nothing to send (rows: never-written row, node + // mirror on). `decideMaintenance` had an `adopt-node` for this cell; here + // the row is the record and an unstamped push would invent a write time. + if (!row.sshEnabledAt) return "noop"; + return "push-plane"; +} + +/** + * Send one node the SSH state the plane holds, so both copies end + * byte-identical. + * + * `local` is skipped: the control-plane host has no agent socket and no + * mirror file — its answer to the gate is the DB row itself, which is exactly + * why the server account's SSH runs check the row and nothing else. + * + * @param nodeId - the node to tell; `local` is a no-op + * @param state - the STORED value, relayed verbatim; never re-stamped here, + * or the relay would outrank the decision it is carrying + */ +export async function pushSetSshEnabled(nodeId: string, state: NodeSshEnabledWire): Promise { + if (nodeId === LOCAL_NODE_ID) return; + await sendCommand(nodeId, { type: "set_ssh_enabled", on: state.on, changedAt: state.changedAt }); +} + +/** + * {@link pushSetSshEnabled}, fire-and-forget with the failure logged — the + * shape `pushSetMaintenanceBestEffort` established, for the same reasons. + * + * An offline node simply misses it: the flag is already on the plane's row, + * which is what refuses every SSH act plane-side, and the node re-learns the + * value on its next `ready` through {@link reconcileSshEnabled}. There is + * nothing a caller could usefully await or retry. + */ +export function pushSetSshEnabledBestEffort(nodeId: string, state: NodeSshEnabledWire): void { + void pushSetSshEnabled(nodeId, state).catch((err: unknown) => { + logger + .withError(err) + .warn(`ssh-enabled push failed for node ${nodeId}; its mirror stays stale until it reconnects`); + }); +} + +/** + * Reconcile one node's report against the plane's row — the body of the + * `onSshEnabled` lifecycle hook, fed by `ready.sshEnabled` and by the + * `ssh_enabled` event a machine sends when its mirror moved underneath it. + * + * The whole hook is one read and at most one fire-and-forget push: there is + * nothing to stop (an SSH flip kills no panes), nothing to write (the row + * already stands), and nothing to await (a slow node must not hold the + * socket's frame queue on a flag the row is already enforcing). A row that + * disagrees with the machine pushes; a machine that disagrees with nothing + * says nothing further. Never the other direction — that rule is the point. + * + * @param nodeId - the SOCKET's authenticated identity, never a frame's claim + * @param reported - the machine's mirror answer, or undefined when it + * reported none + */ +export async function reconcileSshEnabled(nodeId: string, reported: NodeSshEnabledWire | undefined): Promise { + const { repos } = getRequestlessContext(); + const row = await repos.nodes.findById(nodeId); + if (!row) return; + const stamp = row.sshEnabledAt; + if (decideSshEnabled(row, reported) !== "push-plane" || stamp === null) return; + // (`decideSshEnabled` only answers "push-plane" when a stamp exists; the + // null check is what shows the type what the decide already proved.) + pushSetSshEnabledBestEffort(nodeId, { on: row.sshEnabled === 1, changedAt: stamp }); +} diff --git a/apps/server/api/src/services/nodes/ssh-identity.ts b/apps/server/api/src/services/nodes/ssh-identity.ts new file mode 100644 index 000000000..0e78f3302 --- /dev/null +++ b/apps/server/api/src/services/nodes/ssh-identity.ts @@ -0,0 +1,139 @@ +import { assertImportableSigningJwk } from "@/api/public-jwk.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { audit } from "@/services/audit.js"; +import { sshRegisterIdentity } from "@/services/nodes/ssh-rpc.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The §4.3 signing-key bootstrap (spec 2026-10-08): nodes that enrolled + * before M2 hold a signing keypair on disk but have never reported its + * public half. On `ready`, a machine whose `signingPublicKey` slot is EMPTY + * is asked once ({@link sshRegisterIdentity}) over its already-authenticated + * link, and the answer lands through {@link deliverSigningKey}. + * + * **The guard that makes this safe is the anti-silent-rotation rule.** A + * report that DIFFERS from a key already on file is refused, NOT stored: + * the machine that rotated its identity unilaterally must never repair its + * own history by overwriting the record the relay verifies against (the + * peer-side repair path is §4.5, a separate deliberate owner act). Same + * bytes, or a first fill, are the only writes this seam can ever cause - + * plus the identities repository's `fillSigningPublicKey` CAS, which makes + * even a raced double-report answerable: whoever lands first owns the slot. + * + * No key VALUE ever enters a log line or an audit row (Global Constraint); + * the audit names the node and `via: "link"`, and the warnings name the node + * and the shape of the problem. + */ + +/** + * File one machine's reported signing public key into its `node:` identity + * record. The whole guard table (spec 2026-10-08 §4.3): + * + * | slot holds | report | → | + * |---|---|---| + * | nothing | importable public JWK | store it, audit `node.ssh_identity.register` (`via: "link"`), return it | + * | the SAME bytes | same bytes | no-op success, return it (idempotent: every re-report of a stable key is quiet) | + * | a DIFFERENT key | anything else | REFUSE: return the existing bytes, overwrite nothing | + * | anything | unparseable / non-importable / private-carrying JWK | refuse BEFORE any write, return null (the enroll lesson: nothing that fails the import gate is stored) | + * + * A node with no identity row at all gets `null`: the row is enroll's + * artifact (its encryption half is the seal key); this seam never invents + * one. + * + * @param nodeId - the node the report is about (the socket's authenticated + * identity at every call site; never a frame's self-claim) + * @param signingPublicKey - the machine's JSON-serialized public JWK, as the + * agent answered it (wire-validated string; importability checked HERE) + * @returns the key now on file (stored, or the existing one the report was + * refused against), or null when nothing is on file and nothing was stored + */ +export async function deliverSigningKey(nodeId: string, signingPublicKey: string): Promise { + const { repos } = getRequestlessContext(); + // Validate BEFORE touching the row: a malformed report is refused exactly + // like the enroll route refuses it, and the refusal never reaches the DB. + // assertImportableSigningJwk carries the public-store rule (EC/P-256 shape, + // real curve-point import for ES256, and NO `d` - a "public" key that is + // secretly a private one is refused, not stored). + let parsedKey: unknown; + try { + parsedKey = JSON.parse(signingPublicKey); + } catch { + parsedKey = null; + } + try { + await assertImportableSigningJwk(parsedKey); + } catch { + // The refuse line names the machine and the shape, never the payload. + logger.warn(`ssh identity: node ${nodeId} reported an unusable signing key; nothing stored`); + return null; + } + + const principalId = `node:${nodeId}`; + const row = await repos.identities.findByPrincipal(principalId); + if (!row) { + logger.warn(`ssh identity: node ${nodeId} has no identity record; refusing to invent one`); + return null; + } + if (row.signingPublicKey !== null) { + // The slot is owned. Same bytes: the quiet idempotent yes. Different + // bytes: the §4.3 own-registration refusal - return the EXISTING key and + // change nothing, whatever the caller expected. + if (row.signingPublicKey !== signingPublicKey) { + logger.warn( + `ssh identity: node ${nodeId} reported a signing key DIFFERENT from the one on file; ` + + "keeping the existing key (a genuine change needs a re-enroll plus per-peer re-pairs, spec §4.2/§4.5)", + ); + } + return row.signingPublicKey; + } + + // The CAS write: only this call's UPDATE can flip the empty slot, so two + // racy reports (two `ready` frames) cannot double-store or clobber. + const stored = await repos.identities.fillSigningPublicKey(principalId, signingPublicKey); + if (!stored) { + const after = await repos.identities.findByPrincipal(principalId); + return after?.signingPublicKey ?? null; + } + await audit({ + actorUserId: null, // the link delivered it; no human asked + action: "node.ssh_identity.register", + targetType: "node", + targetId: nodeId, + // ids/via only, never the key value (docs/security.md §10). + metadataJson: JSON.stringify({ via: "link" }), + }); + return signingPublicKey; +} + +/** + * The `ready`-time bootstrap body (spec 2026-10-08 §4.3): ask a node that has + * never delivered its signing key, once, and file the answer. + * + * The NO-SPAM gate is the first read: a slot that already holds a key (the + * overwhelming steady state) answers without a command, so an agent that + * reconnects hourly costs nothing after its first contact. A node with NO + * identity row is skipped too - there is no slot to fill and no write this + * seam may make. + * + * Swallows its own failures (offline/timeout/unsupported are routine at + * `ready`; a machine that did not answer simply asks again on the next + * contact), so the fire-and-forget call site can be a bare statement. + * + * @param nodeId - the socket's authenticated identity, never a frame's claim + */ +export async function bootstrapSshIdentityOnReady(nodeId: string): Promise { + if (nodeId === LOCAL_NODE_ID) return; // no socket to ask over + try { + const { repos } = getRequestlessContext(); + const row = await repos.identities.findByPrincipal(`node:${nodeId}`); + if (!row || row.signingPublicKey !== null) return; // no slot, or already filled + const reported = await sshRegisterIdentity(nodeId); + await deliverSigningKey(nodeId, reported); + } catch (err: unknown) { + // `debug`, not warn: an offline mid-boot or an agent that has not + // upgraded into the command is the ordinary case, and the next `ready` + // retries by construction. + logger.withError(err).debug(`ssh identity: §4.3 bootstrap for node ${nodeId} did not land; retrying next ready`); + } +} diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts new file mode 100644 index 000000000..410ff4fb8 --- /dev/null +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -0,0 +1,310 @@ +import { + type MachineSshResult, + machineSshExec, + machineSshExecStatus, + readMachineAgentIdentities, + readMachineHostKey, +} from "@internal/pane-runtime"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { repairLocalMachinePin } from "@/services/ssh-local-participant.js"; + +/** Preserve the RPC refusal contract for the in-process machine operations. */ +function localResult(result: MachineSshResult): unknown { + if (!result.ok) throw new SshRpcError("refused", "server SSH account operation refused", "local", result.error); + return result.data; +} + +import { + type NodeSshAgentIdentitiesResult, + type NodeSshAliasListResult, + type NodeSshExecKickResult, + type NodeSshExecStatusResult, + type NodeSshHostKeyResult, + type NodeSshMachinePinRepairResult, + type NodeSshResolveOutcomeWire, + parseNodeSshAgentIdentities, + parseNodeSshAliasList, + parseNodeSshExecKick, + parseNodeSshExecStatus, + parseNodeSshHostKey, + parseNodeSshIdentity, + parseNodeSshMachinePinRepair, + parseNodeSshResolveOutcome, + type SshMachinePinRepairCommand, +} from "@internal/subshell-protocol"; +import { NodeRpcError, sendCommand } from "@/services/nodes/node-rpc.js"; + +/** + * The ssh commands as plane-side RPC wrappers (spec 2026-10-07 §5 for the + * discovery/resolve pair; spec 2026-10-08 §4.3 for the identity bootstrap and + * the §5.4 agent roster the approval screen enumerates). Each is `sendCommand` + * plus ITS frozen result parser: + * the value these return is a validated plain object (or the function throws), + * so no caller can branch on a machine answer it has not parsed. + * + * **The outcome's `accepted:false` arm is NOT an error.** "This config needs a + * ProxyCommand" is a successful resolution the human must read (§2's refusal + * doctrine), and it travels IN THE DATA; these wrappers return the whole + * outcome and refuse only what the transport or the wire grammar refused. + * + * Every failure leaves as one {@link SshRpcError} whose `kind` says which + * door closed, so the service maps statuses without re-deriving them from + * error sentences: + * - `offline` — no live socket (or it dropped before the frame was sent); + * - `unsupported` — the connected agent does not know the command (an old + * app; the remedy is the node update); + * - `timeout` — the machine did not answer inside the RPC deadline; + * - `refused` — the agent answered `ok:false` (its own mirror gate, a missing + * ssh binary, …); `detail` carries the agent's verbatim string for the LOG, + * never for a response body; + * - `malformed` — `ok:true` with a payload the frozen validator rejects (a + * protocol violation; treated as a refusal, loudly). + */ + +/** Which door closed on a machine-level ssh RPC failure. */ +export type SshRpcFailure = "offline" | "unsupported" | "timeout" | "refused" | "malformed"; + +export class SshRpcError extends Error { + readonly kind: SshRpcFailure; + readonly nodeId: string; + /** The agent's `error` string verbatim on a `refused` failure (log/diagnosis only). */ + readonly detail: string | undefined; + + constructor(kind: SshRpcFailure, message: string, nodeId: string, detail?: string) { + super(message); + this.name = "SshRpcError"; + this.kind = kind; + this.nodeId = nodeId; + this.detail = detail; + } +} + +/** Map the RPC layer's failure onto the ssh surface's failure kind. */ +function mapRpcError(nodeId: string, err: NodeRpcError): SshRpcError { + const kind: SshRpcFailure = + err.code === "offline" + ? "offline" + : err.code === "unsupported" + ? "unsupported" + : err.code === "timeout" + ? "timeout" + : "refused"; + return new SshRpcError(kind, err.message, nodeId, err.detail); +} + +/** Ask one machine for the NAMES of its usable SSH aliases (never config contents). */ +export async function sshDiscover(nodeId: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_discover_aliases" }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshAliasList(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed alias list`, nodeId); + } + return parsed; +} + +/** + * Ask one machine to resolve one destination token into the approved + * snapshot (or its named refusal, which is a SUCCESSFUL answer). + */ +export async function sshResolve(nodeId: string, alias: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_resolve_config", alias }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshResolveOutcome(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed resolve outcome`, nodeId); + } + return parsed; +} + +/** + * Ask one machine to report its OWN relay signing public key (spec + * 2026-10-08 §4.3). The command carries no input: the machine answers about + * itself, and the plane-side guard (deliverSigningKey) owns what a report + * may do - this wrapper only moves validated bytes across the link. + * @returns the answer's `signingPublicKey` string (importability is checked + * by the STORE, not here; this layer's job is the wire grammar) + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshRegisterIdentity(nodeId: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_register_identity" }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshIdentity(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed signing identity`, nodeId); + } + return parsed.signingPublicKey; +} + +/** + * Ask one machine for the `known_hosts` entries it has recorded for one + * resolved destination (spec 2026-10-08 §9, Task 12): the host-key capture + * whose answer becomes the pin the relay-open delivers to B. The destination + * is the whole input because OpenSSH's host-key lookup is per destination; + * the triple is sent RESOLVED so a later config edit cannot retarget the ask. + * An empty answer is a SUCCESS (`{ lines: [] }`, the honest "recorded + * nothing"); only a transport/wire failure throws. + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshHostKey( + nodeId: string, + destination: { host: string; port: number; user: string | null }, +): Promise { + let data: unknown; + try { + data = + nodeId === "local" + ? localResult(await readMachineHostKey({ type: "ssh_host_key", ...destination })) + : await sendCommand(nodeId, { type: "ssh_host_key", ...destination }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshHostKey(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed host-key list`, nodeId); + } + return parsed; +} + +/** + * Ask one machine for its LIVE agent's public roster (spec 2026-10-08 §5.4, + * Task 11): the approval screen's choice list, as the frozen validator + * narrows it - fingerprints plus comments, the blobs already withheld on the + * machine (the grammar cannot carry them). The command asks the WHOLE + * roster, so this wrapper has no input beyond the target: a selection sent + * from the plane would be a decision about A's keys made where A's agent is + * the only witness. + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshAgentIdentities(nodeId: string): Promise { + let data: unknown; + try { + data = + nodeId === "local" + ? localResult(await readMachineAgentIdentities()) + : await sendCommand(nodeId, { type: "ssh_agent_identities" }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshAgentIdentities(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed agent roster`, nodeId); + } + return parsed; +} + +/** + * Kick one machine's non-interactive setup run (spec 2026-10-08 §7, Task 14): + * the `ssh_exec` command carries the rendered config, the composed flag tail, + * and the installer one-liner - the SETUP KEY rides inside `command`, and + * this layer is the only place it exists plane-side. The ack says the run + * started; nothing here retains or re-speaks the command. + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshExec( + nodeId: string, + args: { + execId: string; + configPath: string; + fileContent: string; + presetFlags: string[]; + command: string; + relay: boolean; + agentSocketPath: string | null; + timeoutMs: number; + }, +): Promise { + let data: unknown; + try { + data = + nodeId === "local" + ? localResult(await machineSshExec(SUBSHELL_SERVER_DATA_DIR, { type: "ssh_exec", ...args })) + : await sendCommand(nodeId, { type: "ssh_exec", ...args }, { timeoutMs: 30_000 }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshExecKick(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed exec ack`, nodeId); + } + return parsed; +} + +/** + * Poll one machine for the state of a kicked setup run. The answer's streams + * are the machine's OWN redacted capture (Task 14's node-side rule); the + * CALLER redacts again before keeping or speaking a word of them - a machine + * answer about its own hygiene is never trusted (the socket-path doctrine, + * applied to output). + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshExecStatus(nodeId: string, execId: string): Promise { + let data: unknown; + try { + // 30 s, not the 10 s default: the ANSWER is cheap (the run itself is + // off-chain by design) but the frame rides the node's serial command + // chain, so a slow earlier command can park this poll behind it. A + // timed-out poll costs more than a late one: the caller names + // exec-lost-timeout, revokes the unspent setup key, and reports a live + // install as lost. The headroom is the whole honest mitigation. + data = + nodeId === "local" + ? localResult(await machineSshExecStatus(SUBSHELL_SERVER_DATA_DIR, { type: "ssh_exec_status", execId })) + : await sendCommand(nodeId, { type: "ssh_exec_status", execId }, { timeoutMs: 30_000 }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshExecStatus(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed exec status`, nodeId); + } + return parsed; +} + +/** + * Re-deliver ONE peer's registered public pair to a machine and have it + * replace that peer's stored pin (spec 2026-10-08 §4.5, Task 17). The command + * is the re-pair; the ack is `{repaired: true, peerNodeId}` - the plane's + * service matches the echo by equality and treats every other shape as the + * `malformed` refusal. The peer-key pair is sent as READ from the identities + * store (the registered public halves); this layer adds the transport, the + * signature, and the grammar's `d`-refusal on receipt - it does not + * reinterpret the pair. + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshMachinePinRepair( + nodeId: string, + cmd: SshMachinePinRepairCommand, +): Promise { + let data: unknown; + try { + data = nodeId === "local" ? repairLocalMachinePin(cmd) : await sendCommand(nodeId, cmd); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshMachinePinRepair(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed pin-repair ack`, nodeId); + } + return parsed; +} diff --git a/apps/server/api/src/services/nodes/subshell-paths.ts b/apps/server/api/src/services/nodes/subshell-paths.ts index f3b3fb3ea..bec98b45f 100644 --- a/apps/server/api/src/services/nodes/subshell-paths.ts +++ b/apps/server/api/src/services/nodes/subshell-paths.ts @@ -1,3 +1,4 @@ +import { buildSshConfigPath } from "@internal/pane-runtime"; import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; /** @@ -20,3 +21,17 @@ export function subshellLogDir(): string { export function subshellLogPath(id: string): string { return `${subshellLogDir()}/${id}.log`; } + +/** + * The rendered ssh config for one pane ON THE SERVER's own disk — the local + * twin of the node-side path the agent derives from its own dataDir + * (spec 2026-10-07 decision 4). The composition is the pane-runtime + * {@link buildSshConfigPath} itself, not a re-spelling of its template: + * plane and node agree byte-for-byte because they call ONE function, and the + * `LocalLauncher` launch path refuses a plan whose `configPath` disagrees + * with this derivation. Throws on an id outside the composition guard, like + * every other call site. + */ +export function subshellSshConfigPath(id: string): string { + return buildSshConfigPath(SUBSHELL_SERVER_DATA_DIR, id); +} diff --git a/apps/server/api/src/services/notify.service.ts b/apps/server/api/src/services/notify.service.ts index d015bece3..a1e827a4f 100644 --- a/apps/server/api/src/services/notify.service.ts +++ b/apps/server/api/src/services/notify.service.ts @@ -9,7 +9,6 @@ import { NotificationsRepository } from "@/db/repositories/notifications.reposit import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; import type { Database } from "@/db/types/index.js"; -import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import { beginBackupStateWrite } from "@/services/backup-capture-lock.js"; import { badgeCount, @@ -34,6 +33,7 @@ import { logger } from "@/utils/logger.js"; * `/attention` route's body union — hooks can only report * `turn_complete`/`needs_attention`. */ +/** Subshell lifecycle notifications. */ export type NotifyKind = "turn_complete" | "needs_attention" | "exited" | "crashed" | "crashed_final" | "maintenance"; const BODY: Record = { @@ -136,11 +136,17 @@ export function createNotifyService(deps: NotifyServiceDeps) { * rows pruned only on the relay's DeviceNotRegistered verdict. A service * built without the device transport behaves exactly as it did before the * mobile app existed (invariant 2). + * */ /** @returns the number of relay-usable device tokens this fan-out targeted */ - async function notifyDevices(row: SubshellTable, kind: NotifyKind): Promise { + async function fanOutExpo( + userId: string, + subjectId: string, + kind: NotifyKind, + waitingSince: string | null, + ): Promise { if (!deps.devices) return 0; - const enrolled = await deps.devices.listByUser(row.userId); + const enrolled = await deps.devices.listByUser(userId); if (enrolled.length === 0) return 0; // One pass partitions relay-usable rows from junk; the junk rows are ones // the relay can never use, so they go now rather than on every send. @@ -156,11 +162,11 @@ export function createNotifyService(deps: NotifyServiceDeps) { // unreachable — the blessed predicate comes from the registry (importing // it from subshell-manager would cycle: subshell-manager already imports // this module). - const counts = await subshellsRepo.countsByUser(row.userId, isNodeOffline); - const badge = badgeCount(counts.waiting, kind, row.waitingSince); + const counts = await subshellsRepo.countsByUser(userId, isNodeOffline); + const badge = badgeCount(counts.waiting, kind, waitingSince); const messages = buildExpoMessages( live.map((t) => t.token), - row.id, + subjectId, kind, badge, ); @@ -184,6 +190,37 @@ export function createNotifyService(deps: NotifyServiceDeps) { return live.length; } + /** + * Web-push fan-out for one user, the transport the subshell bell has always + * used. Returns the number of subscriptions ATTEMPTED (the unseen gate's + * "an attempt happened" test), pruning only the permanently-dead endpoints + * (403/404/410) the sender contract names. + */ + async function fanOutWebPush(userId: string, payload: object): Promise { + const subs = await deps.subs.listByUser(userId); + if (subs.length === 0) return 0; + const serialized = JSON.stringify(payload); + for (const sub of subs) { + try { + await send({ endpoint: sub.endpoint, p256dh: sub.p256dh, auth: sub.auth }, serialized); + } catch (err) { + const status = (err as { statusCode?: number }).statusCode; + // 404/410 = the endpoint is gone. 403 = the gateway rejects our + // VAPID JWT for this binding (Apple's BadJwtToken: the + // subscription was made against a different server key — e.g. + // after vapid.json rotation). Nothing we sign can ever heal it, + // so it joins the prune set; the client re-subscribes on its + // next enablePush (which now re-binds unconditionally). + if (status === 403 || status === 404 || status === 410) { + await deps.subs.deleteByEndpoint(sub.endpoint); + } else { + logger.withError(err).warn(`push send failed (kept): ${sub.endpoint.slice(0, 60)}…`); + } + } + } + return subs.length; + } + return { async vapidPublicKey(): Promise { return (await keys()).publicKey; @@ -217,29 +254,8 @@ export function createNotifyService(deps: NotifyServiceDeps) { // The transports are independent. Start the device fan-out NOW, before // the sequential web-push loop, so a phone never waits behind N HTTPS // round-trips to browser push gateways (review, efficiency #6). - const deviceDelivery = notifyDevices(row, kind); - const subs = await deps.subs.listByUser(row.userId); - if (subs.length > 0) { - const payload = JSON.stringify(buildNotificationPayload(row, kind)); - for (const sub of subs) { - try { - await send({ endpoint: sub.endpoint, p256dh: sub.p256dh, auth: sub.auth }, payload); - } catch (err) { - const status = (err as { statusCode?: number }).statusCode; - // 404/410 = the endpoint is gone. 403 = the gateway rejects our - // VAPID JWT for this binding (Apple's BadJwtToken: the - // subscription was made against a different server key — e.g. - // after vapid.json rotation). Nothing we sign can ever heal it, - // so it joins the prune set; the client re-subscribes on its - // next enablePush (which now re-binds unconditionally). - if (status === 403 || status === 404 || status === 410) { - await deps.subs.deleteByEndpoint(sub.endpoint); - } else { - logger.withError(err).warn(`push send failed (kept): ${sub.endpoint.slice(0, 60)}…`); - } - } - } - } + const deviceDelivery = fanOutExpo(row.userId, row.id, kind, row.waitingSince); + const attempted = await fanOutWebPush(row.userId, buildNotificationPayload(row, kind)); const liveDevices = await deviceDelivery; // The urgency sticks only behind an ATTEMPTED delivery: the condition // is targets existing (a send was made to at least one), not a gateway @@ -247,7 +263,7 @@ export function createNotifyService(deps: NotifyServiceDeps) { // received nothing and was attempted nothing — such an owner must not // have the pane's future escalations silenced by an attempt that never // existed. - if (subs.length > 0 || liveDevices > 0) { + if (attempted > 0 || liveDevices > 0) { await subshellsRepo.update(subshellId, { lastPushUrgency: urgency }); publishLive({ kind: "subshell.changed", id: subshellId }); } diff --git a/apps/server/api/src/services/ssh-host-pins.service.ts b/apps/server/api/src/services/ssh-host-pins.service.ts new file mode 100644 index 000000000..ab0dbddb3 --- /dev/null +++ b/apps/server/api/src/services/ssh-host-pins.service.ts @@ -0,0 +1,452 @@ +import { createHash } from "node:crypto"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { isSshKnownHostsPinLine, type NodeSshHostKeyResult } from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { SshHostPinsRepository } from "@/db/repositories/ssh-host-pins.repository.js"; +import type { SshHostPinTable } from "@/db/types/ssh-host-pins.db-types.js"; +import { parseSshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { type AuditEventInput, audit } from "@/services/audit.js"; +import { SshRpcError, sshHostKey } from "@/services/nodes/ssh-rpc.js"; +import { logger } from "@/utils/logger.js"; + +/** + * Destination trust follows the key home. Capture its known_hosts entry or + * accept an explicit operator pin, store it per owner and canonical destination, + * then deliver it to the connecting machine for strict host-key checking. + * Missing or ambiguous captures refuse the connection. A changed key never + * overwrites an existing pin: verify it separately, delete the old pin and retry. + * Audit records contain public fingerprints, never key bytes. + */ + +/** Why a capture refused; each code names its own door (the routes render the copy). */ +export type SshHostPinFailure = + | "no-pin" + | "ambiguous" + | "changed" + | "invalid-line" + | "offline" + | "unsupported" + | "timeout" + | "refused" + | "malformed"; + +/** The loud refusal every pin act raises. No row exists after a thrown `changed`-class failure. */ +export class SshHostPinError extends Error { + readonly code: SshHostPinFailure; + /** The canonical destination the act was about (ids/hosts in copy, never key bytes). */ + readonly destination: string; + + constructor(code: SshHostPinFailure, message: string, destination: string) { + super(message); + this.name = "SshHostPinError"; + this.code = code; + this.destination = destination; + } +} + +const repo = new SshHostPinsRepository(db); + +/** + * True when a failed insert hit this table's UNIQUE + * `(owner_user_id, destination)` index (idx_ssh_host_pins_owner_destination): + * the `node-errors` message-containment posture, scoped to the table so any + * OTHER constraint violation stays loud instead of being swallowed as a race. + */ +function isUniquePinViolation(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return msg.includes("UNIQUE constraint failed") && msg.includes("ssh_host_pins"); +} + +/** The service's world. Production at {@link defaultPinsDeps}; tests install a fake. */ +export interface SshHostPinsDeps { + /** The one clock: row stamps. */ + nowIso(): string; + /** + * Ask one machine for its `known_hosts` entries for one destination + * (default: `ssh-rpc.sshHostKey`, the `??` at the call site being the + * production wiring). A test seam so the capture surface can pin its + * fail-closed behavior without a live ssh-keygen. + */ + fetchHostKey?( + nodeId: string, + destination: { host: string; port: number; user: string | null }, + ): Promise; +} + +let depsOverride: SshHostPinsDeps | null = null; + +function pinsDeps(): SshHostPinsDeps { + if (depsOverride) return depsOverride; + return { nowIso: () => new Date().toISOString() }; +} + +/** + * Install (or with null, drop) the whole seam. + * @internal test-only - the suite pins capture/fail-closed without a node. + */ +export function setSshHostPinsDepsForTests(deps: SshHostPinsDeps | null): void { + depsOverride = deps; +} + +/* ------------------------------------------------------------------ */ +/* the line -> fingerprint extraction (public identifiers, never keys) */ +/* ------------------------------------------------------------------ */ + +/** OpenSSH key-type tokens as known_hosts spells them (plain, cert, and security-key families). */ +const KEY_TYPE_RE = /^(?:ssh|ecdsa|sk)-[A-Za-z0-9][A-Za-z0-9@._-]*$/; +/** Strict standard base64 (the known_hosts field's own spelling), shape only. */ +const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +/** + * The `SHA256:` display fingerprint of a known_hosts line's key material - + * OpenSSH's own notation: SHA-256 over the decoded public blob (the agent + * wire encoding is the same bytes a known_hosts base64 field decodes to), + * base64url WITHOUT padding. A line with no extractable (type, base64) pair + * is malformed; a line whose several pairs disagree on the key is `ambiguous` + * for CAPTURE purposes, so the return is a SET and the callers decide what + * plurality means. + */ +export function hostKeyFingerprints(line: string): string[] { + const tokens = line.split(/\s+/); + const fps = new Set(); + for (let i = 0; i + 1 < tokens.length; i += 1) { + const type = tokens[i]; + const b64 = tokens[i + 1]; + if (type === undefined || b64 === undefined) continue; + if (!KEY_TYPE_RE.test(type) || !B64_RE.test(b64)) continue; + const bytes = Buffer.from(b64, "base64"); + if (bytes.length === 0) continue; + fps.add(`SHA256:${createHash("sha256").update(bytes).digest("base64url")}`); + } + return [...fps]; +} + +/* ------------------------------------------------------------------ */ +/* capture */ +/* ------------------------------------------------------------------ */ + +/** The line a capture would pin, chosen from A's answer; raises the named refusals. */ +function chooseCapturedLine(destination: string, lines: string[]): string { + if (lines.length === 0) { + throw new SshHostPinError( + "no-pin", + `no known_hosts entry for ${destination}: connect to the destination once from the key home, or supply its host key explicitly`, + destination, + ); + } + const perLine = lines.map((line) => ({ line, fps: hostKeyFingerprints(line) })); + if (perLine.some((e) => e.fps.length === 0)) { + throw new SshHostPinError( + "invalid-line", + `the host-key answer for ${destination} carried an unparsable entry`, + destination, + ); + } + const distinct = new Set(perLine.flatMap((e) => e.fps)); + if (distinct.size > 1) { + // Several KEYS (not several spellings of one key) match the destination: + // pinning would pick one of A's entries on the capture's own authority, + // and that is exactly the coin flip §9's byte-equality posture refuses. + throw new SshHostPinError( + "ambiguous", + `the key home recorded more than one host key for ${destination}; clean the known_hosts entry or supply the key explicitly`, + destination, + ); + } + const first = perLine[0]; + if (first === undefined) { + // Unreachable (lines.length was checked above), but the grammar's "pick + // the one key" step never casts: an empty pick is a named refusal, not a + // crash. + throw new SshHostPinError("no-pin", `no known_hosts entry for ${destination}`, destination); + } + return first.line; +} + +/** + * Ask A for the destination's `known_hosts` entries and pick the one line to + * pin (the fetch half of {@link captureHostPin}, split out so the explicit + * path never builds an answer it will not read). Every transport failure + * leaves as the named {@link SshHostPinError}; the empty answer is the + * `no-pin` refusal, never a fabricated key. + */ +async function fetchAndChooseLine(aNodeId: string | null, destination: string): Promise { + const triple = parseSshCanonicalDestination(destination); + if (triple === null) { + throw new SshHostPinError( + "invalid-line", + `${destination} is not a canonical user@host:port destination`, + destination, + ); + } + // Without a supplied line there is no pin without a key home to ask: the + // trust screen supplies lines, never fetches. + if (aNodeId === null) { + throw new SshHostPinError("invalid-line", `no key home to ask for ${destination}'s host key`, destination); + } + let answer: NodeSshHostKeyResult; + try { + const fetcher = pinsDeps().fetchHostKey ?? sshHostKey; // the `??` is the production wiring + answer = await fetcher(aNodeId, triple); + } catch (err) { + if (err instanceof SshRpcError) throw rpcFailure(err, destination); + throw err; + } + return chooseCapturedLine(destination, answer.lines); +} + +/** + * The decision over a STANDING row, shared by the serialized read and the + * insert-collision readback so a raced capture decides EXACTLY as the + * serialized one: a differing key is §9's byte-equality hard block (nothing + * written, nothing deleted - the operator's re-decide is the only way past), + * the same key is the TOFU record agreeing with itself (the row stands, + * updatedAt = the last accepted match, no second create row). + */ +async function acceptStandingPin( + ownerUserId: string, + existing: SshHostPinTable, + fps: string[], + destination: string, +): Promise { + const stored = hostKeyFingerprints(existing.hostKey); + if (stored.length !== 1 || stored[0] !== fps[0]) { + // §9's byte-equality hard block, at the plane's edge: A now reports a + // different key than the pin. Nothing written, nothing deleted - the + // operator's re-decide (delete + fresh capture) is the only way past. + throw new SshHostPinError( + "changed", + `the key home now reports a different host key for ${destination} than the pinned one`, + destination, + ); + } + // The same key again is the TOFU record agreeing with itself: the row + // stands (updatedAt = the last accepted match), no second create row. + await repo.touchPin(ownerUserId, destination, pinsDeps().nowIso()); + return { ...existing, updatedAt: pinsDeps().nowIso() }; +} + +/** + * Capture (or accept, idempotently) the owner's pin for one canonical + * destination. `hostKeyLine` supplies the explicit-pin posture (the operator's + * key, not a fetch); absent it, A's `known_hosts` is asked over the signed + * RPC. Returns the standing row. Raises {@link SshHostPinError} with the + * named cause on every fail-closed door, writing NOTHING: a stored pin that + * disagrees is `changed` (the hard block, never an overwrite), a fresh + * capture audits `node.ssh_host_pin.create` naming destination + fingerprint. + */ +export async function captureHostPin(args: { + ownerUserId: string; + /** The key home the fetch asks; null when the line is SUPPLIED (the trust screen names no machine). */ + aNodeId: string | null; + destination: string; // canonical `user@host:port` + hostKeyLine?: string; +}): Promise { + const { ownerUserId, aNodeId, destination } = args; + if (!isSshKnownHostsPinLine(args.hostKeyLine ?? "") && args.hostKeyLine !== undefined) { + throw new SshHostPinError( + "invalid-line", + `the supplied host key for ${destination} is not one known_hosts line`, + destination, + ); + } + const line = args.hostKeyLine ?? (await fetchAndChooseLine(aNodeId, destination)); + const fps = hostKeyFingerprints(line); + if (fps.length !== 1) { + throw new SshHostPinError( + "invalid-line", + `the host key for ${destination} carries no single public key`, + destination, + ); + } + const existing = await repo.getPin(ownerUserId, destination); + if (existing !== null) return await acceptStandingPin(ownerUserId, existing, fps, destination); + const now = pinsDeps().nowIso(); + const row: SshHostPinTable = { + id: crypto.randomUUID(), + ownerUserId, + destination, + hostKey: line, + createdAt: now, + updatedAt: now, + }; + // The get-then-insert above races the UNIQUE (owner_user_id, destination) + // index: two concurrent FIRST captures of one destination both read "no + // row". A collision is decided, never surfaced raw: the loser re-reads the + // winner's row and accepts it through the SAME rule the serialized path + // applies (same key = the idempotent touch, different key = the changed + // block). A winner that vanished between the refused insert and the + // readback (a concurrent delete) gets one retry; a second double-race + // refuses by name rather than looping - a non-violation DB error is not a + // race and stays loud. + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await repo.insertPin(row); + break; + } catch (err) { + if (!isUniquePinViolation(err)) throw err; + const standing = await repo.getPin(ownerUserId, destination); + if (standing !== null) return await acceptStandingPin(ownerUserId, standing, fps, destination); + if (attempt === 1) { + throw new SshHostPinError( + "changed", + `the host-pin store refused the capture for ${destination} twice while its row kept changing; ask again`, + destination, + ); + } + } + } + await pinAudit(ownerUserId, "node.ssh_host_pin.create", aNodeId, { + destination, + fingerprint: fps[0], // the public identifier ONLY, never the line's bytes (§10) + via: args.hostKeyLine !== undefined ? "explicit" : "captured", + }); + return row; +} + +/** Read the owner's pin for one destination, or null (no audit, no side effect). */ +export async function hostPinFor(args: { ownerUserId: string; destination: string }): Promise { + return await repo.getPin(args.ownerUserId, args.destination); +} + +/** Delete the owner's pin (the §9 recovery's first half); false for foreign AND absent. */ +export async function deleteHostPin(args: { + ownerUserId: string; + destination: string; +}): Promise { + const existing = await repo.getPin(args.ownerUserId, args.destination); + if (existing === null) return null; + if (!(await repo.deletePin(args.ownerUserId, args.destination))) return null; // raced away + const fps = hostKeyFingerprints(existing.hostKey); + await pinAudit(args.ownerUserId, "node.ssh_host_pin.delete", null, { + destination: args.destination, + fingerprint: fps[0] ?? null, // names the removed pin; never its bytes + }); + return existing; +} + +/** One pin as the trust screen reads it: destination + fingerprint, NEVER key bytes. */ +export interface SshHostPinView { + /** Pin row id (uuid) */ + id: string; + /** Canonical resolved destination `user@host:port` */ + destination: string; + /** The pinned key's `SHA256:` display fingerprint (a public identifier; the line itself is not serialized) */ + fingerprint: string; + /** ISO 8601 first capture (the TOFU moment) */ + createdAt: string; + /** ISO 8601 of the last accepted match */ + updatedAt: string; +} + +/** The trust screen's list: every one of the owner's pins, display-shaped. */ +export async function listHostPins(args: { ownerUserId: string }): Promise { + const rows = await repo.listPins(args.ownerUserId); + return rows.map((row) => ({ + id: row.id, + destination: row.destination, + fingerprint: hostKeyFingerprints(row.hostKey)[0] ?? "", + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); +} + +/* ------------------------------------------------------------------ */ +/* refusal mapping: the RPC's kinds and the capture's own doors */ +/* ------------------------------------------------------------------ */ + +/** The coded refusal shape shared by trust and connection operations. */ +export type SshHostPinRefusal = { status: 400 | 403 | 404 | 409 | 502; code: BackendErrorCodes; message: string }; + +/** Map the capture's own error onto the coded refusal its caller renders. */ +export function hostPinRefusal(err: SshHostPinError): SshHostPinRefusal { + switch (err.code) { + case "no-pin": + return { + status: 409, + code: BackendErrorCodes.SSH_HOST_PIN_MISSING, + message: + "The machine supplying your keys has no saved host key for that destination. Connect to it once from that machine and verify its key, or add a verified host key in Settings → SSH.", + }; + case "ambiguous": + return { + status: 409, + code: BackendErrorCodes.SSH_HOST_PIN_MISSING, + message: + "The machine supplying your keys has multiple host keys saved for this destination. Verify the correct key and update its known_hosts entry, then try again.", + }; + case "changed": + return { + status: 409, + code: BackendErrorCodes.SSH_HOST_PIN_CHANGED, + message: + "The destination’s host key has changed. Verify the new key with its administrator through a trusted channel before removing the saved pin and reconnecting.", + }; + case "invalid-line": + return { status: 400, code: BackendErrorCodes.SSH_HOST_PIN_INVALID, message: err.message }; + case "offline": + return { + status: 409, + code: BackendErrorCodes.NODE_OFFLINE, + message: "The machine supplying your keys is offline. Start its Subshell daemon and try again.", + }; + case "unsupported": + return { + status: 409, + code: BackendErrorCodes.NODE_OUTDATED, + message: "Update Subshell on the machine supplying your keys from its node page to read host keys.", + }; + case "timeout": + return { + status: 409, + code: BackendErrorCodes.NODE_UNREACHABLE, + message: + "The machine supplying your keys didn't answer the host-key request in time. Check its connection and try again.", + }; + case "refused": + case "malformed": + return { + status: 502, + code: BackendErrorCodes.SSH_NODE_REFUSED, + message: + "The machine supplying your keys refused the host-key request. Check that SSH is enabled on that machine.", + }; + } +} + +/** The RPC error as the capture's own named cause (the machine's text goes to the LOG, never the refusal). */ +function rpcFailure(err: SshRpcError, destination: string): SshHostPinError { + if (err.kind === "refused" || err.kind === "malformed") { + logger + .withError(err) + .warn(`ssh host-key rpc ${err.kind} failed against node ${err.nodeId}: ${err.detail ?? err.message}`); + } + return new SshHostPinError(err.kind, `host-key capture for ${destination} failed: ${err.kind}`, destination); +} + +function pinAudit( + actorUserId: string, + action: string, + aNodeId: string | null, + metadata: Record, +): Promise { + const event: AuditEventInput = { + actorUserId, + action, + targetType: aNodeId === null ? null : "node", + targetId: aNodeId, + metadataJson: JSON.stringify(metadata), + }; + return audit(event); +} + +/** Read the stored destination pin, capturing from the key home's trust on first use. */ +export async function ensureDestinationPin(args: { + ownerUserId: string; + aNodeId: string; + destination: string; +}): Promise<{ line: string }> { + const existing = await hostPinFor({ ownerUserId: args.ownerUserId, destination: args.destination }); + if (existing !== null) return { line: existing.hostKey }; + return { line: (await captureHostPin(args)).hostKey }; +} diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts new file mode 100644 index 000000000..d72909f6e --- /dev/null +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -0,0 +1,634 @@ +import { rmSync } from "node:fs"; +import { homedir as osHomedir } from "node:os"; +import { dirname } from "node:path"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { + buildSshConfigPath, + buildSshKnownHostsPath, + discoverSshAliases, + findBinary, + getHarness, + renderSshConfigContents, + resolveSshAliasConfig, + sshDestinationToken, + sshOptionTokens, +} from "@internal/pane-runtime"; +import { + type NodeSshAgentIdentity, + type NodeSshAliasListResult, + type NodeSshResolveOutcomeWire, + parseNodeSshAliasList, + parseSshConnectionSnapshot, + SSH_NAME_MAX_CHARS, + type SshConnectionSnapshotWire, +} from "@internal/subshell-protocol"; +import { loadNodeGate } from "@/api/nodes/node-gate.js"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { db } from "@/db/index.js"; +import { SettingsRepository } from "@/db/repositories/settings.repository.js"; +import { SshSavedHostsRepository } from "@/db/repositories/ssh-saved-hosts.repository.js"; +import { LOCAL_NODE_ID, type NodeTable } from "@/db/types/nodes.db-types.js"; +import type { SshSavedHostTable } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import type { SubshellTable } from "@/db/types/subshells.db-types.js"; +import { audit } from "@/services/audit.js"; +import { getLive } from "@/services/nodes/node-registry.js"; +import { SshRpcError, sshAgentIdentities, sshDiscover, sshResolve } from "@/services/nodes/ssh-rpc.js"; +import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; +import { gateSshNode } from "@/services/ssh-policy.service.js"; +import { prepareRelayLeg } from "@/services/ssh-relay-launch.service.js"; + +export { gateSshNode } from "@/services/ssh-policy.service.js"; + +import { closeRelayForPaneExit, sshRelayPaneEnv } from "@/services/ssh-relay.service.js"; +import type { SubshellsService } from "@/services/subshells.service.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The SSH launcher surface (spec 2026-10-07 §5/§7, plan 2 Task 7): the gate, + * the resolve trip, the compose, the launch, the ledger, the audit. + * + * **Responsibilities in order, each pinned by `api/ssh/__tests__/ssh-routes.test.ts`:** + * 1. GATE: the central SSH policy answers from the plane's row and the caller's + * access BEFORE any `sendCommand` — the predicate says yes/no, this file + * names the cause in the message (spec §12); the code is one + * `SSH_GATE_OFF` for both causes. A held agent node is refused with the + * update remedy before any resolve (a held socket answers nothing but + * `update`, and a launch aimed at it is a frame the agent will never see). + * 2. The destination is validated with the wire grammar's own alias rule + * BEFORE the machine is asked; discovery answers and hand-typed tokens + * take the SAME resolve path (`ssh_resolve_config`), so nothing composes a + * snapshot the resolver did not approve. + * 3. The refusal shape splits by endpoint, per the frozen doctrine: `resolve` + * answers a refusal outcome IN THE DATA (200, a fact the human reads); + * `launch` and `saved-hosts PUT` refuse it (422 `{outcome}`), because + * acting on an unapproved destination is the act they are. + * 4. Compose happens only against a snapshot re-validated by + * `parseSshConnectionSnapshot` at THIS trust boundary (the RPC validator + * already ran; the renderer's own entry check is the third copy — the + * tier-1 grammar doc names re-validation at each boundary the + * load-bearing defense, not the plane's approval). + * 5. The audit `ssh.launch` lands AFTER a successful launch, never before — + * the same posture as `subshell.create`, which the manager records only + * once the spawn returned. A refused resolve, a thrown create, an offline + * machine: the trail stays silent about the act that did not happen. + * Saved-host CRUD audits nothing (the prompts precedent: a preference row + * is not an event). + */ + +/** The refusal union the routes render verbatim (a returned `status()` body, never a thrown). */ +export type SshRefusal = + | { status: 400 | 403 | 404 | 409 | 502; code: BackendErrorCodes; message: string } + | { status: 422; outcome: Extract }; + +/** The service answer shape: a value, or the refusal the route returns as-is. */ +export type SshAnswer = { ok: true; value: T } | { ok: false; refusal: SshRefusal }; + +function ok(value: T): SshAnswer { + return { ok: true, value }; +} + +function codedRefusal(status: 400 | 403 | 404 | 409 | 502, code: BackendErrorCodes, message: string): SshAnswer { + return { ok: false, refusal: { status, code, message } }; +} + +function refusedRefusal(outcome: Extract): SshAnswer { + return { ok: false, refusal: { status: 422, outcome } }; +} + +/** + * The destination as the wire grammar reads an alias (ssh-frames.ts + * `isAliasName`, same numbers): 1..{@link SSH_NAME_MAX_CHARS}, no leading + * `-`, no whitespace or control characters. Discovery answers and manual + * tokens take this SAME path before any machine is asked — the tier-1 + * injection posture, applied at the surface rather than trusted to the + * renderer's last-station check. + */ +export function isWireSafeSshDestination(value: string): boolean { + return value.length > 0 && value.length <= SSH_NAME_MAX_CHARS && !value.startsWith("-") && !/\s|\p{Cc}/u.test(value); +} + +/** + * Everything the ssh launch needs, composed from an APPROVED snapshot, the + * target machine's dataDir, and the new pane's id (spec 2026-10-07 decision + * 4: the path is DERIVED from the two facts each end already holds). The + * derived path rides the launch frame's ssh member, but only as a CLAIM the + * other end never trusts: `apps/node/agent/src/commands/launch.ts` re-derives + * it from its own dataDir and the pane id and refuses unless the frame's + * `configPath` is byte-equal (the LocalLauncher byte-checks in-process), so + * naming the path on the wire grants no write the machine did not choose. + * Handoff 4's exact tail: every option token, then `--`, + * then the bare host; `SSH_AUTH_SOCK` joins the pane env only when the + * snapshot names a socket (decision 3's scoped exception). + * + * Throws (an impossible-state guard, never a user path) when the snapshot + * fails the grammar re-check at this boundary, or the id is outside the + * path-composition guard. + */ +export function composeSshLaunch(args: { + snapshot: SshConnectionSnapshotWire; + targetDataDir: string; + subshellId: string; + /** + * The relay session's pinned host-key file path (Task 12, spec 2026-10-08 + * §9). PRESENT is the relay mode: the rendered config forces + * `StrictHostKeyChecking yes` and names this one file as the ONLY trust + * source - the pane's ssh verifies D against A's recorded key, never + * against B's ambient known_hosts. ABSENT is the M1 direct launch: the + * accept-new posture, byte-for-byte. The path is `buildSshKnownHostsPath` + * of the SAME two facts the config path derives from; B writes the pin + * delivered on the signed relay-open to its own derivation of them. + */ + hostPinPath?: string; +}): { + configPath: string; + fileContent: string; + presetFlags: string[]; + extraPaneEnv?: Record; +} { + const approved = parseSshConnectionSnapshot(args.snapshot); + if (approved === null) throw new Error("ssh compose: snapshot failed grammar re-validation"); + const configPath = buildSshConfigPath(args.targetDataDir, args.subshellId); + const fileContent = renderSshConfigContents( + approved, + args.hostPinPath === undefined ? undefined : { hostPinPath: args.hostPinPath }, + ); + const presetFlags = [...sshOptionTokens(approved, configPath), "--", sshDestinationToken(approved)]; + return approved.authAgentSocket === null + ? { configPath, fileContent, presetFlags } + : { configPath, fileContent, presetFlags, extraPaneEnv: { SSH_AUTH_SOCK: approved.authAgentSocket } }; +} + +/** Relay trust currently pins one canonical destination, not jump hosts or host-key aliases. */ +export function relaySnapshotRefusal(snapshot: SshConnectionSnapshotWire): SshAnswer | null { + const settings: string[] = []; + if (snapshot.proxyJumps.length > 0) settings.push("ProxyJump"); + if (snapshot.hostKeyAlias !== null) settings.push("HostKeyAlias"); + if (settings.length === 0) return null; + return codedRefusal( + 409, + BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + `Using SSH keys from another machine does not yet support ${settings.join(" or ")}. Use keys on the connecting machine, or choose a destination without these settings.`, + ); +} + +/* ------------------------------------------------------------------ */ +/* config lifecycle: the LOCAL sweep, ONE function for every teardown */ +/* point (spec 2026-10-07 decision 4; plan-2 handoff 2) */ +/* ------------------------------------------------------------------ */ + +/** + * Best-effort `rm -rf` of the per-pane ssh config dir a LOCAL ssh pane wrote + * on this disk: `/ssh/` — the dirname of the + * byte-derived config path the LocalLauncher wrote into. ONE function, called + * from every site where such a pane stops existing, so no copy can drift: + * + * - the shared death transition (the manager's `#applyDeath`), the DETERMINISTIC + * driver: the 60 s reconcile reaches it whenever the process is gone, so a + * lost `pane-died` hook can no longer leave the dir behind (e2e spec 22's + * intermittent miss); + * - the pane's own death report (the service's `reportExit`), which fires even + * when the row is already retired: a terminate kills the pane, the tmux + * `pane-died` hook races the retire stamp, and whichever order they land in + * the config must be gone once the report has been heard; + * - the create rollback: a LOCAL ssh launch that wrote its config then threw + * past the spawn retires the row here, and the half-built dir must go with it; + * - the terminate verb, AFTER the kill: terminate revokes the pane's token + * synchronously with the kill, so the dying hook's report can arrive 401 + * and run no sweep at all (e2e spec 22) — the hand that kills must also + * remove the dir; + * - the maintenance kill (the manager's `terminateForMaintenance`), which + * reaches the manager's own kill directly, never passing through the + * service verb above: without its own sweep a maintenance window left + * every local ssh config dir behind; + * - the delete path: the row is gone before the sweep could ever ride the + * exit hook, and the artifacts list must not carry the config path either, + * because removing the file and leaving the DIR behind is not a removal. + * + * The guard is the row's OWN facts (`local` node ∧ the snapshot column, + * migration 0048) applied HERE, once, so callers hand over the row they + * already hold unconditionally. Agent rows are skipped by rule, not by + * accident — their config lives on the NODE's disk and the agent's own exit + * watcher unlinks it; removing a directory named after a remote pane off THIS + * server would be this host deleting a file that is not its (the delete + * path's per-node launcher doctrine, same reason). + * + * Best-effort by shape: any failure (missing dir, an id outside the path + * guard, a refusing filesystem) costs one log line and nothing else — the + * teardown must land regardless, and a stale config in a dir whose name is a + * dead pane's id is garbage, not a hazard. + */ +export function sweepLocalSshDir(row: Pick | null | undefined): void { + if (!row || row.nodeId !== LOCAL_NODE_ID || row.ssh === null) return; + try { + rmSync(dirname(subshellSshConfigPath(row.id)), { recursive: true, force: true }); + } catch (err) { + logger.withError(err).warn(`ssh config sweep failed for local pane ${row.id}`); + } +} + +/* ------------------------------------------------------------------ */ +/* the gate (spec §4.3: the plane's row, then the held check, BEFORE */ +/* any command; naming WHY is this surface's copy, per §12) */ +/* ------------------------------------------------------------------ */ + +/** + * The machine refused or could not answer; the code family, never the + * agent's own text. Exported for the §7 setup-here act, whose ssh_exec RPCs + * must map failures through the SAME doors (spec §12's naming doctrine: one + * cause, one sentence, wherever it was reached). + */ +export function rpcRefusal(err: SshRpcError): SshAnswer { + if (err.kind === "offline") { + return codedRefusal( + 409, + BackendErrorCodes.NODE_OFFLINE, + "That machine is offline. Start its Subshell daemon and try again.", + ); + } + if (err.kind === "unsupported") { + return codedRefusal( + 409, + BackendErrorCodes.NODE_OUTDATED, + "Update Subshell on that machine from its node page to use SSH.", + ); + } + if (err.kind === "timeout") { + return codedRefusal( + 409, + BackendErrorCodes.NODE_UNREACHABLE, + "That machine didn't answer the SSH request in time. Check its connection and try again.", + ); + } + // refused / malformed: the machine's own answer (`ssh disabled on this + // node`, no ssh binary, a payload that failed the frozen validator). Its + // verbatim text goes to the LOG, never to the response (the + // files-remote-browse posture: agent refusal strings are not client API). + logger.withError(err).warn(`ssh rpc ${err.kind} failed against node ${err.nodeId}: ${err.detail ?? err.message}`); + return codedRefusal( + 502, + BackendErrorCodes.SSH_NODE_REFUSED, + err.nodeId === "local" + ? "The server service account could not use SSH. Ensure ssh is installed and that this service has an absolute SSH_AUTH_SOCK pointing to its running SSH agent. Keys in another login session are not automatically available to the service." + : "The connecting machine refused the SSH request. Check that SSH is switched on there and that ssh is installed.", + ); +} + +/* ------------------------------------------------------------------ */ +/* transports: the agent answers over the signed RPC; `local` runs */ +/* the SAME pane-runtime engines in-process (no agent socket exists — */ +/* ssh-frames.ts spells the commands transport-agnostic for exactly */ +/* this, and the server account's gate is the DB row, not a mirror). */ +/* ------------------------------------------------------------------ */ + +async function nodeAliases(row: NodeTable): Promise> { + if (row.kind === "local") { + const found = parseNodeSshAliasList(discoverSshAliases({ homeDir: osHomedir() })); + if (!found) { + // Unreachable by construction (the discovery engine is OURS); refuse + // loudly rather than pass anything the wire validator rejected. + return codedRefusal(502, BackendErrorCodes.SSH_NODE_REFUSED, "This host could not produce its alias list."); + } + return ok(found); + } + try { + return ok(await sshDiscover(row.id)); + } catch (err) { + if (err instanceof SshRpcError) return rpcRefusal(err); + throw err; + } +} + +async function nodeResolve(row: NodeTable, destination: string): Promise> { + if (row.kind === "local") { + // The same ladder the agent runs (decision 2: the rule travels, the path + // is the machine's own) — for `local`, this process IS the machine. + const spec = getHarness("ssh")?.detectSpec; + const sshBin = spec ? await findBinary(spec.binaryName, spec.envOverride, spec.knownPaths) : null; + if (sshBin === null) { + return codedRefusal( + 502, + BackendErrorCodes.SSH_NODE_REFUSED, + "This host has no ssh binary installed, so it cannot resolve a destination.", + ); + } + return ok(await resolveSshAliasConfig(destination, { sshBin, homeDir: osHomedir() })); + } + try { + return ok(await sshResolve(row.id, destination)); + } catch (err) { + if (err instanceof SshRpcError) return rpcRefusal(err); + throw err; + } +} + +/** The node's dataDir at the compose instant, or null (the offline class). Exported with the gate. */ +export function targetDataDir(row: NodeTable): string | null { + // local: the server's own dir (LocalLauncher byte-checks this exact + // derivation); agent: the ready-reported `dataDir`, which the AGENT + // re-derives from itself and refuses a byte-mismatch on. + return row.kind === "local" ? SUBSHELL_SERVER_DATA_DIR : (getLive(row.id)?.agent?.dataDir ?? null); +} + +/* ------------------------------------------------------------------ */ +/* endpoints */ +/* ------------------------------------------------------------------ */ + +/** `GET /api/ssh/aliases` — the machine's usable alias names (gate, then the transport). */ +export async function sshListAliases(viewerId: string, nodeId: string): Promise> { + const gate = await gateSshNode(viewerId, nodeId); + if (!gate.ok) return gate; + return await nodeAliases(gate.value.row); +} + +/** + * `POST /api/ssh/resolve` — the destination's outcome. A refusal rides IN THE + * DATA (200): which setting blocked is a fact the human reads, not a failure + * of this request. + */ +export async function sshResolveDestination( + viewerId: string, + nodeId: string, + destination: string, +): Promise> { + const gate = await gateSshNode(viewerId, nodeId); + if (!gate.ok) return gate; + if (!isWireSafeSshDestination(destination)) { + return codedRefusal(400, BackendErrorCodes.ALIAS_UNSAFE, UNSAFE_DESTINATION_COPY); + } + return await nodeResolve(gate.value.row, destination); +} + +const UNSAFE_DESTINATION_COPY = + "Use an SSH destination of at most 253 characters. It can't start with a dash or contain whitespace or control characters."; + +/** + * `POST /api/ssh/launch` — gate, resolve, compose, launch, ledger, audit. + * The pane arrives through the ordinary create path (`harnessId: "ssh"`, + * presetless: `presetFlags` CARRY the launch, the working directory gets the + * launch node's home by the spec-2026-10-01 terminal default), and the ssh + * member + snapshot ride the plumbing this task added to `createSubshell`. + * `restartOnExit` inherits the preset — a presetless launch inherits nothing + * (0): ssh panes are forced off auto-restart by construction, decision 7's + * other half. + */ +export async function sshLaunch(args: { + viewerId: string; + nodeId: string; + destination: string; + name?: string; + /** Optional key machine; current launch access on both machines authorizes relay use. */ + keyHomeNodeId?: string; + fingerprints?: readonly string[]; + /** The REQUEST-scoped subshells service (the create path is its own). */ + subshells: Pick; +}): Promise> { + const gate = await gateSshNode(args.viewerId, args.nodeId); + if (!gate.ok) return gate; + const row = gate.value.row; + if (!isWireSafeSshDestination(args.destination)) { + return codedRefusal(400, BackendErrorCodes.ALIAS_UNSAFE, UNSAFE_DESTINATION_COPY); + } + if (args.fingerprints !== undefined && args.keyHomeNodeId === undefined) { + return codedRefusal( + 400, + BackendErrorCodes.SSH_KEYS_INVALID, + "Choose a machine to supply your SSH keys before selecting them.", + ); + } + // Explicit own-machine key homes use the direct account path. A supplied subset cannot be broadened. + if (args.keyHomeNodeId === row.id) { + if (args.fingerprints !== undefined) + return codedRefusal( + 400, + BackendErrorCodes.SSH_KEYS_INVALID, + "Use this machine's own keys without a key selection, or choose a different key machine to select a subset.", + ); + args = { ...args, keyHomeNodeId: undefined }; + } + const resolved = await nodeResolve(row, args.destination); + if (!resolved.ok) return resolved; + if (!resolved.value.accepted) return refusedRefusal(resolved.value); + const snapshot = resolved.value.snapshot; // parseNodeSshResolveOutcome already narrowed + rebuilt it + if (args.keyHomeNodeId !== undefined) { + const refusal = relaySnapshotRefusal(snapshot); + if (refusal) return refusal; + } + const dataDir = targetDataDir(row); + if (dataDir === null) { + // Between the resolve and here the machine dropped, or it connected + // without reporting `ready` facts yet — there is no dataDir to derive a + // path from, so nothing was composed or spawned. + return codedRefusal( + 409, + BackendErrorCodes.NODE_OFFLINE, + "That machine hasn't finished connecting to the server. Wait for it to connect and try again.", + ); + } + const subshellId = crypto.randomUUID(); + // Open the relay before creating the pane. Access, the selected live keys, + // destination trust and both relay endpoints must all succeed first. + let relay: { socketPath: string; ref: string; fingerprints: string[] } | null = null; + if (args.keyHomeNodeId !== undefined) { + const gateA = await gateSshNode(args.viewerId, args.keyHomeNodeId); + if (!gateA.ok) return gateA; + const aRow = gateA.value.row; + const leg = await prepareRelayLeg({ + viewerId: args.viewerId, + aNode: { id: aRow.id, name: aRow.name }, + bNodeId: row.id, + fingerprints: args.fingerprints, + // The canonical triple the pin is keyed by, from the SAME validated + // snapshot that the connecting machine resolved. + destination: sshCanonicalDestination({ host: snapshot.host, port: snapshot.port, user: snapshot.user }), + paneId: subshellId, + }); + if (!leg.ok) return leg; + relay = leg.value; + } + const composed = composeSshLaunch({ + snapshot, + targetDataDir: dataDir, + subshellId, + // Relay mode (Task 12): the config pins B's trust source to the file the + // signed relay-open just wrote beside it; a direct launch renders the M1 + // accept-new bytes untouched. + ...(relay ? { hostPinPath: buildSshKnownHostsPath(dataDir, subshellId) } : {}), + }); + // Relay mode RE-POINTS the scoped exception (spec §5.2): the socket the + // pane's ssh authenticates through is B's proxy the broker just verified, + // never the snapshot's agent socket even if the config names one. Still + // one key, still the ssh invocation only, still never the whole-pane env. + if (relay) composed.extraPaneEnv = sshRelayPaneEnv(relay.socketPath); + // The create can still refuse (node switched off, harness gone, a + // lockdown, a maintenance window) — those ride the global error handler + // with their own codes. They land here, AFTER the resolve: the honest + // order, since this act's gate (SSH use) is strictly narrower than the + // launch's and every refusal below writes no pane. + let created; + try { + created = await args.subshells.createSubshell({ + userId: args.viewerId, + harnessId: "ssh", + presetId: null, + // The presetless-terminal default (spec 2026-10-01 §2): the launch node's + // home, resolved by the create path itself - exactly what the wizard's + // "connect from this machine" journey wants as the pane's starting cwd. + workingDir: undefined, + name: args.name, + nodeId: row.id, + machineActor: false, // the routes are cookie-only (requireCookieActor) + crossAgent: false, + subshellId, + ssh: { + configPath: composed.configPath, + fileContent: composed.fileContent, + snapshot, + // Preserve the actual selected keys, including the complete roster + // resolved for callers that omitted selection. Later acts cannot widen it. + ...(relay ? { keyHomeNodeId: args.keyHomeNodeId as string, relayFingerprints: relay.fingerprints } : {}), + }, + presetFlags: composed.presetFlags, + ...(composed.extraPaneEnv ? { extraPaneEnv: composed.extraPaneEnv } : {}), + }); + } catch (err) { + // A session was opened under a pane id that will never have a child: + // the child-exit word is the honest cut (the process is gone at t=0), + // and the sweep is the same best-effort helper every death site uses. + // The refusal the caller sees is the create's own, untouched. + if (relay) closeRelayForPaneExit(subshellId); + throw err; + } + // Recency FIRST (a bookkeeping refresh the human already sees as part of the + // launch), then the audit — both AFTER the pane exists. The ledger is + // best-effort (a failed touch must not turn a live pane into an error, the + // recentPaths posture); the audit sink never throws by contract. + const destination = sshCanonicalDestination({ host: snapshot.host, port: snapshot.port, user: snapshot.user }); + const now = new Date().toISOString(); + await new SshSavedHostsRepository(db) + .touch({ ownerUserId: args.viewerId, destination, nodeId: row.id, alias: snapshot.alias, at: now }) + .catch((err: unknown) => { + logger.withError(err).warn(`ssh saved-hosts recency touch failed for ${args.viewerId}/${destination}`); + }); + // Success-only, matching the manager's own `subshell.create` posture (which + // is recorded after the spawn returned): a refusal writes nothing to that + // trail, and an act that failed to spawn is not an event. + await audit({ + actorUserId: args.viewerId, + action: "ssh.launch", + targetType: "node", + targetId: row.id, + // Only session ids and destination metadata enter the success audit. + metadataJson: JSON.stringify( + relay + ? { nodeId: row.id, destination, subshellId: created.id, relayRef: relay.ref } + : { nodeId: row.id, destination, subshellId: created.id }, + ), + }); + return ok({ subshellId: created.id }); +} + +/** + * `PUT /api/ssh/saved-hosts` — save a destination: the SAME gate + resolve + * (the canonical key must come from a validated snapshot's fields, handoff + * 1), refusal-shaped outcomes refuse with 422 exactly as a launch would. + * Unaudited (prompts precedent: this is a preference row, not an event). + */ +export async function sshSaveHost(args: { + viewerId: string; + nodeId: string; + destination: string; + /** Explicit display token; absent falls back to the snapshot's alias. */ + alias?: string; +}): Promise> { + const gate = await gateSshNode(args.viewerId, args.nodeId); + if (!gate.ok) return gate; + if (!isWireSafeSshDestination(args.destination)) { + return codedRefusal(400, BackendErrorCodes.ALIAS_UNSAFE, UNSAFE_DESTINATION_COPY); + } + const resolved = await nodeResolve(gate.value.row, args.destination); + if (!resolved.ok) return resolved; + if (!resolved.value.accepted) return refusedRefusal(resolved.value); + const snapshot = resolved.value.snapshot; + const row = await new SshSavedHostsRepository(db).markSaved({ + ownerUserId: args.viewerId, + destination: sshCanonicalDestination({ host: snapshot.host, port: snapshot.port, user: snapshot.user }), + nodeId: gate.value.row.id, + alias: args.alias ?? snapshot.alias, + at: new Date().toISOString(), + }); + return ok(row); +} + +/** `DELETE /api/ssh/saved-hosts/:id` — false for foreign AND absent (one 404). */ +export async function sshRemoveSavedHost(viewerId: string, id: string): Promise { + return await new SshSavedHostsRepository(db).remove(viewerId, id); +} + +/** The public key roster, gated exactly like discovery, resolve and launch. */ +export async function listNodeAgentIdentities(args: { + viewerId: string; + aNodeId: string; +}): Promise> { + const gate = await gateSshNode(args.viewerId, args.aNodeId); + if (!gate.ok) return gate; + try { + return ok(await sshAgentIdentities(args.aNodeId)); + } catch (err) { + if (err instanceof SshRpcError) return rpcRefusal(err); + throw err; + } +} + +/** The GET payload of the launcher screen: the owner's rows plus their default machine. */ +export async function sshSavedHostsView(viewerId: string): Promise<{ + saved: SshSavedHostTable[]; + recent: SshSavedHostTable[]; + defaultNodeId: string | null; +}> { + const repo = new SshSavedHostsRepository(db); + const [saved, recent, defaultNodeId] = await Promise.all([ + repo.listSaved(viewerId), + repo.listRecent(viewerId), + getSshDefaultNode(viewerId), + ]); + return { saved, recent, defaultNodeId }; +} + +/* ------------------------------------------------------------------ */ +/* preferences: the default connecting machine, a per-user setting */ +/* ------------------------------------------------------------------ */ + +/** + * The preference lives in the instance `settings` table under a per-user + * NAMESPACED key because that is the only JSON key/value store this schema + * has and a migration for one string was not on Task 7's map: `user_meta` + * columns each need their own migration, and 0048 (the ledger + the snapshot + * column) was frozen before preferences were wired. The namespace keeps two + * users' defaults from sharing a row; nothing renders `settings` rows raw, so + * the key shape is storage detail, not API surface. + */ +const sshDefaultNodeKey = (userId: string): string => `ssh.defaultNode:${userId}`; + +export async function getSshDefaultNode(userId: string): Promise { + return (await new SettingsRepository(db).get(sshDefaultNodeKey(userId), null)) ?? null; +} + +/** + * `PATCH /api/ssh/preferences`. The WRITE validates the node exists and is + * visible to the caller (404 otherwise — the ordinary invisibility, never a + * "you may not" leak). The READ path (above) deliberately returns the stored + * id WITHOUT a visibility read: a node deleted under a saved default must not + * silently clear a preference the human chose, and a preference that + * re-validates on every page load would make the launcher's first GET + * machine-visibility-coupled; the SPA resolves the id against its node list + * and decides what to show. + */ +export async function sshSetDefaultNode(viewerId: string, nodeId: string | null): Promise> { + if (nodeId !== null) { + const gate = await loadNodeGate(viewerId, nodeId); + if (!gate) return codedRefusal(404, BackendErrorCodes.NOT_FOUND_ERROR, "Node not found"); + } + await new SettingsRepository(db).set(sshDefaultNodeKey(viewerId), nodeId); + return ok(nodeId); +} diff --git a/apps/server/api/src/services/ssh-local-identity.ts b/apps/server/api/src/services/ssh-local-identity.ts new file mode 100644 index 000000000..1933d7ac4 --- /dev/null +++ b/apps/server/api/src/services/ssh-local-identity.ts @@ -0,0 +1,108 @@ +import { createPrivateKey, createPublicKey } from "node:crypto"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { RelayMachineIdentity } from "@internal/pane-runtime"; +import { bytesOfJwk } from "@internal/subshell-protocol"; +import { exportJWK, generateKeyPair } from "jose"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; + +/** Dedicated relay identity and machine trust directory, separate from command signing and pane identities. */ +export function localRelayIdentityDir(): string { + return join(SUBSHELL_SERVER_DATA_DIR, "ssh-relay"); +} + +/** Validate both public/private pairs, retaining the exact public JWK strings peers pin. */ +function validatedIdentity(value: unknown): RelayMachineIdentity { + if (!value || typeof value !== "object") throw new Error("invalid identity"); + const identity = value as RelayMachineIdentity; + for (const [publicJwk, privateJwk] of [ + [identity.publicJwk, identity.privateJwk], + [identity.signingPublicJwk, identity.signingPrivateJwk], + ]) { + if (typeof publicJwk !== "string" || typeof privateJwk !== "string") throw new Error("invalid identity"); + const publicBytes = bytesOfJwk(publicJwk); + const derived = createPublicKey(createPrivateKey({ key: JSON.parse(privateJwk), format: "jwk" })).export({ + format: "jwk", + }); + if (!Buffer.from(publicBytes).equals(Buffer.from(bytesOfJwk(JSON.stringify(derived))))) + throw new Error("identity pair mismatch"); + } + return identity; +} + +/** Read the dedicated file without quarantining it: subsequent calls/restarts must refuse the same corruption. */ +export async function readLocalRelayIdentity(directory: string): Promise { + try { + return validatedIdentity(JSON.parse(await readFile(join(directory, "identity.json"), "utf8"))); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") throw err; + throw new Error( + "The server SSH relay identity is unreadable. Restore ssh-relay/identity.json from backup; existing identity material was not replaced.", + ); + } +} + +/** Single-flight disk creation plus immutable database registration. A rejected initialization stays rejected. */ +export function createLocalRelayIdentityProvider( + directory: string, + register: (identity: RelayMachineIdentity) => Promise, +) { + let pending: Promise | undefined; + return (): Promise => + (pending ??= (async () => { + let identity: RelayMachineIdentity; + try { + identity = await readLocalRelayIdentity(directory); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); + const pair = async (alg: string) => { + const keys = await generateKeyPair(alg, { crv: "P-256", extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(keys.publicKey)), + privateJwk: JSON.stringify(await exportJWK(keys.privateKey)), + }; + }; + const encryption = await pair("ECDH-ES"); + const signing = await pair("ES256"); + identity = { ...encryption, signingPublicJwk: signing.publicJwk, signingPrivateJwk: signing.privateJwk }; + try { + await writeFile(join(directory, "identity.json"), JSON.stringify(identity), { mode: 0o600, flag: "wx" }); + } catch (writeError) { + if ((writeError as NodeJS.ErrnoException).code !== "EEXIST") throw writeError; + identity = await readLocalRelayIdentity(directory); + } + } + await register(identity); + return identity; + })()); +} + +/** Compare-and-register prevents first-use races and never rotates either existing public half. */ +export async function registerLocalRelayIdentity(identity: RelayMachineIdentity): Promise { + const repo = new IdentitiesRepository(db); + const row = await repo.registerIfMatching({ + principalId: "node:local", + publicKey: identity.publicJwk, + signingPublicKey: identity.signingPublicJwk, + displayName: null, + }); + if (!row) + throw new Error( + "The server SSH relay identity differs from its registered identity. Restore its identity file or explicitly repair the server relay identity registration after verifying both fingerprints.", + ); +} + +let provider: ReturnType | undefined; +/** Lazy initialization: importing the server or running a CLI subcommand performs no I/O. */ +export function ensureLocalRelayIdentity(): Promise { + provider ??= createLocalRelayIdentityProvider(localRelayIdentityDir(), registerLocalRelayIdentity); + return provider(); +} +/** Re-read only after an explicit identity registration repair (or test teardown). */ +export function resetLocalRelayIdentity(): void { + provider = undefined; +} diff --git a/apps/server/api/src/services/ssh-local-participant.ts b/apps/server/api/src/services/ssh-local-participant.ts new file mode 100644 index 000000000..4caa32e1d --- /dev/null +++ b/apps/server/api/src/services/ssh-local-participant.ts @@ -0,0 +1,117 @@ +import { open, seal } from "@internal/mcp-core"; +import { + decodePeerEncryptionJwk, + MachinePinStore, + openARelaySession, + openBRelaySession, + type RelayMachineIdentity, + RelaySessions, +} from "@internal/pane-runtime"; +import { + bytesOfJwk, + fingerprintJwk, + type NodeCommandBody, + type NodeSshFingerprintReport, + parseRelayFrame, + type RelayFrame, +} from "@internal/subshell-protocol"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { ensureLocalRelayIdentity, localRelayIdentityDir } from "@/services/ssh-local-identity.js"; +import { logger } from "@/utils/logger.js"; + +/** Server adapter over the same sealed participant used by nodes. The broker supplies its authenticated-origin pump. */ +export function createLocalRelayParticipant(args: { + dataDir: string; + identityDir: string; + identity(): Promise; + sendRelayFrame(frame: RelayFrame): void; + resolveAgentSocket?: () => string | null; + log(line: string): void; +}) { + const sessions = new RelaySessions(args.log); + let stopped = false; + return { + sessions, + async command(cmd: NodeCommandBody): Promise { + if (cmd.type === "ssh_relay_close") return { ref: cmd.ref, closed: sessions.close(cmd.ref, cmd.reason) }; + if (stopped) throw new Error("server relay participant is shutting down"); + if (cmd.type !== "ssh_relay_open") throw new Error("unsupported local relay command"); + const common = { + dataDir: args.dataDir, + pinDataDir: args.identityDir, + identity: args.identity, + seal, + open, + relay: sessions, + selfNodeId: LOCAL_NODE_ID, + sendRelayFrame: args.sendRelayFrame, + log: args.log, + cmd, + }; + // In-process calls have no serial WebSocket command chain. Await A's probe so B cannot lose its first request. + const result = + cmd.role === "A" + ? await openARelaySession({ ...common, resolveAgentSocket: args.resolveAgentSocket }) + : await openBRelaySession({ ...common, paneId: cmd.paneId }); + if (stopped) { + sessions.close(cmd.ref, "child-exit"); + throw new Error("server relay participant stopped during open"); + } + return { role: cmd.role, relayId: cmd.relayId, ...result }; + }, + inbound(frame: RelayFrame): void { + if (stopped || !parseRelayFrame(frame)) throw new Error("local relay frame unavailable or malformed"); + sessions.onInboundRelayFrame(frame); + }, + closeAll(): void { + stopped = true; + sessions.closeAll("child-exit"); + }, + }; +} + +/** Lazily composed production participant; no broker import and no import-time I/O. */ +export function productionLocalRelayParticipant(sendRelayFrame: (frame: RelayFrame) => void) { + return createLocalRelayParticipant({ + dataDir: SUBSHELL_SERVER_DATA_DIR, + identityDir: localRelayIdentityDir(), + identity: ensureLocalRelayIdentity, + sendRelayFrame, + log: (line) => logger.debug(line), + }); +} + +/** Live public trust report for the server's managed node detail. */ +export async function localSshTrustReport(): Promise { + const identity = await ensureLocalRelayIdentity(); + return { + own: { + signing: await fingerprintJwk(identity.signingPublicJwk), + encryption: await fingerprintJwk(identity.publicJwk), + }, + peers: await Promise.all( + new MachinePinStore(localRelayIdentityDir()).entries().map(async ({ nodeId, pin }) => ({ + nodeId, + signing: await fingerprintJwk(pin.signing), + encryption: await fingerprintJwk(pin.encryption), + })), + ), + }; +} + +/** The admin-authorized repair changes only the named peer, with both public halves revalidated. */ +export function repairLocalMachinePin(cmd: { + peerNodeId: string; + peerSigningPublicKey: string; + peerEncryptPublicKey: string; +}) { + if (cmd.peerNodeId === LOCAL_NODE_ID) throw new Error("a machine is never its own relay peer"); + bytesOfJwk(cmd.peerSigningPublicKey); + const encryption = decodePeerEncryptionJwk(cmd.peerEncryptPublicKey); + new MachinePinStore(localRelayIdentityDir()).repair(cmd.peerNodeId, { + signing: cmd.peerSigningPublicKey, + encryption, + }); + return { repaired: true as const, peerNodeId: cmd.peerNodeId }; +} diff --git a/apps/server/api/src/services/ssh-machine-pins.service.ts b/apps/server/api/src/services/ssh-machine-pins.service.ts new file mode 100644 index 000000000..77b18d485 --- /dev/null +++ b/apps/server/api/src/services/ssh-machine-pins.service.ts @@ -0,0 +1,237 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import type { SshMachinePinRepairCommand } from "@internal/subshell-protocol"; +import { loadNodeGate } from "@/api/nodes/node-gate.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { type AuditEventInput, audit } from "@/services/audit.js"; +import { SshRpcError, sshMachinePinRepair } from "@/services/nodes/ssh-rpc.js"; +import { ensureLocalRelayIdentity } from "@/services/ssh-local-identity.js"; +import { base64OfJwk } from "@/services/ssh-relay.service.js"; +import { logger } from "@/utils/logger.js"; + +/** + * Explicit machine trust repair: only the enrolled node's owner, or an admin + * managing the server host, may replace a peer pin. The peer's registered + * public keys are delivered through the signed node command or the trusted + * local call boundary. Either kind of machine may be the peer. Success is + * audited only after the machine acknowledges; no key bytes enter the audit. + */ + +/** Why a re-pair refused; each code names its own door (the route renders the copy). */ +export type SshMachinePinRepairFailure = + | "not-owner" + | "no-node" + | "same-node" + | "no-peer" + | "peer-identity-missing" + | "offline" + | "unsupported" + | "timeout" + | "refused" + | "malformed"; + +/** The loud refusal every re-pair raises. Nothing is written or audited after one. */ +export class SshMachinePinRepairError extends Error { + readonly code: SshMachinePinRepairFailure; + /** The node the refusal is about (A or the peer, depending on the door). */ + readonly nodeId: string; + + constructor(code: SshMachinePinRepairFailure, message: string, nodeId: string) { + super(message); + this.name = "SshMachinePinRepairError"; + this.code = code; + this.nodeId = nodeId; + } +} + +/** The service's world. Production at {@link defaultRepairDeps}; tests install a fake. */ +export interface SshMachinePinsDeps { + /** + * Deliver the signed re-pair command to A (default: `ssh-rpc.sshMachinePinRepair`). + * A test seam so the service's fail-closed behavior never touches a socket. + */ + sendRepair?(nodeId: string, cmd: SshMachinePinRepairCommand): Promise<{ repaired: true; peerNodeId: string }>; +} + +let depsOverride: SshMachinePinsDeps | null = null; + +function repairDeps(): SshMachinePinsDeps { + return depsOverride ?? {}; +} + +/** + * Install (or with null, drop) the whole seam. + * @internal test-only - the suite pins owner/offline/audit behavior without a node. + */ +export function setSshMachinePinsDepsForTests(deps: SshMachinePinsDeps | null): void { + depsOverride = deps; +} + +/** + * Re-pair one peer on one machine. The full act: owner + eligibility checks, + * read the peer's registered public pair, send the signed command (A online, + * ack echoed by equality), audit ids-only. Raises {@link + * SshMachinePinRepairError} with the named cause on every fail-closed door, + * writing NOTHING on any of them. + * + * @param actorUserId - the cookie human performing the act; must OWN A + * @param nodeId - A: the machine whose pin store is repaired + * @param peerNodeId - the peer whose stored entry the command re-delivers for + */ +export async function repairMachinePin(args: { + actorUserId: string; + nodeId: string; + peerNodeId: string; +}): Promise { + const { actorUserId, nodeId, peerNodeId } = args; + const { repos } = getRequestlessContext(); + + const aRow = await repos.nodes.findById(nodeId); + if (!aRow) throw new SshMachinePinRepairError("no-node", `node "${nodeId}" has no row`, nodeId); + const canRepair = + aRow.kind === "local" + ? (await loadNodeGate(actorUserId, nodeId))?.isAdmin === true + : aRow.ownerUserId === actorUserId; + if (!canRepair) + throw new SshMachinePinRepairError("not-owner", "Only this machine's manager may repair its trust", nodeId); + if (peerNodeId === nodeId) { + // A machine pinning ITSELF is a contradiction the pairing design has no + // word for (the node refuses it too - both doors name it before a byte + // moves). + throw new SshMachinePinRepairError("same-node", "a machine is never its own relay peer", nodeId); + } + + // The peer: an existing machine with a complete registered identity. + // Foreign ownership of the peer is no obstacle - the act re-delivers what + // the peer's own enrollment registered, to a store its owner commands. + const peerRow = await repos.nodes.findById(peerNodeId); + if (!peerRow) throw new SshMachinePinRepairError("no-peer", `peer node "${peerNodeId}" has no row`, peerNodeId); + if (peerNodeId === LOCAL_NODE_ID) { + try { + await ensureLocalRelayIdentity(); + } catch { + throw new SshMachinePinRepairError( + "peer-identity-missing", + "The server relay identity needs administrator recovery", + peerNodeId, + ); + } + } + const identity = await repos.identities.findByPrincipal(`node:${peerNodeId}`); + if (!identity || identity.signingPublicKey === null) { + throw new SshMachinePinRepairError( + "peer-identity-missing", + `peer "${peerNodeId}" has not registered its relay identity`, + peerNodeId, + ); + } + + // The delivered pair is the peer's REGISTERED public halves, read here and + // nowhere else, in the relay-open's own carriage (acceptance (h)). + const cmd: SshMachinePinRepairCommand = { + type: "ssh_machine_pin_repair", + peerNodeId, + peerSigningPublicKey: identity.signingPublicKey, + peerEncryptPublicKey: base64OfJwk(identity.publicKey), + }; + + // A online: the act sends a signed command, like relay-open. Every RPC + // failure leaves as the named door; nothing has been audited yet, and + // nothing will be on these paths (no audit-as-success on a refusal). + const sender = repairDeps().sendRepair ?? sshMachinePinRepair; // the `??` is the production wiring + let ack: { repaired: true; peerNodeId: string }; + try { + ack = await sender(nodeId, cmd); + } catch (err) { + if (err instanceof SshRpcError) { + if (err.kind === "refused" || err.kind === "malformed") { + // The machine's own words go to the LOG (they may carry its reasons; + // they never carry OUR key material - the node refuses by shape), + // and the refusal the human reads names the door, not the bytes. + logger + .withError(err) + .warn(`ssh machine-pin repair ${err.kind} against node ${err.nodeId}: ${err.detail ?? err.message}`); + } + throw new SshMachinePinRepairError( + err.kind, + `re-pair of "${peerNodeId}" on "${nodeId}" failed: ${err.kind}`, + nodeId, + ); + } + throw err; + } + if (!ack.repaired || ack.peerNodeId !== peerNodeId) { + // The ack must match the act by equality (the relay-open's byte-check + // doctrine, applied to the repair): an ack about a different peer, or a + // soft no in a success envelope, is a protocol violation refused loudly. + throw new SshMachinePinRepairError("malformed", `node "${nodeId}" answered a mismatched pin-repair ack`, nodeId); + } + + // The success half: audit ids ONLY - never key bytes, never fingerprints, + // never the delivered pair (§10; Global Constraints). + const event: AuditEventInput = { + actorUserId, + action: "node.ssh_machine_pin.repair", + targetType: "node", + targetId: nodeId, + metadataJson: JSON.stringify({ nodeIdOfA: nodeId, peerNodeId }), + }; + await audit(event); +} + +/** Coded refusals for the route (the host-pin mapper's shape, one per door). */ +export type SshMachinePinRepairRefusal = { + status: 400 | 403 | 404 | 409 | 502; + code: BackendErrorCodes; + message: string; +}; + +/** Map the service's named refusal onto the HTTP shape the route renders. */ +export function machinePinRepairRefusal(err: SshMachinePinRepairError): SshMachinePinRepairRefusal { + switch (err.code) { + case "not-owner": + return { + status: 403, + code: BackendErrorCodes.SSH_OWNER_INPUT_ONLY, + message: "Repairing trust requires the node owner, or an administrator for the server host.", + }; + case "no-node": + case "no-peer": + return { status: 404, code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Node not found" }; + case "same-node": + return { status: 400, code: BackendErrorCodes.BAD_REQUEST, message: "A machine is never its own relay peer." }; + case "peer-identity-missing": + return { + status: 409, + code: BackendErrorCodes.SSH_RELAY_IDENTITY_MISSING, + message: + "That peer has not registered its relay identity yet. Re-enroll it, or wait for its next check-in to deliver the key.", + }; + case "offline": + return { + status: 409, + code: BackendErrorCodes.NODE_OFFLINE, + message: "That machine must be online to replace its saved identity. Start its Subshell daemon and try again.", + }; + case "unsupported": + return { + status: 409, + code: BackendErrorCodes.NODE_OUTDATED, + message: "Update Subshell on that machine from its node page before replacing its saved identity.", + }; + case "timeout": + return { + status: 409, + code: BackendErrorCodes.NODE_UNREACHABLE, + message: "That machine didn't respond in time. Check its connection and try again.", + }; + case "refused": + case "malformed": + return { + status: 502, + code: BackendErrorCodes.SSH_NODE_REFUSED, + message: + "The machine refused to replace the saved identity. Verify the peer’s registered key through a trusted channel before trying again.", + }; + } +} diff --git a/apps/server/api/src/services/ssh-policy.service.ts b/apps/server/api/src/services/ssh-policy.service.ts new file mode 100644 index 000000000..4e965b95c --- /dev/null +++ b/apps/server/api/src/services/ssh-policy.service.ts @@ -0,0 +1,75 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { loadNodeGate, type NodeGate } from "@/api/nodes/node-gate.js"; +import { db } from "@/db/index.js"; +import { nodeCanLaunchOn } from "@/lib/node-access.js"; +import { lockdownEnabled } from "@/services/lockdown.js"; +import { getHeld, getLive } from "@/services/nodes/node-registry.js"; +import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import type { SshAnswer } from "@/services/ssh-launch.service.js"; + +export interface SshBlocker { + code: BackendErrorCodes; + message: string; +} + +/** One policy for machine discovery, SSH operations and UI readiness. */ +export function sshPolicy( + gate: NodeGate, + state: { + lockdown: boolean; + serverAccountEnabled: boolean; + held: boolean; + online: boolean; + ready: boolean; + }, +): { canConnect: boolean; canConfigure: boolean; blockers: SshBlocker[] } { + const blockers: SshBlocker[] = []; + const block = (code: BackendErrorCodes, message: string) => blockers.push({ code, message }); + if (state.lockdown) + block(BackendErrorCodes.SSH_GATE_OFF, "This instance is in lockdown. Ask an admin to end it before using SSH."); + if (!nodeCanLaunchOn(gate.row.kind, gate.access, gate.granted, false, state.serverAccountEnabled)) { + block( + BackendErrorCodes.SSH_GATE_OFF, + gate.row.kind === "local" && !state.serverAccountEnabled + ? "Launching subshells on the server is switched off. An admin can allow it in server settings." + : "You need launch access to this machine before using its SSH account.", + ); + } + if (gate.row.sshEnabled !== 1) + block( + BackendErrorCodes.SSH_GATE_OFF, + gate.row.kind === "local" + ? "SSH is off on this machine. An admin can enable it in machine settings." + : "SSH is off on this machine. Its owner can enable it in machine settings.", + ); + if (gate.row.maintenance === 1) + block(BackendErrorCodes.NODE_IN_MAINTENANCE, "End maintenance on this machine before using SSH."); + if (state.held) block(BackendErrorCodes.NODE_PROTOCOL_HELD, "Update Subshell on this machine before using SSH."); + else if (!state.online) block(BackendErrorCodes.NODE_OFFLINE, "Start Subshell on this machine to bring it online."); + else if (!state.ready) + block(BackendErrorCodes.NODE_OFFLINE, "Wait for this machine to finish connecting to the server."); + return { canConnect: blockers.length === 0, canConfigure: gate.canManage, blockers }; +} + +export async function sshReadiness(gate: NodeGate) { + const live = gate.row.kind === "agent" ? getLive(gate.row.id) : undefined; + return sshPolicy(gate, { + lockdown: await lockdownEnabled(db), + serverAccountEnabled: await serverSubshellsEnabled(db), + held: gate.row.kind === "agent" && !!getHeld(gate.row.id), + online: gate.row.kind === "local" || (!!live && !live.closing), + ready: gate.row.kind === "local" || !!live?.agent?.dataDir, + }); +} + +/** Invisible and absent machines have the same 404; refuse before any RPC. */ +export async function gateSshNode(viewerId: string, nodeId: string): Promise> { + const gate = await loadNodeGate(viewerId, nodeId); + if (!gate) + return { ok: false, refusal: { status: 404, code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Node not found" } }; + const policy = await sshReadiness(gate); + const blocker = policy.blockers[0]; + if (blocker) + return { ok: false, refusal: { status: blocker.code === BackendErrorCodes.SSH_GATE_OFF ? 403 : 409, ...blocker } }; + return { ok: true, value: gate }; +} diff --git a/apps/server/api/src/services/ssh-relay-launch.service.ts b/apps/server/api/src/services/ssh-relay-launch.service.ts new file mode 100644 index 000000000..647055ddd --- /dev/null +++ b/apps/server/api/src/services/ssh-relay-launch.service.ts @@ -0,0 +1,177 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { isSshFingerprints, SSH_MAX_SELECTED_FINGERPRINTS } from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { ensureDestinationPin, hostPinRefusal, SshHostPinError } from "@/services/ssh-host-pins.service.js"; +import { listNodeAgentIdentities, type SshAnswer } from "@/services/ssh-launch.service.js"; +import { ensureLocalRelayIdentity } from "@/services/ssh-local-identity.js"; +import { gateSshNode } from "@/services/ssh-policy.service.js"; +import { getRelayBroker, type RelayPeerKeys, SshRelayRefusal } from "@/services/ssh-relay.service.js"; + +function refused(refusal: Exclude, { ok: true }>["refusal"]): SshAnswer { + return { ok: false, refusal }; +} + +/** Select current agent identities. Omitted selection uses the whole roster only within the cap. */ +export function selectRelayFingerprints( + roster: readonly { fingerprint: string }[], + requested?: readonly string[], +): SshAnswer { + if (requested !== undefined && (!Array.isArray(requested) || requested.some((key) => typeof key !== "string"))) { + return refused({ + status: 400, + code: BackendErrorCodes.SSH_KEYS_INVALID, + message: "Select keys from the key machine's current SSH agent roster.", + }); + } + const selected = requested === undefined ? roster.map((key) => key.fingerprint) : [...new Set(requested)]; + if (selected.length > SSH_MAX_SELECTED_FINGERPRINTS) + return refused({ + status: 400, + code: BackendErrorCodes.SSH_KEYS_OVER_LIMIT, + message: `Select at most ${SSH_MAX_SELECTED_FINGERPRINTS} SSH keys before connecting. No keys were selected automatically.`, + }); + if (!isSshFingerprints(selected) || selected.some((key) => !roster.some((item) => item.fingerprint === key))) { + return refused({ + status: 400, + code: BackendErrorCodes.SSH_KEYS_INVALID, + message: "Select keys from the key machine's current SSH agent roster.", + }); + } + return { ok: true, value: selected }; +} + +/** Prepare one user-scoped relay; current launch access on both machines authorizes it. */ +export async function prepareRelayLeg(args: { + viewerId: string; + aNode: { id: string; name: string }; + bNodeId: string; + destination: string; + paneId: string; + fingerprints?: readonly string[]; +}): Promise> { + const broker = getRelayBroker(); + const identityGenerations = { + a: broker.identityGeneration(args.aNode.id), + b: broker.identityGeneration(args.bNodeId), + }; + for (const nodeId of [args.aNode.id, args.bNodeId]) { + const gate = await gateSshNode(args.viewerId, nodeId); + if (!gate.ok) return gate; + } + const roster = await listNodeAgentIdentities({ viewerId: args.viewerId, aNodeId: args.aNode.id }); + if (!roster.ok) return roster; + const selection = selectRelayFingerprints(roster.value.identities, args.fingerprints); + if (!selection.ok) return selection; + const selected = selection.value; + if ([args.aNode.id, args.bNodeId].includes("local")) { + try { + await ensureLocalRelayIdentity(); + } catch { + return refused({ + status: 409, + code: BackendErrorCodes.SSH_RELAY_IDENTITY_MISSING, + message: + "The server relay identity is unavailable or differs from its registration. An admin must restore or repair its SSH identity before connecting.", + }); + } + } + const identities = new IdentitiesRepository(db); + const [rowA, rowB] = await Promise.all([ + identities.findByPrincipal(`node:${args.aNode.id}`), + identities.findByPrincipal(`node:${args.bNodeId}`), + ]); + // The two halves the broker pairs: the registered ES256 signing JWK and the + // ECDH-ES encryption JWK, both read from the identities store, never from + // the caller. A row without either half is the named refusal - the §4.3 + // `ready` bootstrap or a re-enroll is the only thing that fills a slot. + const peerHalf = (row: typeof rowA): RelayPeerKeys | null => + row && row.signingPublicKey !== null + ? { signingPublicKey: row.signingPublicKey, encryptionPublicJwk: row.publicKey } + : null; + const aPeer = peerHalf(rowA); + const bPeer = peerHalf(rowB); + if (!aPeer || !bPeer) { + return refused({ + status: 409, + code: BackendErrorCodes.SSH_RELAY_IDENTITY_MISSING, + message: + "One of the machines hasn’t registered its SSH relay identity. Wait for it to reconnect, then check its SSH settings if the identity is still missing.", + }); + } + // The pin door (spec §9, Task 12): read the destination's stored pin, + // capturing from A's `known_hosts` at first open when none stands. + // Every failure refuses the + // launch BEFORE any session exists - a relay without a pin would mean B + // ambient-TOFUing D, which is the one posture the design refuses. + let hostPin: string; + try { + hostPin = ( + await ensureDestinationPin({ ownerUserId: args.viewerId, aNodeId: args.aNode.id, destination: args.destination }) + ).line; + } catch (err) { + if (err instanceof SshHostPinError) return refused(hostPinRefusal(err)); + throw err; + } + let socketPath: string; + let ref: string; + try { + const opened = await broker.openRelay({ + userId: args.viewerId, + identityGenerations, + fingerprints: selected, + paneId: args.paneId, + aNode: args.aNode.id, + bNode: args.bNodeId, + aPeer, + bPeer, + hostPin, + }); + socketPath = opened.socketPath; + ref = opened.ref; + } catch (err) { + if (err instanceof SshRelayRefusal) { + return refused({ + status: 409, + code: BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + message: relayRefusalCopy(err.code), + }); + } + throw err; + } + for (const nodeId of [args.aNode.id, args.bNodeId]) { + const gate = await gateSshNode(args.viewerId, nodeId); + if (!gate.ok) { + await broker.closeRelay(ref, "access-revoked"); + return gate; + } + } + return { ok: true, value: { socketPath, ref, fingerprints: selected } }; +} +/** Human copy per broker refusal code; ids only, never key material or sockets. */ +function relayRefusalCopy(code: SshRelayRefusal["code"]): string { + switch (code) { + case "identity-repair": + return "A machine relay identity is being repaired or changed while connecting. Retry after its identity repair finishes."; + case "access-denied": + return "SSH launch access changed while the connection was opening. Check access to both machines and retry."; + case "quota": + return "That machine has reached its limit for SSH relay connections. Close one or wait for it to finish, then try again."; + case "handshake": + return "The machines couldn't establish the SSH relay. Check that both are online and try again."; + case "node-off": + return "SSH was switched off on one of the machines while connecting. Enable it and try again."; + case "no-node": + case "no-datadir": + return "One of the machines hasn't finished connecting to the server. Wait for it to connect and try again."; + case "same-node": + return "Choose the connecting machine’s own keys to use keys from that machine."; + case "bad-socket-path": + return "The server couldn't verify the connecting machine's SSH relay socket. No subshell was started."; + case "bad-pane-id": + case "bad-fingerprints": + return "The SSH relay received invalid session details. No subshell was started."; + case "bad-host-pin": + return "The destination's saved host key is invalid. No subshell was started."; + } +} diff --git a/apps/server/api/src/services/ssh-relay.service.ts b/apps/server/api/src/services/ssh-relay.service.ts new file mode 100644 index 000000000..e1d4012b0 --- /dev/null +++ b/apps/server/api/src/services/ssh-relay.service.ts @@ -0,0 +1,745 @@ +import { buildAgentSocketPath } from "@internal/pane-runtime"; +import { + isSshFingerprints, + isSshKnownHostsPinLine, + isSshPaneId, + NODE_MAX_FRAME_BYTES, + type NodeCommandBody, + parseRelayFrame, + type RelayFrame, + SSH_RELAY_CLOSE_REASONS, + SSH_RELAY_LIFETIME_MS, + SSH_RELAY_MAX_PER_NODE, + SSH_RELAY_TEARDOWN_GRACE_MS, + type SshNodeCommandBody, + type SshRelayCloseReason, + type SshRelayOpenCommand, +} from "@internal/subshell-protocol"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import type { NodeKind } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { type AuditEventInput, audit } from "@/services/audit.js"; +import { getLive } from "@/services/nodes/node-registry.js"; +import { binaryPayload, sendCommand } from "@/services/nodes/node-rpc.js"; +import { productionLocalRelayParticipant } from "@/services/ssh-local-participant.js"; +import { gateSshNode } from "@/services/ssh-policy.service.js"; +import { logger } from "@/utils/logger.js"; + +/** Encrypted relay broker: pair two machines for one user's launch, forward opaque + * frames, enforce quotas, strict host pins, peer identities and bounded lifetime. + * Current SSH launch policy is checked before opening and share revocation cuts + * affected sessions. The broker retains no key material or frame payloads. + */ + +/** The peer's registered relay keys, as the identities store holds them (public halves only). */ +export interface RelayPeerKeys { + /** The peer's ES256 signing public key: JSON-serialized public JWK (§4.2's reporting spelling). */ + signingPublicKey: string; + /** + * The peer's ECDH-ES encryption public key: JSON-serialized public JWK. + * The signed command carries it as base64 of its UTF-8 bytes (acceptance + * (h)); the pin stores the decoded string byte-equal. + */ + encryptionPublicJwk: string; +} + +/** Why an `openRelay` refused. Each code names its own door; the message says more. */ +export type SshRelayRefusalCode = + | "identity-repair" + | "access-denied" + | "bad-pane-id" + | "bad-fingerprints" + | "bad-host-pin" + | "same-node" + | "no-node" + | "node-off" + | "no-datadir" + | "quota" + | "handshake" + | "bad-socket-path"; + +/** The loud refusal {@link RelayBroker.openRelay} throws. No session exists after one. */ +export class SshRelayRefusal extends Error { + readonly code: SshRelayRefusalCode; + /** The node the refusal is about, when it is node-shaped. */ + readonly nodeId: string | undefined; + + constructor(code: SshRelayRefusalCode, message: string, nodeId?: string) { + super(message); + this.name = "SshRelayRefusal"; + this.code = code; + this.nodeId = nodeId; + } +} + +/** + * The per-node relay pump's refusal (acceptance (c)): a frame that could not + * be delivered. Thrown, never swallowed - the endpoint's deliver-or-throw + * contract and the broker's teardown both depend on the raise. + */ +export class RelaySendError extends Error { + readonly nodeId: string; + + constructor(nodeId: string, message: string) { + super(`relay send to node "${nodeId}" refused: ${message}`); + this.name = "RelaySendError"; + this.nodeId = nodeId; + } +} + +/** The observable view of one brokered session. Never carries a blob, key, or payload (§5.5). */ +export interface RelaySessionInfo { + relayId: string; + ref: string; + userId: string; + paneId: string; + aNode: string; + bNode: string; + /** Epoch-ms expiry (lifetime cap, §5.6). */ + expiresAt: number; + /** B's verified proxy socket path; null until B's open ack lands. */ + socketPath: string | null; + aOpened: boolean; + bOpened: boolean; +} + +/** Inputs to one user-scoped relay session. */ +export interface OpenRelayInput { + /** The user whose current launch access authorizes both machines. */ + userId: string; + /** Selected current agent fingerprints, checked before signing. */ + fingerprints: readonly string[]; + /** The B-side pane the pairing serves (grammar (b)); pre-minted subshell id. */ + paneId: string; + /** The key home's node id. */ + aNode: string; + /** The connecting machine's node id. */ + bNode: string; + /** A's registered public halves, from A's identity row. */ + aPeer: RelayPeerKeys; + /** B's registered public halves, from B's identity row. */ + bPeer: RelayPeerKeys; + /** + * The destination's pinned `known_hosts` line (spec 2026-10-08 §9, Task + * 12), REQUIRED and validated with the wire grammar's own predicate before + * anything is signed. B's open writes it to the pane's 0600 pinned file; + * a broker call without it is a broker bug, refused like the bad-pane-id + * and bad-fingerprints doors beside it. + */ + hostPin: string; + /** Epochs captured before identity preparation; stale prepared keys must not cross a repair. */ + identityGenerations: { a: number; b: number }; +} + +/** What a successful `openRelay` answers: the pairing, plus the socket the pane's scoped env must point at. */ +export interface OpenRelayResult { + relayId: string; + /** The opaque routing ref - the plane's blind pairing key (the plan's `sessionRef`). */ + ref: string; + /** ISO 8061 expiry: the lifetime cap (§5.6). */ + expiresAt: string; + /** B's proxy socket, byte-verified against the shared derivation (acceptance (e)). */ + socketPath: string; +} + +/** Opaque timer handle: the scheduler seam's own identity, carried untouched to `cancel`. */ +export type TimerHandle = unknown; + +/** The broker's world, every seam injectable (production default at the bottom). */ +export interface RelayBrokerDeps { + /** The signed command transport (`node-rpc.sendCommand`). */ + sendCommand(nodeId: string, cmd: NodeCommandBody, opts?: { timeoutMs?: number }): Promise; + /** The per-node relay pump; MUST throw on any non-delivery (acceptance (c)). */ + sendRelayFrame(nodeId: string, frame: RelayFrame): void; + /** The plane's own node row (kind + gate), or null when there is none. */ + nodeRow(nodeId: string): Promise<{ kind: NodeKind; sshEnabled: number | null } | null | undefined>; + /** The node's dataDir from its live `ready` facts, or null (not fully connected). */ + nodeDataDir(nodeId: string): string | null; + /** Current launch/SSH policy for this user on both machines. */ + authorize(userId: string, nodeId: string): Promise; + /** The audit sink (best-effort by contract, never throws). */ + audit(event: AuditEventInput): Promise; + nowMs(): number; + /** Injected scheduler so the 30 s / 5 s windows drive without waiting. */ + schedule(fn: () => void, ms: number): TimerHandle; + cancel(handle: TimerHandle): void; + log(line: string): void; +} + +/** The broker surface: open, route, the named cuts, and the census views. */ +export interface RelayBroker { + /** Broker A+B for one pane for one user; throws {@link SshRelayRefusal} loudly. */ + openRelay(input: OpenRelayInput): Promise; + /** Route one grammar-parsed relay frame from `nodeId` BLIND to its peer (§5.5). */ + routeRelayFrame(nodeId: string, frame: RelayFrame): void; + /** The single teardown entry: named reason to BOTH sides + the close audit. Idempotent. */ + closeRelay(ref: string, reason: SshRelayCloseReason): Promise; + /** Close sessions touching this machine whose users lost SSH launch access. */ + closeUnauthorizedForNode(nodeId: string): Promise; + /** Pane-death hook (the §5.6 child-exit cut; wired at the sweep sites). Returns sessions closed. */ + closeForPane(paneId: string, reason: SshRelayCloseReason): Promise; + /** §5.1's over-cap refusal: close the named ref with reason `over-cap`. */ + refuseOverCap(ref: string): Promise; + /** The `a-dropped` cut, witnessed by the node socket's death. Returns sessions closed. */ + onNodeSocketClosed(nodeId: string): Promise; + /** Live sessions touching this node in EITHER role (the quota's count). */ + activeRelayCount(nodeId: string): number; + /** The observable view of one ref (test/diagnosis); null when nothing is brokered under it. */ + sessionInfo(ref: string): RelaySessionInfo | null; + /** Drop everything (timer-cancelling; daemon-test isolation). */ + reset(): void; + /** Close all sessions and their endpoints during process shutdown. */ + shutdown(): Promise; + /** Capture before reading registered keys; changes invalidate prepared and in-flight opens. */ + identityGeneration(nodeId: string): number; + /** Exclude opens while draining both roles and applying registration/cache changes. */ + withNodeIdentityRepair(nodeId: string, repair: () => Promise): Promise; +} + +/** One brokered pairing, in memory only (§5.3: durable life is the audit rows). */ +interface Session { + relayId: string; + ref: string; + userId: string; + paneId: string; + aNode: string; + bNode: string; + expiresAt: number; + socketPath: string | null; + aOpened: boolean; + bOpened: boolean; + lifetimeTimer: TimerHandle; + graceTimer: TimerHandle; +} + +/** + * Acceptance (h): base64 of the UTF-8 bytes of the JSON public JWK - the ONE + * spelling the node decodes back into the byte-equal pin string. Exported for + * the §4.5 re-pair service, which re-delivers the same pair in the same + * carriage (ssh-machine-pins.service.ts); two carriers of one canonical + * spelling must not be two implementations of it. + */ +export function base64OfJwk(jwkJson: string): string { + return Buffer.from(jwkJson, "utf8").toString("base64"); +} + +function openCmd(input: OpenRelayInput, role: "A" | "B", ref: string, relayId: string): SshRelayOpenCommand { + const peer = role === "A" ? input.bPeer : input.aPeer; // each side pins the PEER, never itself + return { + type: "ssh_relay_open", + relayId, + ref, + role, + aNodeId: input.aNode, + bNodeId: input.bNode, + peerSigningPublicKey: peer.signingPublicKey, + peerEncryptPublicKey: base64OfJwk(peer.encryptionPublicJwk), + fingerprints: [...input.fingerprints], + lifetimeMs: SSH_RELAY_LIFETIME_MS, + paneId: input.paneId, + hostPin: input.hostPin, + }; +} + +/** Build a broker over the given seams. The module singleton (bottom) is one of these with the real world. */ +export function createRelayBroker(deps: RelayBrokerDeps): RelayBroker { + /** The ONE map: opaque ref -> session (§5.3). A ref nothing brokered is refused, never guessed. */ + const byRef = new Map(); + const draining = new Map }>(); + const repairing = new Set(); + const identityGenerations = new Map(); + const identityGeneration = (nodeId: string): number => identityGenerations.get(nodeId) ?? 0; + const advanceIdentityGeneration = (nodeId: string): void => { + identityGenerations.set(nodeId, identityGeneration(nodeId) + 1); + }; + + const infoOf = (s: Session): RelaySessionInfo => ({ + relayId: s.relayId, + ref: s.ref, + userId: s.userId, + paneId: s.paneId, + aNode: s.aNode, + bNode: s.bNode, + expiresAt: s.expiresAt, + socketPath: s.socketPath, + aOpened: s.aOpened, + bOpened: s.bOpened, + }); + + const closeSends = (s: Session, reason: SshRelayCloseReason): Promise[]> => { + // The named close to BOTH sides, best-effort on the transport (the audit + // records the cut whatever the sockets do; a refused close delivery names + // the side in the log and nothing more). + const cmd: SshNodeCommandBody = { type: "ssh_relay_close", ref: s.ref, reason }; + return Promise.allSettled([ + Promise.resolve().then(() => deps.sendCommand(s.aNode, cmd, { timeoutMs: SSH_RELAY_TEARDOWN_GRACE_MS })), + Promise.resolve().then(() => deps.sendCommand(s.bNode, cmd, { timeoutMs: SSH_RELAY_TEARDOWN_GRACE_MS })), + ]); + }; + + const closeRelay = async (ref: string, reason: SshRelayCloseReason): Promise => { + const s = byRef.get(ref); + if (!s) return false; + // Grammar membership is a code-path guard, not paranoia theater: the + // node's parser refuses an unnamed close, and §5.1's posture is that an + // unnamed close is the silence this family refuses. + if (!(SSH_RELAY_CLOSE_REASONS as readonly string[]).includes(reason)) { + deps.log(`relay broker: close refused for ${ref}: reason "${reason}" is not a named reason`); + return false; + } + // Remove FIRST, arm the cut's sends next: a concurrent route/drop must + // never forward onto a session already being torn down. + byRef.delete(ref); + deps.cancel(s.lifetimeTimer); + deps.cancel(s.graceTimer); + const done = Promise.withResolvers(); + draining.set(ref, { session: s, done: done.promise }); + try { + await closeSends(s, reason); + await deps.audit({ + actorUserId: null, // the plane brokered it; the human's act is the launch audit + action: "node.ssh_relay.close", + targetType: "node", + targetId: s.aNode, + // ids, hosts, reason - never keys, fingerprints, sockets, or payloads + // (Global Constraints; docs/security.md §10). + metadataJson: JSON.stringify({ + relayId: s.relayId, + ref: s.ref, + userId: s.userId, + paneId: s.paneId, + aNodeId: s.aNode, + bNodeId: s.bNode, + reason, + }), + }); + } finally { + draining.delete(ref); + done.resolve(); + } + return true; + }; + + const forEach = (match: (s: Session) => boolean): Session[] => [...byRef.values()].filter(match); + + const openRelay = async (input: OpenRelayInput): Promise => { + const generations = input.identityGenerations; + const identityChanged = (): boolean => + repairing.has(input.aNode) || + repairing.has(input.bNode) || + generations.a !== identityGeneration(input.aNode) || + generations.b !== identityGeneration(input.bNode); + const repairRefusal = () => + new SshRelayRefusal( + "identity-repair", + "Relay identity repair interrupted this open; retry with the current registered identities", + ); + if (identityChanged()) throw repairRefusal(); + // Validation FIRST: a malformed input is refused before a row is read, a + // session exists, or a byte is audited. + if (!isSshPaneId(input.paneId)) { + throw new SshRelayRefusal("bad-pane-id", `relay open refused: paneId "${input.paneId}" is not a path id`); + } + if (!isSshFingerprints(input.fingerprints)) { + throw new SshRelayRefusal( + "bad-fingerprints", + "relay open refused: the selected fingerprint set is malformed or over SSH_MAX_SELECTED_FINGERPRINTS", + ); + } + // Task 12 (spec §9): the destination's pin travels on the open, and the + // grammar's own line predicate is the broker's check too - a relay + // session with no pin (or with a multi-line smuggle) is refused BEFORE + // anything is signed or sent, so "the relay carries a pin" is + // enforced at the mint, not hoped for at the endpoints. + if (!isSshKnownHostsPinLine(input.hostPin)) { + throw new SshRelayRefusal( + "bad-host-pin", + "relay open refused: the destination host pin is missing, empty, or not one known_hosts line", + ); + } + if (input.aNode === input.bNode) { + // A relay bridges two machines; a one-machine pairing means a caller + // that should not have asked at all. + throw new SshRelayRefusal("same-node", "relay open refused: A and B name the same machine"); + } + // (d) the gate: BOTH machines must have SSH switched on, fail closed. + // The plane's row is the authoritative half of the double gate (the node + // re-checks its own mirror on delivery; both refusals are this posture). + for (const nodeId of [input.aNode, input.bNode]) { + const row = await deps.nodeRow(nodeId); + if (!row) throw new SshRelayRefusal("no-node", `relay open refused: node "${nodeId}" has no row`, nodeId); + + if (row.sshEnabled !== 1) { + throw new SshRelayRefusal("node-off", `relay open refused: SSH is not enabled on "${nodeId}"`, nodeId); + } + } + for (const nodeId of [input.aNode, input.bNode]) { + if (!(await deps.authorize(input.userId, nodeId))) { + throw new SshRelayRefusal("access-denied", "SSH launch access is no longer available", nodeId); + } + } + // The socket-path byte-check needs B's dataDir; without `ready` facts the + // plane cannot verify B's answer, so it refuses rather than trust it. + const bDataDir = deps.nodeDataDir(input.bNode); + if (bDataDir === null) { + throw new SshRelayRefusal( + "no-datadir", + `relay open refused: "${input.bNode}" has not reported its data dir yet`, + input.bNode, + ); + } + // (g) the quota, before ANY state: a 9th live session on either node is + // a loud refusal that touched nothing - no audit, no command, no record. + for (const nodeId of [input.aNode, input.bNode]) { + const live = forEach((s) => s.aNode === nodeId || s.bNode === nodeId).length; + if (live >= SSH_RELAY_MAX_PER_NODE) { + throw new SshRelayRefusal( + "quota", + `relay open refused: node "${nodeId}" already holds ${live} live relay sessions (max ${SSH_RELAY_MAX_PER_NODE})`, + nodeId, + ); + } + } + + if (identityChanged()) throw repairRefusal(); + const relayId = crypto.randomUUID(); + const ref = crypto.randomUUID(); // opaque pairing key; entropy is minted, never negotiated + const expiresAt = deps.nowMs() + SSH_RELAY_LIFETIME_MS; + const s: Session = { + relayId, + ref, + userId: input.userId, + paneId: input.paneId, + aNode: input.aNode, + bNode: input.bNode, + expiresAt, + socketPath: null, + aOpened: false, + bOpened: false, + lifetimeTimer: deps.schedule(() => { + void closeRelay(ref, "lifetime-expiry").catch((err: unknown) => deps.log(`relay close threw: ${String(err)}`)); + }, SSH_RELAY_LIFETIME_MS), + graceTimer: deps.schedule(() => { + if (s.aOpened && s.bOpened) return; + void closeRelay(ref, "handshake-grace").catch((err: unknown) => deps.log(`relay close threw: ${String(err)}`)); + }, SSH_RELAY_TEARDOWN_GRACE_MS), + }; + byRef.set(ref, s); + await deps.audit({ + actorUserId: null, + action: "node.ssh_relay.open", + targetType: "node", + targetId: input.aNode, + metadataJson: JSON.stringify({ + relayId, + ref, + userId: input.userId, + paneId: input.paneId, + aNodeId: input.aNode, + bNodeId: input.bNode, + fingerprintCount: input.fingerprints.length, // the COUNT, never the set + lifetimeMs: SSH_RELAY_LIFETIME_MS, + }), + }); + + if (identityChanged()) { + await closeRelay(ref, "access-revoked"); + throw repairRefusal(); + } + // Deliver the pairing. Both opens go out now; the acks complete the + // handshake the grace timer watches. The open command's transport + // deadline is the grace itself - past that window the session is already + // cut, so waiting longer only delays the refusal the human reads. + const expectedSocketPath = buildAgentSocketPath(bDataDir, input.paneId); + const [resA, resB] = await Promise.allSettled([ + deps.sendCommand(input.aNode, openCmd(input, "A", ref, relayId), { timeoutMs: SSH_RELAY_TEARDOWN_GRACE_MS }), + deps.sendCommand(input.bNode, openCmd(input, "B", ref, relayId), { timeoutMs: SSH_RELAY_TEARDOWN_GRACE_MS }), + ]); + if (identityChanged()) { + await closeRelay(ref, "access-revoked"); + throw repairRefusal(); + } + if (resA.status === "fulfilled") s.aOpened = true; + if (resB.status === "fulfilled") s.bOpened = true; + + if (resB.status === "fulfilled") { + // (e): B answers the socket it bound. The plane re-derives the path and + // refuses a byte mismatch - a machine answer never names the path the + // pane's ssh gets pointed at. + const ack = resB.value as { socketPath?: unknown }; + if (typeof ack?.socketPath !== "string" || ack.socketPath !== expectedSocketPath) { + await closeRelay(ref, "handshake-grace"); + throw new SshRelayRefusal( + "bad-socket-path", + `relay open refused: "${input.bNode}" answered a socket path the plane did not derive`, + input.bNode, + ); + } + s.socketPath = ack.socketPath; + } + + if (!(s.aOpened && s.bOpened) || !byRef.has(ref)) { + // Either side refused (pin moved, mirror off, offline, timeout) or the + // grace already cut the pairing: the handshake never completed. Close + // under the handshake word (idempotent if the grace already did) - the + // grammar names no "refused-open", and §5.6's window cut is the honest + // bucket for a pairing that did not come up on both ends. + await closeRelay(ref, "handshake-grace"); + const why = + resA.status === "rejected" + ? `A refused or did not answer: ${String(resA.reason)}` + : resB.status === "rejected" + ? `B refused or did not answer: ${String(resB.reason)}` + : "the handshake window closed before both sides answered"; + throw new SshRelayRefusal("handshake", `relay open refused: ${why}`); + } + + // Both sides are live: the grace is no longer owed (the lifetime cap stays + // as §5.6's ceiling). + deps.cancel(s.graceTimer); + return { relayId, ref, expiresAt: new Date(expiresAt).toISOString(), socketPath: s.socketPath as string }; + }; + + const routeRelayFrame = (nodeId: string, frame: RelayFrame): void => { + // §5.5 in one line's worth of reading: look up the ref, find the peer, + // copy the frame. The blob never comes anywhere near this function's + // comprehension - it is passed by reference and that IS the forwarding. + if (!parseRelayFrame(frame)) { + deps.log("malformed relay frame refused"); + return; + } + const s = byRef.get(frame.ref); + if (!s) { + // A stale ref (the session was cut around the plane's own close) or a + // stranger pointing at someone's pairing: refused, logged by ref, and + // NOTHING else - refs are opaque ids, not secrets, and naming one is + // how an operator correlates the two machines' logs. + deps.log(`relay frame for unbrokered ref ${frame.ref}: refused`); + return; + } + const fromA = s.aNode === nodeId; + if (!fromA && s.bNode !== nodeId) { + deps.log(`relay frame for ref ${frame.ref} from non-party ${nodeId}: refused`); + return; + } + if (frame.direction !== (fromA ? "A2B" : "B2A")) { + deps.log(`relay direction refused for ${frame.ref}`); + return; + } + const peer = fromA ? s.bNode : s.aNode; + try { + deps.sendRelayFrame(peer, frame); + } catch (err) { + deps.log(`relay frame for ref ${frame.ref} could not reach ${peer}: ${String(err)}`); + if (fromA) { + // A's reply cannot reach B: the grammar has no b-dropped word, §5.6 + // does not list B's link as a cut, and T7's handoff is explicit that + // the caps own the end of a B-side stall. Lose this frame; the + // lifetime, child exit, or revoke cuts the session. + return; + } + // B's request cannot reach A: THAT is §5.6's A-dropping (and the + // phantom-request hang acceptance (c) exists to kill). Cut now with the + // named reason; B is still reachable to hear it. + void closeRelay(frame.ref, "a-dropped").catch((err2: unknown) => deps.log(`relay close threw: ${String(err2)}`)); + } + }; + + return { + openRelay, + routeRelayFrame, + closeRelay, + async closeUnauthorizedForNode(nodeId) { + let n = 0; + for (const s of forEach((x) => x.aNode === nodeId || x.bNode === nodeId)) { + if (!(await deps.authorize(s.userId, s.aNode)) || !(await deps.authorize(s.userId, s.bNode))) { + if (await closeRelay(s.ref, "access-revoked")) n += 1; + } + } + return n; + }, + async closeForPane(paneId, reason) { + let n = 0; + for (const s of forEach((x) => x.paneId === paneId)) if (await closeRelay(s.ref, reason)) n += 1; + return n; + }, + refuseOverCap: (ref) => closeRelay(ref, "over-cap"), + async onNodeSocketClosed(nodeId) { + // ONLY the A-side cut (§5.6: "A-drop ... as earlier cuts"). B's link + // may redial with its registry intact; B's real end is the child-exit + // sweep or a cap, not this witness. + let n = 0; + for (const s of forEach((x) => x.aNode === nodeId)) if (await closeRelay(s.ref, "a-dropped")) n += 1; + return n; + }, + activeRelayCount(nodeId) { + return forEach((s) => s.aNode === nodeId || s.bNode === nodeId).length; + }, + sessionInfo(ref) { + const s = byRef.get(ref); + return s ? infoOf(s) : null; + }, + identityGeneration, + async withNodeIdentityRepair(nodeId, repair) { + if (repairing.has(nodeId)) + throw new SshRelayRefusal("identity-repair", "This machine's relay identity is already being repaired", nodeId); + repairing.add(nodeId); + advanceIdentityGeneration(nodeId); + try { + const touchesNode = (s: Session) => s.aNode === nodeId || s.bNode === nodeId; + // A close removes its routable session before awaiting transport. Also + // drain those already-closing participants before replacing their keys. + const alreadyClosing = [...draining.values()].filter(({ session }) => touchesNode(session)); + await Promise.all([ + ...alreadyClosing.map(({ done }) => done), + ...forEach(touchesNode).map((s) => closeRelay(s.ref, "access-revoked")), + ]); + await repair(); + } finally { + // Invalidate preparation begun during repair, including a failed repair. + advanceIdentityGeneration(nodeId); + repairing.delete(nodeId); + } + }, + async shutdown() { + await Promise.all([...byRef.keys()].map((ref) => closeRelay(ref, "child-exit"))); + }, + reset() { + for (const s of byRef.values()) { + deps.cancel(s.lifetimeTimer); + deps.cancel(s.graceTimer); + } + byRef.clear(); + }, + }; +} + +/* ------------------------------------------------------------------ */ +/* the real per-node pump (acceptance (c)): deliver, or THROW */ +/* ------------------------------------------------------------------ */ + +/** + * Write one relay frame to `nodeId`'s CURRENT live socket, sealed through + * its established link (a relay frame is never plaintext: the version gate + * guarantees any node that carries one speaks protocol 18, whose sockets are + * handshake-classified). Throws {@link RelaySendError} on every non-delivery: + * no live connection, a closing/superseded record, an unestablished link, or + * a failing write. The broker's teardown and the endpoints' deliver-or-throw + * contract both read the throw; a silent return is the phantom-hang bug. + */ +export function sendRelayFrameOverNodeSocket(nodeId: string, frame: RelayFrame): void { + const conn = getLive(nodeId); + if (!conn) throw new RelaySendError(nodeId, "node has no live connection"); + if (conn.closing) throw new RelaySendError(nodeId, "connection is closing (superseded or revoked)"); + if (!conn.link) throw new RelaySendError(nodeId, "encrypted link is not established"); + const payload = JSON.stringify(frame); + if (Buffer.byteLength(payload) > NODE_MAX_FRAME_BYTES) { + throw new RelaySendError(nodeId, `frame exceeds ${NODE_MAX_FRAME_BYTES} bytes`); + } + try { + conn.ws.send(binaryPayload(conn.link.sealFrame(payload))); + } catch (err) { + throw new RelaySendError(nodeId, `socket send failed: ${err instanceof Error ? err.message : String(err)}`); + } +} + +/* ------------------------------------------------------------------ */ +/* the pane-scoped env (acceptance (e)) + the death sweeps */ +/* ------------------------------------------------------------------ */ + +/** + * The pane env the relay launch exports: EXACTLY the scoped `SSH_AUTH_SOCK` + * exception, the one key the `ssh` invocation gets through `createSubshell`'s + * `extraPaneEnv` member - the same channel `composeSshLaunch` uses for the + * snapshot's agent socket (spec 2026-10-07 §5.2, decision 3). Never merged + * into the whole-pane env (Global Constraints: the agent-capable socket + * belongs to the connecting process, not the pane's shell). + */ +export function sshRelayPaneEnv(socketPath: string): { SSH_AUTH_SOCK: string } { + return { SSH_AUTH_SOCK: socketPath }; +} + +/** + * The §5.6 child-exit cut for one pane, best-effort by shape (the + * `sweepLocalSshDir` doctrine restated for relay sessions): every site where + * a pane stops existing calls it, the broker's map lookup no-ops the + * overwhelmingly common "this pane never relayed" case, and a failure costs + * one log line. The reason is `child-exit` for every stop path: the pane's + * process is gone, which is what the word means to the endpoints. + */ +export function closeRelayForPaneExit(paneId: string | null | undefined): void { + if (!paneId) return; + try { + void getRelayBroker() + .closeForPane(paneId, "child-exit") + .catch((err: unknown) => logger.warn(`relay sweep for pane ${paneId} failed: ${String(err)}`)); + } catch (err) { + logger.withError(err).warn(`relay sweep for pane ${paneId} threw`); + } +} + +/** Row-shaped twin of {@link closeRelayForPaneExit} for the sites that hold a row. */ +export function sweepRelayForPane(row: { id: string } | null | undefined): void { + closeRelayForPaneExit(row?.id); +} + +/* ------------------------------------------------------------------ */ +/* production wiring + module singleton */ +/* ------------------------------------------------------------------ */ + +let defaultDeps: RelayBrokerDeps | undefined; +let localParticipant: ReturnType | undefined; +function getLocalParticipant() { + localParticipant ??= productionLocalRelayParticipant((frame) => getRelayBroker().routeRelayFrame("local", frame)); + return localParticipant; +} + +/** Release local sockets immediately and close brokered peers within the broker teardown deadline. */ +export async function shutdownRelayBroker(): Promise { + localParticipant?.closeAll(); + await singleton?.shutdown(); +} + +function productionDeps(): RelayBrokerDeps { + defaultDeps ??= { + sendCommand: (nodeId, cmd, opts) => + nodeId === "local" ? getLocalParticipant().command(cmd) : sendCommand(nodeId, cmd, opts ?? {}), + sendRelayFrame: (nodeId, frame) => + nodeId === "local" ? getLocalParticipant().inbound(frame) : sendRelayFrameOverNodeSocket(nodeId, frame), + async nodeRow(nodeId) { + const row = await getRequestlessContext().repos.nodes.findById(nodeId); + return row ? { kind: row.kind, sshEnabled: row.sshEnabled } : null; + }, + nodeDataDir: (nodeId) => + nodeId === "local" ? SUBSHELL_SERVER_DATA_DIR : (getLive(nodeId)?.agent?.dataDir ?? null), + authorize: async (userId, nodeId) => (await gateSshNode(userId, nodeId)).ok, + audit, + nowMs: () => Date.now(), + schedule: (fn, ms) => { + const t = setTimeout(fn, ms); + t.unref?.(); // a brokered relay must never hold the process (or a test runner) open + return t as unknown as TimerHandle; + }, + cancel: (handle) => clearTimeout(handle as ReturnType), + log: (line) => logger.debug(line), + }; + return defaultDeps; +} + +let singleton: RelayBroker | undefined; + +/** The broker the handler routing and the pane-death sweeps use (lazy, like `getNodeWsDeps`). */ +export function getRelayBroker(): RelayBroker { + singleton ??= createRelayBroker(productionDeps()); + return singleton; +} + +/** + * Install (or with null, drop) the module singleton. + * @internal test-only - relay-frames routing and the sweep helpers reach the + * broker through it, and a suite with injected seams must be able to point + * those two entry points at a recorder. + */ +export function setRelayBrokerForTests(broker: RelayBroker | null): void { + singleton = broker ?? undefined; +} diff --git a/apps/server/api/src/services/ssh-setup-here.service.ts b/apps/server/api/src/services/ssh-setup-here.service.ts new file mode 100644 index 000000000..638d6bf19 --- /dev/null +++ b/apps/server/api/src/services/ssh-setup-here.service.ts @@ -0,0 +1,527 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { buildSshKnownHostsPath, shellQuote } from "@internal/pane-runtime"; +import { + isSshFingerprints, + parseSshConnectionSnapshot, + redactSshSetupKeyLines, + type SshConnectionSnapshotWire, +} from "@internal/subshell-protocol"; +import { APP_BASE_URL } from "@/constants.js"; +import { db } from "@/db/index.js"; +import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; +import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { audit } from "@/services/audit.js"; +import { getLive } from "@/services/nodes/node-registry.js"; +import { SshRpcError, sshExec, sshExecStatus } from "@/services/nodes/ssh-rpc.js"; +import { + composeSshLaunch, + gateSshNode, + relaySnapshotRefusal, + rpcRefusal, + type SshAnswer, + targetDataDir, +} from "@/services/ssh-launch.service.js"; +import { getRelayBroker, type RelayBroker } from "@/services/ssh-relay.service.js"; +import { prepareRelayLeg } from "@/services/ssh-relay-launch.service.js"; +import type { SshSetupStage } from "@/services/ssh-setup-progress.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The "Set up Subshell here" act (spec 2026-10-08 §7, Task 14): turn the + * destination D of an open SSH-terminal pane into an enrolled node by running + * the ORDINARY enrollment install on D, over a SEPARATE non-interactive + * `ssh D ''` that reuses the pane's authorization and leaves the + * pane itself untouched. The plan, in the order it runs: + * + * 1. THE PANE: owner's row (a foreign id is the 404 the ownership axis + * demands), its approved snapshot (D), and its key home column (0052): the + * pane was a relay pane exactly when it names an A. + * 2. THE GATES: B re-gated through the launcher's own door; in relay mode A + * too, AND live - the act signs with A's agent, so an offline key home is + * refused with A-online NAMED before a key is minted or a pane is touched. + * 3. THE KEY: a fresh single-use setup key, minted by the ordinary + * repository, owned by the caller (the new node's future owner). It is + * never typed anywhere a pane or a log can read: it rides the signed + * node-link command, then B's short-lived ssh argv, then D's install URL - + * the three accepted postures §7 names - and nothing else. The act's + * refusal copy, its audit rows, and every retained byte pass through the + * redactor (belt on the plane: the machine's own redaction is the first + * station, this one does not trust it). + * 4. THE RUN: `ssh_exec` on B - compose exactly like the pane launch + * (relay-mode render pins D's host key the same way), kick it off B's + * command chain, and poll `ssh_exec_status` to the terminal answer. The + * answer's output is PARSED into a controlled status verb; raw installer + * output is never echoed to a caller. + * 5. THE EGRESS CAUSE: an install that could not reach the plane answers + * `SSH_UPGRADE_EGRESS` - a code of its own, distinct from any key error, + * per §7. The pane is untouched either way (this act never writes one). + * 6. SUCCESS: the new node's `ready` frame, awaited with a bounded wait + * (the setup key's row carries the node id enrollment consumed it for; + * the registry's `agent` facts appear only when `ready` lands). Success + * is that fact, not the installer's own cheer line. + * + * Every exit AFTER THE KEY EXISTS writes ONE `node.ssh_upgrade.run` audit + * row naming ids, the destination, the relay session, and the OUTCOME/stage - never + * the key, never the installer's words; an unexpected internal throw records + * its own `internal-error` stage on the way out (best effort) before + * propagating. Refusals raised by the gates write no row - there is no key + * or session to account for there. + */ + +/** How long B's ssh child may run before the node-side deadline ends it. An installer pulls a ~100 MB binary; this is minutes, honestly. */ +export const SSH_SETUP_EXEC_TIMEOUT_MS = 8 * 60_000; + +/** The plane's poll margin past the node's own deadline (the deadline kills the child; the margin only decides when we stop asking). */ +const EXEC_POLL_MARGIN_MS = 60_000; + +/** The bounded wait for the new node to enroll (spend the key) and report `ready` after the installer exited 0. */ +export const SETUP_READY_BUDGET_MS = 60_000; + +/** The poll cadence for both waits; tests collapse it to zero with the injected clock. */ +const SETUP_POLL_MS = 1_500; + +/** The service's world. Production at {@link defaultDeps}; tests install a fake. */ +export interface SshSetupHereDeps { + /** Epoch-ms clock driving both bounded waits. */ + nowMs(): number; + /** Sleep between polls (production: real timers). */ + sleepMs(ms: number): Promise; + /** The node's ssh child deadline for this act (production: {@link SSH_SETUP_EXEC_TIMEOUT_MS}). */ + execTimeoutMs: number; + /** The plane's poll margin past that deadline (production: {@link EXEC_POLL_MARGIN_MS}). */ + execPollMarginMs: number; + /** The enroll+ready budget after a clean install (production: {@link SETUP_READY_BUDGET_MS}). */ + readyBudgetMs: number; + /** Poll cadence (production: {@link SETUP_POLL_MS}). */ + pollMs: number; + /** The relay broker the act's own pairing is closed through. */ + broker(): RelayBroker; +} + +let depsOverride: SshSetupHereDeps | null = null; + +function setupDeps(): SshSetupHereDeps { + if (depsOverride) return depsOverride; + return { + nowMs: () => Date.now(), + sleepMs: (ms) => new Promise((r) => setTimeout(r, ms)), + execTimeoutMs: SSH_SETUP_EXEC_TIMEOUT_MS, + execPollMarginMs: EXEC_POLL_MARGIN_MS, + readyBudgetMs: SETUP_READY_BUDGET_MS, + pollMs: SETUP_POLL_MS, + broker: () => getRelayBroker(), + }; +} + +/** + * Install (or with null, drop) the whole seam. + * @internal test-only - no suite waits eight minutes for a child or sixty + * seconds for a `ready` frame. + */ +export function setSshSetupHereDepsForTests(deps: SshSetupHereDeps | null): void { + depsOverride = deps; +} + +function coded(status: 400 | 403 | 404 | 409 | 502, code: BackendErrorCodes, message: string): SshAnswer { + return { ok: false, refusal: { status, code, message } }; +} + +/** + * Does this failed-install answer say "D could not reach the plane" (§7's + * NAMED egress stage)? Two shapes carry that fact, and both matter: the + * rendered installer's own sentence (it was running and a fetch failed + * mid-way), and curl's own connect/DNS failure - the act's FIRST fetch is + * install.sh itself, so a DARK destination never prints a script line at + * all; its answer is curl's stderr and curl's exit code alone (6 resolve, + * 7 connect, 28 timeout). A key or artifact failure never looks like this: + * those answer an HTTP status (curl 22, or the script's own advice), not a + * failed connect, and the belt arm keys on the missing `==>` banner, which + * only the pre-script curl failure lacks. + */ +function couldNotReachPlane(output: string, code: number | null): boolean { + if (output.includes("could not reach") || output.includes("could not fetch the checksum")) return true; + if (/\bcurl: \((6|7|28)\)/.test(output)) return true; + const lower = output.toLowerCase(); + if (lower.includes("could not resolve host") || /\bfailed( to)? connect\b/.test(lower)) return true; + return (code === 6 || code === 7) && !output.includes("==>"); +} + +/** Enrollment identity, with an explicit flag when its connection is not confirmed. */ +export interface SetupHereResult { + nodeId: string; + /** False when enrollment completed but the new machine has not reported ready. */ + connected?: boolean; +} + +/** + * Run the act for one pane. The caller is the pane's owner (a cookie actor; + * a foreign or absent pane is one 404, the ordinary invisibility). Refusals + * name their stage; the key never rides one. + */ +export async function setupHere(args: { + viewerId: string; + paneId: string; + onProgress?: (stage: SshSetupStage) => void; +}): Promise> { + const deps = setupDeps(); + const pane = await db.selectFrom("subshells").selectAll().where("id", "=", args.paneId).executeTakeFirst(); + if (!pane || pane.userId !== args.viewerId) { + return coded(404, BackendErrorCodes.NOT_FOUND_ERROR, "Subshell not found"); + } + if (pane.ssh === null) { + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "Open an SSH subshell to set up Subshell on its destination.", + ); + } + let snapshot: SshConnectionSnapshotWire | null = null; + let relayFingerprints: string[] | null = null; + try { + const stored: unknown = JSON.parse(pane.ssh); + snapshot = parseSshConnectionSnapshot(stored); + if ( + stored && + typeof stored === "object" && + "relayFingerprints" in stored && + isSshFingerprints(stored.relayFingerprints) + ) { + relayFingerprints = stored.relayFingerprints; + } + } catch { + snapshot = null; + } + if (snapshot === null) { + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "Couldn't read this subshell's saved SSH destination. Start a new SSH subshell and try setup again.", + ); + } + + // Gates first (B, then A in relay mode): every refusal below writes no key, + // opens no session, and leaves the pane running. + const gateB = await gateSshNode(args.viewerId, pane.nodeId); + if (!gateB.ok) return gateB; + const dataDirB = targetDataDir(gateB.value.row); + if (dataDirB === null) { + return coded( + 409, + BackendErrorCodes.NODE_OFFLINE, + "That machine hasn't finished connecting to the server. Wait for it to connect and try again.", + ); + } + const destination = sshCanonicalDestination({ host: snapshot.host, port: snapshot.port, user: snapshot.user }); + + // The act's own ephemeral id: the config dir, the pinned-file path, the + // relay socket, and the exec registry key ALL derive from it. It is not the + // pane's id - the pane's config and socket stay exactly as they are. + const execId = crypto.randomUUID(); + + let relayRef: string | null = null; + let aNodeId: string | null = null; + const keyHome = pane.keyHomeNodeId; + if (keyHome !== null && keyHome !== undefined) { + const unsupported = relaySnapshotRefusal(snapshot); + if (unsupported) return unsupported; + if (relayFingerprints === null) { + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "This pane has no valid saved SSH key selection. Relaunch the SSH connection and choose its keys before setting up Subshell here.", + ); + } + aNodeId = keyHome; + const gateA = await gateSshNode(args.viewerId, keyHome); + if (!gateA.ok) return gateA; + if (gateA.value.row.kind === "agent" && !getLive(keyHome)) { + return coded( + 409, + BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + `Setup needs SSH keys from ${gateA.value.row.name}. Bring that machine online and try again.`, + ); + } + const leg = await prepareRelayLeg({ + viewerId: args.viewerId, + aNode: { id: gateA.value.row.id, name: gateA.value.row.name }, + bNodeId: pane.nodeId, + fingerprints: relayFingerprints, + destination, + // The pairing serves the EXEC, so the ephemeral id is its paneId: the + // proxy socket and the delivered host pin land in the exec's own + // directory, never the pane's. + paneId: execId, + }); + if (!leg.ok) return leg; + relayRef = leg.value.ref; + } + + // The single-use key, minted only once every door above has opened. + const keys = new NodeSetupKeysRepository(db); + const keyRow = await keys.create(args.viewerId); + + const composed = composeSshLaunch({ + snapshot, + targetDataDir: dataDirB, + subshellId: execId, + ...(relayRef !== null ? { hostPinPath: buildSshKnownHostsPath(dataDirB, execId) } : {}), + }); + // BatchMode first: the exec must never fall back to a password or a + // host-key PROMPT - auth failure or pin mismatch is a fast refusal, an + // interactive prompt would sit out the deadline on a terminal no one holds. + const presetFlags = ["-o", "BatchMode=yes", ...composed.presetFlags]; + // The installer one-liner, exactly the copy-paste act §7 describes, with + // the scripted name (the pane's destination host) because the exec has no + // terminal to answer `setup`'s naming question. shellQuote every token; + // `bash -c` so pipefail exists and a failed fetch is not hidden by the + // empty-stdin pipe. + const installUrl = `${APP_BASE_URL}/install.sh?setup_key=${keyRow.key}`; + const nodeName = snapshot.host; + const remoteCommand = `bash -c ${shellQuote( + `set -o pipefail; curl -fsSL ${shellQuote(installUrl)} | SUBSHELL_NODE_NAME=${shellQuote(nodeName)} bash`, + )}`; + + // The audit this act OWNS: written at every exit with ids and the outcome + // only. Built once per path; the key never enters it (never interpolated). + const runAudit = async (outcome: "enrolled" | "refused", cause: string, newNodeId?: string): Promise => { + await audit({ + actorUserId: args.viewerId, + action: "node.ssh_upgrade.run", + targetType: "node", + targetId: pane.nodeId, + metadataJson: JSON.stringify({ + paneId: pane.id, + bNodeId: pane.nodeId, + ...(aNodeId !== null ? { aNodeId } : {}), + ...(relayRef !== null ? { relayRef } : {}), + destination, + execId, + outcome, + cause, // a STAGE word chosen by this file, never machine text + ...(newNodeId !== undefined ? { newNodeId } : {}), + }), + }); + }; + /** Best-effort: an unspent key is revoked when the act gives up; a spent one stands as enroll's record. */ + const revokeUnspentKey = async (): Promise => { + try { + // Atomic with enrollment's compare-and-set: never erase its consumed + // record between a state read and deletion. + await db.deleteFrom("nodeSetupKeys").where("id", "=", keyRow.id).where("usedAt", "is", null).execute(); + } catch (err) { + logger.withError(err).warn(`setup-here: could not revoke the unspent key for pane ${pane.id}`); + } + }; + /** Enrollment is durable even if installation or the connecting link fails afterward. */ + const enrolledResult = async (cause: string): Promise | null> => { + const spent = await keys.findById(keyRow.id); + const nodeId = spent?.consumedNodeId; + if (!nodeId) return null; + const connected = getLive(nodeId)?.agent !== undefined; + await runAudit("enrolled", connected ? "ready" : cause, nodeId); + return { ok: true, value: { nodeId, ...(connected ? {} : { connected: false }) } }; + }; + /** + * The post-mint invariant: even an unexpected internal throw (not the + * machine refusing, something HERE failing) leaves its terminal row. + * Best-effort: the audit system must never replace the original error. + */ + const auditInternalFailure = async (): Promise => { + try { + await runAudit("refused", "internal-error"); + } catch (auditErr) { + logger.withError(auditErr).warn(`setup-here: could not audit the internal failure for pane ${pane.id}`); + } + }; + /** Close the exec's own relay pairing (the act's child is gone; the socket should be). */ + const closeExecRelay = async (): Promise => { + if (relayRef === null) return; + try { + await deps.broker().closeForPane(execId, "child-exit"); + } catch (err) { + logger.withError(err).warn(`setup-here: relay close for exec ${execId} failed`); + } + }; + + args.onProgress?.("installing"); + + // KICK. A refusal here has minted a key and opened a relay and must leave + // both as it found them (revoke the unspent key, cut the fresh session). + try { + await sshExec(pane.nodeId, { + execId, + configPath: composed.configPath, + fileContent: composed.fileContent, + presetFlags, + command: remoteCommand, + relay: relayRef !== null, + agentSocketPath: relayRef === null ? snapshot.authAgentSocket : null, + timeoutMs: deps.execTimeoutMs, + }); + } catch (err) { + await closeExecRelay(); + await revokeUnspentKey(); + const enrolled = await enrolledResult(err instanceof SshRpcError ? `exec-${err.kind}` : "internal-error"); + if (enrolled) return enrolled; + if (err instanceof SshRpcError) { + const r = rpcRefusal(err); + await runAudit("refused", `exec-${err.kind}`); + return r; + } + await auditInternalFailure(); + throw err; + } + + // POLL to the terminal answer. The node holds the deadline; the plane only + // stops asking past its own margin. + const pollDeadline = deps.nowMs() + deps.execTimeoutMs + deps.execPollMarginMs; + let terminal: Awaited>; + for (;;) { + try { + terminal = await sshExecStatus(pane.nodeId, execId); + } catch (err) { + await closeExecRelay(); + await revokeUnspentKey(); + const enrolled = await enrolledResult(err instanceof SshRpcError ? `exec-lost-${err.kind}` : "internal-error"); + if (enrolled) return enrolled; + if (err instanceof SshRpcError) { + await runAudit("refused", `exec-lost-${err.kind}`); + return rpcRefusal(err); + } + await auditInternalFailure(); + throw err; + } + if (terminal.state === "done") break; + if (deps.nowMs() > pollDeadline) { + await closeExecRelay(); + await revokeUnspentKey(); + const enrolled = await enrolledResult("install-deadline"); + if (enrolled) return enrolled; + await runAudit("refused", "install-deadline"); + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "Installation timed out. No machine was enrolled. Check the destination before trying again.", + ); + } + await deps.sleepMs(deps.pollMs); + } + await closeExecRelay(); + + // The belt redaction: the machine's answer is the first station's word, + // this plane keeps NOTHING raw. Output informs the verb and then goes. + const output = redactSshSetupKeyLines(`${terminal.stdout}\n${terminal.stderr}`); + const installOk = terminal.code === 0 && !terminal.timedOut; + + if (!installOk) { + await revokeUnspentKey(); + // Awaited, not fire-and-forget: the trail of the act that just refused + // must be readable when the caller sees the refusal (the `ssh.launch` + // posture inverted - there success audits last, here every exit audits + // BEFORE the answer leaves). + const fail = async (cause: string, refusal: SshAnswer): Promise> => { + const enrolled = await enrolledResult(cause); + if (enrolled) return enrolled; + await runAudit("refused", cause); + return refusal; + }; + if (terminal.timedOut) { + return await fail( + "install-timeout", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "Installation timed out. No machine was enrolled. Check the destination before trying again.", + ), + ); + } + // The named causes, each from a phrase the rendered installer prints (the + // script's OWN words are the parser's contract; none of them is echoed), + // or from curl's own connect-failure shape when the script never ran. + if (couldNotReachPlane(output, terminal.code)) { + // §7's egress refusal: its own code, distinct from any key error, and + // the pane keeps running untouched. + return await fail( + "egress", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_EGRESS, + `The destination couldn't reach ${APP_BASE_URL} to install Subshell. No machine was enrolled. Your SSH subshell keeps running.`, + ), + ); + } + if (output.includes("setup key was rejected")) { + return await fail( + "enroll-key", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The installer rejected the setup key because it was invalid, expired, or already used. No machine was enrolled. Try setup again to use a new key.", + ), + ); + } + if (output.includes("could not provide a")) { + return await fail( + "artifact", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "This server couldn't provide a node build for the destination's platform. Ask an admin to check the server logs and available node downloads, then try again.", + ), + ); + } + if (output.includes("tmux is still missing") || output.includes("refuse to enroll")) { + return await fail( + "preflight-tmux", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "Install tmux on the destination, then try setup again. Subshell needs it to run terminal sessions.", + ), + ); + } + if (output.includes("already have a node named")) { + return await fail( + "enroll-name", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "You already own a node with this name. Rename the existing node or remove it if you no longer need it, then try setup again.", + ), + ); + } + return await fail( + `install-exit-${terminal.code ?? "signal"}`, + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "Installation stopped before the destination enrolled. Check the destination before trying setup again.", + ), + ); + } + + // Success is the new node's `ready`, not the installer's cheer. The setup + // key's row learns the node id enrollment spent it for; the registry's + // agent facts appear exactly when that node's `ready` frame lands. + args.onProgress?.("connecting"); + const readyDeadline = deps.nowMs() + deps.readyBudgetMs; + for (;;) { + const spent = await keys.findById(keyRow.id); + const newNodeId = spent?.consumedNodeId ?? null; + if (newNodeId !== null && getLive(newNodeId)?.agent !== undefined) { + await runAudit("enrolled", "ready", newNodeId); + return { ok: true, value: { nodeId: newNodeId } }; + } + if (deps.nowMs() > readyDeadline) break; + await deps.sleepMs(deps.pollMs); + } + await revokeUnspentKey(); + const enrolled = await enrolledResult("ready-timeout"); + if (enrolled) return enrolled; + await runAudit("refused", "enroll-missing"); + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The installer finished, but no machine enrolled with its setup key. Check the destination before trying setup again.", + ); +} diff --git a/apps/server/api/src/services/ssh-setup-progress.ts b/apps/server/api/src/services/ssh-setup-progress.ts new file mode 100644 index 000000000..624a5f30c --- /dev/null +++ b/apps/server/api/src/services/ssh-setup-progress.ts @@ -0,0 +1,95 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import type { SshAnswer } from "@/services/ssh-launch.service.js"; +import type { SetupHereResult } from "@/services/ssh-setup-here.service.js"; + +/** Only host-selected stages cross the status endpoint; installer output never does. */ +export type SshSetupStage = "checking" | "installing" | "connecting" | "enrolled" | "complete" | "failed"; +export interface SshSetupProgress { + /** Server-observed stage; no installer output is exposed. */ + stage: SshSetupStage; + /** ISO timestamp for elapsed-time display. */ + startedAt: string; + /** Enrolled destination, including one still waiting to connect. */ + nodeId: string | null; + /** Safe failure explanation. */ + error: string | null; +} + +/** Owner-scoped, bounded status for installs that outlive a browser request or dialog. */ +export function createSshSetupTracker(now = Date.now) { + const entries = new Map< + string, + { progress: SshSetupProgress; settledAt: number | null; result: Promise> } + >(); + const key = (owner: string, pane: string) => JSON.stringify([owner, pane]); + const prune = () => { + for (const [id, entry] of entries) + if (entry.settledAt !== null && now() - entry.settledAt > 3_600_000) entries.delete(id); + }; + return { + read(owner: string, pane: string): SshSetupProgress | null { + prune(); + const value = entries.get(key(owner, pane))?.progress; + return value ? { ...value } : null; + }, + run( + owner: string, + pane: string, + work: (stage: (value: SshSetupStage) => void) => Promise>, + ): Promise> { + prune(); + const id = key(owner, pane); + const existing = entries.get(id); + if (existing && existing.progress.stage !== "failed") return existing.result; + if (!existing && entries.size >= 500) + return Promise.resolve({ + ok: false, + refusal: { + status: 409, + code: BackendErrorCodes.SSH_UPGRADE_FAILED, + message: "Too many recent setup operations. Try again later.", + }, + }); + const progress: SshSetupProgress = { + stage: "checking", + startedAt: new Date(now()).toISOString(), + nodeId: null, + error: null, + }; + const entry = { + progress, + settledAt: null as number | null, + result: Promise.resolve().then(() => + work((stage) => { + progress.stage = stage; + }), + ), + }; + entries.set(id, entry); + entry.result = entry.result.then( + (answer) => { + entry.settledAt = now(); + progress.stage = answer.ok ? (answer.value.connected === false ? "enrolled" : "complete") : "failed"; + if (answer.ok) progress.nodeId = answer.value.nodeId; + else + progress.error = + answer.refusal.status === 422 ? "The destination could not be set up." : answer.refusal.message; + return answer; + }, + () => { + entry.settledAt = now(); + progress.stage = "failed"; + progress.error = "Setup status could not be confirmed. Check the destination and Nodes before retrying."; + return { + ok: false, + refusal: { status: 502, code: BackendErrorCodes.SSH_NODE_REFUSED, message: progress.error }, + }; + }, + ); + return entry.result; + }, + }; +} + +/** No timers, credentials, sockets or disk access at import time. */ +export const sshSetupTracker = createSshSetupTracker(); diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index d33dc3811..bbe5c0764 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -17,6 +17,7 @@ import { type NodeProbeEntry, normalizeLabel, parseNodeProbeEntries, + type SshConnectionSnapshotWire, } from "@internal/subshell-protocol"; import { harnessUsable } from "@/api/harness-utils.js"; import type { PresetsRepository } from "@/db/repositories/presets.repository.js"; @@ -58,6 +59,8 @@ import { subshellLogPath } from "@/services/nodes/subshell-paths.js"; import { getNotifyService, type NotifyKind } from "@/services/notify.service.js"; import { EMPTY_PRESET, parsePreset } from "@/services/preset-definition.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; +import { closeRelayForPaneExit, sweepRelayForPane } from "@/services/ssh-relay.service.js"; import { issueSubshellToken, revokeSubshellToken } from "@/services/subshell-tokens.js"; import { logger } from "@/utils/logger.js"; @@ -128,6 +131,32 @@ const defaultNotify: (subshellId: string, kind: NotifyKind) => Promise = a await getNotifyService().notifySubshell(id, kind); }; +/** + * The composed half of an ssh-pane launch (spec 2026-10-07 decision 4, Task 7 + * plumbing): the derived config path, the rendered bytes, and the APPROVED + * snapshot and relay selection the row keeps. `createSubshell` serializes + * both into `subshells.ssh`; the LaunchPlan member carries only + * `configPath`/`fileContent` (the snapshot never rides the wire — the + * launcher re-derives the path and the renderer produced the bytes). + */ +export interface SshLaunchPlumbing { + /** `buildSshConfigPath(, )` — derived, never chosen. */ + configPath: string; + /** `renderSshConfigContents(snapshot)`. */ + fileContent: string; + /** The snapshot the resolver approved (grammar-validated at each boundary already). */ + snapshot: SshConnectionSnapshotWire; + /** + * RELAY MODE (spec 2026-10-08 §7): the key home whose agent signed the + * connection, stored on the row (migration 0052) so "Set up Subshell here" + * can re-open the pairing after the relay session's own memory is gone. + * Absent on every direct launch. + */ + keyHomeNodeId?: string; + /** Exact keys used by the relay, retained for later acts without widening. */ + relayFingerprints?: readonly string[]; +} + /** * Orchestrates agent subshells: validate inputs, spawn a tmux-backed harness, * stream output to a per-subshell log file, and reconcile DB state with what @@ -317,6 +346,10 @@ export class SubshellManagerService { notify, nodeId, crossAgent, + ssh, + presetFlags, + extraPaneEnv, + subshellId, }: { userId: string; /** Harness plugin to launch — required, with or without a preset. */ @@ -356,7 +389,40 @@ export class SubshellManagerService { * silent. Only creation writes it; a restart reuses the row. */ crossAgent?: number; + /** + * The composed ssh pane (Task 7 plumbing): the row gets + * `ssh = JSON.stringify(snapshot)`, the LaunchPlan gets the config member, + * and the launch is refused nothing else — an ssh pane is a terminal-type + * harness whose flags arrive through `presetFlags`. + */ + ssh?: SshLaunchPlumbing; + /** + * The launch preset's flags when a caller composes them (the ssh service + * does; no one else does today). Present ⇒ the launch preset is + * `{ ...EMPTY_PRESET, flags }` — a FRESH object, since EMPTY_PRESET is the + * shared frozen constant every other presetless launch reads. Ignored when + * a preset row is also given (the row wins: presetFlags exists for the + * presetless compose path, not to edit somebody's saved preset). + */ + presetFlags?: string[]; + /** + * Env additions merged into the pane's `subshellEnv` at the compose site + * (spec 2026-10-07 decision 3: an ssh pane's `SSH_AUTH_SOCK` — the pane's + * command IS the ssh process, so the pane env is the ssh env). Merged + * AFTER the preset-independent `SUBSHELL_*` block, before the launch. + */ + extraPaneEnv?: Record; + /** + * Pre-assigned row id, for callers that must compose id-derived facts + * BEFORE the row exists (the ssh config path is derived from the pane's + * id; Task 7). Shape-guarded; production callers pass nothing and get the + * `crypto.randomUUID()` they always got. + */ + subshellId?: string; }): Promise<{ id: string; tmuxSocket: string; apiKey: string; promptDelivered: boolean }> { + if (subshellId !== undefined && !/^[a-zA-Z0-9_-]{1,64}$/.test(subshellId)) { + throw new Error("createSubshell: subshellId is outside the id grammar"); + } const presetRow = presetId ? await this.#presets.findById(presetId) : undefined; if (presetId && (!presetRow || presetRow.userId !== userId)) { throw new Error("Preset not found"); @@ -373,13 +439,21 @@ export class SubshellManagerService { const launcher = this.#launcherFor(targetNode); const realPath = await launcher.validateWorkingDir(workingDir); await assertDirAllowed(targetNode, realPath); - const preset = presetRow ? parsePreset(presetRow) : EMPTY_PRESET; + const preset = presetRow + ? parsePreset(presetRow) + : presetFlags !== undefined + ? // Fresh object every time: EMPTY_PRESET is the shared frozen constant + // every presetless launch reads, and mutating it (or handing out the + // shared reference with new flags hung on it) would leak ssh option + // tokens into unrelated panes. + { ...EMPTY_PRESET, flags: [...presetFlags] } + : EMPTY_PRESET; const binary = await launcher.resolveBinary(harness); if (!binary) { throw new Error(`Harness "${harness.name}" is not installed on this machine.`); } - const id = crypto.randomUUID(); + const id = subshellId ?? crypto.randomUUID(); const socket = tmuxSocketFor(id); // Display name vs LAUNCH name (titling spec 2026-09-03): only a name a // HUMAN chose travels to the pane command — an unnamed create passes "" @@ -423,6 +497,19 @@ export class SubshellManagerService { // How the pane was OPENED, recorded once; the rail's category and the // caller's bell default both read this. 0 = the DB default, human. crossAgent: crossAgent === 1 ? 1 : 0, + // The ssh snapshot column IS the pane's kind fact (migration 0048): the + // owner-only input rule, the restart refusal and the delete sweep all + // read its presence. NULL for every pane this call did not compose from + // an approved snapshot. + ssh: ssh + ? JSON.stringify({ + ...ssh.snapshot, + ...(ssh.relayFingerprints !== undefined ? { relayFingerprints: ssh.relayFingerprints } : {}), + }) + : null, + // The relay pane's key home, recorded with the row (migration 0052): + // immutable thereafter, and no direct pane carries it. + keyHomeNodeId: ssh?.keyHomeNodeId ?? null, }); // The token is minted AFTER the row exists (issueSubshellToken writes the @@ -448,6 +535,10 @@ export class SubshellManagerService { // means nothing on the node; the agent's own dataDir is the truth there. subshellEnv.SUBSHELL_DATA_DIR = facts.dataDir; } + // Caller-composed env (Task 7: the ssh pane's SSH_AUTH_SOCK, decision 3) + // merges after the SUBSHELL_* block — `subshellEnv` is a per-launch + // object built just above, never a shared constant, so assigning is safe. + if (extraPaneEnv) Object.assign(subshellEnv, extraPaneEnv); // Command assembly happens INSIDE launch, which runs inside this try: // a rejected env key throws there, and the row + token must roll back // like any other spawn failure below. @@ -464,6 +555,9 @@ export class SubshellManagerService { mcpConfigPath, harnessSession, reporter, + // The composed ssh member (spec 2026-10-07 decision 4). Conditional + // spread: absent on every non-ssh launch, byte-identical plan there. + ...(ssh ? { ssh: { configPath: ssh.configPath, fileContent: ssh.fileContent } } : {}), }); if (prompt?.trim()) { promptDelivered = await this.#deliverPrompt( @@ -486,6 +580,15 @@ export class SubshellManagerService { } catch { // kill is best-effort; the row + token rollback below must still run } + // A LOCAL ssh launch writes its config inside `launch`, before the spawn + // that threw here — so retiring the row must retire the dir too, or a + // half-built `/ssh/` leaks until a manual delete. The guard + // no-ops every non-local / non-ssh create; `targetNode` keeps a remote + // pane's dir on ITS node's disk untouched. + sweepLocalSshDir({ id, nodeId: targetNode, ssh: ssh ? JSON.stringify(ssh.snapshot) : null }); + // A relay session (M2) bound to this pane must die with the create too: + // the B proxy socket would otherwise outlive the row it was named for. + closeRelayForPaneExit(id); await this.#subshells.markTerminated(id, new Date().toISOString()); await this.#revokeTokenOrUnlink(id); publishLive({ kind: "subshell.changed", id }); @@ -936,12 +1039,12 @@ export class SubshellManagerService { * Terminate one subshell because its NODE entered maintenance * (spec 2026-09-14 §5.2). * - * {@link terminateSubshell} plus a push, and the push is the whole reason - * this exists as a second method rather than a flag: that path is - * deliberately silent because the operator clicked it, and here they did - * not. A node owner's window stops subshells belonging to everyone the node - * was shared with — people who cannot see the node, did not act, and would - * otherwise find a dead pane with no account of why. + * {@link terminateSubshell} plus a push and the local ssh config sweep, and + * the push is the whole reason this exists as a second method rather than a + * flag: that path is deliberately silent because the operator clicked it, + * and here they did not. A node owner's window stops subshells belonging to + * everyone the node was shared with — people who cannot see the node, did + * not act, and would otherwise find a dead pane with no account of why. * * The row's OWN `userId` is passed through, never an actor's: the terminate * path is owner-keyed and would silently skip every row but the caller's. @@ -959,10 +1062,21 @@ export class SubshellManagerService { * the claim too, which is right: nothing was running to stop, and its owner * heard about that death when it happened. * + * The sweep runs whether or not this call claimed the transition (it is an + * idempotent `rm -rf`, and the helper's guard no-ops every row that is not + * a LOCAL ssh pane): the kill inside {@link terminateSubshell} revokes the + * pane's token at the same moment, so the dying hook's report can arrive + * 401 and sweep nothing — exactly the race the terminate verb documented + * (e2e spec 22). The hand that kills removes the dir; without this, a + * maintenance window left every local ssh config dir behind. + * * @param row - the running row to stop, freshly read */ async terminateForMaintenance(row: SubshellTable): Promise { - if (await this.terminateSubshell(row.userId, row.id)) void this.#notify(row.id, "maintenance"); + const stopped = await this.terminateSubshell(row.userId, row.id); + sweepLocalSshDir(row); // guard: local node ∧ snapshot; agent rows skip (their disk, their watcher) + sweepRelayForPane(row); // relay sessions are per-pane and die with the pane (M2 §5.6 child-exit) + if (stopped) void this.#notify(row.id, "maintenance"); } /** @@ -998,15 +1112,28 @@ export class SubshellManagerService { // attach-replay artifact; the MCP config holds no secrets but nothing // should be left behind). The layout lives behind the launcher seam // (spec §6.4): a local row leaves exactly its replay log; an agent node - // names the triple (log + MCP config + the agent's own meta record — - // deliberately left behind by a kill, so the DELETE unlinks it) from its - // live `ready` facts, and an OFFLINE agent answers `[]`: no facts, no - // layout to name paths from, artifacts age out with the node (§5.6). + // names its triple (log + MCP config + the agent's own meta record — + // deliberately left behind by a kill, so the DELETE unlinks it) plus the + // ssh config at the node-derived path, from its live `ready` facts, and + // an OFFLINE agent answers `[]`: no facts, no layout to name paths from, + // artifacts age out with the node (§5.6). // The local short-circuit is row-keyed because a TEST launcher answers for // EVERY node id (the phase-0 suites) — a fake standing in for `local` // must keep receiving the local artifact set, never the agent triple. - const artifacts = row.nodeId === LOCAL_NODE_ID ? [launcher.logPath(id)] : launcher.subshellArtifacts(id); + // A LOCAL ssh pane's config is deliberately NOT in that list: it lives in + // its own directory under the server's dataDir, and unlinking the file + // while leaving the DIR is not a removal. `sweepLocalSshDir` (the one + // sweep function, shared with the exit report and terminate) removes the + // dir, keyed on the same row facts the old list entry consulted: the + // node id here, the snapshot column (`row.ssh !== null`, migration 0048) + // inside the guard — a row that merely NAMES the ssh harness has no + // config to find, and a snapshot under any other harness id still owns + // one. + const sshPane = row.ssh !== null; + const artifacts = row.nodeId === LOCAL_NODE_ID ? [launcher.logPath(id)] : launcher.subshellArtifacts(id, sshPane); await launcher.removeArtifacts(artifacts); + sweepLocalSshDir(row); // guard: local node ∧ snapshot; agent rows skip (their disk, their watcher) + sweepRelayForPane(row); // the pane stops existing in every flavor: any brokered relay ends here // And the generated MCP config (no secrets, but nothing to leave behind). try { unlinkSync(subshellMcpConfigPath(id)); @@ -1334,6 +1461,14 @@ export class SubshellManagerService { publishLive({ kind: "subshell.changed", id: row.id }); logger.info(`subshell process absent (no socket): ${row.id}`); await this.#notifyDeath(row); + // This no-socket branch stamps alive→dead directly rather than + // through #applyDeath, so the ssh-config sweep that lives there must + // be repeated here (guard no-ops every non-local / non-ssh row). A + // real ssh pane always carries a socket at create, so this is the + // fixture-shaped belt that keeps "whichever hand first notices + // removes the dir" true for EVERY alive→dead stamp, not most. + sweepLocalSshDir(row); + closeRelayForPaneExit(row.id); // the same belt for relay sessions } } continue; @@ -1469,16 +1604,19 @@ export class SubshellManagerService { } /** - * The shared death transition — the ONE place a running row is stamped - * alive→dead. Every entry point that learns of a death calls this: the - * sweep (local tmux probe or the agent `probe`/`exit` census), an agent - * `exit` event, and the `subshells_report` reconnect census. Idempotent by - * construction: the stamp + death push fire only while the FRESH row is - * still `running` with `alive: 1`, and a row a restart owns - * (`restartInFlight`) is off-limits. After the (possible) transition the - * auto-restart ladder runs for parked rows, and a subshell that will never - * come back — opted out or backoff-exhausted — has its bearer revoked here. - * The cached preview dies with the pane. + * The shared death transition — where a probe- or report-driven death is + * stamped alive→dead AND the pane's artifacts (preview cache, ssh config dir, + * idle tmux server) are cleared. Every entry point that learns a LIVE pane is + * gone calls this: the sweep (local tmux probe or the agent `probe`/`exit` + * census), an agent `exit` event, and the `subshells_report` reconnect census. + * (The reconcile's no-socket fast path — a row that cannot be alive — stamps + * directly rather than routing here, and repeats the ssh sweep so no + * alive→dead path misses it.) Idempotent by construction: the stamp + death + * push fire only while the FRESH row is still `running` with `alive: 1`, and a + * row a restart owns (`restartInFlight`) is off-limits. After the (possible) + * transition the auto-restart ladder runs for parked rows, and a subshell that + * will never come back — opted out or backoff-exhausted — has its bearer + * revoked here. * @param row - the row as the caller saw it; only its id must still be true * @param opts.exitCode - the observed pane exit code (null = never seen) * @param opts.endedAt - ISO stamp for the death (the agent's clock for remote events) @@ -1518,6 +1656,19 @@ export class SubshellManagerService { // "Never" means opted out OR the backoff limit was exhausted (the // sweep gives up at that count, so the bearer would linger otherwise). const restarted = await this.maybeAutoRestart(fresh); + // The ssh config dir is a pane artifact like the preview cache and the idle + // tmux server below: it dies with the pane, HERE, in the ONE shared + // transition. Doing it only from the tmux `pane-died` hook's self-report + // left a gap — the hook is best-effort, and a report lost to a 401 or a + // dropped exec left the dir behind until a manual teardown; the 60 s + // reconcile reaches this same line whenever the process is gone, so this is + // the deterministic sweep. Guarded on `!restarted` (never clobber a live + // re-render) and by the helper itself (local ∧ ssh; every other row no-ops). + // The relay cut runs whatever the restart ladder decided: an agent-capable + // ssh pane never auto-restarts (M1 decision 7), and a restart of anything + // else must not strand the relay session of the process that just died. + closeRelayForPaneExit(fresh.id); + if (!restarted) sweepLocalSshDir(fresh); if (!restarted && (fresh.restartOnExit !== 1 || fresh.backoffCount >= 5)) { // Terminal: this subshell will never come back, so its bearer must // not linger. Revoke failures here must not abort the sweep for the @@ -2081,6 +2232,8 @@ export function toSubshellView( waitingSince: string | null; lastPushUrgency: number | null; crossAgent: number; + /** The ssh snapshot column (migration 0048); read for presence only, never parsed here. */ + ssh: string | null; }, status: string, /** The subshell's current screen, bottom-first-trimmed; empty when not running. */ @@ -2139,6 +2292,9 @@ export function toSubshellView( // (spec 2026-09-23) — what turns the rail's dot into a bell. unseenPush: row.lastPushUrgency !== null, access, + // The pane's KIND fact: the ssh snapshot column's PRESENCE (migration + // 0048). The snapshot itself stays never-serialized (its db-types doc). + ssh: row.ssh !== null, // Agent node unreachable right now (see the param doc) — the UI's // "node offline" chip; false for every local subshell. nodeOffline, diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index b24feb566..76a12fd93 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -18,6 +18,7 @@ import type { PresetTable } from "@/db/types/presets.db-types.js"; import type { SubshellSharePermission } from "@/db/types/subshell-shares.db-types.js"; import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import { loadNodeAccess, type NodeAccessDeps, nodeCanLaunch, nodeCanLaunchOn } from "@/lib/node-access.js"; +import { paneInputAllowed } from "@/lib/ssh-pane-access.js"; import { type Access, accessAtLeast, loadSubshellAccess, resolveSubshellAccess } from "@/lib/subshell-access.js"; import { BaseService, type CommonServiceParams } from "@/services/base.service.js"; import { publishLive } from "@/services/live-bus.js"; @@ -38,11 +39,14 @@ import { import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; import { getNotifyService } from "@/services/notify.service.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; +import { closeRelayForPaneExit, sweepRelayForPane } from "@/services/ssh-relay.service.js"; import { PROMPT_POLL_MS, PROMPT_SETTLE_TIMEOUT_MS, RestartInFlightSwapError, readSubshellLogWindow, + type SshLaunchPlumbing, SubshellManagerService, } from "@/services/subshell-manager.service.js"; import { extendSubshellToken, subshellTokenTtlSeconds } from "@/services/subshell-tokens.js"; @@ -53,6 +57,30 @@ type SubshellView = NonNullable, + access: Exclude, +): Exclude { + if (access === "edit" && row.ssh !== null) return "view"; + return access; +} + /** A sharing grant as returned to the client, with the grantee label resolved. */ interface SubshellShareView { /** Share row id */ @@ -412,6 +440,10 @@ export class SubshellsService extends BaseService { nodeId, machineActor, crossAgent, + ssh, + presetFlags, + extraPaneEnv, + subshellId, }: { /** Owner of the new subshell (never taken from the body). */ userId: string; @@ -449,6 +481,19 @@ export class SubshellsService extends BaseService { * they can still turn it on from the pane's menu. */ crossAgent: boolean; + /** + * The composed ssh pane (Task 7 plumbing, spec 2026-10-07 §5/decision 4), + * forwarded to the manager untouched: the snapshot column, the LaunchPlan + * config member, the flags preset, the merged pane env, the pre-assigned + * id the config path was derived from. The ssh-launch service + * (`services/ssh-launch.service.ts`) is the only caller that passes these; + * every other door (REST create, MCP, mobile) launches panes the ordinary + * way and passes none of them. + */ + ssh?: SshLaunchPlumbing; + presetFlags?: string[]; + extraPaneEnv?: Record; + subshellId?: string; }): Promise<{ id: string; tmuxSocket: string; promptDelivered: boolean }> { // Lockdown is the FIRST question the instance asks of every create // (operator ask 2026-09-24): it answers valid bodies and invalid ones @@ -611,6 +656,13 @@ export class SubshellsService extends BaseService { // OFF — agent-to-agent comms are not news a human needs rung for — // and it stays togglable exactly like any other pane's. notify: !crossAgent, + // The ssh plumbing rides to the manager untouched (its own doc there + // says what each part writes); `undefined` everywhere else keeps the + // pre-Task-7 launch byte-identical. + ...(ssh ? { ssh } : {}), + ...(presetFlags !== undefined ? { presetFlags } : {}), + ...(extraPaneEnv !== undefined ? { extraPaneEnv } : {}), + ...(subshellId !== undefined ? { subshellId } : {}), }) .catch(rethrowLaunchRefusal); // Feed the picker's Recents (and the new-subshell form's pre-fill) from @@ -644,7 +696,7 @@ export class SubshellsService extends BaseService { const accessBy = new Map>(); for (const row of rows) { const access = resolveSubshellAccess(viewerId, row.userId, sharesBy.get(row.id) ?? []); - accessBy.set(row.id, access === "none" ? "view" : access); + accessBy.set(row.id, effectiveViewerAccess(row, access === "none" ? "view" : access)); } const views = await this.#manager.toViews(rows, opts); return views.map((view) => ({ @@ -662,14 +714,33 @@ export class SubshellsService extends BaseService { * or on any enrolled node, since both reach this plane the same way. * * Thin by design: the route has already established that this is the - * subshell's own key, and the manager owns the one death transition the - * sweep also runs through — so nothing here decides anything, it only - * carries the timestamp. + * subshell's own key, and the manager owns the one death transition. The + * row read below decides nothing either — it only feeds the shared LOCAL + * ssh config sweep (its own guard skips every other row shape); the death + * transition itself carries just the timestamp. + * + * The sweep here is DELIBERATELY unconditional, not deferred to the + * transition below: `applySelfReportedExit` returns early when the row is + * already retired, and the hook-vs-retire race (a terminate kills the pane, + * its `pane-died` report lands after the row is gone) must still remove the + * config. The complementary gap — a `pane-died` hook lost entirely, so no + * report ever reaches here — is closed by the sweep in the shared + * `#applyDeath`, which the 60 s reconcile drives off the process being gone. * * @param id - the subshell whose pane exited * @param exitCode - tmux's `#{pane_dead_status}`, null when it could not be read */ async reportExit(id: string, exitCode: number | null): Promise { + // The read is part of the best-effort sweep too: a throwing row read costs + // a log line, never the death transition below (the pre-helper code had the + // read inside its try for exactly this). + sweepLocalSshDir( + await this.repos.subshells.findById(id).catch((err: unknown) => { + logger.withError(err).warn(`ssh config sweep skipped for pane ${id}; the row read failed`); + return null; + }), + ); + closeRelayForPaneExit(id); // the pane's own report is the §5.6 child-exit signal await this.#manager.applySelfReportedExit(id, exitCode, new Date().toISOString()); } @@ -809,11 +880,13 @@ export class SubshellsService extends BaseService { if (!subshell) throw new SubshellError("not_found", "Subshell not found"); // One extra read on a single-row path: the gate resolves access without // handing back the grants it looked at, and the disclosure warning needs - // the audience, not just the caller's own level. + // the audience, not just the caller's own level. The gate's resolver hands + // back the RAW level; the ssh view downgrade (plan 3 decision 1) rides the + // same helper the list applies, so both doors answer alike. const shares = (await this.repos.subshellShares.listForSubshells([id])).get(id) ?? []; return { ...subshell, - access: access === "none" ? "view" : access, + access: effectiveViewerAccess(row, access === "none" ? "view" : access), sharedWithViewer: access !== "owner" && shares.some((share) => share.granteeUserId === null || share.granteeUserId === viewerId), ...shareExposure(shares), @@ -862,6 +935,29 @@ export class SubshellsService extends BaseService { return { ok: true }; } + /** + * The ssh owner-only carve-out on an input act, applied AFTER the visibility + * + grant gate (so a stranger still gets the ordinary 404, and a `view` + * grantee the ordinary 403). An ssh pane runs a real shell under the OWNER's + * ssh identity on the far machine, so its input is the owner's alone: an + * `edit` grantee who is not the owner is refused, and so is any bearer + * machine credential (a pane must not type into itself). Ordinary panes + * (`ssh === null`) pass through untouched. The rule lives in + * {@link paneInputAllowed}, shared with the WS keystroke door (spec + * 2026-10-07 §5.4, decision 5). + * @throws ApiError 403 SSH_OWNER_INPUT_ONLY, before anything is typed. + */ + #requireInputAuthority(row: SubshellTable, viewerId: string, actor: GuardActor): void { + // A bearer actor is a `subshell-key` OR a `system-key`; a human is `cookie`. + if (!paneInputAllowed(row, viewerId, actor !== "cookie")) { + throwApiError({ + code: BackendErrorCodes.SSH_OWNER_INPUT_ONLY, + message: "Only the owner can type in this SSH subshell", + doNotLog: true, + }); + } + } + /** * Types text into a RUNNING pane over REST (spec 2026-09-25 MCP DX), the * input the live attach socket already carries, given an HTTP door for @@ -898,6 +994,9 @@ export class SubshellsService extends BaseService { * nothing is typed into a row that is not there), and 409 * NODE_OFFLINE when its agent node has no live connection (the * create/restart mapper again). + * @throws ApiError 403 SSH_OWNER_INPUT_ONLY when the pane is an ssh pane and + * the caller is not its owner (or is a bearer credential), before the + * launcher is resolved and nothing is typed. */ async sendSubshellInput( viewerId: string, @@ -907,6 +1006,7 @@ export class SubshellsService extends BaseService { actor: GuardActor, ): Promise<{ ok: true }> { const { row } = await this.#gate(viewerId, id, "edit", actor); + this.#requireInputAuthority(row, viewerId, actor); // The TWO facts, both required — a lesson from the live incident // (2026-09-25): `status` is the lifecycle INTENT and a pane that exited // on its own parks at `status: "running"` with `alive: 0` (parked is what @@ -957,7 +1057,9 @@ export class SubshellsService extends BaseService { * a terminal; 409 EXEC_IN_FLIGHT when another exec already holds the * pane's lease; 409 EXEC_PANE_BUSY when the quiet probe finds the * log growing. All four refuse before anything is typed, and any - * launcher refusal maps through the create/restart mapper. + * launcher refusal maps through the create/restart mapper. Exec on + * an ssh pane also carries the input rule: a non-owner 403s + * SSH_OWNER_INPUT_ONLY before even the first quiet probe (spec §5.4). */ async execInTerminal( viewerId: string, @@ -967,6 +1069,10 @@ export class SubshellsService extends BaseService { actor: GuardActor, ): Promise { const { row } = await this.#gate(viewerId, id, "edit", actor); + // The ssh carve-out precedes the running/harness/quiet checks: exec TYPES, + // so it carries the input rule verbatim, and a non-owner never reaches the + // first quiet probe (spec 2026-10-07 §5.4). + this.#requireInputAuthority(row, viewerId, actor); if (row.status !== "running" || row.alive !== 1) { throwApiError({ code: BackendErrorCodes.SUBSHELL_NOT_RUNNING, @@ -1226,6 +1332,19 @@ export class SubshellsService extends BaseService { prompt?: string, ): Promise<{ id: string; tmuxSocket: string; promptDelivered: boolean }> { const { row } = await this.#gate(viewerId, id, "edit", actor); + // An ssh pane refuses restart (spec 2026-10-07 decision 7): its session + // lives on the far end of a socket, and a restart would spawn a NEW ssh + // process the human never asked to reconnect — the launcher is the way + // back in. Keyed to the `subshells.ssh` column (the kind fact, migration + // 0048), asked AFTER the visibility gate so a stranger still gets the + // ordinary 404, and before anything this path reads or writes. + if (row.ssh !== null) { + throwApiError({ + code: BackendErrorCodes.SSH_NO_RESTART, + message: "SSH panes are not restarted; launch a new one to reconnect", + doNotLog: true, + }); + } // Lockdown is instance-wide, so it is asked before anything machine-shaped // is read. It names no machine, per the restart path's own rule (the row's // owner is the only guaranteed viewer of this refusal). And a stopped row's @@ -1353,6 +1472,14 @@ export class SubshellsService extends BaseService { async terminateSubshell(viewerId: string, id: string, actor: GuardActor): Promise<{ ok: true }> { const { row } = await this.#gate(viewerId, id, "edit", actor); await this.#manager.terminateSubshell(row.userId, id); + // AFTER the kill, the same sweep the dying hook runs — and it cannot be + // left TO the hook: terminate revokes the pane's token synchronously with + // the kill, so the `pane-died` report can arrive 401 and never sweep + // (e2e spec 22). The hand that killed removes the dir; the hook, whenever + // it lands, stays the second sure thing (its sweep is idempotent). The + // helper's guard no-ops this for every non-local-ssh row. + sweepLocalSshDir(row); + sweepRelayForPane(row); // terminate kills the ssh child too: its relay dies with it return { ok: true }; } diff --git a/apps/server/api/src/ws/__tests__/attach-resolve.test.ts b/apps/server/api/src/ws/__tests__/attach-resolve.test.ts index a7cd9ccdb..df7e675bb 100644 --- a/apps/server/api/src/ws/__tests__/attach-resolve.test.ts +++ b/apps/server/api/src/ws/__tests__/attach-resolve.test.ts @@ -403,6 +403,78 @@ describe("resolveAttach", () => { }); }); +describe("resolveAttach canInput: the ssh owner-only keystroke rule (spec §5.4)", () => { + // The WS door is `handleSubshellMessage`'s `data.canInput`, and BOTH attach + // paths now carry the value `resolveAttach` computes here. So this is where + // the ssh carve-out is decided for keystrokes; the socket just obeys. A + // keystroke is DROPPED (no error frame) when it refuses, exactly as a `view` + // grantee's is — that is the WS posture, so `canInput` is the whole surface. + + /** A row whose `ssh` column is set (the kind fact, migration 0048). */ + async function seedSshRow(): Promise<{ id: string; userId: string }> { + const { repos } = getRequestlessContext(); + seq += 1; + const row = await repos.subshells.create({ + id: crypto.randomUUID(), + userId: `u-attach-resolve-${seq}`, + presetId: "p-test", + harnessId: "ssh", + name: "attach-ssh-input", + workingDir: "/tmp", + tmuxSocket: "subshell-attach-ssh", + ssh: JSON.stringify({ destination: "host.example", user: "theo", port: 22 }), + }); + return { id: row.id, userId: row.userId }; + } + + const canInput = async (query: string): Promise => { + const out = await resolveAttach(request(query)); + if (!out.ok) throw new Error(`attach refused: ${JSON.stringify(out)}`); + return out.canInput; + }; + + it("an ordinary pane + an edit grantee types (canInput unchanged from the grant)", async () => { + const row = await seedRow(); + const { repos } = getRequestlessContext(); + const guest = `u-attach-ssh-editor-${seq}`; + await repos.subshellShares.replaceForSubshell(row.id, [{ granteeUserId: guest, permission: "edit" }], row.userId); + expect(await canInput(`subshell=${row.id}&token=${issueWsToken(guest)}`)).toBe(true); + }); + + it("an ssh pane + the OWNER (unbound token) types", async () => { + const row = await seedSshRow(); + // `issueWsToken(userId)` with no subshell is a cookie-minted unbound + // identity: the human's own keystrokes, so bearerActor is false. + expect(await canInput(`subshell=${row.id}&token=${issueWsToken(row.userId)}`)).toBe(true); + }); + + it("an ssh pane + an EDIT grantee is refused (access says edit, canInput says no)", async () => { + const row = await seedSshRow(); + const { repos } = getRequestlessContext(); + const guest = `u-attach-ssh-blocked-${seq}`; + await repos.subshellShares.replaceForSubshell(row.id, [{ granteeUserId: guest, permission: "edit" }], row.userId); + const out = await resolveAttach(request(`subshell=${row.id}&token=${issueWsToken(guest)}`)); + expect(out.ok).toBe(true); + // The grant is genuinely `edit` — the refusal is the ssh kind, not access. + if (out.ok) { + expect(out.access).toBe("edit"); + expect(out.canInput).toBe(false); + } + }); + + it("an ssh pane + its OWN scoped key is refused (decision 5: no self-typing)", async () => { + const row = await seedSshRow(); + // A scoped token records the OWNER as its identity, so `access` resolves to + // owner; only `bearerActor` closes the self-typing door. + const out = await resolveAttach(request(`subshell=${row.id}&token=${issueWsToken(row.userId, row.id)}`)); + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.access).toBe("owner"); + expect(out.canInput).toBe(false); + } + }); +}); + describe("resolveAttach clears the unseen push (spec 2026-09-23)", () => { const email = `attach-unseen-${crypto.randomUUID()}@subshell.local`; const pw = "attach-unseen-pass-1"; diff --git a/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts b/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts index ed163bd5d..778fb664f 100644 --- a/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts +++ b/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts @@ -231,7 +231,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { scriptHappy(sim); const { ws, sent } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "tail_start on the wire"); // Command ORDER through the real sendCommand: liveness, the tail's JOIN @@ -284,7 +284,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("log_read", { bytes_b64: "", next: 0, size: 0 }); const { ws, sent, closed } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "tail armed at offset 0"); // Terminal frames only: the socket also carries `viewers` presence now. @@ -310,7 +310,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { // still starts at EOF (12) — history ships inside the capture, never as // re-played log bytes. await setOwnerCap(2); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "tail at EOF"); expect(sim.cmdsOf("capture")).toEqual([{ type: "capture", subshellId: SID, lines: 2 }]); expect((sim.cmdsOf("tail_start")[0] as { fromByte: number }).fromByte).toBe(12); @@ -324,7 +324,14 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("capture", "SCREEN"); scriptLogRead(sim, { bytes: "l1\nl2\nl3\n", size: 12 }); await setOwnerCap(9999); - await attachRemoteSubshellWs(fakeBrowser().ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs( + fakeBrowser().ws, + attachRow(), + new RemoteLauncher(NODE_ID), + "owner", + attachParams(), + true, + ); await until(() => sim.cmdsOf("capture").length === 1, "capture (cap 200)"); expect((sim.cmdsOf("capture")[0] as { lines?: number }).lines).toBe(200); @@ -334,7 +341,14 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim2.answer("capture", "SCREEN"); scriptLogRead(sim2, { bytes: "l1\nl2\nl3\n", size: 12 }); await setOwnerCap(0); - await attachRemoteSubshellWs(fakeBrowser().ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs( + fakeBrowser().ws, + attachRow(), + new RemoteLauncher(NODE_ID), + "owner", + attachParams(), + true, + ); await until(() => sim2.cmdsOf("capture").length === 1, "capture (cap 1)"); expect((sim2.cmdsOf("capture")[0] as { lines?: number }).lines).toBe(1); }); @@ -357,6 +371,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 132, rows: 43 } }), + true, ); await until(() => sim.cmdTypes().includes("tail_start"), "tail armed"); // Resize lands ahead of the capture so the replay matches the client @@ -405,6 +420,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 132, rows: 43 } }), + true, ); await until(() => sim.cmdTypes().includes("capture"), "capture"); // Exactly ONE resize — the fit. The nudge would add 133-then-back. @@ -426,6 +442,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 132, rows: 43 } }), + true, ); await until(() => sim.cmdTypes().includes("capture"), "capture"); expect(sim.cmdsOf("resize")).toEqual([{ type: "resize", subshellId: SID, cols: 132, rows: 43 }]); @@ -446,7 +463,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("pane_cursor", { x: 4, y: 2 }); const { ws, sent } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sent.length > 0, "replay"); // tmux's 0-based (4,2) becomes the 1-based CUP ESC[3;5H, and the replay // frame — the browser's first paint — must end with it. @@ -457,7 +474,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { it("refuses 4004 'node offline' when the node has no live connection (nothing hits the wire)", async () => { const { ws, sent, closed } = fakeBrowser(); // No attachConnection for NODE_ID — the registry says offline. - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); expect(closed).toEqual([{ code: 4004, reason: "node offline" }]); expect(sent).toEqual([]); }); @@ -467,7 +484,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("probe", [{ subshellId: SID, alive: false, exitCode: 1 }]); const { ws, sent, closed } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); expect(closed).toEqual([{ code: 4004, reason: "subshell not running" }]); expect(sent).toEqual([]); expect(sim.cmdTypes()).toEqual(["probe"]); // no capture, no reads, no tail @@ -480,7 +497,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { sim.answer("capture", fail("can't find session: pane gone")); // tmux stderr, verbatim const { ws, sent, closed } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); expect(closed).toEqual([{ code: 4004, reason: "subshell not running" }]); expect(sent).toEqual([]); // The pump is armed before the capture (join-point rule), so a refusal @@ -497,7 +514,7 @@ describe("attachRemoteSubshellWs — the §6.5 flow on the wire", () => { scriptHappy(sim); const { ws, sent, closed } = fakeBrowser(() => 4 * 1024 * 1024 + 1); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "tail armed"); // Terminal frames only: the socket also carries `viewers` presence now. expect(sent.filter((f) => !f.includes('"type":"viewers"'))).toEqual([ @@ -522,7 +539,7 @@ describe("attachRemoteSubshellWs — input/resize/cleanup ride the shared handle scriptHappy(sim); const { ws } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "edit", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "edit", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "attached"); handleSubshellMessage(ws, JSON.stringify({ type: "input", data: "ls\r" })); @@ -539,7 +556,29 @@ describe("attachRemoteSubshellWs — input/resize/cleanup ride the shared handle scriptHappy(sim); const { ws } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "view", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "view", attachParams(), false); + await until(() => sim.cmdTypes().includes("tail_start"), "attached"); + + handleSubshellMessage(ws, JSON.stringify({ type: "input", data: "rm -rf /\r" })); + handleSubshellMessage(ws, JSON.stringify({ type: "resize", cols: 80, rows: 24 })); + await until(() => sim.cmdsOf("resize").length === 1, "resize through"); + expect(sim.cmdsOf("input")).toEqual([]); + }); + + it("canInput:false at edit access types NOTHING (the ssh carve-out arrives as the required argument, not a grant re-derivation)", async () => { + // The §5.4 posture split: `access` still says `edit` for an ssh pane's + // grantee (they may WATCH, and would type on an ordinary pane); the ONE + // fact that stops them is `canInput:false`, computed by `resolveAttach` + // and passed here. The relay must not read the grant to decide input — + // a fallback that did would reopen the ssh relay door to exactly this + // caller. Task 8's `attach-resolve.test.ts` ("an ssh pane + an EDIT + // grantee is refused (access says edit, canInput says no)") pins the + // COMPUTATION; this pins the CONSUMPTION. + const sim = makeNodeSim(); + scriptHappy(sim); + const { ws } = fakeBrowser(); + + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "edit", attachParams(), false); await until(() => sim.cmdTypes().includes("tail_start"), "attached"); handleSubshellMessage(ws, JSON.stringify({ type: "input", data: "rm -rf /\r" })); @@ -553,7 +592,7 @@ describe("attachRemoteSubshellWs — input/resize/cleanup ride the shared handle scriptHappy(sim); const { ws } = fakeBrowser(); - await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "attached"); cleanupSubshellWs(ws); @@ -595,7 +634,14 @@ describe("attachRemoteSubshellWs — input/resize/cleanup ride the shared handle ); const { ws } = fakeBrowser(); - const attaching = attachRemoteSubshellWs(ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + const attaching = attachRemoteSubshellWs( + ws, + attachRow(), + new RemoteLauncher(NODE_ID), + "owner", + attachParams(), + true, + ); await until(() => sim.cmdTypes().includes("capture"), "parked in the capture"); cleanupSubshellWs(ws); // browser vanished mid-attach release?.("SCREEN"); @@ -637,6 +683,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 120, rows: 40 } }), + true, ); // The laptop alone: the pane holds ITS size. expect(sim.cmdsOf("resize").at(-1)).toEqual({ type: "resize", subshellId: SID, cols: 120, rows: 40 }); @@ -648,6 +695,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 60, rows: 20 } }), + true, ); // The joiner's attach really did drive the pane... @@ -683,6 +731,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 132, rows: 43 } }), + true, ); const geometry = sent @@ -716,6 +765,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 120, rows: 40 } }), + true, ); await attachRemoteSubshellWs( pocketed.ws, @@ -723,6 +773,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 40, rows: 12 }, deviceLabel: "Phone", hidden: true }), + true, ); // The hidden joiner takes no part: the pane stays at the laptop's size. @@ -754,6 +805,7 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => new RemoteLauncher(NODE_ID), "owner", attachParams({ size: { cols: 40, rows: 12 } }), + true, ); cleanupSubshellWs(ws); // browser gone before the attach assigned anything await attaching; @@ -779,9 +831,9 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => const first = fakeBrowser(); const second = fakeBrowser(); - await attachRemoteSubshellWs(first.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(first.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); await until(() => sim.cmdTypes().includes("tail_start"), "first tail"); - await attachRemoteSubshellWs(second.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams()); + await attachRemoteSubshellWs(second.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); expect(sim.cmdTypes().filter((t) => t === "tail_start")).toHaveLength(1); diff --git a/apps/server/api/src/ws/attach-resolve.ts b/apps/server/api/src/ws/attach-resolve.ts index 0bde440ba..90b6aeb56 100644 --- a/apps/server/api/src/ws/attach-resolve.ts +++ b/apps/server/api/src/ws/attach-resolve.ts @@ -15,6 +15,7 @@ import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; import { resolveCookieSession } from "@/lib/session-cookie.js"; +import { paneInputAllowed } from "@/lib/ssh-pane-access.js"; import { type Access, accessAtLeast, loadSubshellAccess } from "@/lib/subshell-access.js"; import { accountDisabled } from "@/services/account-status.js"; import { backupPasswordChangeRequired } from "@/services/backup-admin-recovery.js"; @@ -47,6 +48,16 @@ export interface AttachResolved { row: SubshellTable; /** The caller's access to it — `view` watches, `edit`/`owner` may type. */ access: Access; + /** + * Whether this socket may TYPE — the ONE WS keystroke authority, decided + * here and carried to both attach paths (local + remote) so they stop each + * deriving it from `access` alone. `edit`/`owner` grant AND the ssh + * owner-only carve-out ({@link paneInputAllowed}): an ssh pane drops a + * non-owner's or a scoped (bearer-minted) token's keystrokes exactly as it + * drops a `view` grantee's (spec 2026-10-07 §5.4). For an ordinary pane + * this is exactly `accessAtLeast(access, "edit")`. + */ + canInput: boolean; /** What the client declared on its URL. */ params: AttachParams; /** @@ -179,6 +190,16 @@ export async function resolveAttach(input: AttachRequest): Promise { const { size, deviceLabel, hidden, wireMode } = params; if (!getLive(row.nodeId)) { @@ -157,8 +169,12 @@ export async function attachRemoteSubshellWs( // keys the plane→node input hold by it. nodeId: row.nodeId, logFile: "", - // Only `edit`/`owner` may type into the pane; a `view` grantee watches. - canInput: accessAtLeast(access, "edit"), + // Whether THIS socket may type: `resolveAttach`'s one authority (edit/owner + // grant AND the ssh owner-only carve-out, spec §5.4), taken verbatim. No + // `access`-derived fallback: that pre-§5.4 rule would silently reopen the + // ssh relay door to a caller that omitted the argument, so the argument is + // required and an omission is a type error. + canInput, capacity: size ?? undefined, hidden, // Presence identity: who this viewer is in the `viewers` frame. The id diff --git a/apps/server/api/src/ws/subshell-ws.ts b/apps/server/api/src/ws/subshell-ws.ts index 5d7003183..1d9549f08 100644 --- a/apps/server/api/src/ws/subshell-ws.ts +++ b/apps/server/api/src/ws/subshell-ws.ts @@ -1,7 +1,6 @@ import { parseClientFrame } from "@internal/subshell-protocol"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; -import { accessAtLeast } from "@/lib/subshell-access.js"; import { launcherFor } from "@/services/nodes/launcher-registry.js"; import { replayLineCap } from "@/services/nodes/log-tail.js"; import type { RemoteLauncher } from "@/services/nodes/remote-launcher.js"; @@ -92,7 +91,7 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { ws.close(resolved.code, resolved.reason); return; } - const { row, access, params, attendsPush } = resolved; + const { row, access, canInput, params, attendsPush } = resolved; // Stash WHO authenticated onto the socket before either path continues — // the local `Object.assign` and the remote relay's both build their own // `data` literals, and this channel is what lets an account disable find @@ -115,7 +114,7 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { // The WHOLE params struct, not a hand-picked few: this call site is where // `hidden` went missing for node panes, because it took each input as its // own positional argument and one of them was simply never added. - await attachRemoteSubshellWs(ws, row, launcher as RemoteLauncher, access, params); + await attachRemoteSubshellWs(ws, row, launcher as RemoteLauncher, access, params, canInput); return; } // Split out so the rest of this function sees a non-null socket, and so the @@ -157,8 +156,9 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { // session's holds. nodeId: row.nodeId, logFile: subshellLogPath(row.id), - // Only `edit`/`owner` may type into the pane; a `view` grantee watches. - canInput: accessAtLeast(access, "edit"), + // Whether THIS socket may type: `edit`/`owner` grant AND the ssh owner-only + // carve-out, both folded at the one authority (`resolveAttach`). + canInput, // This viewer's own capacity, from the connect URL. One input to the // shared decision below — never applied on its own. capacity: params.size ?? undefined, diff --git a/apps/server/api/tsconfig.build.json b/apps/server/api/tsconfig.build.json index d84e3e347..673468734 100644 --- a/apps/server/api/tsconfig.build.json +++ b/apps/server/api/tsconfig.build.json @@ -1,9 +1,11 @@ { "extends": "./tsconfig.json", "//": [ - "Build-only config. Identical to tsconfig.json except that test files and", - "the cross-suite fixtures under src/test-helpers/ are excluded from emit,", - "so dist/ contains shipping code only.", + "Build-only config. Adds the emit layout (rootDir/outDir, kept out of the", + "shared config below so the relay integration test can import agent", + "sources outside src/ without a TS6059) and excludes test files and the", + "cross-suite fixtures under src/test-helpers/ from emit, so dist/ contains", + "shipping code only.", "", "Two things depended on this: `prod` runs from dist/, so compiled tests were", "being shipped; and a bare `bun test` picked up both src/ and dist/ copies,", @@ -12,5 +14,9 @@ "verify-types deliberately stays on tsconfig.json (the full project) so test", "files are still type-checked — they just aren't emitted." ], + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, "exclude": ["src/**/__tests__/**", "src/test-helpers/**", "src/test-preload.ts"] } diff --git a/apps/server/api/tsconfig.json b/apps/server/api/tsconfig.json index 550f4b7f6..ae790a6da 100644 --- a/apps/server/api/tsconfig.json +++ b/apps/server/api/tsconfig.json @@ -1,9 +1,15 @@ { "extends": "@internal/tsconfig/tsconfig.json", "include": ["src/**/*"], + "//": [ + "No rootDir/outDir here (the typecheck config): the relay integration test", + "under src/**/__tests__ imports the agent's relay modules from", + "apps/node/agent IN PLACE, and an emit layout (explicit or inferred from", + "outDir) makes every such file a TS6059. The build config pins both.", + "The DOM lib (which the agent's own tsconfig carries too) supplies the", + "WebCrypto globals (JsonWebKey) the agent's relay modules reference." + ], "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", "module": "NodeNext", "moduleResolution": "node16", "paths": { diff --git a/apps/server/web/AGENTS.md b/apps/server/web/AGENTS.md index a67319535..c25582447 100644 --- a/apps/server/web/AGENTS.md +++ b/apps/server/web/AGENTS.md @@ -413,3 +413,34 @@ tap-only strip never takes draggable tabs off a window holding a mouse. **Working on the terminal, dockview, touch/swipe handling, or shared-grid sizing, and before ANY `dockview-react` upgrade (which a standing hand-run probe gates): read apps/server/web/docs/terminal-gotchas.md first.** + +## SSH relay launch and setup + +The SSH terminal choice in New subshell can use loaded SSH-agent keys from +another machine the user can launch on. Both the connecting machine and key +source follow existing machine launch access; configuration stays owner/admin. +There are no separate SSH grants or approvals. The default omits `keyHome` +and uses the connecting machine's own keys. A relay launch carries explicit +fingerprints, retained for “Set up Subshell here”; never silently substitute or +widen the selection when keys disappear or a machine goes offline. + +The reusable SSH wizard lives inside the existing dialog and preserves the +launch choices and workspace/split context. Its intents cover connecting, +preparing a machine, and selecting another machine's keys. Finishing setup +returns to the form and requires a fresh Start SSH subshell click. Settings +and machine pages open the same wizard. Old `/connect` links open the same +subshell dialog through `/new?kind=ssh`. Failed reads must stay distinct from +empty lists, with retry actions. Read `docs/launch-form-picker.md` for the +shared draft, readiness, enrollment, and launch-callback contracts. + +“Set up Subshell here” can be dismissed while running. Its owner-scoped status +read observes stages selected by the server, never installer output. Reopening +or refreshing joins the same running operation; a duplicate POST also joins it. +Completed results remain for one hour in the running server process. This is +not a durable job across server restarts: after a restart, check Nodes and the +destination before retrying an interrupted install. Keep that limitation honest +in any future recovery UI. + +Enrollment without a confirmed connection is terminal status `enrolled`, with +the node id retained. Link to that node and its service recovery; do not offer +another install or describe it as connected. diff --git a/apps/server/web/docs/connect-page.md b/apps/server/web/docs/connect-page.md new file mode 100644 index 000000000..0b26c52d1 --- /dev/null +++ b/apps/server/web/docs/connect-page.md @@ -0,0 +1,26 @@ +# The Connect page + +The Connect page and SSH launch dialogs use the same connection form and setup +wizard. Users choose a destination, the machine that connects to it, and +optionally another machine whose SSH agent supplies the keys. + +Destination suggestions include saved destinations, recent connections, and +aliases from the selected machine’s SSH config. Users can also type a +destination. The server resolves it on the connecting machine before launching. +The form displays the server’s refusal and any blocked SSH settings so users +know what to change. + +Keep unavailable machines visible with their reason. The setup wizard can +enable SSH when the user has configuration access, or enroll another machine. +Preparing a machine never starts a connection automatically: the user reviews +their choices and selects **Start SSH subshell**. + +The destination field uses an input and suggestion panel rather than the shared +combobox. Base UI’s held input loses typed text when its items change on each +keystroke; the working-directory field uses the same approach. + +Use **connecting machine** for the machine that runs SSH and **machine supplying +your keys** when explaining the optional remote key source. The code’s +`keyHome` identifier doesn’t need to appear in user-facing explanations. An +**SSH agent** is the program that holds SSH keys, distinct from an AI agent or +the Subshell daemon. diff --git a/apps/server/web/docs/launch-form-picker.md b/apps/server/web/docs/launch-form-picker.md index 1a0d5bdf2..a8d1e84ee 100644 --- a/apps/server/web/docs/launch-form-picker.md +++ b/apps/server/web/docs/launch-form-picker.md @@ -157,3 +157,48 @@ Mobile's New screen does NOT mirror this tier (operator scope call: web only; `agent-default.ts` keeps its "change one, change both" for the AGENT rule only). + +## SSH subshells + +New subshell, Add/Split, and New workspace offer the same `SubshellKindPicker`: +Agent or terminal, or SSH terminal. The SSH choice uses `ConnectPanel`, with +callbacks that attach the created subshell to the caller's workspace instead +of navigating away. Keep launch pending until that attachment finishes. + +The SSH destination accepts typed text directly; suggestions retain their +canonical destination until edited. Explain the connecting machine beside its +picker. Alternate key machines belong under Advanced SSH options and require +an explicit selection of current SSH-agent fingerprints; no keys are selected +automatically, and a disappeared selected key blocks launch. The server can +connect or supply remote agent keys under the same launch-access rule as nodes. +Saved destination management lives in Settings → SSH. + +**SSH Wizard** is available from empty, partial, and ready connection forms, +personal SSH settings, and each node's overview. It stays inside the caller's +existing dialog: Connect to a destination, Prepare a machine for SSH, and Use +keys from another machine share one draft and adapt their steps to current +readiness. Setup completion never launches a pane. Prepare ends with Done or +Connect now; remote keys ends with Use this setup, returning to the same form. +The original parent callback still owns workspace selection or split attachment, +and busy state lasts until asynchronous attachment finishes. Changing a parent +mode keeps the destination, canonical suggestion, machine, scoped fingerprints, +and remember choice. + +Enabling SSH is an explicit owner/admin configuration act, independent of launch +access. Offline, maintenance, version, missing-access, and read-failure blockers +remain visible and retryable; selections never silently change when readiness +changes. Enrollment embeds the shared setup-key fields, preserves the generated +key and chosen method/address across Back, and matches that exact key's +`consumedNodeId`. Enrollment and coming online are separate states. The instance's +enrollment policy disables adding a machine while keeping existing choices usable. +Polling lives only on mounted open SSH surfaces. + +Remote key enumeration reads the Subshell process's SSH agent. An empty roster +means keys need loading; an unreachable agent means that process needs access to +`SSH_AUTH_SOCK`, which may differ from an interactive login shell's environment. +Normal same-machine SSH can use identity files without any agent. Readiness does +not claim that authentication to the destination has been tested. + +`/connect` is a compatibility redirect to `/new?kind=ssh`, preserving validated +connection choices. The quick-add provider opens the shared dialog over the +subshell list; there is no separate Connect navigation item or page. diff --git a/apps/server/web/package.json b/apps/server/web/package.json index 72118dc8c..0355d4e9c 100644 --- a/apps/server/web/package.json +++ b/apps/server/web/package.json @@ -12,7 +12,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe src", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 12 bun test --timeout 30000 --pass-with-no-tests", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 12 bun test --timeout 45000 --pass-with-no-tests", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/server/web/src/components/__tests__/app-sidebar-account.test.tsx b/apps/server/web/src/components/__tests__/app-sidebar-account.test.tsx index 47095267c..421b04e87 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar-account.test.tsx +++ b/apps/server/web/src/components/__tests__/app-sidebar-account.test.tsx @@ -102,6 +102,7 @@ let restoreQuickAdd: () => void; beforeEach(() => { const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openSshLaunch: () => {}, openNewWorkspace: () => {}, }); restoreQuickAdd = () => spy.mockRestore(); diff --git a/apps/server/web/src/components/__tests__/app-sidebar-group.test.tsx b/apps/server/web/src/components/__tests__/app-sidebar-group.test.tsx index 0d428f349..55406d10b 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar-group.test.tsx +++ b/apps/server/web/src/components/__tests__/app-sidebar-group.test.tsx @@ -104,6 +104,7 @@ describe("the Server Settings group's open/close wiring", () => { const restoreFetch = stubFetch(true); const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openSshLaunch: () => {}, openNewWorkspace: () => {}, }); try { @@ -183,6 +184,7 @@ describe("the Server Settings group's open/close wiring", () => { const restoreFetch = stubFetch(false); const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openSshLaunch: () => {}, openNewWorkspace: () => {}, }); try { @@ -221,6 +223,7 @@ describe("the Settings group", () => { const restoreFetch = stubFetch(false); // NOT an admin: the group is ungated const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openSshLaunch: () => {}, openNewWorkspace: () => {}, }); try { diff --git a/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx b/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx index e8acfec31..f8dc16f60 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx +++ b/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx @@ -194,6 +194,7 @@ const withRail = async ( const restoreFetch = stubFetch(subshells, state); const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openSshLaunch: () => {}, openNewWorkspace: () => {}, }); try { @@ -660,7 +661,11 @@ describe("the eye toggle (operator ask 2026-09-27)", () => { it("reads a hidden section back from storage on the next mount", async () => { localStorage.setItem(HIDDEN_KEY, JSON.stringify({ subshells: true })); const restore = stubFetch([subshell({ id: "a", name: "one", nodeId: "local" })]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail(); // The eye is ALWAYS drawn (it is the way back); it says Show, and the @@ -701,7 +706,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { draft("w1", "2026-08-28T16:45:00.000Z"), draft("w2", "2026-09-01T09:05:00.000Z"), ]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/"); await waitFor(() => expect(document.querySelector("a[href='/workspaces/w1']")).toBeTruthy()); @@ -717,7 +726,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { it("highlights the draft you are standing in", async () => { const restore = stubFetch([], { failNodes: false }, [], undefined, [draft("w1", "2026-08-28T16:45:00.000Z")]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/workspaces/w1"); await waitFor(() => expect(document.querySelector("a[href='/workspaces/w1']")).toBeTruthy()); @@ -732,7 +745,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { it("collapses a Workspaces section on its header and remembers it", async () => { const restore = stubFetch([], { failNodes: false }, [], undefined, [draft("w1", "2026-08-28T16:45:00.000Z")]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/"); await waitFor(() => expect(document.getElementById("ws-group-drafts")).toBeTruthy()); @@ -767,7 +784,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { updatedAt: "2026-09-25T00:00:00.000Z", }; const restore = stubFetch([], { failNodes: false }, [saved], undefined, [draft("d1", "2026-08-28T16:45:00.000Z")]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/workspaces/s1"); await waitFor(() => expect(document.querySelector("a[href='/workspaces/d1']")).toBeTruthy()); @@ -786,7 +807,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { draft("w1", "2026-08-28T16:45:00.000Z"), draft("w2", "2026-08-28T16:45:30.000Z"), ]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/"); await waitFor(() => expect(document.querySelector("a[href='/workspaces/w2']")).toBeTruthy()); @@ -808,7 +833,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { // so a user with nothing saved typing a non-matching query unmounted the // section — the input they were typing in — with no way to clear it. const restore = stubFetch([], { failNodes: false }, [], undefined, [draft("d1", "2026-08-28T16:45:00.000Z")]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/"); const input = await screen.findByLabelText("Filter workspaces"); @@ -832,7 +861,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { updatedAt: "2026-09-25T00:00:00.000Z", }; const restore = stubFetch([], { failNodes: false }, [saved]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/"); await waitFor(() => expect(screen.getByText("My saved")).toBeTruthy()); @@ -880,7 +913,11 @@ describe("the workspace search filter (operator ask 2026-09-27)", () => { draft("d1", "Doc-thing", "2026-08-28T16:45:00.000Z"), draft("d2", "Other", "2026-08-28T16:45:30.000Z"), ]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/"); const savedHeader = () => screen.getByRole("button", { name: /^Saved/ }) as HTMLButtonElement; @@ -906,7 +943,11 @@ describe("the workspace search filter (operator ask 2026-09-27)", () => { it("filters the workspace list as you type, over the whole set", async () => { const rows = [ws("w1", "Alpha", "2026-09-20T00:00:00.000Z"), ws("w2", "Docs", "2026-09-25T00:00:00.000Z")]; const restore = stubFetch([], { failNodes: false }, rows); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail(); const input = await screen.findByLabelText("Filter workspaces"); diff --git a/apps/server/web/src/components/__tests__/app-sidebar.test.ts b/apps/server/web/src/components/__tests__/app-sidebar.test.ts index 8fd469941..157dac278 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar.test.ts +++ b/apps/server/web/src/components/__tests__/app-sidebar.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { ServerCog, Settings, SlidersHorizontal } from "lucide-react"; +import { Fingerprint, ServerCog, Settings, SlidersHorizontal } from "lucide-react"; import { isNavGroup, visibleNavEntries, visibleNavItems } from "@/components/sidebar/sidebar-nav"; describe("sidebar nav icons (spec 2026-09-02 §3, 2026-09-11 §3.1)", () => { @@ -20,6 +20,22 @@ describe("sidebar nav icons (spec 2026-09-02 §3, 2026-09-11 §3.1)", () => { expect(entries.filter(isNavGroup).find((g) => g.id === "server-settings")?.icon).toBe(ServerCog); }); + it("SSH launches with subshells rather than a separate navigation page", () => { + expect(items.some((i) => i.to === "/connect")).toBe(false); + }); + + it("SSH takes Fingerprint, in the personal Settings group (its ledger is per-owner)", () => { + // /settings/ssh rides the member's own group, not Server Settings: the + // grants ledger is the caller's (spec 2026-10-08 §8), and a non-admin + // must reach it. The path under /settings/ is deliberate; General lights + // exact-match only, so the two groups cannot both claim the page. + expect(items.find((i) => i.to === "/settings/ssh")?.icon).toBe(Fingerprint); + const personal = entries.filter(isNavGroup).find((g) => g.id === "personal-settings"); + expect(personal?.children.some((c) => c.to === "/settings/ssh")).toBe(true); + // And it is visible to a member: the WHOLE personal group is ungated. + expect(visibleNavItems(false).some((i) => i.to === "/settings/ssh")).toBe(true); + }); + it("no two visible icons are the same — group headers included", () => { // Icons are how the collapsed rail names a page, so a duplicate there is // two rows that read as one. The group's own icon counts: it sits in the diff --git a/apps/server/web/src/components/__tests__/clone-subshell-dialog.test.tsx b/apps/server/web/src/components/__tests__/clone-subshell-dialog.test.tsx index d5cf02ed6..198af03b6 100644 --- a/apps/server/web/src/components/__tests__/clone-subshell-dialog.test.tsx +++ b/apps/server/web/src/components/__tests__/clone-subshell-dialog.test.tsx @@ -37,6 +37,7 @@ function makeSource(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/existing-subshell-list.test.tsx b/apps/server/web/src/components/__tests__/existing-subshell-list.test.tsx index 4e029ec6f..214b112e6 100644 --- a/apps/server/web/src/components/__tests__/existing-subshell-list.test.tsx +++ b/apps/server/web/src/components/__tests__/existing-subshell-list.test.tsx @@ -45,6 +45,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, nodeOffline: false, ...overrides, }; diff --git a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx index cb3c77845..92844f32a 100644 --- a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx +++ b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx @@ -1358,10 +1358,12 @@ describe("NetworkPluginCard: the rules that are not about one state", () => { // 7.6s wall for this act/NDJSON chain at 0.4 CPU (it passes in 25ms // idle). bun's default 5s per-test timeout was firing mid-chain. The // per-wait budgets stay honest; the load allowance is NOT here anymore - - // the package's script carries `--timeout 30000` (issue #261 round 2, - // after CI's serial run measured this file's sibling chain at 26.2 s - // against a 20 s per-test ceiling), and one knob per package is the rule - // the server package's testing-notes record for exactly this whack-a-mole. + // the package's script carries `--timeout 45000` (issue #261 round 2 set it + // to 30000 after CI's serial run measured this file's sibling chain at + // 26.2 s against a 20 s ceiling; the 2026-10-08 serial run timed out the + // fail-then-succeed test BELOW at 30.098 s on a larger suite, moving the + // knob one more notch), and one knob per package is the rule the server + // package's testing-notes record for exactly this whack-a-mole. it("a failed act says so", async () => { failedPublishThenLeave(); await renderCard(JOINED); diff --git a/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx b/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx index eca4b9887..65c4b1c9a 100644 --- a/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx +++ b/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx @@ -55,6 +55,7 @@ function node(overrides: Partial): Node { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/components/__tests__/node-row.test.tsx b/apps/server/web/src/components/__tests__/node-row.test.tsx index 7b2d51b51..f30afc40d 100644 --- a/apps/server/web/src/components/__tests__/node-row.test.tsx +++ b/apps/server/web/src/components/__tests__/node-row.test.tsx @@ -29,6 +29,7 @@ const BASE: Node = { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, }; diff --git a/apps/server/web/src/components/__tests__/open-in-browser.test.tsx b/apps/server/web/src/components/__tests__/open-in-browser.test.tsx index 70fa74908..2361d0952 100644 --- a/apps/server/web/src/components/__tests__/open-in-browser.test.tsx +++ b/apps/server/web/src/components/__tests__/open-in-browser.test.tsx @@ -103,6 +103,7 @@ function stubFetch() { // provider would mount two dialogs and their data, none of it under test. const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openSshLaunch: () => {}, openNewWorkspace: () => {}, }); return () => { @@ -155,6 +156,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/pane-diagnostics-hud.test.tsx b/apps/server/web/src/components/__tests__/pane-diagnostics-hud.test.tsx index 52eba59a8..f4b59b40c 100644 --- a/apps/server/web/src/components/__tests__/pane-diagnostics-hud.test.tsx +++ b/apps/server/web/src/components/__tests__/pane-diagnostics-hud.test.tsx @@ -32,6 +32,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/quick-add.test.tsx b/apps/server/web/src/components/__tests__/quick-add.test.tsx index 8480c6f3a..218a09f0b 100644 --- a/apps/server/web/src/components/__tests__/quick-add.test.tsx +++ b/apps/server/web/src/components/__tests__/quick-add.test.tsx @@ -74,7 +74,7 @@ describe("QuickAddProvider", () => { await act(async () => { screen.getByText("rail + (subshell)").click(); }); - expect(await screen.findByText("Launch an agent in a working directory.")).toBeDefined(); + expect(await screen.findByText("Start an agent, a terminal, or an SSH session in a subshell.")).toBeDefined(); // The workspace dialog stays closed — the two triggers are independent. expect(screen.queryByText("Start it with subshells already tiled in, or empty.")).toBeNull(); await flush(); @@ -86,7 +86,7 @@ describe("QuickAddProvider", () => { screen.getByText("rail + (workspace)").click(); }); expect(await screen.findByText("Start it with subshells already tiled in, or empty.")).toBeDefined(); - expect(screen.queryByText("Launch an agent in a working directory.")).toBeNull(); + expect(screen.queryByText("Start an agent, a terminal, or an SSH session in a subshell.")).toBeNull(); await flush(); }); diff --git a/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx b/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx new file mode 100644 index 000000000..3a3d0d606 --- /dev/null +++ b/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SetupHereDialog } from "@/components/setup-here-dialog"; +import type { SubshellView } from "@/types/subshell"; + +/** Minimal ssh pane view (the menu fixture's shape) with overrides. */ +function makeSshPane(overrides: Partial = {}): SubshellView { + return { + id: "pane-1", + presetId: null, + harnessId: "ssh", + nodeId: "mac-mini", + nodeOffline: false, + name: "deploy box", + nameLocked: false, + workingDir: "/home/scripted", + status: "running", + createdAt: "2026-10-08T00:00:00.000Z", + endedAt: null, + lastOutputAt: null, + activity: "active", + alive: true, + exitCode: null, + startedAt: null, + backoffCount: 0, + restartOnExit: false, + nextRestartAt: null, + notify: false, + waitingSince: null, + unseenPush: false, + access: "owner", + ssh: true, + ...overrides, + }; +} + +describe("SetupHereDialog", () => { + afterEach(cleanup); + + /** fetch recorder; the setup POST answers per the case's script. */ + function mockFetch(answer: { status?: number; body: unknown }) { + const calls: { url: string; body: unknown }[] = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + calls.push({ url: url.pathname, body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined }); + return Promise.resolve( + url.pathname.startsWith("/api/ssh/setup-here/") + ? new Response(JSON.stringify({ operation: null })) + : new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 }), + ); + }) as typeof fetch; + return { calls, restore: () => (globalThis.fetch = original) }; + } + + async function settle(): Promise { + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + } + + async function renderDialog(subshell: SubshellView, onOpenChange = (_: boolean) => {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const rootRoute = createRootRoute(); + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => , + }); + const nodeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/nodes/$id", + component: () => null, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nodeRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + defaultPreload: false, + }); + await router.load(); + render( + + + , + ); + await settle(); + } + + it("explains setup under a static title, then posts the pane id", async () => { + const { calls, restore } = mockFetch({ body: { nodeId: "new-node-1" } }); + try { + await renderDialog(makeSshPane()); + expect(screen.getByText("Set up Subshell here?")).toBeTruthy(); + expect(screen.getByText(/keeps running/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + await settle(); + const post = calls.find((c) => c.url === "/api/ssh/setup-here"); + expect(post?.body).toEqual({ paneId: "pane-1" }); + } finally { + restore(); + } + }); + + it("success names the enrolled machine and links to its page", async () => { + const { restore } = mockFetch({ body: { nodeId: "new-node-1" } }); + try { + let closed = false; + await renderDialog(makeSshPane(), () => (closed = true)); + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + await waitFor(() => expect(screen.queryByText(/Installing on the destination/)).toBeNull()); + const link = screen.getByRole("link", { name: "Open its page" }) as HTMLAnchorElement; + expect(link.getAttribute("href")).toContain("/nodes/new-node-1"); + // Done leaves; the finished answer does not outlive the dialog (mount- + // while-open posture, checked at the menu level). + fireEvent.click(screen.getByRole("button", { name: "Done" })); + expect(closed).toBe(true); + } finally { + restore(); + } + }); + + it("links to an enrolled offline machine without offering another installation", async () => { + const { restore } = mockFetch({ body: { nodeId: "offline-node", connected: false } }); + try { + await renderDialog(makeSshPane()); + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + expect(await screen.findByText(/haven’t confirmed its connection/)).toBeTruthy(); + expect(screen.queryByText("The machine enrolled and connected.")).toBeNull(); + expect(screen.getByRole("link", { name: "Open its page" }).getAttribute("href")).toContain("/nodes/offline-node"); + expect(screen.queryByRole("button", { name: "Set up Subshell" })).toBeNull(); + } finally { + restore(); + } + }); + + it("the server's named refusal is shown verbatim, in destructive, and the act can be retried", async () => { + const { calls, restore } = mockFetch({ + status: 409, + body: { + code: "SSH_UPGRADE_EGRESS", + message: + "The destination could not reach http://plane.test, so it cannot install Subshell. Nothing was enrolled; the connection and its pane are untouched.", + }, + }); + try { + await renderDialog(makeSshPane()); + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + await waitFor(() => expect(screen.getByText(/could not reach/)).toBeTruthy()); + expect(screen.getByText(/untouched/)).toBeTruthy(); + // The act button comes back: a refused act is retriable (the key was + // minted and revoked fresh per run). + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + await settle(); + expect(calls.filter((c) => c.url === "/api/ssh/setup-here")).toHaveLength(2); + } finally { + restore(); + } + }); + it("can close and reopen an install without another POST, including after a page remount", async () => { + const original = globalThis.fetch; + let posts = 0; + let operation: { stage: string; startedAt: string; nodeId: string | null; error: null } | null = null; + let finish!: (value: Response) => void; + globalThis.fetch = ((_input: unknown, init?: RequestInit) => { + if (init?.method === "POST") { + posts++; + operation = { stage: "installing", startedAt: new Date().toISOString(), nodeId: null, error: null }; + return new Promise((resolve) => { + finish = resolve; + }); + } + return Promise.resolve(new Response(JSON.stringify({ operation }))); + }) as typeof fetch; + try { + let closed = false; + await renderDialog(makeSshPane(), () => { + closed = true; + }); + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + fireEvent.click(await screen.findByRole("button", { name: "Continue working" })); + expect(closed).toBe(true); + cleanup(); + await renderDialog(makeSshPane()); + expect(screen.getByText(/Installing on the destination/)).toBeTruthy(); + expect((screen.getByRole("button", { name: "Setting up…" }) as HTMLButtonElement).disabled).toBe(true); + expect(posts).toBe(1); + operation = { stage: "complete", startedAt: new Date().toISOString(), nodeId: "new-node", error: null }; + finish(new Response(JSON.stringify({ nodeId: "new-node" }))); + expect(await screen.findByRole("link", { name: "Open its page" }, { timeout: 3000 })).toBeTruthy(); + expect(posts).toBe(1); + } finally { + globalThis.fetch = original; + } + }); +}); diff --git a/apps/server/web/src/components/__tests__/sidebar-nav.test.ts b/apps/server/web/src/components/__tests__/sidebar-nav.test.ts index 2138424ed..51b6593af 100644 --- a/apps/server/web/src/components/__tests__/sidebar-nav.test.ts +++ b/apps/server/web/src/components/__tests__/sidebar-nav.test.ts @@ -36,17 +36,22 @@ describe("visibleNavItems", () => { for (const flag of [false, undefined]) { const paths = visibleNavItems(flag).map((i) => i.to); for (const page of GROUP_PAGES) expect(paths).not.toContain(page); - expect(paths).toEqual(["/", "/workspaces", "/nodes", "/presets", "/prompts"]); + // `/settings/ssh` rides the PERSONAL group: its path is under + // /settings but the page is the caller's own ledger (spec 2026-10-08 + // §8), so a member reaches it and the gate never hides it. + expect(paths).toEqual(["/", "/workspaces", "/nodes", "/presets", "/prompts", "/settings/ssh"]); } }); it("gives an admin every group page, flattened in rail order", () => { expect(visibleNavItems(true).map((i) => i.to)).toEqual([ "/", + "/workspaces", "/nodes", "/presets", "/prompts", + "/settings/ssh", ...GROUP_PAGES, ]); }); diff --git a/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx b/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx new file mode 100644 index 000000000..8d229bfcc --- /dev/null +++ b/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { cleanup, render, screen } from "@testing-library/react"; +import { SSH_VIEW_ONLY_COPY, SshPaneLabels } from "@/components/ssh-pane-labels"; +import type { SubshellView } from "@/types/subshell"; + +afterEach(() => cleanup()); + +/** The fields this header row reads; the rest of the view is irrelevant here. */ +function row(overrides: Partial = {}): SubshellView { + return { id: "s-1", ssh: false, access: "owner", ...overrides } as SubshellView; +} + +describe("SshPaneLabels", () => { + it("labels an owner's ssh pane with the SSH badge and says nothing more", () => { + render(); + expect(screen.queryByText("SSH")).not.toBeNull(); + expect(screen.queryByText(SSH_VIEW_ONLY_COPY)).toBeNull(); + }); + + it("labels an edit grantee's ssh pane with the badge only (the sentence is the viewer's)", () => { + render(); + expect(screen.queryByText("SSH")).not.toBeNull(); + expect(screen.queryByText(SSH_VIEW_ONLY_COPY)).toBeNull(); + }); + + it("tells a view-only ssh viewer why typing is off", () => { + render(); + expect(screen.queryByText("SSH")).not.toBeNull(); + expect(screen.queryByText("Only the owner can type in this SSH subshell.")).not.toBeNull(); + // One sentence pinned exactly (copy rule), and no em/en dash in it. + expect(SSH_VIEW_ONLY_COPY).toBe("Only the owner can type in this SSH subshell."); + expect(SSH_VIEW_ONLY_COPY).not.toMatch(/[–—]/); + }); + + it("stays silent for an ordinary pane at any access (no regression for view grantees)", () => { + const view = render(); + expect(view.container.textContent).toBe(""); + cleanup(); + const owner = render(); + expect(owner.container.textContent).toBe(""); + }); + + it("renders nothing while the record has not arrived", () => { + const { container } = render(); + expect(container.textContent).toBe(""); + }); + + it("treats an absent ssh field as not-ssh (an older cached payload)", () => { + // A payload cached before the field existed has no `ssh` key at all; + // the component's test is `=== true`, so it must read as not-ssh. + const cached = { id: "s-1", access: "view" } as SubshellView; + const { container } = render(); + expect(container.textContent).toBe(""); + }); +}); diff --git a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx index c4ed25f43..1922bdf9b 100644 --- a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx @@ -38,6 +38,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } @@ -656,3 +657,37 @@ describe("SubshellActionsMenu — prompt input availability", () => { } }); }); + +describe("SubshellActionsMenu - Set up Subshell here (spec 2026-10-08 §7)", () => { + afterEach(cleanup); + + it("offers it on the owner's live SSH pane and opens the dialog", async () => { + const { restore } = mockFetch(); + try { + await renderMenu(makeSubshell({ ssh: true })); + await openMenu("subshell"); + fireEvent.click(screen.getByRole("menuitem", { name: "Set up Subshell here…" })); + expect(await screen.findByText("Set up Subshell here?")).toBeTruthy(); + } finally { + restore(); + } + }); + + for (const hidden of [ + { ssh: false }, + { ssh: true, access: "edit" as const }, + { ssh: true, access: "view" as const }, + { ssh: true, nodeOffline: true }, + ]) { + it(`does not offer it for ${JSON.stringify(hidden)}`, async () => { + const { restore } = mockFetch(); + try { + await renderMenu(makeSubshell(hidden)); + await openMenu("subshell"); + expect(screen.queryByRole("menuitem", { name: "Set up Subshell here…" })).toBeNull(); + } finally { + restore(); + } + }); + } +}); diff --git a/apps/server/web/src/components/__tests__/subshell-card.test.tsx b/apps/server/web/src/components/__tests__/subshell-card.test.tsx index fb8abf740..15e7da1fc 100644 --- a/apps/server/web/src/components/__tests__/subshell-card.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-card.test.tsx @@ -46,6 +46,7 @@ function agent(overrides: Partial = {}): Node { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...overrides, }; @@ -76,6 +77,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/subshell-manager-table.test.tsx b/apps/server/web/src/components/__tests__/subshell-manager-table.test.tsx index 21c5dbd82..41d395937 100644 --- a/apps/server/web/src/components/__tests__/subshell-manager-table.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-manager-table.test.tsx @@ -40,6 +40,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/subshell-status.test.tsx b/apps/server/web/src/components/__tests__/subshell-status.test.tsx index e313ffe4a..853ac6503 100644 --- a/apps/server/web/src/components/__tests__/subshell-status.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-status.test.tsx @@ -27,6 +27,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, nodeOffline: false, ...overrides, }; diff --git a/apps/server/web/src/components/__tests__/switch-preset-dialog.test.tsx b/apps/server/web/src/components/__tests__/switch-preset-dialog.test.tsx index b01e83530..f2b73fc60 100644 --- a/apps/server/web/src/components/__tests__/switch-preset-dialog.test.tsx +++ b/apps/server/web/src/components/__tests__/switch-preset-dialog.test.tsx @@ -30,6 +30,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/terminal-overlay-stack.test.tsx b/apps/server/web/src/components/__tests__/terminal-overlay-stack.test.tsx index 72bddcbae..d1442898f 100644 --- a/apps/server/web/src/components/__tests__/terminal-overlay-stack.test.tsx +++ b/apps/server/web/src/components/__tests__/terminal-overlay-stack.test.tsx @@ -32,6 +32,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/__tests__/workspace-pane-actions.test.tsx b/apps/server/web/src/components/__tests__/workspace-pane-actions.test.tsx index 35c60191a..afad745b9 100644 --- a/apps/server/web/src/components/__tests__/workspace-pane-actions.test.tsx +++ b/apps/server/web/src/components/__tests__/workspace-pane-actions.test.tsx @@ -32,6 +32,7 @@ function row(id: string, access: SubshellView["access"] = "owner"): SubshellView status: "running", alive: true, access, + ssh: false, nodeOffline: false, notify: false, activity: "idle", diff --git a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx new file mode 100644 index 000000000..f1df3cba4 --- /dev/null +++ b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx @@ -0,0 +1,701 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { Node } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { ConnectPanel, SSH_DISCLOSURE_COPY } from "@/components/connect/connect-panel"; + +/** + * The /connect behavior contract (plan 3 Task 3), pinned on the composed + * panel with the wire mocked the way `new-subshell-form.test.tsx` mocks it + * (fetch, not the hooks - the hooks are pinned by `use-ssh.test.tsx`). The + * refusal CODE matrix is pinned purely in `ssh-refusal.test.ts`; two arms run + * through the panel here to prove the wiring (the 422 out of the parsed body, + * the 409 under the machine field). + */ + +interface Call { + method: string; + url: string; + body?: unknown; +} + +function node(overrides: Partial): Node { + return { + id: "n", + name: "node", + kind: "agent", + os: null, + arch: null, + hostname: null, + status: "online", + lastSeenAt: null, + agentVersion: null, + protocolVersion: null, + access: "owner", + canManage: true, + canRetire: true, + canLaunch: true, + allowedDirs: [], + capabilities: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: false, + held: null, + ...overrides, + }; +} + +const HOST_A = node({ id: "a1", name: "mac mini", sshEnabled: true }); +const HOST_B = node({ id: "b1", name: "studio", sshEnabled: false }); +const HOST_C = node({ id: "c1", name: "server room", sshEnabled: true }); + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +interface WireOpts { + nodes?: Node[]; + nodesResponse?: () => Response; + settings?: unknown; + ledger?: unknown; + aliases?: string[]; + launch?: () => Response; +} + +/** Mock the whole /connect surface and record every call with its body. */ +function mockFetch(opts: WireOpts = {}) { + const calls: Call[] = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + const path = url.pathname; + calls.push({ + method, + url: path + url.search, + ...(typeof init?.body === "string" ? { body: JSON.parse(init.body) as unknown } : {}), + }); + if (path === "/api/settings/public") return Promise.resolve(json(opts.settings ?? {})); + if (path === "/api/ssh/readiness") + return Promise.resolve( + opts.nodesResponse?.() ?? + json({ + machines: (opts.nodes ?? []).map((node) => ({ + node, + canConnect: + node.canLaunch && node.sshEnabled && !node.held && !node.maintenance && node.status === "online", + canConfigure: node.canManage, + blockers: !node.sshEnabled + ? [ + { + code: "SSH_GATE_OFF", + message: + node.kind === "local" + ? "SSH is off here. An admin can switch it on from this machine's settings." + : "SSH is off on this machine. Its owner can switch it on from this machine's settings.", + }, + ] + : node.status !== "online" + ? [{ code: "NODE_OFFLINE", message: "Start Subshell on this machine to bring it online." }] + : [], + })), + }), + ); + if (path === "/api/ssh/identities") + return Promise.resolve(json({ identities: [{ fingerprint: "SHA256:AAAA", comment: "Work key" }] })); + if (path === "/api/ssh/saved-hosts" && method === "GET") { + return Promise.resolve(json(opts.ledger ?? { saved: [], recent: [], defaultNodeId: null })); + } + if (path === "/api/ssh/saved-hosts" && method === "PUT") { + return Promise.resolve( + json({ id: "r1", destination: "x", alias: null, nodeId: "a1", savedAt: "t", lastConnectAt: "t" }), + ); + } + if (path === "/api/ssh/preferences") return Promise.resolve(json({ defaultNodeId: "a1" })); + if (path === "/api/ssh/aliases") { + return Promise.resolve(json({ aliases: opts.aliases ?? [], includeCycle: false, truncated: false })); + } + if (path === "/api/ssh/launch") { + return Promise.resolve(opts.launch ? opts.launch() : json({ subshell: { id: "s-1" } }, 201)); + } + return Promise.resolve(json({})); + }) as typeof fetch; + return { calls, restore: () => (globalThis.fetch = original) }; +} + +interface Rendered { + calls: Call[]; + /** Current path: the navigation assertion reads the router state live. */ + pathname: () => string; + restore: () => void; +} + +async function renderPanel( + opts: WireOpts, + initial?: { node?: string; keyHome?: string; destination?: string; fingerprints?: string[] }, +): Promise { + const { calls, restore } = mockFetch(opts); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const rootRoute = createRootRoute(); + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => , + }); + const paneRoute = createRoute({ getParentRoute: () => rootRoute, path: "/subshells/$id", component: () => null }); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + paneRoute, + ...["/nodes", "/nodes/$id"].map((path) => + createRoute({ getParentRoute: () => rootRoute, path, component: () => null }), + ), + ]), + history: createMemoryHistory({ initialEntries: ["/"] }), + defaultPreload: false, + }); + await router.load(); + render( + + + , + ); + await settle(); + return { calls, pathname: () => router.state.location.pathname, restore }; +} + +/** Flush query/effect updates inside act() (the launch-form test idiom). */ +async function settle(): Promise { + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); +} + +/** Base UI opens its machine popup on the pointer path, not a bare click. */ +function openMachinePopup(): void { + const input = document.getElementById("connect-machine"); + if (!input) throw new Error("no machine field"); + act(() => { + for (const type of ["pointerdown", "pointerup"]) { + input.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, pointerType: "mouse" })); + } + for (const type of ["mousedown", "mouseup", "click"]) { + input.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + }); +} + +function machineInput(): HTMLInputElement { + return screen.getByPlaceholderText("Choose a machine") as HTMLInputElement; +} + +function destinationInput(): HTMLInputElement { + return screen.getByPlaceholderText("user@hostname:22 or an SSH alias") as HTMLInputElement; +} + +/** The destination field opens on focus (the working-directory posture). */ +function focusDestination(): void { + fireEvent.focus(destinationInput()); +} + +/** Commit a typed host through its mirror row (the free-text path, §7). */ +async function commitTyped(text: string): Promise { + focusDestination(); + await settle(); + fireEvent.change(destinationInput(), { target: { value: text } }); + await settle(); + fireEvent.click(await screen.findByRole("button", { name: text })); + await settle(); +} + +async function clickConnect(): Promise { + fireEvent.click(screen.getByRole("button", { name: /^Start SSH subshell$/ })); + await settle(); +} + +afterEach(cleanup); + +describe("machine disclosure (contract 1)", () => { + it("keeps a gated-off machine visible, greyed, with the reason naming the remedy", async () => { + const { pathname, restore } = await renderPanel({ nodes: [HOST_A, HOST_B] }); + try { + // HOST_A is the sole SSH-enabled machine, so it pre-selects honestly - + // and HOST_B still stands in the list, disabled. + openMachinePopup(); + await settle(); + const studio = await screen.findByRole("option", { name: /studio/ }); + expect(studio.getAttribute("data-disabled")).toBe(""); + // The row carries the remedy, not just the cause (the changeset's + // promise; the server's gateCause sentences are the pattern). + expect(studio.textContent).toContain( + "SSH is off on this machine. Its owner can switch it on from this machine's settings.", + ); + expect(pathname()).toBe("/"); + } finally { + restore(); + } + }); + + it("names the admin remedy on a gated-off local row, kind-aware like the server's gate", async () => { + const localOff = node({ id: "loc", name: "this host", kind: "local", sshEnabled: false }); + const { restore } = await renderPanel({ nodes: [HOST_A, localOff] }); + try { + openMachinePopup(); + await settle(); + const row = await screen.findByRole("option", { name: /this host/ }); + expect(row.getAttribute("data-disabled")).toBe(""); + expect(row.textContent).toContain("SSH is off here. An admin can switch it on from this machine's settings."); + } finally { + restore(); + } + }); + + it("pre-selects the stored default machine", async () => { + const { restore } = await renderPanel({ + nodes: [HOST_A, HOST_C], + ledger: { saved: [], recent: [], defaultNodeId: "c1" }, + }); + try { + await waitFor(() => expect(machineInput().value).toBe("server room")); + } finally { + restore(); + } + }); + + it("pre-selects the single SSH-enabled machine with the preference unset, and asks nothing extra", async () => { + const { restore } = await renderPanel({ nodes: [HOST_A, HOST_B] }); + try { + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + // The one usable machine is pre-selected honestly: no gold question + // mark, no requirement caption (Connect is live once a destination stands). + const label = document.querySelector('label[for="connect-machine"]'); + expect(label?.querySelector('[class*="after:content"]')).toBeNull(); + expect(screen.queryByText("Choose a connecting machine first.")).toBeNull(); + } finally { + restore(); + } + }); + + it("a stale default with one enabled machine still pre-selects it, unmarked", async () => { + // The ledger points at a vanished node; the only usable machine wins. + const { restore } = await renderPanel({ + nodes: [HOST_A, HOST_B], + ledger: { saved: [], recent: [], defaultNodeId: "vanished-node" }, + }); + try { + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + const label = document.querySelector('label[for="connect-machine"]'); + expect(label?.querySelector('[class*="after:content"]')).toBeNull(); + } finally { + restore(); + } + }); + + for (const unavailable of [ + [], + [HOST_B], + [{ ...HOST_A, status: "offline" as const }], + [{ ...HOST_A, maintenance: true }], + [{ ...HOST_A, canLaunch: false }], + ]) { + it(`replaces the form when no machine can run SSH: ${JSON.stringify(unavailable)}`, async () => { + const { calls, restore } = await renderPanel({ nodes: unavailable }, { node: HOST_A.id, destination: "work" }); + try { + expect(screen.getByText("No machine is ready for SSH")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Start SSH subshell" })).toBeNull(); + expect(screen.queryByLabelText("SSH destination")).toBeNull(); + expect(screen.getByRole("button", { name: "SSH Wizard" })).toBeTruthy(); + expect(calls.some((c) => c.url.startsWith("/api/ssh/aliases"))).toBe(false); + expect(calls.some((c) => c.url === "/api/ssh/launch")).toBe(false); + } finally { + restore(); + } + }); + } + + it("shows a retry instead of an empty state when machines cannot be loaded", async () => { + let failed = true; + const { restore } = await renderPanel({ + nodesResponse: () => + failed + ? json({}, 503) + : json({ machines: [{ node: HOST_A, canConnect: true, canConfigure: true, blockers: [] }] }), + }); + try { + expect(screen.getByText("Could not load connecting machines.")).toBeTruthy(); + expect(screen.queryByText("No machine is ready for SSH")).toBeNull(); + failed = false; + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + } finally { + restore(); + } + }); + + it("does not offer enrollment when the instance forbids it", async () => { + const { restore } = await renderPanel({ + nodes: [], + settings: { viewerIsAdmin: false, allowNodeEnrollment: false }, + }); + try { + fireEvent.click(screen.getByRole("button", { name: "SSH Wizard" })); + await settle(); + fireEvent.click(screen.getByRole("button", { name: "Prepare a machine for SSH" })); + await settle(); + expect((screen.getByRole("button", { name: "Add a machine" }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText(/Adding nodes is turned off/)).toBeTruthy(); + } finally { + restore(); + } + }); + + it("shows server-derived management actions for the server host", async () => { + const { restore } = await renderPanel({ + nodes: [node({ id: "local", name: "Server", kind: "local", sshEnabled: false }), HOST_B], + settings: { viewerIsAdmin: true }, + }); + try { + expect(screen.getByText("No machine is ready for SSH")).toBeTruthy(); + expect(screen.queryByText(/SSH is off here/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Open Server settings" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Open studio settings" })).toBeNull(); + expect(screen.getByRole("button", { name: "SSH Wizard" })).toBeTruthy(); + } finally { + restore(); + } + }); + + it("blocks a stale selected machine even when another machine is ready", async () => { + const { calls, restore } = await renderPanel( + { nodes: [{ ...HOST_A, status: "offline" }, HOST_C] }, + { node: HOST_A.id, destination: "work" }, + ); + try { + expect((screen.getByRole("button", { name: "Start SSH subshell" }) as HTMLButtonElement).disabled).toBe(true); + await clickConnect(); + expect(screen.getByText(/Choose another connecting machine to continue/)).toBeTruthy(); + expect(calls.some((c) => c.url === "/api/ssh/launch")).toBe(false); + } finally { + restore(); + } + }); + + it("several enabled machines and no preference is a required question: gold *, gold caption, no POST", async () => { + const { calls, restore } = await renderPanel({ nodes: [HOST_A, HOST_C] }); + try { + expect(machineInput().value).toBe(""); // never silently chosen + const label = document.querySelector('label[for="connect-machine"]'); + expect(label?.querySelector('[class*="after:content"]')).not.toBeNull(); // the gold star + await commitTyped("box.example"); + await clickConnect(); + const caption = screen.getByText("Choose a connecting machine first."); + expect(caption.className).toContain("text-amber-600"); // gold: nothing typed yet, not red + expect(calls.filter((c) => c.url === "/api/ssh/launch")).toEqual([]); + } finally { + restore(); + } + }); +}); + +describe("destination field (contract 2)", () => { + it("feeds the grouped rows: Saved, Recent, and the picked machine's config", async () => { + const ledger = { + saved: [ + { + id: "s1", + destination: "web01.example.com:22", + alias: "web", + nodeId: "a1", + savedAt: "2026-10-01T00:00:00.000Z", + lastConnectAt: "2026-10-05T00:00:00.000Z", + }, + ], + recent: [ + { + id: "r1", + destination: "db.example.com:22", + alias: null, + nodeId: "a1", + savedAt: null, + lastConnectAt: "2026-10-06T00:00:00.000Z", + }, + ], + defaultNodeId: "a1", + }; + const { restore } = await renderPanel({ nodes: [HOST_A], ledger, aliases: ["workbox"] }); + try { + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + focusDestination(); + await settle(); + const popup = await waitFor(() => { + const el = document.getElementById("connect-destination-panel"); + if (!el) throw new Error("popup not open"); + return el; + }); + // Scoped to the popup: "Recent" is also the ledger's own heading below. + expect(within(popup).getByText("Saved")).toBeDefined(); + expect(within(popup).getByText("Recent")).toBeDefined(); + expect(within(popup).getByText("From mac mini's config")).toBeDefined(); + expect(within(popup).getByRole("button", { name: "web" })).toBeDefined(); + expect(within(popup).getByRole("button", { name: "workbox" })).toBeDefined(); + } finally { + restore(); + } + }); + + it("asks the machine for its aliases only once a gate-ON machine is picked", async () => { + const empty = await renderPanel({ nodes: [] }); + try { + // No machine at all: the picker asks nothing (a half-open panel must not + // ping a node with node= empty). + await settle(); + expect(empty.calls.filter((c) => c.url.startsWith("/api/ssh/aliases"))).toEqual([]); + } finally { + empty.restore(); + } + cleanup(); // one tree at a time: the second render must not stack queries + const picked = await renderPanel({ nodes: [HOST_A] }); + try { + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + expect(picked.calls.some((c) => c.url === "/api/ssh/aliases?node=a1")).toBe(true); + } finally { + picked.restore(); + } + }); + + it("accepts a typed host directly without requiring a suggestion click", async () => { + const { calls, restore } = await renderPanel({ nodes: [HOST_A] }); + try { + fireEvent.change(destinationInput(), { target: { value: "box.example" } }); + expect(destinationInput().value).toBe("box.example"); // the held echo is what will launch + await clickConnect(); + const post = calls.find((c) => c.url === "/api/ssh/launch"); + expect(post?.body).toEqual({ node: "a1", destination: "box.example" }); + } finally { + restore(); + } + }); + + it("a config-alias pick launches the token, and the save sends it as the alias", async () => { + const { calls, restore } = await renderPanel({ nodes: [HOST_A], aliases: ["workbox"] }); + try { + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + focusDestination(); + await settle(); + fireEvent.click(await screen.findByRole("button", { name: "workbox" })); + await settle(); + fireEvent.click(screen.getByLabelText("Remember this destination")); + await clickConnect(); + expect(calls.find((c) => c.url === "/api/ssh/launch")?.body).toEqual({ node: "a1", destination: "workbox" }); + await waitFor(() => expect(calls.some((c) => c.method === "PUT" && c.url === "/api/ssh/saved-hosts")).toBe(true)); + const put = calls.find((c) => c.method === "PUT" && c.url === "/api/ssh/saved-hosts"); + expect(put?.body).toEqual({ node: "a1", destination: "workbox", alias: "workbox" }); + } finally { + restore(); + } + }); +}); + +describe("connect and the ledger (contracts 3, 6)", () => { + it("the button shows the process's own line while it runs and lands on the pane at 201", async () => { + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + const realFetch = globalThis.fetch; + globalThis.fetch = ((input: unknown, _init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/ssh/launch") return gate.then(() => json({ subshell: { id: "s-9" } }, 201)); + if (url.pathname === "/api/ssh/readiness") + return Promise.resolve( + json({ machines: [{ node: HOST_A, canConnect: true, canConfigure: true, blockers: [] }] }), + ); + if (url.pathname === "/api/ssh/saved-hosts") + return Promise.resolve(json({ saved: [], recent: [], defaultNodeId: null })); + return Promise.resolve(json({})); + }) as typeof fetch; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const rootRoute = createRootRoute(); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + createRoute({ getParentRoute: () => rootRoute, path: "/", component: ConnectPanel }), + createRoute({ getParentRoute: () => rootRoute, path: "/subshells/$id", component: () => null }), + ]), + history: createMemoryHistory({ initialEntries: ["/"] }), + defaultPreload: false, + }); + await router.load(); + render( + + + , + ); + await settle(); + try { + await commitTyped("box.example"); + fireEvent.click(screen.getByRole("button", { name: /^Start SSH subshell$/ })); + await settle(); + const button = screen.getByRole("button", { name: /Connecting/ }); // no spinner: the button owns the wait + expect((button as HTMLButtonElement).disabled).toBe(true); + release(); + await waitFor(() => expect(router.state.location.pathname).toBe("/subshells/s-9")); + } finally { + globalThis.fetch = realFetch; + } + }); + + it("Remember starts unchecked; a checked launch PUTs the saved row with no alias, unchecked PUTs nothing", async () => { + const off = await renderPanel({ nodes: [HOST_A] }); + try { + expect((screen.getByLabelText("Remember this destination") as HTMLInputElement).checked).toBe(false); + await commitTyped("box.example"); + await clickConnect(); + await settle(); + expect(off.calls.filter((c) => c.method === "PUT" && c.url === "/api/ssh/saved-hosts")).toEqual([]); + } finally { + off.restore(); + } + cleanup(); // one tree at a time + const on = await renderPanel({ nodes: [HOST_A] }); + try { + await commitTyped("box.example"); + fireEvent.click(screen.getByLabelText("Remember this destination")); + await clickConnect(); + await waitFor(() => + expect(on.calls.some((c) => c.method === "PUT" && c.url === "/api/ssh/saved-hosts")).toBe(true), + ); + const put = on.calls.find((c) => c.method === "PUT" && c.url === "/api/ssh/saved-hosts"); + expect(put?.body).toEqual({ node: "a1", destination: "box.example" }); + } finally { + on.restore(); + } + }); + + it("Use by default PATCHes the connecting-machine preference", async () => { + const { calls, restore } = await renderPanel({ nodes: [HOST_A] }); + try { + await waitFor(() => expect(machineInput().value).toBe("mac mini")); + fireEvent.click(screen.getByRole("button", { name: "Use by default" })); + await waitFor(() => + expect(calls.some((c) => c.method === "PATCH" && c.url === "/api/ssh/preferences")).toBe(true), + ); + expect(calls.find((c) => c.method === "PATCH")?.body).toEqual({ defaultNodeId: "a1" }); + } finally { + restore(); + } + }); +}); + +describe("refusals and disclosure (contracts 4, 5)", () => { + it("renders a 422 outcome's blocked settings verbatim in red, past the message slice", async () => { + const settings = ["ProxyCommand", "LocalForward", ...Array.from({ length: 30 }, (_, i) => `Setting${i}`)]; + const raw = JSON.stringify({ outcome: { accepted: false, code: "unsupported_setting", settings } }); + expect(raw.length).toBeGreaterThan(200); // the 200-char message slice is exercised + const { restore } = await renderPanel({ + nodes: [HOST_A], + launch: () => json({ outcome: { accepted: false, code: "unsupported_setting", settings } }, 422), + }); + try { + await commitTyped("web01"); + await clickConnect(); + const line = await screen.findByText( + (_text, el) => + el?.tagName === "P" && + (el.textContent ?? "").startsWith("This SSH configuration uses settings Subshell doesn't support:"), + ); + expect(line.className).toContain("text-destructive"); // a hard server refusal: red + expect(line.textContent).toContain("Setting29"); // the WHOLE list, not the 200-char slice + expect(line.textContent).toContain("Update them on the connecting machine and try again."); + } finally { + restore(); + } + }); + + it("names the machine in its own 409 sentence, under the machine field", async () => { + const { restore } = await renderPanel({ + nodes: [HOST_A], + launch: () => json({ errId: "e1", code: "NODE_OFFLINE", message: "offline", statusCode: 409 }, 409), + }); + try { + await commitTyped("web01"); + await clickConnect(); + expect(await screen.findByText("mac mini is offline. Start its Subshell daemon and try again.")).toBeDefined(); + } finally { + restore(); + } + }); + + it("explains the destination format and discloses SSH config execution before launch", async () => { + const { restore } = await renderPanel({ nodes: [HOST_A] }); + try { + const line = await screen.findByText(SSH_DISCLOSURE_COPY); + expect(line.className).toContain("text-detail"); + expect(destinationInput().getAttribute("aria-describedby")).toBe("connect-destination-disclosure"); + // The exact sentences (decision 6), dash-free. + expect(SSH_DISCLOSURE_COPY).toBe( + "Connecting uses this machine’s SSH configuration, including any local commands configured with Match exec.", + ); + } finally { + restore(); + } + }); + + it("wires the machine requirement caption as the picker's description", async () => { + // The same aria posture the destination field pins above: whatever the + // machine picker is explaining about itself is its accessible + // description, named by id only while it is on screen. + const { restore } = await renderPanel({ nodes: [HOST_A, HOST_C] }); + try { + await waitFor(() => expect(document.getElementById("connect-machine-requirement")).not.toBeNull()); + expect(machineInput().getAttribute("aria-describedby")).toContain("connect-machine-requirement"); + } finally { + restore(); + } + }); +}); + +describe("relay launch with current launch access", () => { + it("sends the selected key machine and preserves the returned connection choices", async () => { + const { calls, restore } = await renderPanel( + { nodes: [HOST_A, HOST_C] }, + { + node: HOST_A.id, + keyHome: HOST_C.id, + destination: "theo@build.example.com:2222", + fingerprints: ["SHA256:AAAA"], + }, + ); + try { + await clickConnect(); + expect(calls.find((c) => c.url === "/api/ssh/launch")?.body).toEqual({ + node: HOST_A.id, + keyHome: HOST_C.id, + fingerprints: ["SHA256:AAAA"], + destination: "theo@build.example.com:2222", + }); + } finally { + restore(); + } + }); + it("blocks a stale key-machine choice instead of silently using another machine's keys", async () => { + const { calls, restore } = await renderPanel( + { nodes: [HOST_A, { ...HOST_C, status: "offline" }] }, + { node: HOST_A.id, keyHome: HOST_C.id, destination: "work" }, + ); + try { + expect((screen.getByRole("button", { name: "Start SSH subshell" }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText(/selected key machine is unavailable/)).toBeTruthy(); + expect(calls.some((c) => c.url === "/api/ssh/launch")).toBe(false); + } finally { + restore(); + } + }); +}); diff --git a/apps/server/web/src/components/connect/__tests__/destination-options.test.ts b/apps/server/web/src/components/connect/__tests__/destination-options.test.ts new file mode 100644 index 000000000..99a3ff626 --- /dev/null +++ b/apps/server/web/src/components/connect/__tests__/destination-options.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "bun:test"; +import type { SshSavedHost } from "@/lib/ssh"; +import { buildDestinationOptions, configGroupLabel, RECENT_GROUP, SAVED_GROUP } from "../destination-options"; + +/** + * The destination field's list as pure data: the fed groups and their + * headers, the typed mirror row that makes the field accept a host no list + * holds, and the candidate map that keeps DISPLAY (an alias label) out of the + * WIRE (the destination token). The alias token rides only config rows - + * §7's alias-is-display rule. + */ +const row = (over: Partial): SshSavedHost => ({ + id: "r1", + destination: "web01.example.com:22", + alias: null, + nodeId: "n1", + savedAt: null, + lastConnectAt: "2026-10-07T00:00:00.000Z", + ...over, +}); + +describe("buildDestinationOptions", () => { + it("groups saved, recent and config rows under their headers, in order", () => { + const { options, candidates } = buildDestinationOptions({ + saved: [row({ id: "s1", alias: "web" })], + recent: [row({ id: "c1", destination: "db.example.com:22", alias: null })], + aliases: ["workbox"], + machineName: "mac mini", + typed: "", + }); + expect(options.map((o) => o.group)).toEqual([SAVED_GROUP, RECENT_GROUP, configGroupLabel("mac mini")]); + // A saved row LABELS with its alias and searches with its destination. + expect(options[0].label).toBe("web"); + expect(options[0].searchText).toBe("web01.example.com:22"); + expect(options[2].label).toBe("workbox"); + // The wire meanings: the alias label never leaks into the destination. + expect(candidates.get("saved:s1")).toEqual({ destination: "web01.example.com:22" }); + // Only the config row carries the alias token (the save sends it). + expect(candidates.get("config:workbox")).toEqual({ destination: "workbox", aliasToken: "workbox" }); + }); + + it("a recent row already saved reads once, under Saved", () => { + const shared = row({ id: "d1", savedAt: "2026-10-01T00:00:00.000Z" }); + const { options } = buildDestinationOptions({ + saved: [shared], + recent: [shared], + aliases: [], + machineName: null, + typed: "", + }); + expect(options).toHaveLength(1); + expect(options[0].group).toBe(SAVED_GROUP); + }); + + it("typed text with no equal row becomes its own candidate, leading the list", () => { + const { options, candidates } = buildDestinationOptions({ + saved: [row({})], + recent: [], + aliases: [], + machineName: null, + typed: "box.example", + }); + expect(options[0]).toEqual({ value: "typed:box.example", label: "box.example" }); + expect(candidates.get("typed:box.example")).toEqual({ destination: "box.example" }); + }); + + it("typed text a real row already carries gets no mirror row", () => { + const { options } = buildDestinationOptions({ + saved: [row({ id: "s1", destination: "box.example", alias: null })], + recent: [], + aliases: ["box.example"], + machineName: "mac mini", + typed: "box.example", + }); + expect(options.some((o) => o.value.startsWith("typed:"))).toBe(false); + // The real rows still carry it, so the choice exists once, not twice. + expect(options.some((o) => o.label === "box.example")).toBe(true); + }); + + it("no machine picked means no config group, and no aliases leak into it", () => { + const { options } = buildDestinationOptions({ + saved: [], + recent: [], + aliases: ["workbox"], + machineName: null, + typed: "", + }); + expect(options).toEqual([]); + }); +}); diff --git a/apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx b/apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx new file mode 100644 index 000000000..1def92046 --- /dev/null +++ b/apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { Node } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SavedHostsSection } from "@/components/connect/saved-hosts-section"; +import type { SshSavedHost } from "@/lib/ssh"; + +/** + * The ledger under the panel (contract 7): label-over-detail rows, delete on + * Remembered, remember on Recent, saved rows never shown twice, and the two + * one-sentence empties. Wire-mocked the way the panel test mocks it. + */ +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +const savedRow: SshSavedHost = { + id: "s1", + destination: "web01.example.com:22", + alias: "web", + nodeId: "a1", + savedAt: "2026-10-01T00:00:00.000Z", + lastConnectAt: "2026-10-05T00:00:00.000Z", +}; +const recentRow: SshSavedHost = { + id: "r1", + destination: "db.example.com:22", + alias: null, + nodeId: "a1", + savedAt: null, + lastConnectAt: "2026-10-06T00:00:00.000Z", +}; + +function node(overrides: Partial): Node { + return { + id: "n", + name: "node", + kind: "agent", + os: null, + arch: null, + hostname: null, + status: "online", + lastSeenAt: null, + agentVersion: null, + protocolVersion: null, + access: "owner", + canManage: true, + canRetire: true, + canLaunch: true, + allowedDirs: [], + capabilities: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: false, + held: null, + ...overrides, + }; +} + +interface Call { + method: string; + url: string; + body?: unknown; +} + +async function renderSection(ledger: unknown) { + const calls: Call[] = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + calls.push({ + method, + url: url.pathname + url.search, + ...(typeof init?.body === "string" ? { body: JSON.parse(init.body) as unknown } : {}), + }); + if (url.pathname === "/api/ssh/saved-hosts" && method === "GET") return Promise.resolve(json(ledger)); + if (url.pathname === "/api/nodes") { + return Promise.resolve(json({ nodes: [node({ id: "a1", name: "mac mini", sshEnabled: true })] })); + } + return Promise.resolve(json({})); + }) as typeof fetch; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + return { calls, restore: () => (globalThis.fetch = original) }; +} + +afterEach(cleanup); + +describe("SavedHostsSection", () => { + it("renders remembered and recent rows label-over-detail, the machine named in the detail", async () => { + const { restore } = await renderSection({ saved: [savedRow], recent: [savedRow, recentRow], defaultNodeId: null }); + try { + expect(screen.getByText("web")).toBeDefined(); // the ALIAS is the display + expect(screen.getByText("mac mini · 2026-10-01")).toBeDefined(); // saved date rides remembered rows + expect(screen.getByText("db.example.com:22")).toBeDefined(); + expect(screen.getByText("mac mini · 2026-10-06")).toBeDefined(); // last connect rides recents + // The row already Saved appears once, under Remembered (reads once). + expect(screen.getAllByText("web")).toHaveLength(1); + } finally { + restore(); + } + }); + + it("a remembered row forgets by id; a recent row remembers on its own machine", async () => { + const { calls, restore } = await renderSection({ saved: [savedRow], recent: [recentRow], defaultNodeId: null }); + try { + fireEvent.click(screen.getByRole("button", { name: "Forget web" })); + await waitFor(() => expect(calls.some((c) => c.method === "DELETE")).toBe(true)); + expect(calls.find((c) => c.method === "DELETE")?.url).toBe("/api/ssh/saved-hosts/s1"); + fireEvent.click(screen.getByRole("button", { name: "Remember db.example.com:22" })); + await waitFor(() => expect(calls.some((c) => c.method === "PUT")).toBe(true)); + const put = calls.find((c) => c.method === "PUT"); + expect(put?.url).toBe("/api/ssh/saved-hosts"); + expect(put?.body).toEqual({ node: "a1", destination: "db.example.com:22" }); // the row's own machine, no alias + } finally { + restore(); + } + }); + + it("the two empty states are one short sentence each", async () => { + const { restore } = await renderSection({ saved: [], recent: [], defaultNodeId: null }); + try { + expect(screen.getByText("No remembered destinations yet.")).toBeDefined(); + expect(screen.getByText("No recent destinations yet.")).toBeDefined(); + } finally { + restore(); + } + }); +}); diff --git a/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts b/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts new file mode 100644 index 000000000..11faaf439 --- /dev/null +++ b/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "bun:test"; +import { ApiError, NetworkError } from "@internal/node-admin"; +import { isSshResolveRefusal, type SshMachineFacts, sshLaunchRefusal } from "@/components/connect/ssh-refusal"; + +/** + * The refusal matrix of `POST /api/ssh/launch` (launch.route.ts's response + * map), each branch named (Task 3 addition 1). The 422 is the branch with + * two roads: a parsed `{outcome}` body renders the blocked settings verbatim + * even when the display message was sliced to 200 chars, and an old or + * body-less 422 falls back to the message text rather than inventing one. + */ +const machine = (over: Partial): SshMachineFacts => ({ + name: "mac mini", + kind: "agent", + sshEnabled: true, + ...over, +}); + +describe("sshLaunchRefusal", () => { + it("422 with a body renders every blocked setting verbatim, sliced message or not", () => { + const settings = ["ProxyCommand", "LocalForward", "RemoteForward"]; + const raw = JSON.stringify({ outcome: { accepted: false, code: "unsupported_setting", settings } }); + const err = new ApiError(422, raw.slice(0, 200), { body: JSON.parse(raw) }); + const copy = sshLaunchRefusal(err, machine({})); + expect(copy).toEqual({ + text: "This SSH configuration uses settings Subshell doesn't support: ProxyCommand, LocalForward, RemoteForward. Update them on the connecting machine and try again.", + field: "destination", + }); + }); + + it("422 whose code names the whole cause (no settings) says so without an empty list", () => { + const err = new ApiError(422, "", { body: { outcome: { accepted: false, code: "config_missing", settings: [] } } }); + const copy = sshLaunchRefusal(err, machine({})); + expect(copy?.text).toBe( + "Subshell can't connect using this SSH configuration. Update it on the connecting machine and try again.", + ); + expect(copy?.field).toBe("destination"); + }); + + it("422 with no parseable body falls back to the message text", () => { + const err = new ApiError(422, "refused"); // no body: an old server or a proxy page + expect(sshLaunchRefusal(err, machine({}))).toEqual({ text: "API 422: refused", field: "destination" }); + }); + + it("400 names the destination as invalid, not the machine", () => { + expect(sshLaunchRefusal(new ApiError(400, "x", { code: "ALIAS_UNSAFE" }), machine({}))).toEqual({ + text: "Enter a valid SSH destination, such as deploy@example.com:22.", + field: "destination", + }); + }); + + it("403 SSH_GATE_OFF on a gated-off agent names the owner remedy", () => { + expect(sshLaunchRefusal(new ApiError(403, "x", { code: "SSH_GATE_OFF" }), machine({ sshEnabled: false }))).toEqual({ + text: "SSH is off on this machine. Ask its owner to enable it.", + field: "machine", + }); + }); + + it("403 SSH_GATE_OFF on the control-plane host names the admin remedy", () => { + const local = machine({ kind: "local", name: "this host", sshEnabled: false }); + expect(sshLaunchRefusal(new ApiError(403, "x", { code: "SSH_GATE_OFF" }), local)).toEqual({ + text: "An admin can enable SSH on this machine in its settings.", + field: "machine", + }); + }); + + it("403 with SSH enabled does not guess which access rule blocked the request", () => { + expect(sshLaunchRefusal(new ApiError(403, "x", { code: "SSH_GATE_OFF" }), machine({}))).toEqual({ + text: "You can't connect from this machine.", + field: "machine", + }); + }); + + it("404 says the machine isn't available", () => { + expect(sshLaunchRefusal(new ApiError(404, "x", { code: "NOT_FOUND_ERROR" }), machine({}))).toEqual({ + text: "That machine isn't available.", + field: "machine", + }); + }); + + it("409 arms name the machine and their own cause", () => { + expect(sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_OFFLINE" }), machine({}))?.text).toBe( + "mac mini is offline. Start its Subshell daemon and try again.", + ); + expect(sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_PROTOCOL_HELD" }), machine({}))?.text).toBe( + "mac mini runs a Subshell version this server doesn't support. Update it from its node page and try again.", + ); + expect(sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_OUTDATED" }), machine({}))?.text).toBe( + "Update Subshell on mac mini from its node page to use SSH.", + ); + expect(sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_UNREACHABLE" }), machine({}))?.text).toBe( + "mac mini didn't answer the SSH request in time. Check its connection and try again.", + ); + // No machine facts (the row vanished) keeps the sentence grammatical. + expect(sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_OFFLINE" }), null)?.text).toBe( + "That machine is offline. Start its Subshell daemon and try again.", + ); + }); + + it("409 NODE_IN_MAINTENANCE names maintenance, not the missing connection", () => { + // The create under the ssh launch throws this code when the machine is in + // a window (`rethrowLaunchRefusal`, subshells.service.ts) - a live link + // exists, it just takes no new panes, so the offline sentence would lie. + const copy = sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_IN_MAINTENANCE" }), machine({})); + expect(copy?.text).toBe( + "mac mini is in maintenance, so you can't start new subshells on it. Try again when maintenance ends.", + ); + expect(copy?.text).not.toContain("no live connection"); + // The vanish-the-row fallback stays grammatical too. + expect(sshLaunchRefusal(new ApiError(409, "x", { code: "NODE_IN_MAINTENANCE" }), null)?.text).toBe( + "That machine is in maintenance, so you can't start new subshells on it. Try again when maintenance ends.", + ); + }); + + it("502 is the machine's own refusal, with the checks that answer it", () => { + expect(sshLaunchRefusal(new ApiError(502, "x", { code: "SSH_NODE_REFUSED" }), machine({}))).toEqual({ + text: "The connecting machine refused the SSH request. Check that SSH is switched on there and that ssh is installed.", + field: "machine", + }); + }); + + it("a NetworkError renders nothing: the global offline banner already says it", () => { + expect(sshLaunchRefusal(new NetworkError(new TypeError("down")), machine({}))).toBeNull(); + }); + + it("an unexpected failure gets the generic red line, never an API string", () => { + expect(sshLaunchRefusal(new Error("boom"), machine({}))?.text).toBe("Couldn't start the SSH connection."); + expect(sshLaunchRefusal(new ApiError(500, "Internal"), machine({}))?.text).toBe( + "Couldn't start the SSH connection.", + ); + }); + + it("refusal copy uses ordinary punctuation", () => { + // Named codes too, so the per-branch arms are all in the sweep, not just + // the default ones. + const cases: [number, string][] = [ + [422, "ANY"], + [400, "ALIAS_UNSAFE"], + [403, "SSH_GATE_OFF"], + [404, "NOT_FOUND_ERROR"], + [409, "NODE_IN_MAINTENANCE"], + [409, "NODE_PROTOCOL_HELD"], + [409, "NODE_OUTDATED"], + [409, "NODE_UNREACHABLE"], + [409, "NODE_OFFLINE"], + [409, "ANY"], + [502, "SSH_NODE_REFUSED"], + ]; + const texts = cases.flatMap(([status, code]) => { + const copy = sshLaunchRefusal(new ApiError(status, "x", { code }), machine({ sshEnabled: false })); + return copy === null ? [] : [copy.text]; + }); + for (const text of texts) { + expect(text).not.toMatch(/[─—–]/); // U+2500, U+2014, U+2013 + } + }); + + it("isSshResolveRefusal narrows the refused arm only", () => { + expect(isSshResolveRefusal({ accepted: false, code: "config_missing", settings: [] })).toBe(true); + expect(isSshResolveRefusal({ accepted: true, snapshot: {} })).toBe(false); + expect(isSshResolveRefusal({ accepted: false, code: "x" })).toBe(false); // no settings array + expect(isSshResolveRefusal(null)).toBe(false); + expect(isSshResolveRefusal("outcome")).toBe(false); + }); +}); diff --git a/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx b/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx new file mode 100644 index 000000000..31862821b --- /dev/null +++ b/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx @@ -0,0 +1,529 @@ +import { afterEach, expect, it } from "bun:test"; +import type { Node } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ConnectPanel } from "@/components/connect/connect-panel"; +import type { SshInitialChoices } from "@/components/connect/ssh-session-draft"; +import { SshWizardDialog } from "@/components/connect/ssh-wizard-dialog"; +import type { SshMachineReadiness } from "@/lib/ssh"; + +const original = globalThis.fetch; +afterEach(() => { + cleanup(); + globalThis.fetch = original; +}); +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +function machine(id: string, ready = true, configure = true, kind: "agent" | "local" = "agent"): SshMachineReadiness { + const node = { + id, + name: id === "local" ? "Named server" : id, + kind, + os: null, + arch: null, + status: "online", + canLaunch: true, + canManage: configure, + access: configure ? "owner" : "view", + sshEnabled: ready, + maintenance: false, + held: null, + harnesses: [], + } as unknown as Node; + return { + node, + canConnect: ready, + canConfigure: configure, + blockers: ready ? [] : [{ code: "SSH_GATE_OFF", message: "SSH is off on this machine." }], + }; +} +async function setup({ + initial, + machines = [machine("desk"), machine("keys")], + readyError = false, + enrollmentAllowed = true, + start = true, + settingsEntry = false, + onCreated, +}: { + initial?: SshInitialChoices; + machines?: SshMachineReadiness[]; + readyError?: boolean; + enrollmentAllowed?: boolean; + start?: boolean; + settingsEntry?: boolean; + onCreated?: (id: string) => Promise | void; +} = {}) { + const state = { + machines, + readyError, + savedError: false, + aliasesError: false, + identities: [{ fingerprint: "SHA256:AAAA", comment: "Work key" }], + rosterError: false, + consumedNodeId: null as string | null, + setupKeyReads: 0, + enrollmentAllowed, + }; + const calls: { path: string; method: string; body?: unknown }[] = []; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const path = new URL(String(input), "http://localhost").pathname; + const method = init?.method ?? "GET"; + calls.push({ path, method, ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}) }); + if (path === "/api/settings/public") + return json({ + allowNodeEnrollment: state.enrollmentAllowed, + viewerIsAdmin: false, + appBaseUrl: "https://plane.example", + trustedOrigins: ["https://plane.example", "https://other.example"], + }); + if (path === "/api/ssh/readiness") + return state.readyError ? json({ message: "read failed" }, 503) : json({ machines: state.machines }); + if (path === "/api/ssh/saved-hosts") + return state.savedError + ? json({ message: "saved read failed" }, 503) + : json({ saved: [], recent: [], defaultNodeId: null }); + if (path === "/api/ssh/aliases") + return state.aliasesError + ? json({ message: "aliases read failed" }, 503) + : json({ aliases: ["work"], includeCycle: false, truncated: false }); + if (path === "/api/ssh/identities") + return state.rosterError ? json({ message: "No SSH_AUTH_SOCK" }, 502) : json({ identities: state.identities }); + if (path.endsWith("/ssh-enabled")) { + state.machines = state.machines.map((m) => + path.includes(`/${m.node.id}/`) + ? { + ...m, + node: { ...m.node, sshEnabled: true }, + canConnect: m.node.status === "online", + blockers: + m.node.status === "online" ? [] : [{ code: "NODE_OFFLINE", message: "Bring this machine online." }], + } + : m, + ); + return json({ sshEnabled: true }); + } + if (path === "/api/nodes/setup-keys") { + if (method === "POST") return json({ id: "my-key", key: "nsk_mine", expiresAt: "2099-01-01" }); + state.setupKeyReads++; + return json({ + keys: [ + { + id: "my-key", + key: "nsk_mine", + consumedNodeId: state.consumedNodeId, + usedAt: state.consumedNodeId ? "now" : null, + expiresAt: "2099-01-01", + }, + ], + }); + } + if (path === "/api/ssh/launch") return json({ subshell: { id: "created-ssh" } }, 201); + return json({}); + }) as typeof fetch; + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const root = createRootRoute(); + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ + getParentRoute: () => root, + path: "/", + component: () => + settingsEntry ? ( + + ) : ( + {})} /> + ), + }), + createRoute({ getParentRoute: () => root, path: "/nodes/$id", component: () =>

Machine settings

}), + ]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); + render( + + + , + ); + await settle(); + return { state, calls, client }; +} +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + }); +} +async function click(name: string) { + fireEvent.click(await screen.findByRole("button", { name })); + await settle(); +} +async function next() { + const button = await screen.findByRole("button", { name: "Continue" }); + await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)); + fireEvent.click(button); + await settle(); +} +async function choose(label: string, name: string) { + const field = screen.getByRole("combobox", { name: label }); + act(() => { + for (const type of ["pointerdown", "pointerup"]) + field.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, pointerType: "mouse" })); + for (const type of ["mousedown", "mouseup", "click"]) + field.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + }); + // Let the picker finish focusing and restoring its selected label before + // typing the search, or that opening work can overwrite the query. + await settle(); + fireEvent.change(field, { target: { value: name } }); + await settle(); + fireEvent.click(await screen.findByRole("option", { name })); + await settle(); +} + +it("opens the three intents from empty SSH state with a single primary wizard action", async () => { + const { calls } = await setup({ machines: [], start: false }); + expect(screen.getByText("No machine is ready for SSH")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Add a node" })).toBeNull(); + await click("SSH Wizard"); + expect(screen.getByRole("button", { name: "Connect to a destination" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Prepare a machine for SSH" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Use keys from another machine" })).toBeTruthy(); + expect(calls.some((c) => c.path === "/api/ssh/identities")).toBe(false); +}); + +it("connect intent skips satisfied setup, focuses steps, and starts only after review", async () => { + const { calls } = await setup({ initial: { node: "desk" } }); + await click("Connect to a destination"); + expect(document.activeElement?.textContent).toBe("Choose a destination"); + expect((screen.getByRole("button", { name: "Continue" }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.change(screen.getByLabelText("SSH destination"), { target: { value: "deploy@example:22" } }); + await settle(); + await next(); + await next(); + expect(screen.getByRole("heading", { name: "Choose where your keys live" })).toBeTruthy(); + await click("Use connecting machine’s own keys"); + expect(calls.some((c) => c.path === "/api/ssh/launch")).toBe(false); + await click("Start SSH subshell"); + expect(calls.filter((c) => c.path === "/api/ssh/launch")).toEqual([ + { path: "/api/ssh/launch", method: "POST", body: { node: "desk", destination: "deploy@example:22" } }, + ]); +}); + +it("shows destination discovery failures and retries them without losing typed input", async () => { + const { state, client, calls } = await setup({ initial: { node: "desk", destination: "keep-me" } }); + await click("Connect to a destination"); + state.savedError = true; + state.aliasesError = true; + await act(async () => { + await Promise.all([ + client.invalidateQueries({ queryKey: ["ssh-saved-hosts"] }), + client.invalidateQueries({ queryKey: ["ssh-aliases"] }), + ]); + }); + await screen.findByText(/Saved and recent destinations could not be read/); + expect(screen.getByText(/SSH config aliases could not be read/)).toBeTruthy(); + expect((screen.getByLabelText("SSH destination") as HTMLInputElement).value).toBe("keep-me"); + expect((screen.getByRole("button", { name: "Continue" }) as HTMLButtonElement).disabled).toBe(false); + const savedReads = calls.filter((call) => call.path === "/api/ssh/saved-hosts").length; + const aliasReads = calls.filter((call) => call.path === "/api/ssh/aliases").length; + state.savedError = false; + state.aliasesError = false; + await click("Retry destination suggestions"); + await waitFor(() => expect(screen.queryByText(/could not be read/)).toBeNull()); + expect(calls.filter((call) => call.path === "/api/ssh/saved-hosts")).toHaveLength(savedReads + 1); + expect(calls.filter((call) => call.path === "/api/ssh/aliases")).toHaveLength(aliasReads + 1); + expect((screen.getByLabelText("SSH destination") as HTMLInputElement).value).toBe("keep-me"); +}); + +it("prepare intent explicitly enables SSH and never launches; Connect now returns to same form", async () => { + const { calls } = await setup({ + initial: { node: "desk", keyHome: "unavailable", fingerprints: ["SHA256:AAAA"] }, + machines: [machine("desk", false)], + }); + await click("Prepare a machine for SSH"); + await next(); + expect(screen.getByRole("heading", { name: "Prepare the connecting machine" })).toBeTruthy(); + await click("Enable SSH"); + await next(); + expect(screen.getByRole("heading", { name: "Your SSH setup" })).toBeTruthy(); + expect(calls.filter((c) => c.path.endsWith("/ssh-enabled"))).toHaveLength(1); + expect(calls.some((c) => c.path === "/api/ssh/launch")).toBe(false); + expect(calls.some((c) => c.path === "/api/ssh/identities")).toBe(false); + await click("Connect now"); + expect(screen.getByLabelText("SSH destination")).toBeTruthy(); +}); + +it("shared launchable machines can connect while unauthorized configuration remains unavailable", async () => { + const { calls } = await setup({ initial: { node: "shared" }, machines: [machine("shared", false, false)] }); + await click("Prepare a machine for SSH"); + await next(); + expect(screen.queryByRole("button", { name: "Enable SSH" })).toBeNull(); + expect(screen.queryByRole("button", { name: /settings$/ })).toBeNull(); + expect(screen.getByText(/This machine’s owner can enable SSH/)).toBeTruthy(); + expect((screen.getByRole("button", { name: "Continue" }) as HTMLButtonElement).disabled).toBe(true); + expect(calls.some((c) => c.method === "PUT")).toBe(false); +}); + +it("remote keys intent includes the named server and requires an explicit fingerprint selection", async () => { + const { calls } = await setup({ + initial: { node: "desk" }, + machines: [machine("desk", true, false), machine("local", true, true, "local")], + }); + await click("Use keys from another machine"); + await next(); + await choose("Use SSH keys from", "Named server"); + await next(); + expect(calls.filter((c) => c.path === "/api/ssh/identities")).toHaveLength(1); + expect(screen.getByRole("checkbox", { name: "Work key" }).getAttribute("aria-checked")).toBe("false"); + expect((screen.getByRole("button", { name: "Continue" }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByRole("checkbox", { name: "Work key" })); + await settle(); + await next(); + expect(calls.some((c) => c.path === "/api/ssh/launch")).toBe(false); + await click("Use this setup"); + expect(screen.getByLabelText("SSH destination")).toBeTruthy(); + fireEvent.change(screen.getByLabelText("SSH destination"), { target: { value: "target" } }); + await settle(); + await click("Start SSH subshell"); + expect(calls.find((c) => c.path === "/api/ssh/launch")?.body).toEqual({ + node: "desk", + destination: "target", + keyHome: "local", + fingerprints: ["SHA256:AAAA"], + }); +}); + +it("preserves an explicit subset when the connecting machine becomes its key source and blocks form/wizard launch", async () => { + const { calls } = await setup({ + initial: { node: "desk", keyHome: "keys", destination: "keep-me", fingerprints: ["SHA256:AAAA"] }, + start: false, + }); + await choose("Connect through", "keys"); + expect(screen.getByText(/selected the same machine for the connection and the keys/)).toBeTruthy(); + expect((screen.getByLabelText("Use SSH keys from") as HTMLInputElement).value).toBe("keys"); + expect((screen.getByRole("button", { name: "Start SSH subshell" }) as HTMLButtonElement).disabled).toBe(true); + await click("Start SSH subshell"); + expect(calls.some((call) => call.path === "/api/ssh/launch")).toBe(false); + await click("SSH Wizard"); + await click("Connect to a destination"); + await next(); + await next(); + await click("Use keys from another machine"); + expect((screen.getByLabelText("Use SSH keys from") as HTMLInputElement).value).toBe("keys"); + expect((screen.getByRole("button", { name: "Continue" }) as HTMLButtonElement).disabled).toBe(true); + await click("Continue"); + expect(calls.some((call) => call.path === "/api/ssh/launch")).toBe(false); + await click("Cancel wizard"); + await choose("Connect through", "desk"); + expect(screen.getByRole("checkbox", { name: "Work key" }).getAttribute("aria-checked")).toBe("true"); + await click("Start SSH subshell"); + expect(calls.find((call) => call.path === "/api/ssh/launch")?.body).toEqual({ + node: "desk", + keyHome: "keys", + destination: "keep-me", + fingerprints: ["SHA256:AAAA"], + }); +}); + +it("blocks same-machine initial choices until own keys are explicitly chosen", async () => { + const { calls } = await setup({ + initial: { node: "keys", keyHome: "keys", destination: "keep-me", fingerprints: ["SHA256:AAAA"] }, + start: false, + }); + expect((screen.getByLabelText("Use SSH keys from") as HTMLInputElement).value).toBe("keys"); + expect((screen.getByRole("button", { name: "Start SSH subshell" }) as HTMLButtonElement).disabled).toBe(true); + await click("Start SSH subshell"); + expect(calls.some((call) => call.path === "/api/ssh/launch")).toBe(false); + await click("SSH Wizard"); + await click("Connect to a destination"); + await next(); + await next(); + expect(screen.getByText(/selected the same machine for the connection and the keys/)).toBeTruthy(); + await click("Use connecting machine’s own keys"); + expect(screen.queryByText(/selected the same machine for the connection and the keys/)).toBeNull(); + expect(calls.some((call) => call.path === "/api/ssh/launch")).toBe(false); + await click("Start SSH subshell"); + expect(calls.find((call) => call.path === "/api/ssh/launch")?.body).toEqual({ node: "keys", destination: "keep-me" }); +}); + +it("requires a deliberate removal of vanished selected keys before continuing with a current key", async () => { + const { state, calls } = await setup({ + initial: { node: "desk", keyHome: "keys", destination: "keep-me", fingerprints: ["SHA256:AAAA"] }, + }); + await click("Use keys from another machine"); + await next(); + await next(); + state.identities = [{ fingerprint: "SHA256:BBBB", comment: "Replacement key" }]; + await click("Retry keys"); + expect(screen.getByText(/selected key is no longer available/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Remove unavailable key SHA256:AAAA" })).toBeTruthy(); + fireEvent.click(screen.getByRole("checkbox", { name: "Replacement key" })); + await settle(); + expect((screen.getByRole("button", { name: "Continue" }) as HTMLButtonElement).disabled).toBe(true); + expect(calls.some((call) => call.path === "/api/ssh/launch")).toBe(false); + await click("Remove unavailable key SHA256:AAAA"); + expect(screen.queryByText(/selected key is no longer available/)).toBeNull(); + await next(); + await click("Use this setup"); + expect((screen.getByLabelText("SSH destination") as HTMLInputElement).value).toBe("keep-me"); + await click("Start SSH subshell"); + expect(calls.find((call) => call.path === "/api/ssh/launch")?.body).toEqual({ + node: "desk", + keyHome: "keys", + destination: "keep-me", + fingerprints: ["SHA256:BBBB"], + }); +}); + +it("retains canonical destination, remember, and explicit keys across wizard cancel and apply", async () => { + const { calls } = await setup({ + initial: { node: "desk", destination: "target", keyHome: "keys", fingerprints: ["SHA256:AAAA"] }, + start: false, + }); + fireEvent.click(screen.getByLabelText("Remember this destination")); + await click("SSH Wizard"); + await click("Use keys from another machine"); + await next(); + await next(); + expect(screen.getByRole("checkbox", { name: "Work key" }).getAttribute("aria-checked")).toBe("true"); + await click("Cancel wizard"); + expect((screen.getByLabelText("SSH destination") as HTMLInputElement).value).toBe("target"); + expect((screen.getByLabelText("Remember this destination") as HTMLInputElement).checked).toBe(true); + await click("Start SSH subshell"); + expect(calls.find((c) => c.path === "/api/ssh/launch")?.body).toEqual({ + node: "desk", + destination: "target", + keyHome: "keys", + fingerprints: ["SHA256:AAAA"], + }); + expect(calls.find((c) => c.method === "PUT")?.body).toEqual({ node: "desk", destination: "target" }); +}); + +it("blocks disappeared selected keys and explains unreachable service agents separately from empty rosters", async () => { + const { state } = await setup({ initial: { node: "desk", keyHome: "keys", fingerprints: ["SHA256:AAAA"] } }); + await click("Use keys from another machine"); + await next(); + await next(); + state.identities = []; + await click("Retry keys"); + expect(screen.getByText(/No keys are loaded/)).toBeTruthy(); + expect(screen.getByText(/selected key is no longer available/)).toBeTruthy(); + expect((screen.getByRole("button", { name: "Continue" }) as HTMLButtonElement).disabled).toBe(true); + state.rosterError = true; + await click("Retry keys"); + expect(screen.getByText(/interactive shell’s SSH agent may differ/)).toBeTruthy(); + state.rosterError = false; + state.identities = [{ fingerprint: "SHA256:AAAA", comment: "Work key" }]; + await click("Retry keys"); + await next(); + expect(screen.getByRole("heading", { name: "Your SSH setup" })).toBeTruthy(); +}); + +it("preserves wizard draft during readiness failures and an offline selected machine", async () => { + const { state, client } = await setup({ initial: { node: "desk", destination: "keep-me" } }); + await click("Connect to a destination"); + await next(); + state.readyError = true; + await act(async () => { + await client.invalidateQueries({ queryKey: ["ssh-readiness"] }); + }); + await settle(); + expect(screen.getByRole("heading", { name: "Choose a connecting machine" })).toBeTruthy(); + expect((screen.getByRole("button", { name: "Continue" }) as HTMLButtonElement).disabled).toBe(true); + state.readyError = false; + state.machines = [ + { + ...machine("desk"), + node: { ...machine("desk").node, status: "offline" }, + canConnect: false, + blockers: [{ code: "NODE_OFFLINE", message: "Bring this machine online." }], + }, + machine("other"), + ]; + await click("Retry"); + await next(); + expect(screen.getByText("Bring this machine online.")).toBeTruthy(); + await click("Back"); + await click("Back"); + expect((screen.getByLabelText("SSH destination") as HTMLInputElement).value).toBe("keep-me"); +}); + +it("matches only its consumed setup key, retains key/method across Back, and distinguishes enrolled offline", async () => { + const { state, calls } = await setup({ machines: [] }); + await click("Prepare a machine for SSH"); + await click("Add a machine"); + await click("Generate setup key"); + await click("Desktop App"); + expect(screen.getByText("nsk_mine")).toBeTruthy(); + await click("Back"); + await click("Add a machine"); + expect(screen.getByText("nsk_mine")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Desktop App" }).getAttribute("aria-pressed")).toBe("true"); + state.machines = [machine("unrelated")]; + await click("Retry enrollment status"); + expect(screen.getByText("Waiting for a machine to use this setup key.")).toBeTruthy(); + expect((screen.getByRole("button", { name: "Continue" }) as HTMLButtonElement).disabled).toBe(true); + state.consumedNodeId = "enrolled"; + state.machines.push({ + ...machine("enrolled", false), + node: { ...machine("enrolled", false).node, status: "offline" }, + }); + await click("Retry enrollment status"); + expect(screen.getByText("The machine is enrolled. Waiting for it to come online.")).toBeTruthy(); + await next(); + expect(screen.getByText("enrolled")).toBeTruthy(); + expect(calls.filter((c) => c.path === "/api/nodes/setup-keys" && c.method === "POST")).toHaveLength(1); +}); + +it("disabled enrollment explains policy and preserves existing-machine preparation", async () => { + await setup({ initial: { node: "desk" }, enrollmentAllowed: false }); + await click("Prepare a machine for SSH"); + expect((screen.getByRole("button", { name: "Add a machine" }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText(/Adding nodes is turned off/)).toBeTruthy(); + await next(); + expect(screen.getByRole("heading", { name: "Your SSH setup" })).toBeTruthy(); +}); + +it("settings/node entry preselects its machine and Done returns to settings", async () => { + const { calls } = await setup({ initial: { node: "keys" }, settingsEntry: true }); + expect(calls.some((c) => c.path === "/api/ssh/readiness")).toBe(false); + await click("SSH Wizard"); + await click("Prepare a machine for SSH"); + expect((screen.getByRole("combobox", { name: "Machine to prepare" }) as HTMLInputElement).value).toBe("keys"); + await next(); + await click("Done"); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); +}); + +it("holds one launch through an asynchronous creation callback and cannot submit twice", async () => { + let finish: (() => void) | undefined; + const created: string[] = []; + const { calls } = await setup({ + initial: { node: "desk", destination: "target" }, + onCreated: async (id) => { + created.push(id); + await new Promise((resolve) => { + finish = resolve; + }); + }, + }); + await click("Connect to a destination"); + await next(); + await next(); + await click("Use connecting machine’s own keys"); + await click("Start SSH subshell"); + expect(created).toEqual(["created-ssh"]); + expect((screen.getByRole("button", { name: "Starting SSH subshell…" }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole("button", { name: "Cancel wizard" }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Starting SSH subshell…" })); + expect(calls.filter((c) => c.path === "/api/ssh/launch")).toHaveLength(1); + await act(async () => { + finish?.(); + }); +}); diff --git a/apps/server/web/src/components/connect/connect-panel.tsx b/apps/server/web/src/components/connect/connect-panel.tsx new file mode 100644 index 000000000..332037875 --- /dev/null +++ b/apps/server/web/src/components/connect/connect-panel.tsx @@ -0,0 +1,478 @@ +import type { Node } from "@internal/node-admin"; +import { Button, Label } from "@internal/node-admin"; +import { useNavigate } from "@tanstack/react-router"; +import { ChevronDown } from "lucide-react"; +import { type JSX, useCallback, useEffect, useRef, useState } from "react"; +import { DestinationField } from "@/components/connect/destination-field"; +import { buildDestinationOptions } from "@/components/connect/destination-options"; +import { NoSshTargets } from "@/components/connect/no-ssh-targets"; +import { SshKeyStep } from "@/components/connect/ssh-key-step"; +import { type SshRefusalCopy, sshLaunchRefusal } from "@/components/connect/ssh-refusal"; +import { + type SshInitialChoices, + type SshSessionDraft, + type SshWizardIntent, + sshDestination, + sshDraftProblems, + sshKeySelectionProblem, + sshKeySourceProblem, + sshSessionDraft, +} from "@/components/connect/ssh-session-draft"; +import { SshWizard } from "@/components/connect/ssh-wizard"; +import { SshQueryStatus } from "@/components/ssh/query-status"; +import { type ComboboxOption, SearchableSelect } from "@/components/ui/combobox"; +import { Field, FieldGroup } from "@/components/ui/field"; +import { RequiredMark } from "@/components/ui/required-mark"; +import { + useLaunchSsh, + useSaveSshHost, + useSetDefaultNode, + useSshAliases, + useSshNodeRoster, + useSshReadiness, + useSshSavedHosts, +} from "@/hooks/use-ssh"; +import { fieldErrorToned, makeForm, useSubmitDisabled } from "@/lib/form"; +import { nodeOptionLabel } from "@/lib/node-label"; +import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone"; + +/** Shared SSH launch form for new subshells, workspace additions and splits. + * A picked suggestion retains its canonical destination; editing it submits + * the new text directly. Alternate key machines are an advanced choice. + */ +const DESTINATION_IDS = { field: "connect-destination", disclosure: "connect-destination-disclosure" }; +const MACHINE_IDS = { + field: "connect-machine", + requirement: "connect-machine-requirement", + refusal: "connect-machine-refusal", +}; + +/** The disclosure every ssh surface owes before the button (decision 6, verbatim). */ +export const SSH_DISCLOSURE_COPY = + "Connecting uses this machine’s SSH configuration, including any local commands configured with Match exec."; + +export function ConnectPanel({ + initial, + onCreated, + onLeave, + onPendingChange, + draft: controlledDraft, + onDraftChange, + startInWizard = false, + wizardIntent, + onDone, +}: { + initial?: SshInitialChoices; + draft?: SshSessionDraft; + onDraftChange?: (draft: SshSessionDraft) => void; + startInWizard?: boolean; + wizardIntent?: SshWizardIntent; + onDone?: () => void; + onCreated?: (id: string) => Promise | void; + onLeave?: () => void; + onPendingChange?: (pending: boolean) => void; +}): JSX.Element { + const navigate = useNavigate(); + const nodesQuery = useSshReadiness(); + const { data: nodeData } = nodesQuery; + const machines = nodeData?.machines ?? null; + const nodes = machines?.map((machine) => machine.node) ?? null; + const machineBlocker = (id: string) => + machines?.find((machine) => machine.node.id === id)?.blockers[0]?.message ?? null; + const machineCanConnect = (id: string) => + machines?.some((machine) => machine.node.id === id && machine.canConnect) ?? false; + const { data: ledger, isPending: ledgerPending } = useSshSavedHosts(); + const launch = useLaunchSsh(); + const [submitting, setSubmitting] = useState(false); + useEffect(() => { + onPendingChange?.(submitting); + return () => onPendingChange?.(false); + }, [submitting, onPendingChange]); + const save = useSaveSshHost(); + const setDefault = useSetDefaultNode(); + + const [localDraft, setLocalDraft] = useState(() => sshSessionDraft(initial)); + const draft = controlledDraft ?? localDraft; + const setDraft = onDraftChange ?? setLocalDraft; + const nodeId = draft.nodeId; + const keyHome = draft.keyHome; + const dest = draft.destination; + const remember = draft.remember; + const setNodeId = useCallback((nodeId: string) => setDraft({ ...draft, nodeId }), [draft, setDraft]); + const setKeyHome = (keyHome: string) => setDraft({ ...draft, keyHome }); + const setDest = (destination: SshSessionDraft["destination"]) => setDraft({ ...draft, destination }); + const setRemember = (remember: boolean) => setDraft({ ...draft, remember }); + const [wizard, setWizard] = useState(startInWizard); + const [advanced, setAdvanced] = useState(!!initial?.keyHome || !!draft.keyHome); + const submitGuard = useRef(false); + const [createdId, setCreatedId] = useState(null); + const [refusal, setRefusal] = useState(null); + + const targets = nodes ?? []; + const selectedNode: Node | null = (nodes ?? []).find((n) => n.id === nodeId) ?? null; + const defaultNodeId = ledger?.defaultNodeId ?? null; + const enabledCount = targets.filter((n) => machineCanConnect(n.id)).length; + const machineReady = !nodesQuery.isError && selectedNode !== null && machineCanConnect(selectedNode.id); + const selectedBlocker = selectedNode ? machineBlocker(selectedNode.id) : "Choose an available connecting machine."; + // "Settled" means both feeds have answered, so the pre-fill effect has had + // its say: a stored default or the only usable machine has landed, or there + // was nothing to land. From then on an empty machine IS the open question - + // several doors with no preference, or no door at all (spec §7, decision 2). + const machineRequired = nodes !== null && !ledgerPending && nodeId === ""; + + // The machine disclosure keeps gated-off rows visible (decision 3): grey, + // with the reason carrying the remedy, not just the cause - the sentence + // pair mirrors the server's own gate (ssh-launch.service.ts's `gateCause`): + // the control-plane host's switch is an admin's, every other machine's is + // its owner's. + const machineOptions: ComboboxOption[] = targets.map((n) => ({ + value: n.id, + label: nodeOptionLabel(n), + disabled: !machineCanConnect(n.id), + reason: machineBlocker(n.id) ?? undefined, + })); + + // Aliases are the MACHINE's config, asked only while a gate-ON machine is + // picked (spec §7: discovery runs where it can answer). + const aliasesQ = useSshAliases(machineReady && selectedNode ? selectedNode.id : null); + const aliases = aliasesQ.data?.aliases ?? []; + + const { options: destinationOptions, candidates } = buildDestinationOptions({ + saved: ledger?.saved ?? [], + recent: ledger?.recent ?? [], + aliases, + machineName: selectedNode?.name ?? null, + typed: dest.typed, + }); + + // The default pre-fill, once and only over an untouched pick: the stored + // preference wins when its machine is still a usable row; an only child + // pre-selects honestly; several with no preference stay an open question + // (decision 2). Keyed on the loaded node list, never on `targets` (a new + // array every render). + useEffect(() => { + if (nodeId !== "" || machines === null) return; + const usable = machines.filter((machine) => machine.canConnect).map((machine) => machine.node); + if (defaultNodeId !== null && usable.some((n) => n.id === defaultNodeId)) setNodeId(defaultNodeId); + else if (usable.length === 1) setNodeId(usable[0].id); + }, [nodeId, machines, defaultNodeId, setNodeId]); + + const keyOptions: ComboboxOption[] = [ + { value: "connecting", label: "Connecting machine’s own keys" }, + ...(nodes ?? []) + .filter((n) => n.id !== nodeId || n.id === keyHome) + .map((n) => ({ + value: n.id, + label: n.name, + disabled: n.id === nodeId || !machineCanConnect(n.id), + reason: + n.id === nodeId + ? "Choose this machine’s own keys or another key machine." + : (machineBlocker(n.id) ?? undefined), + })), + ]; + const keySourceProblem = sshKeySourceProblem(draft); + const keyReady = !keyHome || keyOptions.some((o) => o.value === keyHome && !o.disabled); + const roster = useSshNodeRoster(keyHome && keyReady ? keyHome : null, !wizard); + const fingerprints = draft.selections[keyHome] ?? []; + const selectionError = sshKeySelectionProblem(fingerprints, roster.data?.identities); + const rosterReady = !keyHome || (!roster.isPending && !roster.isError && selectionError === null); + const destination = sshDestination(draft); + // A committed row's label is the field's text; while that echo stands the + // list is UNFILTERED (the whole ledger again), because the echo is display, + // not a search. Any keystroke releases the pick, and text becomes query. + const pickLabel = dest.pickId !== null ? destinationOptions.find((o) => o.value === dest.pickId)?.label : undefined; + const destinationQuery = dest.pick !== null && dest.typed === pickLabel ? "" : dest.typed; + + // Editing a suggestion releases its canonical value before another launch. + function onDestinationText(text: string): void { + setDest({ pick: null, pickId: null, typed: text }); + } + + /** Clicking (or Enter on) a row commits its candidate; the label becomes + * the field's text, which is what a re-opened list then echoes. */ + function pickDestinationOption(option: ComboboxOption): void { + const candidate = candidates.get(option.value); + if (candidate === undefined) return; + setDest({ pick: candidate, pickId: option.value, typed: option.label }); + } + + const form = makeForm({ + defaultValues: { draft }, + validator: ({ draft }): Record => { + const error = Object.values(sshDraftProblems(draft))[0]; + return error ? { draft: error } : {}; + }, + onSubmit: () => submit(), + }); + useEffect(() => { + form.setFieldValue("draft", draft); + void form.validate("change"); + }, [draft, form]); + const disabled = useSubmitDisabled(form, submitting || !!createdId || !machineReady || !keyReady || !rosterReady); + const canSubmit = + !submitting && + !createdId && + machineReady && + keyReady && + rosterReady && + Object.keys(sshDraftProblems(draft)).length === 0; + + async function submit(): Promise { + // Belt over gate: the button's own disabled condition already holds both + // fields, so this can only fire on a render race, never as a silent no-op. + if (!canSubmit || submitGuard.current) return; + submitGuard.current = true; + setSubmitting(true); + setRefusal(null); + let launchedId: string | null = null; + try { + const created = await launch.mutateAsync({ + node: nodeId, + destination, + ...(keyHome ? { keyHome, fingerprints } : {}), + }); + launchedId = created.subshell.id; + setCreatedId(launchedId); + if (remember) { + // The alias rides ONLY for a config-list pick (an alias token); saved + // and recent rows already carry their own. + save.mutate({ + node: nodeId, + destination, + ...(dest.pick?.aliasToken !== undefined ? { alias: dest.pick.aliasToken } : {}), + }); + } + if (onCreated) await onCreated(created.subshell.id); + else void navigate({ to: "/subshells/$id", params: { id: created.subshell.id } }); + } catch (err) { + setRefusal( + launchedId + ? { + field: "destination", + text: "SSH subshell was created but could not be attached. Add it from the existing subshell list instead.", + } + : sshLaunchRefusal(err, selectedNode), + ); + } finally { + setSubmitting(false); + submitGuard.current = false; + } + } + + // An open machine question speaks the moment it stands: Connect is disabled + // while the picker is empty, and a dim button with no sentence is the + // silent no-op this round refused. Gold, never red - "nothing chosen yet" + // (ruling 2026-09-30) - and when no enabled machine exists the sentence + // names the environment, not the person. + const machineGap = machineRequired + ? fieldErrorToned([ + { + message: + enabledCount > 0 + ? "Choose a connecting machine first." + : "No SSH-enabled machine is available. Enable SSH on a machine first.", + gap: true, + }, + ]) + : null; + // aria-describedby may name nothing that is not rendered, so the + // association is exactly the explanation lines currently on screen. + const machineRefusal = refusal?.field === "machine" ? refusal.text : null; + const machineDescribedBy = + [machineGap ? MACHINE_IDS.requirement : null, machineRefusal !== null ? MACHINE_IDS.refusal : null] + .filter((id): id is string => id !== null) + .join(" ") || undefined; + + if (wizard) + return ( +
+ { + setAdvanced(!!draft.keyHome); + setWizard(false); + }} + onCancel={() => setWizard(false)} + onDone={onDone ?? (() => setWizard(false))} + onLeave={onLeave} + busy={submitting} + launchBlocked={createdId !== null} + /> + {refusal && ( +

+ {refusal.text} +

+ )} +
+ ); + if (nodesQuery.isPending || nodesQuery.isError) + return ( +
+ + +
+ ); + if (enabledCount === 0) return setWizard(true)} />; + + return ( +
+ + + + + + {refusal?.field === "destination" && ( +

+ {refusal.text} +

+ )} +

+ Enter the machine you want to access, for example deploy@example.com:22. Saved destinations and SSH aliases + appear as suggestions. +

+
+ + + + id !== "" && setNodeId(id)} + describedBy={machineDescribedBy} + /> +

+ {selectedNode + ? `${selectedNode.name} runs SSH and must be able to reach the destination.` + : "Choose a Subshell machine that can reach the destination. It runs SSH for this terminal."} +

+ {nodeId !== "" && !machineReady && ( +

+ {selectedBlocker} Choose another connecting machine to continue. +

+ )} + {machineGap && ( +

+ {machineGap.text} +

+ )} + {machineRefusal !== null && ( + + )} + {selectedNode !== null && defaultNodeId !== selectedNode.id && ( + + )} +
+ +
+ + +
+ +
+ setRemember(e.target.checked)} + className="h-4 w-4 rounded border border-input bg-background accent-primary" + /> + +
+ +

{SSH_DISCLOSURE_COPY}

+ +
+
+ ); +} diff --git a/apps/server/web/src/components/connect/destination-field.tsx b/apps/server/web/src/components/connect/destination-field.tsx new file mode 100644 index 000000000..6170b8eca --- /dev/null +++ b/apps/server/web/src/components/connect/destination-field.tsx @@ -0,0 +1,141 @@ +import { Input } from "@internal/node-admin"; +import type { JSX } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { ComboboxOption } from "@/components/ui/combobox"; + +/** + * The destination field: a real input, because the answer may be a host no + * list carries, with an in-flow row panel that opens on focus. The posture + * is the working-directory field's (`directory-picker-input.tsx`), copied + * deliberately: a controlled input, a panel that follows its text, and an + * outside press or Escape as the dismissal. `SearchableSelect` is NOT used + * here on purpose - Base UI's collection reset wipes a held input's typed + * text when its items swap mid-typing (measured and documented in + * `ui/combobox.tsx`), and this field's items must swap on EVERY keystroke: + * the typed mirror row is the list growing to meet what was typed. + * + * The list is the three fed groups (`destination-options.ts`): Saved, + * Recent, and the picked machine's config aliases, each under its header, + * plus the mirror row. Clicking (or pressing Enter on the top row) COMMITS + * a row - the commitment is the candidate the launch carries, never the raw + * echo - and typing over a committed row releases it, so the field's text + * and the panel's truth cannot drift apart. + */ +export function DestinationField({ + id, + describedBy, + placeholder, + typed, + query, + options, + onTextChange, + onPick, +}: { + /** Input id - the Label's htmlFor and the test/e2e anchor */ + id: string; + /** Id of the element explaining this field (the disclosure sentence) */ + describedBy?: string; + placeholder: string; + /** The input's text: typed free text, or the committed row's label */ + typed: string; + /** What filters the list; "" while a committed row's echo stands */ + query: string; + /** The full fed list (groups + mirror row); the field only filters it */ + options: readonly ComboboxOption[]; + onTextChange: (text: string) => void; + /** Commits one row (a click, or Enter on the top visible row) */ + onPick: (option: ComboboxOption) => void; +}): JSX.Element { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + // Dismissal by anything outside the field, or Escape - the directory + // picker's rule, and the listeners live with `open` so an idle field costs + // nothing. + useEffect(() => { + if (!open) return; + function onOutsideClick(e: MouseEvent) { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + } + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + // Dismiss after the target receives its click. Removing these in-flow + // suggestions on pointerdown moves the target before pointerup. + document.addEventListener("click", onOutsideClick); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("click", onOutsideClick); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + // The same filter the shared combobox applies, so the picker and every + // other list in the product answer a query identically: label or searchText, + // case-insensitive contains, trimmed. + const needle = query.trim().toLowerCase(); + const visible = + needle === "" + ? [...options] + : options.filter( + (o) => o.label.toLowerCase().includes(needle) || (o.searchText ?? "").toLowerCase().includes(needle), + ); + + function pick(option: ComboboxOption): void { + onPick(option); + setOpen(false); + } + + let lastGroup: string | undefined; + return ( +
+ onTextChange(e.target.value)} + onFocus={() => setOpen(true)} + placeholder={placeholder} + aria-describedby={describedBy} + onKeyDown={(e) => { + if (e.key === "Enter" && visible.length > 0) { + e.preventDefault(); // committing the top row is the act; it must not submit the page + pick(visible[0]); + } + }} + /> + {open && visible.length > 0 && ( + // The id lets callers (and tests) scope to the popup: a group header + // like "Recent" names the same word the ledger heading below uses, + // and only the popup's copy is the picker's. +
+ {visible.map((option) => { + const header = option.group !== undefined && option.group !== lastGroup; + lastGroup = option.group; + return ( +
+ {header &&
{option.group}
} + +
+ ); + })} +
+ )} + {open && visible.length === 0 &&

No matches

} +
+ ); +} diff --git a/apps/server/web/src/components/connect/destination-options.ts b/apps/server/web/src/components/connect/destination-options.ts new file mode 100644 index 000000000..4e0b63a31 --- /dev/null +++ b/apps/server/web/src/components/connect/destination-options.ts @@ -0,0 +1,98 @@ +import type { ComboboxOption } from "@/components/ui/combobox"; +import type { SshSavedHost } from "@/lib/ssh"; + +/** + * The destination field's list, as pure data (the picker itself is the + * panel's). Three fed groups per spec 2026-10-07 §7: the caller's saved rows, + * their recent rows, and the picked machine's own config aliases - plus the + * one row that makes the field accept free text: a row mirroring whatever is + * typed. Picking is the commitment; the candidate map says what each row + * MEANS on the wire, so display aliases never leak into the launch body. + */ + +/** What choosing one row commits the launch to. */ +export interface DestinationCandidate { + /** The token sent as `destination` (an alias from the config, or a concrete host) */ + destination: string; + /** + * The alias display token, set ONLY for rows that came from the machine's + * config list - the save sends it as the row's display override (§7's + * alias-is-display rule). Saved and recent rows already own an alias. + */ + aliasToken?: string; +} + +/** The typed mirror row's group: none. It leads the list unlabelled. */ +const TYPED_PREFIX = "typed:"; +const SAVED_PREFIX = "saved:"; +const RECENT_PREFIX = "recent:"; +const CONFIG_PREFIX = "config:"; + +/** The group headers, one definition so the panel and its tests agree. */ +export const SAVED_GROUP = "Saved"; +export const RECENT_GROUP = "Recent"; +/** `From mac mini's config` - the aliases belong to the machine, and the row says so. */ +export const configGroupLabel = (machineName: string): string => `From ${machineName}'s config`; + +/** The destination field's full list plus the per-row candidate map. */ +export function buildDestinationOptions(args: { + /** The caller's saved rows (newest first, as the server sends them) */ + saved: readonly SshSavedHost[]; + /** The caller's recent rows; rows already Saved are dropped from Recent */ + recent: readonly SshSavedHost[]; + /** The picked machine's alias names, fetched only while its gate is on */ + aliases: readonly string[]; + /** The picked machine's display name, for the config group header */ + machineName: string | null; + /** The field's current typed text - the mirror row's source */ + typed: string; +}): { options: ComboboxOption[]; candidates: Map } { + const options: ComboboxOption[] = []; + const candidates = new Map(); + + const push = (id: string, option: ComboboxOption, candidate: DestinationCandidate): void => { + options.push(option); + candidates.set(id, candidate); + }; + + // The free-text mirror first: whatever is typed is offered as a row the + // click commits. Suppressed when a real row already carries the same + // token - the row would be a duplicate of a choice already on screen. + const text = args.typed.trim(); + const covered = (token: string): boolean => + args.saved.some((h) => h.destination === token || h.alias === token) || + args.recent.some((h) => h.destination === token || h.alias === token) || + args.aliases.some((a) => a === token); + if (text !== "" && !covered(text)) { + push(`${TYPED_PREFIX}${text}`, { value: `${TYPED_PREFIX}${text}`, label: text }, { destination: text }); + } + + const savedIds = new Set(args.saved.map((h) => h.id)); + for (const row of args.saved) { + const label = row.alias ?? row.destination; + push( + `${SAVED_PREFIX}${row.id}`, + { value: `${SAVED_PREFIX}${row.id}`, label, group: SAVED_GROUP, searchText: row.destination }, + { destination: row.destination }, + ); + } + for (const row of args.recent) { + if (savedIds.has(row.id)) continue; // the destination reads once (the favorites precedent) + const label = row.alias ?? row.destination; + push( + `${RECENT_PREFIX}${row.id}`, + { value: `${RECENT_PREFIX}${row.id}`, label, group: RECENT_GROUP, searchText: row.destination }, + { destination: row.destination }, + ); + } + if (args.machineName !== null) { + for (const token of args.aliases) { + push( + `${CONFIG_PREFIX}${token}`, + { value: `${CONFIG_PREFIX}${token}`, label: token, group: configGroupLabel(args.machineName) }, + { destination: token, aliasToken: token }, + ); + } + } + return { options, candidates }; +} diff --git a/apps/server/web/src/components/connect/no-ssh-targets.tsx b/apps/server/web/src/components/connect/no-ssh-targets.tsx new file mode 100644 index 000000000..b75a95276 --- /dev/null +++ b/apps/server/web/src/components/connect/no-ssh-targets.tsx @@ -0,0 +1,23 @@ +import { Button, CardTitle } from "@internal/node-admin"; +import type { SshMachineReadiness } from "@/lib/ssh"; + +/** One setup entry point even when every existing machine is blocked. */ +export function NoSshTargets({ machines, onWizard }: { machines: SshMachineReadiness[]; onWizard: () => void }) { + return ( +
+ No machine is ready for SSH +

+ An SSH terminal needs an online Subshell machine with SSH enabled to connect from. The wizard can prepare an + existing machine or help you add one. +

+ {machines.map(({ node, blockers }) => ( +

+ {node.name}: {blockers.map((blocker) => blocker.message).join(" ")} +

+ ))} + +
+ ); +} diff --git a/apps/server/web/src/components/connect/saved-hosts-section.tsx b/apps/server/web/src/components/connect/saved-hosts-section.tsx new file mode 100644 index 000000000..fd8bc7584 --- /dev/null +++ b/apps/server/web/src/components/connect/saved-hosts-section.tsx @@ -0,0 +1,111 @@ +import { Star, Trash2 } from "lucide-react"; +import type { JSX } from "react"; +import { useNodes } from "@/hooks/use-nodes"; +import { useDeleteSshHost, useSaveSshHost, useSshSavedHosts } from "@/hooks/use-ssh"; +import type { SshSavedHost } from "@/lib/ssh"; + +/** + * The destination ledger under the Connect panel (spec 2026-10-07 §7): what + * the caller remembered, and what they last connected to. Two lists, one + * shape: `label` over `detail`, differing by weight and colour - the label is + * the display alias or the destination, the detail names the connecting + * machine and the day. Remembered rows carry the way OUT (delete); recent + * rows carry the way IN (remember). Recent rows already Saved read once: the + * remembered copy wins, nothing duplicates. + * + * The save is a plain PUT keyed to the row's own machine - the server + * resolves first and stores the canonical destination, so a rename of the + * alias later cannot re-point the key. The delete is by row id. No confirms: + * a ledger row is cheap to remake, and the ACT that matters is the launch. + */ +export function SavedHostsSection(): JSX.Element { + const { data: ledger } = useSshSavedHosts(); + const { data: nodeData } = useNodes(); + const save = useSaveSshHost(); + const remove = useDeleteSshHost(); + + const saved = ledger?.saved ?? []; + const savedIds = new Set(saved.map((h) => h.id)); + const recent = (ledger?.recent ?? []).filter((h) => !savedIds.has(h.id)); + const nameById = new Map( + (Array.isArray(nodeData?.nodes) ? nodeData.nodes : []).map((n) => [n.id, n.name]), + ); + const machineName = (row: SshSavedHost): string => nameById.get(row.nodeId) ?? row.nodeId; + + return ( +
+
+

Remembered

+ {saved.length === 0 ? ( +

No remembered destinations yet.

+ ) : ( +
    + {saved.map((row) => ( + remove.mutate(row.id), + }} + /> + ))} +
+ )} +
+
+

Recent

+ {recent.length === 0 ? ( +

No recent destinations yet.

+ ) : ( +
    + {recent.map((row) => ( + save.mutate({ node: row.nodeId, destination: row.destination }), + }} + /> + ))} +
+ )} +
+
+ ); +} + +/** One ledger line: label over detail, with the row's single act on the right. */ +function LedgerRow({ + row, + detail, + action, +}: { + row: SshSavedHost; + detail: string; + action: { label: string; icon: typeof Star; onSelect: () => void }; +}): JSX.Element { + const Icon = action.icon; + return ( +
  • +
    +
    {row.alias ?? row.destination}
    +
    {detail}
    +
    + +
  • + ); +} diff --git a/apps/server/web/src/components/connect/ssh-destination-step.tsx b/apps/server/web/src/components/connect/ssh-destination-step.tsx new file mode 100644 index 000000000..b28dc2205 --- /dev/null +++ b/apps/server/web/src/components/connect/ssh-destination-step.tsx @@ -0,0 +1,72 @@ +import { Button } from "@internal/node-admin"; +import { DestinationField } from "@/components/connect/destination-field"; +import { buildDestinationOptions } from "@/components/connect/destination-options"; +import type { SshSessionDraft } from "@/components/connect/ssh-session-draft"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { useSshAliases, useSshSavedHosts } from "@/hooks/use-ssh"; + +/** Reuses the click-dismiss destination picker and its canonical suggestion state. */ +export function SshDestinationStep({ + draft, + onChange, + nodeName, + ready, +}: { + draft: SshSessionDraft; + onChange: (draft: SshSessionDraft) => void; + nodeName?: string; + ready: boolean; +}) { + const ledger = useSshSavedHosts(); + const aliases = useSshAliases(ready ? draft.nodeId : null); + const { options, candidates } = buildDestinationOptions({ + saved: ledger.data?.saved ?? [], + recent: ledger.data?.recent ?? [], + aliases: aliases.data?.aliases ?? [], + machineName: nodeName ?? null, + typed: draft.destination.typed, + }); + const label = options.find((option) => option.value === draft.destination.pickId)?.label; + return ( + + + SSH destination + onChange({ ...draft, destination: { typed, pick: null, pickId: null } })} + onPick={(option) => { + const pick = candidates.get(option.value); + if (pick) onChange({ ...draft, destination: { pick, pickId: option.value, typed: option.label } }); + }} + /> +

    + Enter the destination you want to access, for example deploy@example.com:22, or choose a saved destination. +

    + {(ledger.isError || aliases.isError) && ( +
    +

    + {ledger.isError && "Saved and recent destinations could not be read. "} + {aliases.isError && "SSH config aliases could not be read. "} + You can still type a destination. +

    + +
    + )} +
    +
    + ); +} diff --git a/apps/server/web/src/components/connect/ssh-enrollment-step.tsx b/apps/server/web/src/components/connect/ssh-enrollment-step.tsx new file mode 100644 index 000000000..79d7ec574 --- /dev/null +++ b/apps/server/web/src/components/connect/ssh-enrollment-step.tsx @@ -0,0 +1,80 @@ +import { Button, errMessage } from "@internal/node-admin"; +import { NodeKeySetup } from "@/components/nodes/node-key-setup"; +import { SshQueryStatus } from "@/components/ssh/query-status"; +import { FieldGroup } from "@/components/ui/field"; +import type { useSshEnrollment } from "@/hooks/use-ssh-enrollment"; +import { NODE_ENROLLMENT_OFF_COPY } from "@/lib/node-enrollment"; +import type { SshMachineReadiness } from "@/lib/ssh"; + +/** Embedded enrollment fields; matching the consumed key and live node are separate facts. */ +export function SshEnrollmentStep({ + enrollment, + mayAdd, + machines, + readiness, +}: { + enrollment: ReturnType; + mayAdd: boolean; + machines: SshMachineReadiness[]; + readiness: { refetch: () => unknown }; +}) { + return ( + + {!mayAdd && !enrollment.created ? ( +

    {NODE_ENROLLMENT_OFF_COPY}

    + ) : ( + + + {enrollment.create.isError && ( +

    + {errMessage(enrollment.create.error, "Couldn't generate a setup key. Try again.")} +

    + )} + + } + /> + )} + {enrollment.created && ( + <> + +

    + {enrollment.consumedNodeId + ? machines.find((m) => m.node.id === enrollment.consumedNodeId)?.node.status === "online" + ? "The machine is enrolled and online. Continue to set up SSH." + : "The machine is enrolled. Waiting for it to come online." + : enrollment.row?.usedAt + ? "This key was used, but we couldn't find the machine it enrolled. Check its status again or choose an existing machine." + : "Waiting for a machine to use this setup key."} +

    + + + )} +
    + ); +} diff --git a/apps/server/web/src/components/connect/ssh-key-step.tsx b/apps/server/web/src/components/connect/ssh-key-step.tsx new file mode 100644 index 000000000..052b9e8c2 --- /dev/null +++ b/apps/server/web/src/components/connect/ssh-key-step.tsx @@ -0,0 +1,98 @@ +import { Button, errMessage } from "@internal/node-admin"; +import { sshKeySelectionProblem } from "@/components/connect/ssh-session-draft"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { useSshNodeRoster } from "@/hooks/use-ssh"; + +export const SSH_AGENT_ACCESS_COPY = + "The Subshell process on this machine needs access to SSH_AUTH_SOCK. An interactive shell’s SSH agent may differ from the agent available to its background service."; + +/** Only mounted on the explicit remote-key path. No roster entry is selected implicitly. */ +export function SshKeyStep({ + nodeId, + fingerprints, + onChange, +}: { + nodeId: string | null; + fingerprints: string[]; + onChange: (values: string[]) => void; +}) { + const roster = useSshNodeRoster(nodeId); + const problem = sshKeySelectionProblem(fingerprints, roster.data?.identities); + if (!nodeId) + return ( +

    + The selected key machine is unavailable. Bring it online with SSH enabled, or choose another machine. +

    + ); + return ( + +

    Choose the keys to use. Private keys stay on the key machine.

    + {roster.isPending && ( +

    + Loading SSH agent keys… +

    + )} + {roster.isError && ( +
    +

    + Could not read this machine’s SSH agent: {errMessage(roster.error, "The agent could not be reached.")} +

    +

    {SSH_AGENT_ACCESS_COPY}

    +
    + )} + {roster.data && ( +
    + SSH agent keys + {roster.data.identities.map((key, index) => ( + +
    + + onChange( + checked ? [...fingerprints, key.fingerprint] : fingerprints.filter((f) => f !== key.fingerprint), + ) + } + /> + {key.comment || key.fingerprint} +
    +

    {key.fingerprint}

    +
    + ))} + {fingerprints + .filter((fingerprint) => !roster.data.identities.some((key) => key.fingerprint === fingerprint)) + .map((fingerprint) => ( +
    +

    {fingerprint}

    +

    This SSH agent no longer has the selected key.

    + +
    + ))} + {roster.data.identities.length === 0 && ( +

    + No keys are loaded in this machine’s SSH agent. Load the keys you want to use into that agent, then Retry + keys. +

    + )} +
    + )} + {problem && !roster.isError && ( +

    + {problem} +

    + )} + +
    + ); +} diff --git a/apps/server/web/src/components/connect/ssh-machine-step.tsx b/apps/server/web/src/components/connect/ssh-machine-step.tsx new file mode 100644 index 000000000..e50b7b97d --- /dev/null +++ b/apps/server/web/src/components/connect/ssh-machine-step.tsx @@ -0,0 +1,136 @@ +import { Button, errMessage } from "@internal/node-admin"; +import { useNavigate } from "@tanstack/react-router"; +import { SearchableSelect } from "@/components/ui/combobox"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { useEnableSsh } from "@/hooks/use-ssh"; +import type { SshMachineReadiness } from "@/lib/ssh"; + +/** Lists visible machines even when they need preparation; launch and configuration are distinct rights. */ +export function SshMachineStep({ + machines, + value, + onChange, + label, + exclude, +}: { + machines: SshMachineReadiness[]; + value: string; + onChange: (id: string) => void; + label: string; + exclude?: string; +}) { + return ( + + + {label} + m.node.id !== exclude || m.node.id === value) + .map((m) => ({ + value: m.node.id, + label: m.node.name, + disabled: m.node.id === exclude, + reason: + m.node.id === exclude ? "Choose a different key machine." : m.blockers.map((b) => b.message).join(" "), + }))} + onValueChange={(id) => id && onChange(id)} + /> + {value && !machines.some((m) => m.node.id === value) && ( +

    + The selected machine is no longer available to you. Choose another machine. +

    + )} +
    +
    + ); +} + +/** Explicitly enables SSH only where the readiness response authorizes configuration. */ +export function SshMachineSetup({ + machine, + retry, + onLeave, +}: { + machine?: SshMachineReadiness; + retry: () => unknown; + onLeave?: () => void; +}) { + const enable = useEnableSsh(); + const navigate = useNavigate(); + if (!machine) + return ( +

    + The selected machine is no longer available to you. Go back and choose another machine. +

    + ); + const onlyNeedsEnable = + !machine.node.sshEnabled && + machine.canConfigure && + machine.blockers.length === 1 && + machine.blockers[0].code === "SSH_GATE_OFF"; + const needsSettings = + machine.blockers.some( + (blocker) => blocker.code === "NODE_IN_MAINTENANCE" || blocker.code === "NODE_PROTOCOL_HELD", + ) || + (machine.node.kind === "agent" && !machine.node.canLaunch); + return ( +
    +

    {machine.node.name}

    + {machine.canConnect ? ( +

    + This machine is ready to run SSH. We haven't checked whether you can sign in to the destination. +

    + ) : ( + <> + {machine.blockers.map((blocker) => ( +

    + {blocker.message} +

    + ))} + {!machine.node.sshEnabled && + (machine.canConfigure ? ( + + ) : ( +

    + {machine.node.kind === "local" ? "An admin" : "This machine’s owner"} can enable SSH. Launch access does + not allow changing its configuration. +

    + ))} + {!onlyNeedsEnable && ( + + )} + {machine.canConfigure && needsSettings && ( + + )} + + )} + {enable.isError && ( +

    + {errMessage(enable.error, "Couldn't enable SSH. Try again when the machine is available.")} +

    + )} +
    + ); +} diff --git a/apps/server/web/src/components/connect/ssh-refusal.ts b/apps/server/web/src/components/connect/ssh-refusal.ts new file mode 100644 index 000000000..b37def111 --- /dev/null +++ b/apps/server/web/src/components/connect/ssh-refusal.ts @@ -0,0 +1,142 @@ +import type { Node } from "@internal/node-admin"; +import { ApiError, isNetworkError } from "@internal/node-admin"; +import type { SshResolveOutcome } from "@/lib/ssh"; + +/** + * The launch refusal, in the client's words. `POST /api/ssh/launch` answers + * every no with a status and a code the panel can name (the response map in + * `apps/server/api/src/api/ssh/launch.route.ts`); this file is the ONE place + * that maps them to copy, each cause and its next step, so the panel renders + * a cause and a remedy rather than an `API 409:` string. + * + * The 422 is the special case: its body is not the error envelope but the + * resolve outcome itself (`{outcome}`), and `ApiError.message` slices the + * display text to 200 chars, so the settings list is read from `err.body` + * (the node-admin un-truncation). An older or truncated body falls back to + * the message text; nothing renders a half sentence from a missing field. + */ + +/** The refused arm of the resolve outcome, narrowed from `unknown`. */ +export function isSshResolveRefusal(value: unknown): value is Extract { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return v.accepted === false && typeof v.code === "string" && Array.isArray(v.settings); +} + +/** Which field the refusal belongs under: the token typed, or the machine chosen. */ +export type RefusalField = "destination" | "machine" | "keys"; + +/** The red line the panel renders, and where. */ +export interface SshRefusalCopy { + text: string; + field: RefusalField; +} + +/** What the panel knows about the connecting machine at submit time. */ +export type SshMachineFacts = Pick; + +/** The settings-bearing sentence names each blocked keyword verbatim; the + * code-only arms (empty settings) name the fact and the place to fix it. */ +function refusalFromOutcome(outcome: Extract): string { + return outcome.settings.length > 0 + ? `This SSH configuration uses settings Subshell doesn't support: ${outcome.settings.join(", ")}. Update them on the connecting machine and try again.` + : "Subshell can't connect using this SSH configuration. Update it on the connecting machine and try again."; +} + +/** + * Maps a caught launch failure to copy. A `NetworkError` returns null: the + * app-wide offline banner already says it, and restating "server unreachable" + * under the form would be the same sentence twice. + * @param err - the value from the launch mutation's catch + * @param machine - the picked node's facts (null when the row is unknown) + */ +export function sshLaunchRefusal(err: unknown, machine: SshMachineFacts | null): SshRefusalCopy | null { + if (isNetworkError(err)) return null; + if (err instanceof ApiError) { + if ( + err.code?.startsWith("SSH_RELAY_") || + err.code?.startsWith("SSH_HOST_PIN_") || + err.code?.startsWith("SSH_KEYS_") + ) + return { field: "keys", text: err.message }; + switch (err.status) { + case 400: + // ALIAS_UNSAFE: the token was refused before the machine was asked. + return { text: "Enter a valid SSH destination, such as deploy@example.com:22.", field: "destination" }; + case 403: { + if (err.code === "SSH_GATE_OFF" && machine !== null && !machine.sshEnabled) { + // The gate's off-door. Naming WHO flips it follows the row's kind: + // `local` is an admin's setting, every other machine its owner's + // (spec 2026-10-07 decision 3). + return { + text: + machine.kind === "local" + ? "An admin can enable SSH on this machine in its settings." + : "SSH is off on this machine. Ask its owner to enable it.", + field: "machine", + }; + } + // Other 403s can come from launch access or instance policy. + // Avoid naming a cause without enough information. + return { text: "You can't connect from this machine.", field: "machine" }; + } + case 404: + return { text: "That machine isn't available.", field: "machine" }; + case 409: { + const name = machine?.name ?? "That machine"; + switch (err.code) { + case "NODE_PROTOCOL_HELD": + return { + text: `${name} runs a Subshell version this server doesn't support. Update it from its node page and try again.`, + field: "machine", + }; + case "NODE_OUTDATED": + return { + text: `Update Subshell on ${name} from its node page to use SSH.`, + field: "machine", + }; + case "NODE_UNREACHABLE": + return { + text: `${name} didn't answer the SSH request in time. Check its connection and try again.`, + field: "machine", + }; + case "NODE_IN_MAINTENANCE": + // The create under the launch refuses in a window (`rethrow- + // LaunchRefusal`, subshells.service.ts): the link is LIVE, it + // just takes no new panes - saying "no live connection" here + // would name the wrong fact and the wrong wait. + return { + text: `${name} is in maintenance, so you can't start new subshells on it. Try again when maintenance ends.`, + field: "machine", + }; + default: + // NODE_OFFLINE and any other 409: no live link to carry the ask. + return { + text: `${name} is offline. Start its Subshell daemon and try again.`, + field: "machine", + }; + } + } + case 422: { + // The resolve refused: read the outcome from the parsed body, because + // the message is a 200-char slice of it. + const outcome = + typeof err.body === "object" && err.body !== null ? (err.body as { outcome?: unknown }).outcome : undefined; + if (isSshResolveRefusal(outcome)) return { text: refusalFromOutcome(outcome), field: "destination" }; + return { text: err.message, field: "destination" }; + } + case 502: + // The machine answered its own refusal (no ssh binary, the mirror + // says no); its verbatim text never rides the wire to here. This is + // the server's own sentence (`rpcRefusal`): the two checks that + // answer the machine's no. + return { + text: "The connecting machine refused the SSH request. Check that SSH is switched on there and that ssh is installed.", + field: "machine", + }; + default: + break; + } + } + return { text: "Couldn't start the SSH connection.", field: "machine" }; +} diff --git a/apps/server/web/src/components/connect/ssh-session-draft.ts b/apps/server/web/src/components/connect/ssh-session-draft.ts new file mode 100644 index 000000000..dfc350362 --- /dev/null +++ b/apps/server/web/src/components/connect/ssh-session-draft.ts @@ -0,0 +1,113 @@ +import type { DestinationCandidate } from "@/components/connect/destination-options"; +import { type SshAgentIdentity, type SshMachineReadiness, sshSelectionError } from "@/lib/ssh"; + +export type SshWizardIntent = "connect" | "prepare" | "remote-keys"; +export type SshInitialChoices = { node?: string; destination?: string; keyHome?: string; fingerprints?: string[] }; + +/** A dialog owns this draft across form, wizard, and parent mode changes. */ +export interface SshSessionDraft { + /** Machine that runs SSH. Empty means no deliberate choice yet. */ + nodeId: string; + /** Explicit key machine; retain it even if the connecting machine changes to this id. Empty means own keys. */ + keyHome: string; + /** Explicit selections, separately scoped to each key machine. */ + selections: Record; + /** Display text and canonical suggestion travel together. */ + destination: { pick: DestinationCandidate | null; pickId: string | null; typed: string }; + /** Save this destination after a successful launch. */ + remember: boolean; +} + +export function sshSessionDraft(initial?: SshInitialChoices): SshSessionDraft { + return { + nodeId: initial?.node ?? "", + keyHome: initial?.keyHome ?? "", + selections: initial?.keyHome ? { [initial.keyHome]: initial.fingerprints ?? [] } : {}, + destination: { + pick: initial?.destination ? { destination: initial.destination } : null, + pickId: null, + typed: initial?.destination ?? "", + }, + remember: false, + }; +} + +export function sshDestination(draft: SshSessionDraft): string { + return draft.destination.pick?.destination ?? draft.destination.typed.trim(); +} + +export function sshKeySelectionProblem(fingerprints: string[], identities?: SshAgentIdentity[]): string | null { + if (fingerprints.length === 0) return "Choose at least one SSH agent key."; + const cap = sshSelectionError(fingerprints); + if (cap) return cap; + if (identities && fingerprints.some((fingerprint) => !identities.some((key) => key.fingerprint === fingerprint))) + return "A selected key is no longer available. Load it into the SSH agent again or choose different keys."; + return null; +} + +export function sshKeySourceProblem(draft: SshSessionDraft): string | null { + return draft.keyHome && draft.keyHome === draft.nodeId + ? "You’ve selected the same machine for the connection and the keys. Choose its own keys or another key machine to continue. Your previous key selection is saved." + : null; +} + +export function sshDraftProblems(draft: SshSessionDraft): Record { + if (!draft.nodeId) return { node: "Choose a connecting machine first." }; + if (!sshDestination(draft)) return { destination: "Enter an SSH destination." }; + const error = + sshKeySourceProblem(draft) || + (draft.keyHome ? sshKeySelectionProblem(draft.selections[draft.keyHome] ?? []) : null); + return error ? { keys: error } : {}; +} + +export type SshWizardStep = + | "intent" + | "destination" + | "machine" + | "enrollment" + | "machine-setup" + | "key-choice" + | "key-source" + | "key-setup" + | "keys" + | "review" + | "readiness"; +export const SSH_STEP_LABELS: Record = { + intent: "What would you like to do?", + destination: "Choose a destination", + machine: "Choose a connecting machine", + enrollment: "Add a machine", + "machine-setup": "Prepare the connecting machine", + "key-choice": "Choose where your keys live", + "key-source": "Choose a key machine", + "key-setup": "Prepare the key machine", + keys: "Choose SSH agent keys", + review: "Review your connection", + readiness: "Your SSH setup", +}; + +/** Stable step ids keep a disappearing prerequisite from changing the current screen. */ +export function sshWizardSteps( + intent: SshWizardIntent | null, + draft: SshSessionDraft, + machines: SshMachineReadiness[], + current: SshWizardStep, + adding: boolean, +): SshWizardStep[] { + if (!intent) return ["intent"]; + const machine = machines.find((m) => m.node.id === draft.nodeId); + const key = machines.find((m) => m.node.id === draft.keyHome); + const steps: SshWizardStep[] = ["intent"]; + if (intent === "connect") steps.push("destination"); + steps.push("machine"); + if (adding) steps.push("enrollment"); + if (!machine?.canConnect || current === "machine-setup") steps.push("machine-setup"); + if (intent === "connect") steps.push("key-choice"); + if (intent === "remote-keys" || (intent === "connect" && (draft.keyHome || current === "key-source"))) { + steps.push("key-source"); + if (!key?.canConnect || current === "key-setup") steps.push("key-setup"); + steps.push("keys"); + } + steps.push(intent === "connect" ? "review" : "readiness"); + return steps; +} diff --git a/apps/server/web/src/components/connect/ssh-wizard-dialog.tsx b/apps/server/web/src/components/connect/ssh-wizard-dialog.tsx new file mode 100644 index 000000000..865bc2797 --- /dev/null +++ b/apps/server/web/src/components/connect/ssh-wizard-dialog.tsx @@ -0,0 +1,50 @@ +import { Button } from "@internal/node-admin"; +import { useState } from "react"; +import { ConnectPanel } from "@/components/connect/connect-panel"; +import type { SshInitialChoices, SshWizardIntent } from "@/components/connect/ssh-session-draft"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + formDialogOpenChange, +} from "@/components/ui/dialog"; + +/** Settings entry mounts the same content only while open; it can return to the connection form in-place. */ +export function SshWizardDialog({ initial, intent }: { initial?: SshInitialChoices; intent?: SshWizardIntent }) { + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + return ( + <> + + { + if (!busy) setOpen(next); + })} + > + + + SSH Wizard + + Connect to a destination, prepare a machine, or use keys from another machine. + + + {open && ( + setOpen(false)} + onLeave={() => setOpen(false)} + /> + )} + + + + ); +} diff --git a/apps/server/web/src/components/connect/ssh-wizard.tsx b/apps/server/web/src/components/connect/ssh-wizard.tsx new file mode 100644 index 000000000..257613a95 --- /dev/null +++ b/apps/server/web/src/components/connect/ssh-wizard.tsx @@ -0,0 +1,287 @@ +import { Button } from "@internal/node-admin"; +import { KeyRound, Network, Server } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { SshDestinationStep } from "@/components/connect/ssh-destination-step"; +import { SshEnrollmentStep } from "@/components/connect/ssh-enrollment-step"; +import { SshKeyStep } from "@/components/connect/ssh-key-step"; +import { SshMachineSetup, SshMachineStep } from "@/components/connect/ssh-machine-step"; +import { + SSH_STEP_LABELS, + type SshSessionDraft, + type SshWizardIntent, + sshDestination, + sshKeySelectionProblem, +} from "@/components/connect/ssh-session-draft"; +import { SshQueryStatus } from "@/components/ssh/query-status"; +import { FieldGroup } from "@/components/ui/field"; +import { useSshWizardController } from "@/hooks/use-ssh-wizard-controller"; +import { NODE_ENROLLMENT_OFF_COPY } from "@/lib/node-enrollment"; +import type { SshMachineReadiness } from "@/lib/ssh"; + +export interface SshWizardProps { + /** Optional entry intent. Null opens the three intent actions. */ + intent?: SshWizardIntent; + /** Shared with the connection form and retained by the outer dialog. */ + draft: SshSessionDraft; + /** Writes canonical choices, including an explicitly empty key selection. */ + onDraftChange: (draft: SshSessionDraft) => void; + /** Readiness is supplied by the mounted content host. */ + machines: SshMachineReadiness[]; + /** Retrying never discards the draft or enrollment key. */ + readiness: { isPending: boolean; isError: boolean; refetch: () => unknown }; + /** Actual launch only; setup completion never invokes it. */ + onStart: () => Promise; + /** Return to the same host's connection form. */ + onApplySetup: () => void; + /** Setup-only completion. */ + onDone: () => void; + /** Return to the prior form without undoing completed machine changes. */ + onCancel: () => void; + /** Close before leaving for authorized machine settings. */ + onLeave?: () => void; + /** Includes the parent's asynchronous workspace/split attachment. */ + busy: boolean; + /** A pane already exists; attachment failure must not create a second one. */ + launchBlocked?: boolean; +} + +/** Content-only wizard: stable step ids, explicit setup acts, no nested dialog or navigation on creation. */ +export function SshWizard({ + intent: initialIntent, + draft, + onDraftChange, + machines, + readiness, + onStart, + onApplySetup, + onDone, + onCancel, + onLeave, + busy, + launchBlocked, +}: SshWizardProps) { + const { + intent, + current, + setCurrent, + setAdding, + enrollment, + mayAdd, + machine, + keyMachine, + keySourceProblem, + remote, + fingerprints, + roster, + steps, + index, + form, + disabled, + selectIntent, + patch, + chooseKeys, + } = useSshWizardController({ + intent: initialIntent, + draft, + onDraftChange, + machines, + readiness, + onStart, + onApplySetup, + onDone, + onCancel, + onLeave, + busy, + launchBlocked, + }); + const heading = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: every step transition must move keyboard focus + useEffect(() => { + heading.current?.focus(); + }, [current]); + + return ( +
    +
    +

    + {SSH_STEP_LABELS[current]} +

    +

    + {intent + ? `Step ${index + 1} of ${steps.length}${steps[index + 1] ? ` · Next: ${SSH_STEP_LABELS[steps[index + 1]]}` : ""}` + : "Choose what you want to set up."} +

    +
    + + {keySourceProblem && intent !== "prepare" && current !== "intent" && ( +
    +

    + {keySourceProblem} +

    + {["keys", "review", "readiness"].includes(current) && ( + + )} +
    + )} + {current === "intent" && ( +
    + + + +
    + )} + {current === "destination" && ( + + )} + {current === "machine" && ( + + { + setAdding(false); + patch({ nodeId }); + }} + label={intent === "prepare" ? "Machine to prepare" : "Connect through"} + /> +

    + This machine runs SSH and must be able to reach the destination. Its normal SSH identity files work without + an SSH agent. +

    + + {!mayAdd &&

    {NODE_ENROLLMENT_OFF_COPY}

    } +
    + )} + {current === "enrollment" && ( + + )} + {current === "machine-setup" && } + {current === "key-choice" && ( +
    + + +
    + )} + {current === "key-source" && ( + machine.node.canLaunch)} + value={draft.keyHome} + onChange={chooseKeys} + exclude={draft.nodeId} + label="Use SSH keys from" + /> + )} + {current === "key-setup" && } + {current === "keys" && ( + patch({ selections: { ...draft.selections, [draft.keyHome]: selection } })} + /> + )} + {(current === "review" || current === "readiness") && ( + +

    + {machine?.node.name ?? "Selected machine unavailable"} + {sshDestination(draft) && ` → ${sshDestination(draft)}`} +

    + + {remote && intent !== "prepare" && ( + <> +

    + Keys from {keyMachine?.node.name ?? "unavailable machine"}: {fingerprints.join(", ")} +

    + + {!keySourceProblem && } + {!keySourceProblem && sshKeySelectionProblem(fingerprints, roster.data?.identities) && ( +

    + {sshKeySelectionProblem(fingerprints, roster.data?.identities)} +

    + )} + + )} +

    + We haven't checked whether you can sign in to the destination. Connecting uses this machine’s SSH + configuration, including any local commands configured with Match exec. +

    +
    + )} +
    + + {current !== "intent" && ( + + )} + {current === "readiness" ? ( + <> + + + + ) : ( + !["intent", "key-choice"].includes(current) && ( + + ) + )} +
    +
    + ); +} diff --git a/apps/server/web/src/components/nodes/__tests__/node-harness-card.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-harness-card.test.tsx index 30ea15041..83a0ccf64 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-harness-card.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-harness-card.test.tsx @@ -104,6 +104,7 @@ function view(over: Partial): NodeDetail { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...over, }; diff --git a/apps/server/web/src/components/nodes/__tests__/node-list-row.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-list-row.test.tsx index 7799cc453..465af89b7 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-list-row.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-list-row.test.tsx @@ -35,6 +35,7 @@ function node(overrides: Partial = {}): Node { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/components/nodes/__tests__/node-section-nav.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-section-nav.test.tsx index 881b24fe9..b6101c03d 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-section-nav.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-section-nav.test.tsx @@ -34,6 +34,7 @@ function navNode(overrides: Partial = {}): NodeDetail { access: "owner", canManage: true, canRetire: true, + sshEnabled: false, canLaunch: true, allowedDirs: [], capabilities: [], diff --git a/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx index 5cac6b01f..7b61365d6 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx @@ -29,6 +29,7 @@ function node(overrides: Partial = {}): Node { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx index 7391bf461..bc64345fa 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx @@ -35,6 +35,7 @@ function node(over: Partial = {}): NodeDetail { maintenance: false, maintenanceAt: null, maintenanceSource: null, + sshEnabled: false, held: null, ...over, }; diff --git a/apps/server/web/src/components/nodes/node-key-setup.tsx b/apps/server/web/src/components/nodes/node-key-setup.tsx index c2f3c90db..131f1fd5f 100644 --- a/apps/server/web/src/components/nodes/node-key-setup.tsx +++ b/apps/server/web/src/components/nodes/node-key-setup.tsx @@ -64,7 +64,7 @@ import { installAddresses } from "@/lib/install-addresses"; */ /** The two ways to put a machine on the plane. */ -type Method = "terminal" | "desktop"; +export type Method = "terminal" | "desktop"; const METHOD_OPTIONS = [ { value: "terminal" as const, label: "Terminal" }, @@ -191,6 +191,10 @@ export function NodeKeySetup({ generate, defaultMethod = "terminal", reRegisterName = null, + chosenAddress, + onAddressChange, + chosenMethod, + onMethodChange, }: { keyText: string | null; generate?: ReactNode; @@ -204,12 +208,17 @@ export function NodeKeySetup({ * every new-node flow — this line is the recovery exception, not a reversal. */ reRegisterName?: string | null; + chosenAddress?: string | null; + onAddressChange?: (address: string) => void; + chosenMethod?: Method; + onMethodChange?: (method: Method) => void; }) { const addressId = useId(); const [chosen, setChosen] = useState(null); // Terminal first: it is the one that works on a headless box, which is most of what // gets added. - const [method, setMethod] = useState(defaultMethod); + const [localMethod, setMethod] = useState(defaultMethod); + const method = chosenMethod ?? localMethod; const { appBaseUrl, trustedOrigins, hasMissingTargets, missingNote, unknownNote } = useSetupKeyVerdict(); // The address comes from the trusted-origin allowlist (spec 2026-08-31 §9.3 loopback @@ -229,7 +238,7 @@ export function NodeKeySetup({ // Dropped when no longer on offer (a settings refetch can grow or shrink the list // while this is open), then derived — not synced in an effect, so a selection cannot // survive as a stale string. - const selected = rows.find((url) => url === chosen) ?? rows[0]; + const selected = rows.find((url) => url === (chosenAddress ?? chosen)) ?? rows[0]; return ( <> @@ -241,7 +250,10 @@ export function NodeKeySetup({ fires where the fact is knowable. */}
    - url && (onAddressChange ? onAddressChange(url) : setChosen(url))} + > @@ -259,7 +271,7 @@ export function NodeKeySetup({ ariaLabel="How the machine joins" options={METHOD_OPTIONS} value={method} - onChange={setMethod} + onChange={onMethodChange ?? setMethod} className="w-full" /> {method === "terminal" ? ( diff --git a/apps/server/web/src/components/nodes/node-page-shell.tsx b/apps/server/web/src/components/nodes/node-page-shell.tsx index fcf50166b..91aa669bf 100644 --- a/apps/server/web/src/components/nodes/node-page-shell.tsx +++ b/apps/server/web/src/components/nodes/node-page-shell.tsx @@ -27,6 +27,7 @@ export function NodePageShell({ subtitle, action, children, + errorContent, }: { id: string; /** Overrides the node's name — the Overview page passes an editable field. */ @@ -34,6 +35,8 @@ export function NodePageShell({ subtitle?: string; action?: (node: NodeDetail) => ReactNode; children: (node: NodeDetail) => ReactNode; + /** An independent management recovery read when node detail cannot load. */ + errorContent?: ReactNode; }): JSX.Element { const node = useNode(id); @@ -43,7 +46,11 @@ export function NodePageShell({ return ( - + + {errorContent} ); } diff --git a/apps/server/web/src/components/quick-add.tsx b/apps/server/web/src/components/quick-add.tsx index 88dc43256..45b83d33d 100644 --- a/apps/server/web/src/components/quick-add.tsx +++ b/apps/server/web/src/components/quick-add.tsx @@ -1,11 +1,14 @@ import { createContext, type ReactNode, useContext, useMemo, useState } from "react"; import { LaunchSubshellDialog } from "@/components/sidebar/launch-subshell-dialog"; import { NewWorkspaceDialog } from "@/components/sidebar/new-workspace-dialog"; +import type { sshConnectSearch } from "@/lib/ssh-connect-search"; /** Triggers for the rail's quick-add dialogs, callable from anywhere. */ export interface QuickAddApi { /** Open the New-subshell launch dialog. */ openLaunch: () => void; + /** Open the same launch dialog with SSH choices restored. */ + openSshLaunch: (initial?: ReturnType) => void; /** Open the New-workspace dialog. */ openNewWorkspace: () => void; } @@ -32,6 +35,7 @@ export function useQuickAdd(): QuickAddApi { * sidebar, closing the drawer no longer unmounts a dialog that just opened. */ export function QuickAddProvider({ children }: { children: ReactNode }): ReactNode { + const [initialSsh, setInitialSsh] = useState | undefined>(); const [launchOpen, setLaunchOpen] = useState(false); const [workspaceOpen, setWorkspaceOpen] = useState(false); // A fresh object here would be a new context value on every render of this @@ -39,13 +43,23 @@ export function QuickAddProvider({ children }: { children: ReactNode }): ReactNo // desktop bridge re-subscribed its listener on each one. Setters are stable, // so the empty dependency list is honest. const api = useMemo( - () => ({ openLaunch: () => setLaunchOpen(true), openNewWorkspace: () => setWorkspaceOpen(true) }), + () => ({ + openLaunch: () => { + setInitialSsh(undefined); + setLaunchOpen(true); + }, + openSshLaunch: (initial = {}) => { + setInitialSsh(initial); + setLaunchOpen(true); + }, + openNewWorkspace: () => setWorkspaceOpen(true), + }), [], ); return ( {children} - + {launchOpen && } ); diff --git a/apps/server/web/src/components/setup-here-dialog.tsx b/apps/server/web/src/components/setup-here-dialog.tsx new file mode 100644 index 000000000..7ae81e511 --- /dev/null +++ b/apps/server/web/src/components/setup-here-dialog.tsx @@ -0,0 +1,136 @@ +import { Button } from "@internal/node-admin"; +import { Link } from "@tanstack/react-router"; +import { type JSX, useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useSshSetup } from "@/hooks/use-ssh-setup"; +import type { SubshellView } from "@/types/subshell"; + +/** + * "Set up Subshell here" (spec 2026-10-08 §7, Task 14): the secondary act + * on an open SSH-terminal pane that turns its destination into an enrolled + * node. The server runs the ordinary enrollment install over a separate + * non-interactive connection - the pane keeps running, and the enrollment + * key never enters it. The dialog asks once, retains server-side status through the + * install (minutes are possible), then links to the enrolled machine or + * explains the failure. Enrollment without a confirmed connection is a + * distinct outcome: open the node page rather than reinstalling. + * + * Owner-only by the menu's gate (an SSH pane is input-reserved to its + * owner, and this act enrolls a machine on the owner's account); the server + * re-checks both doors. + */ +export function SetupHereDialog({ + subshell, + open, + onOpenChange, +}: { + /** The SSH-terminal pane whose destination is being set up */ + subshell: SubshellView; + /** Controlled open state, owned by the actions menu (clone-dialog posture) */ + open: boolean; + onOpenChange: (open: boolean) => void; +}): JSX.Element { + const { upgrade, status, operation, pending } = useSshSetup(subshell.id); + const enrolled = operation?.nodeId + ? { nodeId: operation.nodeId, connected: operation.stage === "complete" } + : upgrade.data; + const error = operation?.error ?? (upgrade.error as Error | null)?.message; + const [now, setNow] = useState(Date.now()); + useEffect(() => { + if (!pending) return; + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [pending]); + const elapsed = Math.max( + 0, + Math.floor((now - (operation ? Date.parse(operation.startedAt) : upgrade.submittedAt)) / 1000), + ); + const stage = + operation?.stage === "connecting" + ? "Installed. Waiting for the destination to connect to this server…" + : operation?.stage === "checking" + ? "Checking access and preparing installation…" + : "Installing on the destination; the download can take a few minutes."; + + return ( + + e.stopPropagation()}> + + Set up Subshell here? + {!enrolled && !error && ( + + This runs the Subshell installer on the connected machine using this SSH subshell’s access. The machine + becomes a node you own. Your SSH subshell keeps running. + + )} + + {pending && ( +
    +

    + {stage} +

    +

    + Elapsed: {Math.floor(elapsed / 60)}m {elapsed % 60}s. You can close this dialog and keep working. Reopen + “Set up Subshell here” on this pane to check progress. +

    +
    + )} + {status.isPending && !pending && ( +

    + Checking setup status… +

    + )} + {status.isError && ( +
    +

    + Could not check setup status. Check again before starting another installation. +

    + +
    + )} + {error && !pending && ( +

    + {error} +

    + )} + {enrolled && ( +

    + {enrolled.connected === false + ? "The machine is enrolled, but we haven’t confirmed its connection. " + : "The machine is enrolled and connected. "} + onOpenChange(false)} + > + Open its page + {" "} + {enrolled.connected === false + ? "to check its status. If it stays offline, check the Subshell service on the destination before installing again." + : "to see what it can launch."} +

    + )} + + + {!enrolled && ( + + )} + +
    +
    + ); +} diff --git a/apps/server/web/src/components/sidebar/__tests__/launch-subshell-dialog.test.tsx b/apps/server/web/src/components/sidebar/__tests__/launch-subshell-dialog.test.tsx index d54f1cadc..fbe5fa412 100644 --- a/apps/server/web/src/components/sidebar/__tests__/launch-subshell-dialog.test.tsx +++ b/apps/server/web/src/components/sidebar/__tests__/launch-subshell-dialog.test.tsx @@ -18,7 +18,7 @@ import { LaunchSubshellDialog } from "@/components/sidebar/launch-subshell-dialo */ afterEach(cleanup); -async function renderDialog() { +async function renderDialog(initialSsh?: { destination?: string }) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const rootRoute = createRootRoute(); // The dialog rides a route component: RouterProvider renders its `children` @@ -26,7 +26,7 @@ async function renderDialog() { const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", - component: () => {}} />, + component: () => {}} initialSsh={initialSsh} />, }); const subshellRoute = createRoute({ getParentRoute: () => rootRoute, @@ -61,3 +61,11 @@ describe("LaunchSubshellDialog", () => { expect(start.matches(":disabled, [aria-disabled=true]")).toBe(true); }); }); + +it("focuses the selected SSH type when a link opens the SSH form", async () => { + await renderDialog({ destination: "deploy@host:22" }); + const ssh = screen.getByRole("button", { name: "SSH terminal" }); + expect(ssh.getAttribute("aria-pressed")).toBe("true"); + expect(document.activeElement).toBe(ssh); + expect(screen.getByRole("button", { name: "Agent or terminal" }).getAttribute("aria-pressed")).toBe("false"); +}); diff --git a/apps/server/web/src/components/sidebar/__tests__/subshell-recent-row.test.tsx b/apps/server/web/src/components/sidebar/__tests__/subshell-recent-row.test.tsx index b25d3da24..f446b554c 100644 --- a/apps/server/web/src/components/sidebar/__tests__/subshell-recent-row.test.tsx +++ b/apps/server/web/src/components/sidebar/__tests__/subshell-recent-row.test.tsx @@ -51,6 +51,7 @@ function makeSubshell(overrides: Partial = {}): SubshellView { waitingSince: null, unseenPush: false, access: "owner", + ssh: false, ...overrides, }; } diff --git a/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx b/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx index 62f69a960..bafb84659 100644 --- a/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx +++ b/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx @@ -1,6 +1,8 @@ import { Button } from "@internal/node-admin"; import { useNavigate } from "@tanstack/react-router"; -import { type JSX, useState } from "react"; +import { type JSX, useRef, useState } from "react"; +import { ConnectPanel } from "@/components/connect/connect-panel"; +import { sshSessionDraft } from "@/components/connect/ssh-session-draft"; import { canSubmit, emptyNewSubshellForm, @@ -8,6 +10,7 @@ import { type NewSubshellFormValue, } from "@/components/subshell-picker/launch-form-rules"; import { NewSubshellForm } from "@/components/subshell-picker/new-subshell-form"; +import { type SubshellKind, SubshellKindPicker } from "@/components/subshell-picker/subshell-kind-picker"; import { Dialog, DialogContent, @@ -19,6 +22,7 @@ import { } from "@/components/ui/dialog"; import { useCreateSubshell } from "@/hooks/use-create-subshell"; import { createSubshellErrorMessage } from "@/lib/create-subshell-error"; +import type { sshConnectSearch } from "@/lib/ssh-connect-search"; /** * The rail's quick-launch dialog (spec 2026-09-03 sidebar-quickadd §4a), and @@ -30,18 +34,26 @@ import { createSubshellErrorMessage } from "@/lib/create-subshell-error"; export function LaunchSubshellDialog({ open, onOpenChange, + initialSsh, }: { + initialSsh?: ReturnType; open: boolean; onOpenChange: (open: boolean) => void; }): JSX.Element { + const [sshPending, setSshPending] = useState(false); + const [sshDraft, setSshDraft] = useState(() => sshSessionDraft(initialSsh)); + const [kind, setKind] = useState(initialSsh ? "ssh" : "agent"); + const contentRef = useRef(null); const navigate = useNavigate(); const create = useCreateSubshell(); const [form, setForm] = useState(emptyNewSubshellForm); const [error, setError] = useState(null); function reset() { + setSshDraft(sshSessionDraft(initialSsh)); setForm(emptyNewSubshellForm()); setError(null); + setKind("agent"); } async function submit() { @@ -67,34 +79,61 @@ export function LaunchSubshellDialog({ // A half-filled launch form is not disposable (ruling 2026-09-30): // only a deliberate act closes this - X, Cancel, or a successful add. onOpenChange={formDialogOpenChange((next) => { + if (sshPending || create.isPending) return; onOpenChange(next); if (!next) reset(); })} > - + + interaction === "touch" + ? true + : (contentRef.current?.querySelector('[aria-pressed="true"]') ?? true) + } + > New subshell - Launch an agent in a working directory. + Start an agent, a terminal, or an SSH session in a subshell. - onOpenChange(false)} /> - {error &&

    {error}

    } - - - - + + {kind === "ssh" && open ? ( + onOpenChange(false)} + onCreated={(id) => { + onOpenChange(false); + reset(); + void navigate({ to: "/subshells/$id", params: { id } }); + }} + /> + ) : ( + <> + onOpenChange(false)} /> + {error &&

    {error}

    } + + + + + + )}
    ); diff --git a/apps/server/web/src/components/sidebar/new-workspace-dialog.tsx b/apps/server/web/src/components/sidebar/new-workspace-dialog.tsx index eb81523a2..b9067fe07 100644 --- a/apps/server/web/src/components/sidebar/new-workspace-dialog.tsx +++ b/apps/server/web/src/components/sidebar/new-workspace-dialog.tsx @@ -1,6 +1,8 @@ import { apiPost, Button, errMessage } from "@internal/node-admin"; import { useNavigate } from "@tanstack/react-router"; import { type JSX, useState } from "react"; +import { ConnectPanel } from "@/components/connect/connect-panel"; +import { sshSessionDraft } from "@/components/connect/ssh-session-draft"; import { ExistingSubshellList } from "@/components/subshell-picker/existing-subshell-list"; import { canSubmit, @@ -9,6 +11,7 @@ import { type NewSubshellFormValue, } from "@/components/subshell-picker/launch-form-rules"; import { NewSubshellForm } from "@/components/subshell-picker/new-subshell-form"; +import { type SubshellKind, SubshellKindPicker } from "@/components/subshell-picker/subshell-kind-picker"; import { Dialog, DialogContent, @@ -58,6 +61,8 @@ export function NewWorkspaceDialog({ const { data: workspaces } = useWorkspaces(); const create = useCreateSubshell(); + const [sshDraft, setSshDraft] = useState(() => sshSessionDraft()); + const [kind, setKind] = useState("agent"); const [mode, setMode] = useState("existing"); const [query, setQuery] = useState(""); const [form, setForm] = useState(emptyNewSubshellForm()); @@ -71,7 +76,9 @@ export function NewWorkspaceDialog({ const [createdId, setCreatedId] = useState(null); function reset() { + setSshDraft(sshSessionDraft()); setMode("existing"); + setKind("agent"); setQuery(""); setForm(emptyNewSubshellForm()); setSelected([]); @@ -149,6 +156,7 @@ export function NewWorkspaceDialog({ // A half-filled launch form is not disposable (ruling 2026-09-30): // only a deliberate act closes this - X, Cancel, or a successful add. onOpenChange={formDialogOpenChange((next) => { + if (creating || launching) return; onOpenChange(next); if (!next) reset(); })} @@ -159,16 +167,19 @@ export function NewWorkspaceDialog({ Start it with subshells already tiled in, or empty. - - +
    + { + if (!launching) setMode(next); + }} + /> +
    {createdId ? ( // The workspace exists; the error line above says which adds failed.

    The workspace is created. Enter it to add the rest.

    @@ -183,7 +194,23 @@ export function NewWorkspaceDialog({ onToggle={toggle} /> ) : ( - onOpenChange(false)} /> +
    + + {kind === "ssh" && open ? ( + onOpenChange(false)} + onCreated={(id) => { + setSelected((prev) => [...prev, id]); + setMode("existing"); + }} + /> + ) : ( + onOpenChange(false)} /> + )} +
    )} {error &&

    {error}

    } @@ -206,7 +233,7 @@ export function NewWorkspaceDialog({ > Cancel - {mode === "new" && ( + {mode === "new" && kind === "agent" && ( + + + {query.isPending || query.isError ? ( + + ) : pins.length === 0 ? ( +

    + No pinned destinations yet. When you first connect using keys from another machine, Subshell saves the + destination’s host key from that machine’s known_hosts file. +

    + ) : ( +
      + {pins.map((pin) => ( + + ))} +
    + )} + {addOpen && } +
    + + ); +} + +/** One pinned destination and the delete that re-opens its TOFU decision. */ +function PinRow({ pin }: { pin: SshHostPin }) { + const remove = useDeleteSshHostPin(); + + async function drop() { + const ok = await confirmAction({ + // Static title; the destination rides the body (ruling 2026-09-30). + title: "Remove this pin?", + description: `"${pin.destination}" will need its host key verified again. Confirm the replacement with the destination’s administrator before reconnecting.`, + confirmLabel: "Remove pin", + danger: true, + }); + if (!ok) return; + remove.mutate(pin.destination); + } + + return ( +
  • +
    +
    {pin.destination}
    +
    + +
    +
    + Pinned {pin.createdAt.slice(0, 10)} · last match {pin.updatedAt.slice(0, 10)} +
    + {remove.isError && remove.variables === pin.destination && ( +

    + {errMessage(remove.error, "Couldn't remove the pin.")} +

    + )} +
    + +
  • + ); +} + +/** The add dialog's one validity rule; emptiness is a gap (gold), the server's refusal is the red one. */ +const hostPinSchema = z + .object({ + destination: z.string(), + hostKey: z.string(), + }) + .superRefine((values, ctx) => { + if (values.destination.trim() === "") { + ctx.addIssue({ + code: "custom", + path: ["destination"], + message: "A destination is required", + gap: true, + }); + } + if (values.hostKey.trim() === "") { + ctx.addIssue({ code: "custom", path: ["hostKey"], message: "A host key line is required", gap: true }); + } + }); + +/** + * `POST /api/ssh/host-pins` (the explicit-pin door). The dialog title is + * static: what the person types lands in the fields and the body, never in + * the heading. A server refusal (a bad canonical spelling, a destination + * already pinned to a DIFFERENT key) is the red line: it says "what you typed + * is wrong", and for a conflicting pin the sentence IS the recovery map - + * delete the standing pin first, nothing overwrites. + */ +function AddPinDialog({ onOpenChange }: { onOpenChange: (open: boolean) => void }) { + const create = useCreateSshHostPin(); + const [serverError, setServerError] = useState(null); + + const form = makeForm({ + defaultValues: { destination: "", hostKey: "" }, + validator: hostPinSchema, + onSubmit: async (values) => { + const destination = values.destination.trim(); + const hostKey = values.hostKey.trim(); + if (destination === "" || hostKey === "") return; + setServerError(null); + try { + await create.mutateAsync({ destination, hostKey }); + onOpenChange(false); + } catch (err) { + setServerError(errMessage(err, "Couldn't add the pin.")); + } + }, + }); + const disabled = useSubmitDisabled(form, create.isPending); + + return ( + + + + Add destination pin + + Add a host key you’ve verified with the destination’s administrator. Use this when the machine supplying + your keys can’t find the destination’s key, for example when its SSH config uses HostKeyAlias. + + + + {(field) => ( +
    + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.isTouched && + (() => { + const shown = fieldErrorToned(field.state.meta.errors); + return ( + shown && ( +

    + {shown.text} +

    + ) + ); + })()} +
    + )} +
    + + {(field) => ( +
    + +