diff --git a/.changeset/ssh-launch-presets.md b/.changeset/ssh-launch-presets.md new file mode 100644 index 000000000..d0f88341b --- /dev/null +++ b/.changeset/ssh-launch-presets.md @@ -0,0 +1,12 @@ +--- +"@internal/server": minor +"@internal/node": patch +--- + +Organize saved launch settings into agent and SSH presets. Save SSH connection choices, including authentication preferences, from the launch form or preset library. Keep recent destination suggestions and replace the personal SSH settings page with the SSH preset tab. + +Validate SSH destination syntax before saving, show field-level feedback, and normalize preset names on the server. + +An SSH preset update now keeps the stored alias and description when the request omits them, and refuses a failed save in plain words instead of a raw response body. The SSH preset table follows the shared preset-table layout, and a required field left empty now shows its caption as a gold requirement rather than a red error. + +The `list_presets` MCP tool now says so in its own words: it lists saved agent presets, and SSH presets stay dashboard-only. diff --git a/.changeset/ssh-preset-documentation.md b/.changeset/ssh-preset-documentation.md new file mode 100644 index 000000000..5f271746e --- /dev/null +++ b/.changeset/ssh-preset-documentation.md @@ -0,0 +1,5 @@ +--- +"@internal/docs": patch +--- + +Document agent and SSH presets, the New preset menu, and connection settings saved by SSH presets. Explain when local keys and passwords are supplied and how saved destinations are checked before connecting. diff --git a/.changeset/ssh-website-feature.md b/.changeset/ssh-website-feature.md new file mode 100644 index 000000000..69e2ba1c7 --- /dev/null +++ b/.changeset/ssh-website-feature.md @@ -0,0 +1,5 @@ +--- +"@internal/website": patch +--- + +Add an SSH feature section describing remote shells, authentication options, and saved connection presets. diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 133af7218..95db9a510 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -130,7 +130,8 @@ stay at the source. Bounded configuration scanning does not prevent all local execution during `ssh -G`; trust the connecting account’s SSH configuration. First-use destination confirmation cannot replace a conflicting pin. Host-pin recovery APIs remain even though their settings UI is gone. Passwords enter through the terminal, are not saved as credentials, and are -still visible to trusted transport endpoints. Saved destinations are not trust pins. +still visible to trusted transport endpoints. Recent suggestions and SSH presets +are not trust pins. **Local key signing** (`/api/ssh/local-key*`, cookie-session only) feeds signatures through a session-scoped, sequence-checked, one-use bridge: one key, ≤8 requests, 90 s. The browser worker's disposal is a lifetime boundary, NOT a defense against @@ -185,7 +186,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_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. +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; SSH preset 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/apps/docs/STYLE.md b/apps/docs/STYLE.md index 86e8d9b12..78657b016 100644 --- a/apps/docs/STYLE.md +++ b/apps/docs/STYLE.md @@ -320,7 +320,7 @@ Use diagrams for plugin loading, command flow, and dependencies when they clarif ## Presets and reusable prompts -Keep Presets and Reusable Prompts as distinct task-oriented sections. A preset records agent configuration and optional launch defaults; a reusable prompt records task instructions. A library stack references current saved prompts, while selecting a prompt or stack into a launch form or preset copies its text. Explain that distinction when describing edits, reuse, and deletion. +Keep Presets and Reusable Prompts as distinct task-oriented sections. Agent presets record agent configuration and optional launch defaults; SSH presets record connection choices. A reusable prompt records task instructions. A library stack references current saved prompts, while selecting a prompt or stack into a launch form or agent preset copies its text. Explain that distinction when describing edits, reuse, and deletion. Distinguish typing from submitting: the dashboard's **Insert prompt…** action types text without Enter; the reader reviews it and submits it in the pane. Sharing means every account on the instance, not a selected group. Withdrawing library access cannot recall already-copied text. Prompt instructions are not enforceable security permissions. diff --git a/apps/docs/content/docs/developers/publish-plugin.mdx b/apps/docs/content/docs/developers/publish-plugin.mdx index 5b77e14d2..d458ac682 100644 --- a/apps/docs/content/docs/developers/publish-plugin.mdx +++ b/apps/docs/content/docs/developers/publish-plugin.mdx @@ -92,7 +92,7 @@ Test the packed package against a compiled Subshell server, not only the source For the Bash example: 1. Confirm Bash is installed on the execution machine. -2. In the dashboard sidebar, open **Presets** → **New preset** and choose **Docs Shell**. +2. In the dashboard sidebar, open **Presets** → **New preset** → **New agent preset** and choose **Docs Shell**. 3. Save a preset with the login-shell setting enabled. 4. Open **Subshells** → **New subshell**, choose that preset, and select an accessible working directory and online node. 5. Confirm that the shell starts. Run `printf 'plugin works\n'` in its pane and inspect the output. diff --git a/apps/docs/content/docs/guides/ssh.mdx b/apps/docs/content/docs/guides/ssh.mdx index 94d0b1063..f26be3349 100644 --- a/apps/docs/content/docs/guides/ssh.mdx +++ b/apps/docs/content/docs/guides/ssh.mdx @@ -1,6 +1,6 @@ --- title: "Connect over SSH" -description: "Open an SSH terminal through a node, choose keys or a password, and verify unfamiliar destinations." +description: "Open an SSH terminal through a node, choose keys or a password, verify unfamiliar destinations, and reuse connection choices as SSH presets." --- Open an SSH terminal on another machine through one of your Subshell nodes. @@ -17,12 +17,9 @@ For example, you could open the dashboard on your phone, connect from a node in 2. Select **SSH terminal**. 3. Under **Connect from**, choose the node that will make the SSH connection. 4. Under **SSH destination**, enter an address such as `developer@build.example.com:22`, or choose a suggestion. SSH aliases come from the connecting node's SSH configuration; their suggestions show the resolved username, host, and port when available. -5. Select **Remember this destination** if you want to keep it in your saved suggestions when you start the subshell. +5. To reuse these choices, select **Save as preset** and enter a **Preset name**. Subshell saves the preset before trying the connection. -
- -
Choose the node that makes the connection before entering the destination. The example address is sample data. Select the image to enlarge it.
-
+To fill the form from an existing preset, choose it under **SSH preset**. Review its connection details before starting; selecting a preset doesn't connect. If outgoing SSH is disabled on a node you own, the gold notice explains that starting the subshell will enable it. For someone else's node, its owner must enable SSH first. The form names the owner when available and checks every five seconds while you wait. An administrator manages this setting for the built-in node on the server host. @@ -98,19 +95,32 @@ Connections using the connecting node's own keys or a password use that node's O Once connected, the terminal shows the destination's shell. Its top bar has an **SSH connection** icon with a tooltip identifying it as an SSH connection. If you selected password authentication, wait for its password prompt before typing. Only the owner signed in to the dashboard can type in an SSH subshell; MCP and other bearer credentials cannot send input. Session [sharing](/guides/sharing) lets other users view its output. -## Saved and recent destinations +## SSH presets and recent destinations + +In the dashboard sidebar, open **Presets** → **New preset** → **New SSH preset**. This option is available from either the **Agent** or **SSH** tab. If your SSH library is empty, you can also select **Create your first SSH preset** on that tab. + +Enter a **Name**, choose the connecting node and destination, then select an authentication option. Names can contain up to 120 characters. Select **Save preset** to keep these choices without starting a connection. + +A preset's **⋯** menu offers **Edit**, **Clone preset**, **Delete preset**, and **Connect**. **Connect** opens the new-subshell form with those settings filled in; review them and select **Start SSH subshell** when you're ready. Editing or deleting a preset doesn't change an existing connection or reset a verified destination identity. + +Presets save the resolved username, host, and port, along with the original SSH alias so its routing and key settings still apply. If that alias later resolves to a different address, Subshell blocks the connection until you edit the preset's destination to approve the new address. Recent destinations remain available as suggestions in the connection form, including separate entries for different usernames on the same host. + +The authentication options determine what you save now and what you supply when creating a new SSH subshell: -Open **Settings → SSH** in the dashboard sidebar to manage your destinations. **Saved destinations** contains entries you've remembered; **Recent destinations** lets you save one later. Use a row's **Forget** action to remove it from saved suggestions. +- **Use the connecting node’s keys** uses that node's SSH configuration when connecting. +- **Use another node’s keys** reveals a node picker directly beneath the option. Choose the node and keys to save their source and public-key fingerprints. Subshell checks access and available keys again before connecting. +- **Use a key from my machine** saves the authentication preference. Choose and approve a key when creating a new SSH subshell; your key isn't saved in the preset. +- **Enter a password when connecting** saves the authentication preference. When creating a new SSH subshell, enter your password in the terminal when SSH asks for it. -You don't need a separate label. Subshell uses the address or SSH alias. In the connection form, saved and recent suggestions with an alias show the stored username, host, and port beneath it. Subshell keeps different usernames on the same host as separate destinations. For example, `developer@build.example.com:22` and `operator@build.example.com:22` are distinct entries. +Presets never store your private key, key file, passphrase, native approval, or destination password. -Remembering a destination saves a suggestion, not a password. Forgetting it doesn't close an open subshell or reset its verified identity. +If you select **Save as preset** while launching and the connection fails, the saved preset remains available. Retrying the same choices doesn't create another copy. Previously remembered destinations aren't converted into named presets; create presets for the connections you want to reuse. ## Make the destination a node As the SSH subshell's owner, open its **⋯** menu and choose **Set up Subshell here…** when available. Review the confirmation before running the installer: it installs Subshell on the destination and enrolls it as a node you own. Your SSH subshell keeps running. -A subshell opened with a one-time key cannot reuse that key for this installer. Run the node setup command in its existing terminal, or reconnect using a node’s keys first. +A subshell opened with a one-time key cannot reuse that key for this installer. Run the node setup command in its existing terminal, or reconnect using a node's keys first. This requires node enrollment to be allowed and an SSH login that works without interactive prompts. Password-only login can't supply a stored password to the installer. You can close and reopen the dialog to check progress. Results remain available for one hour while the server keeps running. Progress isn't retained across a server restart; check **Nodes** and the destination before retrying an interrupted installation. If it reports enrollment without a confirmed connection, open the new node's page and check its service before trying another installation. diff --git a/apps/docs/content/docs/mcp/configure.mdx b/apps/docs/content/docs/mcp/configure.mdx index 63f36adc1..efc3308bd 100644 --- a/apps/docs/content/docs/mcp/configure.mdx +++ b/apps/docs/content/docs/mcp/configure.mdx @@ -19,7 +19,7 @@ The compiled server and node binaries both include an `mcp` subcommand. The host ## Manual integration -Hermes and pi require one-time host setup. In the server dashboard's sidebar, choose **Presets** → **New preset** and select the agent. Find **Cross-subshell comms** near the bottom of the form for the registration commands. Run those commands on the machine where that agent will execute. See [Hermes Agent](/agents/hermes) and [pi](/agents/pi). +Hermes and pi require one-time host setup. In the server dashboard's sidebar, choose **Presets** → **New preset** → **New agent preset** and select the agent. Find **Cross-subshell comms** near the bottom of the form for the registration commands. Run those commands on the machine where that agent will execute. See [Hermes Agent](/agents/hermes) and [pi](/agents/pi). For a node with `subshell` on PATH, a standard registration has this shape: diff --git a/apps/docs/content/docs/mcp/tools.mdx b/apps/docs/content/docs/mcp/tools.mdx index ca3e29e68..ae0e190bc 100644 --- a/apps/docs/content/docs/mcp/tools.mdx +++ b/apps/docs/content/docs/mcp/tools.mdx @@ -20,7 +20,7 @@ Successful MCP responses contain JSON encoded in a text content block. Failures | `list_subshells` | List visible sessions and output previews. | | `get_subshell` | Inspect one session. | | `list_nodes` | List owned machines and launch availability. | -| `list_presets` | List saved presets and harness catalog entries. | +| `list_presets` | List saved agent presets and harness catalog entries. | | `create_subshell` | Launch from a saved preset, or open a plain terminal without one. | | `restart_subshell` | Revive a session in place. | | `terminate_subshell` | Stop the process and keep the session record. | @@ -64,7 +64,7 @@ Each harness row has `harnessId`, `name`, `installed`, and an optional refusal ` ## list_presets -Arguments: `{}`. Returns rows with `id`, `name`, and `harnessId`. +Arguments: `{}`. Returns agent presets and harness catalog entries with `id`, `name`, and `harnessId`; SSH presets are dashboard-only and never appear here. Real presets include `crossCommReady`, true when communication is enabled and the preset supplies a node and directory. Harness catalog rows have `catalogOnly: true`; an agent harness catalog row is informational and needs a human to save a preset before it can launch, but a plain terminal harness can launch presetless straight from `create_subshell`. diff --git a/apps/docs/content/docs/presets/create.mdx b/apps/docs/content/docs/presets/create.mdx index 61ff55fbd..41e11a414 100644 --- a/apps/docs/content/docs/presets/create.mdx +++ b/apps/docs/content/docs/presets/create.mdx @@ -5,6 +5,8 @@ description: "Configure a coding agent with reusable flags, environment values, Save a coding-agent configuration from the dashboard. +For SSH connection presets, follow [Connect over SSH](/guides/ssh#ssh-presets-and-recent-destinations). + ## Before you start Sign in to the server dashboard. The agent's harness plugin must be installed and enabled; an administrator manages plugins. Install and authenticate the agent CLI on the execution machine before trying to launch it. @@ -13,7 +15,7 @@ This example creates a Codex preset named **Repository review**. Choose flags su ## Save the configuration -1. In the dashboard sidebar, open **Presets** and select **New preset**. +1. In the dashboard sidebar, open **Presets** → **New preset** → **New agent preset**. 2. Choose **Codex** in **Agent**. 3. Enter `Repository review` in **Name**. 4. In **Paste command**, enter the following command: diff --git a/apps/docs/content/docs/presets/index.mdx b/apps/docs/content/docs/presets/index.mdx index a1e55445a..25af2f44c 100644 --- a/apps/docs/content/docs/presets/index.mdx +++ b/apps/docs/content/docs/presets/index.mdx @@ -1,20 +1,17 @@ --- title: "Presets" -description: "Save coding-agent configuration and launch defaults, then reuse them from the dashboard or MCP." +description: "Save agent launch settings or SSH connection choices and reuse them from the dashboard." --- -Save an agent configuration so you can launch it consistently. +Save agent launch settings or SSH connection choices for later use. ## Why use a preset? -A preset records the agent, command flags, environment values, and restart policy you want to reuse. It can also provide a machine, working directory, and initial prompts. For example, a repository-review preset can launch Codex in your project with review instructions and explicit approval settings. +An agent preset records the agent, command flags, environment values, and restart policy you want to reuse. It can also provide a machine, working directory, and initial prompts. For example, a repository-review preset can launch Codex in your project with review instructions and explicit approval settings. -
- -
Presets are grouped by agent. The Repository review preset is configured for agent-created launches. Select the image to enlarge it.
-
+Open **Presets** in the dashboard sidebar and choose **Agent** or **SSH**. Agent presets are grouped by agent. SSH presets list their destination, connecting node, and authentication choice. The **New preset** menu offers **New agent preset** and **New SSH preset** from either tab. See [Connect over SSH](/guides/ssh#ssh-presets-and-recent-destinations) to create and use an SSH preset. -Presets belong to your account. Saving one doesn't install or authenticate the agent CLI; those steps happen on the machine where the session runs. +Presets belong to your account. Saving an agent preset doesn't install or authenticate the agent CLI; those steps happen on the machine where the session runs. ## Choose a guide diff --git a/apps/docs/content/docs/presets/use.mdx b/apps/docs/content/docs/presets/use.mdx index c0722e79c..0a46a3c16 100644 --- a/apps/docs/content/docs/presets/use.mdx +++ b/apps/docs/content/docs/presets/use.mdx @@ -5,7 +5,9 @@ description: "Launch with saved configuration, edit or clone a preset, and enabl Reuse a saved preset when starting another subshell. -## Launch from the dashboard +For SSH presets, select **SSH** on the **Presets** page. A preset’s **Connect** action opens a filled connection form without starting it. See [Connect over SSH](/guides/ssh#ssh-presets-and-recent-destinations). + +## Launch an agent from the dashboard 1. In the server dashboard sidebar, open **Subshells** and select **New subshell**. 2. Choose the agent and your saved preset. @@ -26,7 +28,7 @@ To remove a library entry, choose **Delete preset** from its **⋯** menu and re ## Use from an agent -MCP's `list_presets` returns your saved preset IDs and `crossCommReady`. A preset is ready for agent-created launches only when its MCP switch is enabled and a machine and directory are saved. Catalog entries aren't saved presets: an agent harness catalog entry needs a saved preset before `create_subshell` can launch it, while a plain terminal harness can launch presetless (see [Terminal sessions](/mcp/terminals)). +MCP's `list_presets` returns agent presets only, including your saved preset IDs and `crossCommReady`. A preset is ready for agent-created launches only when its MCP switch is enabled and a machine and directory are saved. Catalog entries aren't saved presets: an agent harness catalog entry needs a saved preset before `create_subshell` can launch it, while a plain terminal harness can launch presetless (see [Terminal sessions](/mcp/terminals)). ```mermaid %% An agent lists the owner's presets, chooses an eligible saved preset ID, requests a helper, and the server checks access and availability before launching on the preset's node. diff --git a/apps/docs/public/screenshots/preset-library.png b/apps/docs/public/screenshots/preset-library.png deleted file mode 100644 index dc63640d9..000000000 Binary files a/apps/docs/public/screenshots/preset-library.png and /dev/null differ diff --git a/apps/docs/public/screenshots/ssh-connection.png b/apps/docs/public/screenshots/ssh-connection.png deleted file mode 100644 index f6f9a3a85..000000000 Binary files a/apps/docs/public/screenshots/ssh-connection.png and /dev/null differ diff --git a/apps/server/api/src/api/__tests__/presets-route.test.ts b/apps/server/api/src/api/__tests__/presets-route.test.ts index 9e249643a..85813c11f 100644 --- a/apps/server/api/src/api/__tests__/presets-route.test.ts +++ b/apps/server/api/src/api/__tests__/presets-route.test.ts @@ -379,6 +379,7 @@ describe("preset writes carry the launch fields (spec 2026-09-29)", () => { "flagsJson", "harnessId", "id", + "kind", "name", "nodeId", "promptBlocks", diff --git a/apps/server/api/src/api/models.ts b/apps/server/api/src/api/models.ts index a2b33a908..693032d8b 100644 --- a/apps/server/api/src/api/models.ts +++ b/apps/server/api/src/api/models.ts @@ -11,6 +11,7 @@ export const UserInfoSchema = t.Object({ }); export const PresetSchema = t.Object({ + kind: t.Literal("agent", { description: "Agent preset discriminator" }), id: t.String({ description: "Preset id" }), userId: t.String({ description: "Owning user id" }), harnessId: t.String({ description: "Harness plugin id" }), diff --git a/apps/server/api/src/api/presets.route.ts b/apps/server/api/src/api/presets.route.ts index 110f564e3..7cae66fda 100644 --- a/apps/server/api/src/api/presets.route.ts +++ b/apps/server/api/src/api/presets.route.ts @@ -4,14 +4,25 @@ import { Elysia, t } from "elysia"; import { authGuard } from "@/api/auth-guard.js"; import { getAllHarnessIds } from "@/api/harness-utils.js"; import { HarnessSchemaResponseSchema, PresetSchema } from "@/api/models.js"; +import { SshResolveRefusalViewSchema } from "@/api/ssh/ssh-views.js"; +import { + CreateSshPresetSchema, + SSH_PRESET_KEYS, + SshPresetSchema, + sshPresetView, + UpdateSshPresetSchema, +} from "@/api/ssh-presets.js"; import { db } from "@/db/index.js"; import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; +import { SshPresetsRepository } from "@/db/repositories/ssh-presets.repository.js"; import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; import { loadNodeAccess, type NodeAccessDeps, nodeCanLaunch, nodeCanLaunchOn } from "@/lib/node-access.js"; +import { apiModels } from "@/schema/index.js"; import { PORTABLE_MCP_LAUNCH } from "@/services/mcp-resolve.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import { saveSshPreset } from "@/services/ssh-presets.service.js"; /** Max joined prompt text, same cap as the launch form's `prompt` field. */ const MAX_PRESET_PROMPT_CHARS = 20_000; @@ -148,6 +159,11 @@ function findInvalidEnvName(env: Record | undefined): string | u } const CreatePresetBodySchema = t.Object({ + kind: t.Optional( + t.Literal("agent", { + description: "Create an agent preset (the default); send kind ssh on the union body to create an SSH preset", + }), + ), harnessId: t.String({ minLength: 1, description: "Harness plugin id" }), name: t.String({ minLength: 1, maxLength: 120, description: "Preset name" }), description: t.Optional(t.String({ maxLength: 500, description: "Longer description" })), @@ -227,13 +243,19 @@ const UPDATE_PRESET_KEYS = new Set([ * and harvest its bearer token. Machine credentials must not manage presets. */ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) + .use(apiModels) .use(authGuard) .post( "/", - async ({ body, user, actor }) => { + async ({ body, user, actor, status }) => { if (actor !== "cookie") { throw new PresetError("forbidden", "Sign in through the dashboard to manage presets", 403); } + if ("kind" in body && body.kind === "ssh") { + const answer = await saveSshPreset(user.id, body); + if ("refusal" in answer) return status(422, { outcome: answer.refusal }); + return answer; + } const badEnv = findInvalidEnvName(body.env); if (badEnv !== undefined) { throw new PresetError("bad_request", `invalid env var name: ${badEnv}`, 400); @@ -271,20 +293,23 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) workingDir: launch.workingDir ?? null, }); try { - return await repo.create({ - id: crypto.randomUUID(), - userId: user.id, - harnessId: body.harnessId, - name: body.name, - description: body.description ?? null, - envJson: body.env ? JSON.stringify(body.env) : null, - flagsJson: body.flags ? JSON.stringify(body.flags) : null, - settingsJson: body.settings ? JSON.stringify(body.settings) : null, - configIsolation: body.configIsolation ? 1 : 0, - restartOnExit: body.restartOnExit ? 1 : 0, - crossCommEnabled: body.crossCommEnabled ? 1 : 0, - ...launch, - }); + return { + ...(await repo.create({ + id: crypto.randomUUID(), + userId: user.id, + harnessId: body.harnessId, + name: body.name, + description: body.description ?? null, + envJson: body.env ? JSON.stringify(body.env) : null, + flagsJson: body.flags ? JSON.stringify(body.flags) : null, + settingsJson: body.settings ? JSON.stringify(body.settings) : null, + configIsolation: body.configIsolation ? 1 : 0, + restartOnExit: body.restartOnExit ? 1 : 0, + crossCommEnabled: body.crossCommEnabled ? 1 : 0, + ...launch, + })), + kind: "agent" as const, + }; } catch (err) { if (isDuplicateName(err)) { throw new PresetError("duplicate", `You already have a ${body.harnessId} preset named "${body.name}"`, 409); @@ -293,8 +318,23 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) } }, { - body: CreatePresetBodySchema, - response: PresetSchema, + transform({ body }) { + if (typeof body === "object" && body !== null && "kind" in body && body.kind === "ssh") { + const stray = Object.keys(body).find((key) => key !== "kind" && !SSH_PRESET_KEYS.has(key)); + if (stray) throw new PresetError("bad_request", `Unknown property in SSH preset: ${stray}`, 400); + } + }, + body: t.Union([CreatePresetBodySchema, CreateSshPresetSchema]), + response: { + 200: t.Union([PresetSchema, SshPresetSchema]), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 422: t.Object({ outcome: SshResolveRefusalViewSchema }), + 502: "ApiErrorResponse", + }, detail: { operationId: "createPreset", tags: ["presets"], @@ -305,6 +345,15 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) .get( "/", async ({ user, query, actor }) => { + if (query.kind === "ssh" || query.kind === "all") { + if (actor !== "cookie") + throw new PresetError("forbidden", "Sign in through the dashboard to read SSH presets", 403); + } + const sshRows = + query.kind === "ssh" || query.kind === "all" + ? (await new SshPresetsRepository(db).listByUser(user.id)).map(sshPresetView) + : []; + if (query.kind === "ssh") return sshRows; const repo = new PresetsRepository(db); const rows = await repo.listByUser(user.id, query.harnessId); // A disabled or not-installed harness makes its presets unavailable: @@ -318,8 +367,8 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) // the launch picker's grey matrix, client-side; the `?node=any` escape // hatch died with the LOCAL filter it existed to escape. const inStore = new Set(await getAllHarnessIds()); - const visible = rows.filter((p) => inStore.has(p.harnessId)); - if (actor === "cookie") return visible; + const visible = rows.filter((p) => inStore.has(p.harnessId)).map((p) => ({ ...p, kind: "agent" as const })); + if (actor === "cookie") return [...visible, ...sshRows]; // Reads stay open to bearer actors for `list_presets`, but that // tool only projects {id,name,harnessId} — the REST body's `envJson` // was every operator secret (preset.env is secret storage by @@ -332,9 +381,21 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) }, { query: t.Object({ - harnessId: t.Optional(t.String({ description: "Filter by harness id" })), + harnessId: t.Optional( + t.String({ description: "Filter by harness id (applies to agent rows; SSH rows have no harness)" }), + ), + kind: t.Optional( + t.Union([t.Literal("agent"), t.Literal("ssh"), t.Literal("all")], { + description: + "Which kinds to list; defaults to agent only. SSH and all require a browser session; machine actors are refused", + }), + ), }), - response: t.Array(PresetSchema, { description: "User's presets" }), + response: { + 200: t.Array(t.Union([PresetSchema, SshPresetSchema]), { description: "User's presets" }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, detail: { operationId: "listPresets", tags: ["presets"], @@ -394,10 +455,21 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) ) .put( "/:id", - async ({ params, body, user, actor }) => { + async ({ params, body, user, actor, status }) => { if (actor !== "cookie") { throw new PresetError("forbidden", "Sign in through the dashboard to manage presets", 403); } + const sshRepo = new SshPresetsRepository(db); + const sshExisting = await sshRepo.findById(params.id); + if (sshExisting) { + if (sshExisting.userId !== user.id) throw new PresetError("not_found", "Preset not found"); + if (!("authentication" in body)) + throw new PresetError("bad_request", "Send the complete SSH preset fields", 400); + const answer = await saveSshPreset(user.id, body, sshExisting); + if ("refusal" in answer) return status(422, { outcome: answer.refusal }); + return answer; + } + if ("authentication" in body) throw new PresetError("bad_request", "A preset's kind can't be changed", 400); const badEnv = findInvalidEnvName(body.env); if (badEnv !== undefined) { throw new PresetError("bad_request", `invalid env var name: ${badEnv}`, 400); @@ -441,7 +513,7 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) throw err; } if (!updated) throw new PresetError("not_found", "Preset not found"); - return updated; + return { ...updated, kind: "agent" as const }; }, { // Runs BEFORE validation, on the parsed (unstripped) body — the only @@ -449,14 +521,33 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) // eat. Reject every key the update cannot apply, naming the first. transform({ body }) { if (typeof body === "object" && body !== null && !Array.isArray(body)) { - const stray = Object.keys(body).find((key) => !UPDATE_PRESET_KEYS.has(key)); + const sshIntent = "authentication" in body; + if (sshIntent) { + // Validation STRIPS unknown keys (measured, see the union comment + // below), so a partial SSH body would silently match the all-optional + // agent schema and rename the wrong kind of row with a 200. Refuse + // SSH intent that cannot be an SSH update. + const missing = ["name", "nodeId", "destination"].find((key) => !(key in body)); + if (missing !== undefined) + throw new PresetError("bad_request", `Send the complete SSH preset fields (${missing} is missing)`, 400); + } + const stray = Object.keys(body).find((key) => !(sshIntent ? SSH_PRESET_KEYS : UPDATE_PRESET_KEYS).has(key)); if (stray !== undefined) { throw new PresetError("bad_request", `Unknown property in preset update body: ${stray}`, 400); } } }, - body: UpdatePresetBodySchema, - response: PresetSchema, + body: t.Union([UpdatePresetBodySchema, UpdateSshPresetSchema]), + response: { + 200: t.Union([PresetSchema, SshPresetSchema]), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 422: t.Object({ outcome: SshResolveRefusalViewSchema }), + 502: "ApiErrorResponse", + }, detail: { operationId: "updatePreset", tags: ["presets"], @@ -471,6 +562,13 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) if (actor !== "cookie") { throw new PresetError("forbidden", "Sign in through the dashboard to manage presets", 403); } + const sshRepo = new SshPresetsRepository(db); + const sshExisting = await sshRepo.findById(params.id); + if (sshExisting) { + if (sshExisting.userId !== user.id) throw new PresetError("not_found", "Preset not found"); + await sshRepo.delete(params.id); + return { ok: true }; + } const repo = new PresetsRepository(db); const existing = await repo.findById(params.id); if (!existing || existing.userId !== user.id) { diff --git a/apps/server/api/src/api/ssh-presets.ts b/apps/server/api/src/api/ssh-presets.ts new file mode 100644 index 000000000..946f4e78c --- /dev/null +++ b/apps/server/api/src/api/ssh-presets.ts @@ -0,0 +1,90 @@ +import { SSH_FINGERPRINT_PATTERN, SSH_MAX_SELECTED_FINGERPRINTS } from "@internal/subshell-protocol"; +import { t } from "elysia"; +import { SshExpectedDestinationField } from "@/api/ssh/ssh-views.js"; +import type { SshPresetTable } from "@/db/types/ssh-presets.db-types.js"; + +const SshPresetFields = { + name: t.String({ minLength: 1, maxLength: 120, description: "Preset name, unique per owner" }), + description: t.Optional(t.Nullable(t.String({ maxLength: 500, description: "Optional preset description" }))), + nodeId: t.String({ minLength: 1, maxLength: 200, description: "Connecting node id" }), + destination: t.String({ + minLength: 1, + maxLength: 253, + description: "SSH destination; saved as the canonical resolved address", + }), + resolutionAlias: t.Optional( + t.Nullable( + t.String({ + minLength: 1, + maxLength: 253, + description: + "Original SSH token whose alias-scoped routing and key settings are reused with the saved canonical destination constraint; unlike alias, a null here never clears the stored token, resolution falls back to the stored token when the destination is unchanged and to the destination otherwise", + }), + ), + ), + alias: t.Optional( + t.Nullable( + t.String({ + minLength: 1, + maxLength: 253, + description: + "Display alias, never used to resolve a saved destination; omitted on update keeps the stored one, null or whitespace-only clears it", + }), + ), + ), + authentication: t.Union( + [t.Literal("connecting"), t.Literal("remote"), t.Literal("local-key"), t.Literal("password")], + { description: "How a connection authenticates; no credentials are stored" }, + ), + keyHome: t.Optional( + t.Nullable( + t.String({ minLength: 1, maxLength: 200, description: "Node providing selected keys for remote authentication" }), + ), + ), + fingerprints: t.Optional( + t.Nullable( + t.Array(t.String({ pattern: SSH_FINGERPRINT_PATTERN, description: "Selected public key fingerprint" }), { + maxItems: SSH_MAX_SELECTED_FINGERPRINTS, + description: "Public key selection, used only for remote authentication", + }), + ), + ), +}; +export const CreateSshPresetSchema = t.Object( + { + kind: t.Literal("ssh", { description: "SSH preset discriminator" }), + ...SshPresetFields, + expectedDestination: SshExpectedDestinationField, + }, + { additionalProperties: false }, +); +export const UpdateSshPresetSchema = t.Object( + { ...SshPresetFields, expectedDestination: SshExpectedDestinationField }, + { additionalProperties: false }, +); +export const SshPresetSchema = t.Object({ + ...SshPresetFields, + kind: t.Literal("ssh", { description: "SSH preset discriminator" }), + id: t.String({ description: "Preset id" }), + userId: t.String({ description: "Owning user id" }), + nodeId: t.Nullable(t.String({ description: "Connecting node id; null after its deletion" })), + description: t.Nullable(t.String({ description: "Optional preset description" })), + alias: t.Nullable(t.String({ description: "Display-only destination alias" })), + resolutionAlias: t.Nullable( + t.String({ description: "Original SSH resolution token, guarded by the canonical destination" }), + ), + keyHome: t.Nullable(t.String({ description: "Remote key node id; null when unset or deleted" })), + fingerprints: t.Array(t.String({ description: "Selected public key fingerprint" }), { + description: "Remote key selection, empty for other authentication modes", + }), + createdAt: t.String({ description: "Creation timestamp" }), + updatedAt: t.String({ description: "Last update timestamp" }), +}); +export const SSH_PRESET_KEYS = new Set([...Object.keys(SshPresetFields), "expectedDestination"]); +export function sshPresetView(row: SshPresetTable) { + return { + ...row, + kind: "ssh" as const, + fingerprints: row.fingerprints === null ? [] : (JSON.parse(row.fingerprints) as string[]), + }; +} diff --git a/apps/server/api/src/api/ssh/__tests__/destination-verification-route.test.ts b/apps/server/api/src/api/ssh/__tests__/destination-verification-route.test.ts index 0e348c6fc..11224c478 100644 --- a/apps/server/api/src/api/ssh/__tests__/destination-verification-route.test.ts +++ b/apps/server/api/src/api/ssh/__tests__/destination-verification-route.test.ts @@ -183,3 +183,22 @@ it("validates the request and authenticates before asking any node", async () => expect((await post("verify-destination", { ...request, destination: "x".repeat(254) })).status).toBe(400); expect(scans).toBe(0); }); + +it("blocks redirected preset discovery before scanning or offering trust", async () => { + const result = await post("verify-destination", { ...request, expectedDestination: "developer@original.test:2222" }); + expect(result.status).not.toBe(200); + expect(await result.json()).toMatchObject({ code: "CONFIG_INVALID" }); + expect(scans).toBe(0); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toEqual([]); +}); +it("rechecks the preset constraint when accepting a trust challenge", async () => { + const result = await post("verify-destination", { ...request, expectedDestination: dest }); + expect(result.status).toBe(200); + const offer = (await result.json()) as { token: string }; + snapshot = { ...snapshot, host: "changed.test" }; + const accepted = await post("verify-destination/accept", { token: offer.token }); + expect(accepted.status).not.toBe(200); + expect(await accepted.json()).toMatchObject({ code: "CONFIG_INVALID" }); + expect(scans).toBe(1); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toEqual([]); +}); 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 index 34a283980..7bfad2fd7 100644 --- a/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts +++ b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts @@ -15,6 +15,7 @@ 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 { presetRoutes } from "@/api/presets.route.js"; import { sshRoutes } from "@/api/ssh/index.js"; import { db } from "@/db/index.js"; import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; @@ -44,24 +45,24 @@ import { attachScriptedNode, ok, SCRIPTED_DATA_DIR, statDirEcho } from "@/test-h * `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 + * DATA (200, tier-1 grammar); `POST /launch` and the SSH preset save 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. + * - **Recent destinations**: lists never carry another owner's rows; + * preferences store the default node id and the read tolerates the node + * vanishing (preset foreign-delete 404s live under preset CRUD). * - **Audit**: `ssh.launch` lands on SUCCESS only, with `{nodeId, destination, - * subshellId}` and nothing else; saved-host CRUD audits nothing (prompts precedent). + * subshellId}` and nothing else; preset 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 app = new Elysia().use(errorHandlerPlugin).use(sshRoutes).use(presetRoutes); const pw = "ssh-api-1"; const ownerEmail = `ssha-owner-${crypto.randomUUID()}@subshell.local`; @@ -206,7 +207,8 @@ beforeAll(async () => { afterAll(async () => { resetNodeRegistryForTests(); for (const id of createdSubshellIds) await new SubshellsRepository(db).delete(id).catch(() => {}); - await db.deleteFrom("sshSavedHosts").execute(); + await db.deleteFrom("sshRecentDestinations").execute(); + await db.deleteFrom("sshPresets").execute(); for (const id of createdNodeIds.concat(NODE_ON, NODE_OFF)) await nodes.deleteById(id).catch(() => {}); await db .updateTable("nodes") @@ -516,7 +518,7 @@ describe("POST /api/ssh/launch", () => { } }); - it("a successful launch records the recency row (saved_at stays null until the human saves)", async () => { + it("a successful launch records the recent destination", async () => { const scripted = attachScriptedNode(NODE_ON, { ssh_resolve_config: () => ({ accepted: true, snapshot: ACCEPTED }), launch: ok, @@ -527,13 +529,12 @@ describe("POST /api/ssh/launch", () => { expect(res.status).toBe(201); await res.json(); const rows = await db - .selectFrom("sshSavedHosts") + .selectFrom("sshRecentDestinations") .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 { @@ -542,97 +543,34 @@ describe("POST /api/ssh/launch", () => { }); }); -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 () => { +describe("/api/ssh/recent-destinations", () => { + it("GET lists only the caller's recent destinations", 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") + .insertInto("sshRecentDestinations") .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 }); + const resA = await sshFetch("/api/ssh/recent-destinations", { cookie: ownerCookie }); expect(resA.status).toBe(200); const viewA = (await resA.json()) as { - saved: { destination: string }[]; - recent: unknown[]; + recent: { destination: string }[]; defaultNodeId: string | null; }; - expect(viewA.saved.map((r) => r.destination)).toContain("a-private.test:22"); + expect(viewA.recent.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"); + const resB = await sshFetch("/api/ssh/recent-destinations", { cookie: editorCookie }); + const viewB = (await resB.json()) as { recent: { destination: string }[] }; + expect(viewB.recent.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)", () => { @@ -640,11 +578,11 @@ describe("/api/ssh/preferences (default connecting machine)", () => { 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 () => { + it("stores a visible node's id, reads it back on the recent-destinations 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 { + const view = (await (await sshFetch("/api/ssh/recent-destinations", { cookie: ownerCookie })).json()) as { defaultNodeId: string | null; }; expect(view.defaultNodeId).toBe(NODE_ON); @@ -669,7 +607,7 @@ describe("/api/ssh/preferences (default connecting machine)", () => { }); await patchPreferences(ownerCookie, transient); await nodes.deleteById(transient); - const view = (await (await sshFetch("/api/ssh/saved-hosts", { cookie: ownerCookie })).json()) as { + const view = (await (await sshFetch("/api/ssh/recent-destinations", { cookie: ownerCookie })).json()) as { defaultNodeId: string | null; }; expect(view.defaultNodeId).toBe(transient); @@ -1006,3 +944,462 @@ describe("one-use native SSH key routing", () => { } }); }); + +describe("SSH preset CRUD", () => { + const body = (name: string) => ({ + kind: "ssh", + name, + nodeId: NODE_ON, + destination: "work", + authentication: "connecting", + fingerprints: [], + }); + const save = (value: Record, cookie = ownerCookie) => + sshFetch("/api/presets", { method: "POST", cookie, body: value }); + it("rejects blank names and normalizes names before duplicate checks on create and update", async () => { + const scripted = silentScript(NODE_ON); + try { + for (const name of [" ", "\t", "bad\nname"]) expect((await save(body(name))).status).toBe(400); + expect(scripted.cmdsOf("ssh_resolve_config")).toHaveLength(0); + const created = await save(body(" Trimmed SSH name ")); + expect(created.status).toBe(200); + const row = (await created.json()) as { id: string; name: string }; + expect(row.name).toBe("Trimmed SSH name"); + expect((await save(body(" trimmed ssh NAME "))).status).toBe(409); + const { kind: _kind, ...update } = body(" "); + const refused = await sshFetch(`/api/presets/${row.id}`, { method: "PUT", cookie: ownerCookie, body: update }); + expect(refused.status).toBe(400); + await sshFetch(`/api/presets/${row.id}`, { method: "DELETE", cookie: ownerCookie }); + } finally { + scripted.detach(); + } + }); + it("keeps stored alias and description when an update omits them, and clears only on explicit null", async () => { + const scripted = silentScript(NODE_ON); + const created = await save({ ...body("Omit keeps"), description: "kept note" }); + try { + expect(created.status).toBe(200); + const row = (await created.json()) as { id: string }; + const put = (extra: Record) => + sshFetch(`/api/presets/${row.id}`, { + method: "PUT", + cookie: ownerCookie, + body: { + name: "Omit keeps", + nodeId: NODE_ON, + destination: CANON, + authentication: "connecting", + fingerprints: [], + ...extra, + }, + }); + const kept = await put({}); + expect(kept.status).toBe(200); + expect(await kept.json()).toMatchObject({ alias: "work", description: "kept note" }); + // Omitting decided nothing, so no re-resolution rode the update. + expect(scripted.cmdsOf("ssh_resolve_config")).toHaveLength(1); // the create only + // A whitespace-only alias says "no alias" once trimmed, like the name's rule. + const blanked = await put({ alias: " " }); + expect(blanked.status).toBe(200); + expect(await blanked.json()).toMatchObject({ alias: null }); + const renamed = await put({ alias: " Tight alias " }); + expect(renamed.status).toBe(200); + expect(await renamed.json()).toMatchObject({ alias: "Tight alias" }); + const cleared = await put({ alias: null, description: null }); + expect(cleared.status).toBe(200); + expect(await cleared.json()).toMatchObject({ alias: null, description: null }); + await sshFetch(`/api/presets/${row.id}`, { method: "DELETE", cookie: ownerCookie }); + } finally { + scripted.detach(); + } + }); + it("refuses cross-kind updates instead of renaming the wrong row", async () => { + const scripted = silentScript(NODE_ON); + try { + const agent = (await sshFetch("/api/presets", { + method: "POST", + cookie: ownerCookie, + body: { harnessId: "terminal", name: "Cross agent" }, + })) as Response; + expect(agent.status).toBe(200); + const agentRow = (await agent.json()) as { id: string }; + // SSH intent missing a required SSH field must NOT fall through to the + // all-optional agent schema (which would rename the agent row, 200). + const partial = await sshFetch(`/api/presets/${agentRow.id}`, { + method: "PUT", + cookie: ownerCookie, + body: { name: "Hijacked", authentication: "connecting", destination: "work" }, + }); + expect(partial.status).toBe(400); + expect(((await partial.json()) as { message: string }).message).toMatch(/complete SSH preset fields/); + const still = ( + (await (await sshFetch("/api/presets", { cookie: ownerCookie })).json()) as { + id: string; + name: string; + }[] + ).find((p) => p.id === agentRow.id)?.name; + expect(still).toBe("Cross agent"); + // A COMPLETE SSH body against an agent row is the kind-change refusal. + expect( + ( + await sshFetch(`/api/presets/${agentRow.id}`, { + method: "PUT", + cookie: ownerCookie, + body: { name: "Cross agent", nodeId: NODE_ON, destination: "work", authentication: "connecting" }, + }) + ).status, + ).toBe(400); + const sshCreated = await save(body("Cross ssh")); + expect(sshCreated.status).toBe(200); + const sshRow = (await sshCreated.json()) as { id: string }; + // The reverse direction: an agent-shaped body cannot update an SSH row. + const reverse = await sshFetch(`/api/presets/${sshRow.id}`, { + method: "PUT", + cookie: ownerCookie, + body: { name: "Solo agent key" }, + }); + expect(reverse.status).toBe(400); + await sshFetch(`/api/presets/${sshRow.id}`, { method: "DELETE", cookie: ownerCookie }); + await sshFetch(`/api/presets/${agentRow.id}`, { method: "DELETE", cookie: ownerCookie }); + } finally { + scripted.detach(); + } + }); + it("switching the connecting node re-resolves the stored alias under the saved address guard", async () => { + const alt = `n-ssh-alt-${crypto.randomUUID()}`; + createdNodeIds.push(alt); + await mkNode(alt, true); + const on = silentScript(NODE_ON); + const readSsh = async () => + (await (await sshFetch("/api/presets?kind=ssh", { cookie: ownerCookie })).json()) as { + id: string; + nodeId: string; + destination: string; + }[]; + try { + const created = await save(body("Node switch")); + expect(created.status).toBe(200); + const row = (await created.json()) as { id: string }; + const switchBody = (nodeId: string) => ({ + name: "Node switch", + nodeId, + destination: CANON, + authentication: "connecting", + fingerprints: [], + }); + // A node that cannot reproduce the saved address is refused, row untouched. + const refused = attachScriptedNode(alt, { ssh_resolve_config: () => REFUSED }); + try { + const denied = await sshFetch(`/api/presets/${row.id}`, { + method: "PUT", + cookie: ownerCookie, + body: switchBody(alt), + }); + expect(denied.status).toBe(422); + expect((await readSsh()).find((p) => p.id === row.id)?.nodeId).toBe(NODE_ON); + } finally { + refused.detach(); + } + // The server-derived guard: the NEW node answers accepted, but the alias + // resolves to a different address than the stored destination. That is + // CONFIG_INVALID, not a silent rewrite of a still-correct row. + const altDrift = attachScriptedNode(alt, { + ssh_resolve_config: (cmd) => + cmd.type === "ssh_resolve_config" && cmd.alias === "work" + ? { accepted: true, snapshot: snapshot({ host: "elsewhere.test" }) } + : REFUSED, + }); + try { + const drifted = await sshFetch(`/api/presets/${row.id}`, { + method: "PUT", + cookie: ownerCookie, + body: switchBody(alt), + }); + expect(drifted.status).toBe(400); + const stayed = (await readSsh()).find((p) => p.id === row.id); + expect(stayed?.nodeId).toBe(NODE_ON); + expect(stayed?.destination).toBe(CANON); + } finally { + altDrift.detach(); + } + // The stored ALIAS must carry the re-resolve, not the canonical string + // (which the new machine would read as a literal host and rewrite + // nonsense), and the saved address must guard it. + const altTokens: unknown[] = []; + const altOk = attachScriptedNode(alt, { + ssh_resolve_config: (cmd) => { + if (cmd.type === "ssh_resolve_config") altTokens.push(cmd.alias); + return cmd.type === "ssh_resolve_config" && cmd.alias === "work" + ? { accepted: true, snapshot: ACCEPTED } + : REFUSED; + }, + }); + try { + const switched = await sshFetch(`/api/presets/${row.id}`, { + method: "PUT", + cookie: ownerCookie, + body: switchBody(alt), + }); + expect(switched.status).toBe(200); + expect(await switched.json()).toMatchObject({ nodeId: alt, destination: CANON, resolutionAlias: "work" }); + expect(altTokens).toEqual(["work"]); + } finally { + altOk.detach(); + } + await sshFetch(`/api/presets/${row.id}`, { method: "DELETE", cookie: ownerCookie }); + expect(await readSsh()).toEqual([]); + } finally { + on.detach(); + } + }); + it("audits NOTHING across create, update, and delete (prompts precedent)", async () => { + const scripted = silentScript(NODE_ON); + const auditCount = () => db.selectFrom("auditEvents").select("id").execute(); + const before = await auditCount(); + try { + const created = await save(body("Audit silent")); + expect(created.status).toBe(200); + const row = (await created.json()) as { id: string }; + const { kind: _kind, ...update } = body("Audit silent"); + const updated = await sshFetch(`/api/presets/${row.id}`, { + method: "PUT", + cookie: ownerCookie, + body: { ...update, destination: CANON, description: "edited note" }, + }); + expect(updated.status).toBe(200); + expect((await sshFetch(`/api/presets/${row.id}`, { method: "DELETE", cookie: ownerCookie })).status).toBe(200); + } finally { + scripted.detach(); + } + // A preference row is not an event; preset CRUD never touches the trail. + expect((await auditCount()).length).toBe(before.length); + }); + it("creates canonical owner-scoped presets without touching recents or launching", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await save(body("SSH canonical")); + expect(res.status).toBe(200); + const row = (await res.json()) as Record; + expect(row).toMatchObject({ + kind: "ssh", + destination: CANON, + alias: "work", + userId: ownerId, + fingerprints: [], + keyHome: null, + }); + expect(scripted.cmdsOf("launch")).toHaveLength(0); + const foreign = await sshFetch(`/api/presets/${row.id}`, { method: "DELETE", cookie: editorCookie }); + expect(foreign.status).toBe(404); + const mine = await sshFetch("/api/presets?kind=ssh", { cookie: ownerCookie }); + expect(((await mine.json()) as { id: string }[]).some((p: { id: string }) => p.id === row.id)).toBe(true); + const other = await sshFetch("/api/presets?kind=ssh", { cookie: editorCookie }); + expect(await other.json()).toEqual([]); + expect((await sshFetch("/api/presets", { cookie: ownerCookie })).status).toBe(200); + const duplicate = await save(body("ssh CANONICAL")); + expect(duplicate.status).toBe(409); + const changed: Record = { ...row, name: "SSH renamed" }; + const { kind, id, userId, createdAt, updatedAt, ...update } = changed; + const count = scripted.cmdsOf("ssh_resolve_config").length; + scripted.detach(); + const edited = await sshFetch(`/api/presets/${id}`, { method: "PUT", cookie: ownerCookie, body: update }); + expect(edited.status).toBe(200); + expect(((await edited.json()) as { destination: string }).destination).toBe(CANON); + expect(scripted.cmdsOf("ssh_resolve_config")).toHaveLength(count); + expect( + ( + await sshFetch(`/api/presets/${id}`, { + method: "PUT", + cookie: ownerCookie, + body: { ...update, kind: "agent" }, + }) + ).status, + ).toBe(400); + expect((await sshFetch(`/api/presets/${id}`, { method: "DELETE", cookie: ownerCookie })).status).toBe(200); + } finally { + scripted.detach(); + } + }); + it("preserves alias settings and blocks redirected save, clone, preparation and launch", async () => { + let host = "example.test"; + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: (command) => ({ + accepted: true, + snapshot: snapshot({ + host, + user: "deploy", + ...(command.type === "ssh_resolve_config" && command.alias === "work" + ? { + identityFiles: ["/tmp/deploy-key"], + proxyJumps: [{ host: "jump.test", user: null, port: 22 }], + } + : {}), + }), + }), + launch: ok, + stat_dir: statDirEcho, + }); + try { + const saved = await save(body("Alias settings")); + expect(saved.status).toBe(200); + const row = (await saved.json()) as Record; + const canonical = "deploy@example.test:22"; + expect(row).toMatchObject({ destination: canonical, resolutionAlias: "work" }); + const listed = await sshFetch("/api/presets?kind=ssh", { cookie: ownerCookie }); + expect(await listed.json()).toContainEqual(row); + const launched = await launch( + { node: NODE_ON, destination: row.resolutionAlias, expectedDestination: canonical }, + ownerCookie, + ); + expect(launched.status).toBe(201); + const launchedId = ((await launched.json()) as { subshell: { id: string } }).subshell.id; + createdSubshellIds.push(launchedId); + const config = scripted.cmdsOf("launch")[0]?.ssh?.fileContent; + expect(config).toContain("IdentityFile /tmp/deploy-key"); + expect(scripted.cmdsOf("launch")[0]?.argv).toContain("ProxyJump=jump.test"); + host = "replacement.test"; + const clone = { + ...body("Alias clone"), + destination: canonical, + resolutionAlias: "work", + expectedDestination: canonical, + }; + expect((await save(clone)).status).toBe(400); + expect((await save({ ...clone, name: "Canonical redirect", resolutionAlias: canonical })).status).toBe(400); + const redirected = await launch( + { node: NODE_ON, destination: "work", expectedDestination: canonical, authentication: "password" }, + ownerCookie, + ); + expect(redirected.status).toBe(400); + expect(await redirected.json()).toMatchObject({ code: "CONFIG_INVALID" }); + const canonicalRedirect = await launch( + { node: NODE_ON, destination: canonical, expectedDestination: canonical }, + ownerCookie, + ); + expect(canonicalRedirect.status).toBe(400); + expect(await canonicalRedirect.json()).toMatchObject({ code: "CONFIG_INVALID" }); + expect( + ( + await sshFetch("/api/ssh/local-key", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_ON, destination: "work", expectedDestination: canonical }, + }) + ).status, + ).not.toBe(200); + expect(scripted.cmdsOf("ssh_host_key")).toHaveLength(0); + expect(scripted.cmdsOf("ssh_key_bridge")).toHaveLength(0); + expect(scripted.cmdsOf("launch")).toHaveLength(1); + const edited = await sshFetch(`/api/presets/${row.id}`, { + method: "PUT", + cookie: ownerCookie, + body: { + name: "Alias settings", + nodeId: NODE_ON, + authentication: "connecting", + destination: "replacement.test", + resolutionAlias: null, + }, + }); + expect(edited.status).toBe(200); + expect(await edited.json()).toMatchObject({ + destination: "deploy@replacement.test:22", + resolutionAlias: "replacement.test", + }); + expect(await db.selectFrom("sshPresets").selectAll().where("name", "=", "Alias clone").execute()).toEqual([]); + expect((await sshFetch(`/api/presets/${row.id}`, { method: "DELETE", cookie: ownerCookie })).status).toBe(200); + } finally { + scripted.detach(); + } + }); + it("requires explicit authentication and rejects secret and unrelated fields", async () => { + for (const field of ["localKeyId", "privateKey", "keyFile", "approval", "token", "env", "harnessId"]) { + expect((await save({ ...body(`bad ${field}`), [field]: "secret" })).status).toBe(400); + } + const { authentication, ...missing } = body("missing auth"); + expect((await save(missing)).status).toBe(400); + expect((await save({ ...body("wrong key node"), authentication: "remote", keyHome: NODE_ON })).status).toBe(400); + expect((await save({ ...body("password key"), authentication: "password", keyHome: NODE_OFF })).status).toBe(400); + expect( + (await save({ ...body("bad fingerprints"), authentication: "remote", keyHome: NODE_OFF, fingerprints: ["bad"] })) + .status, + ).toBe(400); + }); + it("refuses overlong resolved destinations before writing and keeps the list readable", async () => { + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: snapshot({ host: "h".repeat(250), user: "operator" }) }), + }); + try { + const name = "overlong resolved address"; + const response = await save(body(name)); + expect(response.status).toBe(400); + expect(JSON.stringify(await response.json())).toContain("resolved SSH destination is too long"); + const rows = await db + .selectFrom("sshPresets") + .selectAll() + .where("userId", "=", ownerId) + .where("name", "=", name) + .execute(); + expect(rows).toEqual([]); + expect((await sshFetch("/api/presets?kind=ssh", { cookie: ownerCookie })).status).toBe(200); + } finally { + scripted.detach(); + } + }); + it("requires a nonempty remote key selection", async () => { + for (const fingerprints of [undefined, null, []]) { + const response = await save({ + ...body("empty remote keys"), + authentication: "remote", + keyHome: NODE_OFF, + fingerprints, + }); + expect(response.status).toBe(400); + expect(JSON.stringify(await response.json())).toContain("Select at least one SSH key"); + } + expect( + await db + .selectFrom("sshPresets") + .selectAll() + .where("userId", "=", ownerId) + .where("name", "=", "empty remote keys") + .execute(), + ).toEqual([]); + }); + it("stores remote key choices without requiring that key node to be live or SSH enabled", async () => { + const scripted = silentScript(NODE_ON); + try { + const response = await save({ + ...body("remote offline"), + authentication: "remote", + keyHome: NODE_OFF, + fingerprints: [`SHA256:${"A".repeat(43)}`], + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ keyHome: NODE_OFF, fingerprints: [`SHA256:${"A".repeat(43)}`] }); + expect((await save({ ...body("invisible"), nodeId: "no-node" })).status).toBe(404); + expect((await save({ ...body("refused"), destination: "proxy-box" })).status).toBe(422); + } finally { + await db.deleteFrom("sshPresets").where("name", "=", "remote offline").execute(); + scripted.detach(); + } + }); + it("refuses bearer reads and writes for SSH while preserving the default agent read", async () => { + const subshellId = crypto.randomUUID(); + createdSubshellIds.push(subshellId); + await new SubshellsRepository(db).create({ + id: subshellId, + userId: ownerId, + harnessId: "terminal", + name: "preset bearer", + workingDir: "/tmp", + presetId: null, + tmuxSocket: null, + }); + const key = await issueSubshellToken(subshellId, ownerId); + for (const kind of ["ssh", "all"]) + expect((await sshFetch(`/api/presets?kind=${kind}`, { bearer: key })).status).toBe(403); + expect((await sshFetch("/api/presets", { bearer: key })).status).toBe(200); + expect((await sshFetch("/api/presets", { method: "POST", body: body("bearer"), bearer: key })).status).toBe(403); + }); +}); diff --git a/apps/server/api/src/api/ssh/index.ts b/apps/server/api/src/api/ssh/index.ts index d217bbf1c..5599d5489 100644 --- a/apps/server/api/src/api/ssh/index.ts +++ b/apps/server/api/src/api/ssh/index.ts @@ -1,8 +1,8 @@ import { Elysia } from "elysia"; import { sshAliasesRoute } from "@/api/ssh/aliases.route.js"; import { sshLaunchRoute } from "@/api/ssh/launch.route.js"; +import { sshRecentDestinationsRoutes } from "@/api/ssh/recent-destinations.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"; @@ -17,7 +17,7 @@ export const sshRoutes = new Elysia({ prefix: "/api/ssh" }) .use(sshResolveRoute) .use(sshLaunchRoute) .use(sshLocalKeyRoutes) - .use(sshSavedHostsRoutes) + .use(sshRecentDestinationsRoutes) .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 diff --git a/apps/server/api/src/api/ssh/launch.route.ts b/apps/server/api/src/api/ssh/launch.route.ts index 7639f0bac..becb58df8 100644 --- a/apps/server/api/src/api/ssh/launch.route.ts +++ b/apps/server/api/src/api/ssh/launch.route.ts @@ -2,7 +2,12 @@ import { normalizeLabel, SSH_ROSTER_MAX_IDENTITIES } from "@internal/subshell-pr 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 { + SshDestinationField, + SshExpectedDestinationField, + 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"; @@ -34,6 +39,7 @@ export const sshLaunchRoute = new Elysia() viewerId: user.id, nodeId: body.node, destination: body.destination, + expectedDestination: body.expectedDestination, // 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), @@ -63,6 +69,7 @@ export const sshLaunchRoute = new Elysia() ), node: t.String({ minLength: 1, description: "Connecting machine ('local' = the control-plane host)" }), destination: SshDestinationField, + expectedDestination: SshExpectedDestinationField, 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 }), diff --git a/apps/server/api/src/api/ssh/local-key.route.ts b/apps/server/api/src/api/ssh/local-key.route.ts index 3248d46d2..474b0e327 100644 --- a/apps/server/api/src/api/ssh/local-key.route.ts +++ b/apps/server/api/src/api/ssh/local-key.route.ts @@ -2,12 +2,13 @@ import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; import { isSshKeyBridgePayload, SSH_LOCAL_KEY_MAX_BASE64 } from "@internal/subshell-protocol"; import { Elysia, t } from "elysia"; import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; -import { SshDestinationField } from "@/api/ssh/ssh-views.js"; +import { SshDestinationField, SshExpectedDestinationField } from "@/api/ssh/ssh-views.js"; import { closeLocalSshKey, exchangeLocalSshKey, prepareLocalSshKey } from "@/services/ssh-local-key.service.js"; const PrepareSchema = t.Object({ node: t.String({ minLength: 1, maxLength: 128, description: "Connecting node id" }), destination: SshDestinationField, + expectedDestination: SshExpectedDestinationField, }); const IdSchema = t.Object({ id: t.String({ format: "uuid", description: "Temporary signing request id" }) }); const ReplySchema = t.Object({ @@ -32,7 +33,7 @@ export const sshLocalKeyRoutes = new Elysia() requireCookieActor(actor, "Local SSH keys require a browser session."); if (!session) throw new Error("Browser session is unavailable."); set.headers["cache-control"] = "no-store"; - return prepareLocalSshKey(user.id, session.id, body.node, body.destination); + return prepareLocalSshKey(user.id, session.id, body.node, body.destination, body.expectedDestination); }, { body: PrepareSchema, diff --git a/apps/server/api/src/api/ssh/recent-destinations.route.ts b/apps/server/api/src/api/ssh/recent-destinations.route.ts new file mode 100644 index 000000000..0e5b56760 --- /dev/null +++ b/apps/server/api/src/api/ssh/recent-destinations.route.ts @@ -0,0 +1,71 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SshRecentDestinationViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { apiModels } from "@/schema/index.js"; +import { sshRecentDestinationsView, sshSetDefaultNode } from "@/services/ssh-launch.service.js"; + +/** Recent connections belong to their owner; preferences only choose a default node. */ +export const sshRecentDestinationsRoutes = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/recent-destinations", + async ({ user, actor }) => { + requireCookieActor(actor, "SSH recent destinations are restricted to browser sessions"); + return await sshRecentDestinationsView(user.id); + }, + { + response: { + 200: t.Object({ + recent: t.Array(SshRecentDestinationViewSchema, { + description: "Most recently connected destinations, 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: "listSshRecentDestinations", + tags: ["ssh"], + description: "Lists the caller's recent SSH destinations and default connecting machine", + }, + }, + ) + .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/resolve.route.ts b/apps/server/api/src/api/ssh/resolve.route.ts index a65abcd78..abada0414 100644 --- a/apps/server/api/src/api/ssh/resolve.route.ts +++ b/apps/server/api/src/api/ssh/resolve.route.ts @@ -1,6 +1,11 @@ import { Elysia, t } from "elysia"; import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; -import { SshDestinationField, SshResolveOutcomeViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { + SshDestinationField, + SshExpectedDestinationField, + SshResolveOutcomeViewSchema, + throwCodedRefusal, +} from "@/api/ssh/ssh-views.js"; import { apiModels } from "@/schema/index.js"; import { sshResolveDestination } from "@/services/ssh-launch.service.js"; @@ -12,7 +17,7 @@ import { sshResolveDestination } from "@/services/ssh-launch.service.js"; * **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 + * (`/launch`, SSH preset save) 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 @@ -26,7 +31,7 @@ export const sshResolveRoute = new Elysia() "/resolve", async ({ body, user, actor }) => { requireCookieActor(actor, "SSH resolution is restricted to browser sessions"); - const answer = await sshResolveDestination(user.id, body.node, body.alias); + const answer = await sshResolveDestination(user.id, body.node, body.alias, body.expectedDestination); if (!answer.ok) return throwCodedRefusal(answer.refusal); return answer.value; }, @@ -34,6 +39,7 @@ export const sshResolveRoute = new Elysia() body: t.Object({ node: t.String({ minLength: 1, description: "Node to resolve on ('local' = the control-plane host)" }), alias: SshDestinationField, + expectedDestination: SshExpectedDestinationField, }), response: { 200: SshResolveOutcomeViewSchema, diff --git a/apps/server/api/src/api/ssh/saved-hosts.route.ts b/apps/server/api/src/api/ssh/saved-hosts.route.ts deleted file mode 100644 index a814ef5a0..000000000 --- a/apps/server/api/src/api/ssh/saved-hosts.route.ts +++ /dev/null @@ -1,182 +0,0 @@ -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/ssh-views.ts b/apps/server/api/src/api/ssh/ssh-views.ts index 53a9f50bb..d02f7db2c 100644 --- a/apps/server/api/src/api/ssh/ssh-views.ts +++ b/apps/server/api/src/api/ssh/ssh-views.ts @@ -5,7 +5,7 @@ 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 + * preset save's 422; the recent-destination row rides GET). One definition per * shape — the schemas are the API contract, and two copies of a contract * drift. */ @@ -85,13 +85,12 @@ export const SshResolveOutcomeViewSchema = t.Union([ SshResolveRefusalViewSchema, ]); -/** One saved/recency row as its owner sees it. */ -export const SshSavedHostViewSchema = t.Object({ +/** One recent destination as its owner sees it. */ +export const SshRecentDestinationViewSchema = 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" }), }); @@ -147,3 +146,12 @@ export const SshAgentIdentityViewSchema = t.Object({ }), comment: t.String({ description: "OpenSSH's label for the key, as the agent reports it (display only)" }), }); + +/** Optional canonical constraint for destinations restored from an SSH preset. */ +export const SshExpectedDestinationField = t.Optional( + t.String({ + minLength: 1, + maxLength: 253, + description: "Expected canonical user@host:port from a saved preset; refuse changed resolution before effects", + }), +); diff --git a/apps/server/api/src/api/ssh/trust.route.ts b/apps/server/api/src/api/ssh/trust.route.ts index b61bdc888..d3ac6b01b 100644 --- a/apps/server/api/src/api/ssh/trust.route.ts +++ b/apps/server/api/src/api/ssh/trust.route.ts @@ -2,9 +2,9 @@ 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 { SshDestinationField, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; -import { parseSshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { SshDestinationField, SshExpectedDestinationField, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; import { apiModels } from "@/schema/index.js"; +import { parseSshCanonicalDestination } from "@/services/ssh-destination.js"; import { acceptDestinationVerification, prepareDestinationVerification, @@ -27,6 +27,7 @@ const VerifyDestinationRequestSchema = t.Object({ node: t.String({ minLength: 1, maxLength: 128, description: "Connecting node id" }), keyHome: t.String({ minLength: 1, maxLength: 128, description: "Node supplying the selected keys" }), destination: SshDestinationField, + expectedDestination: SshExpectedDestinationField, }); const VerifyDestinationResponseSchema = t.Object({ token: t.String({ description: "Account-bound, single-use confirmation valid for five minutes" }), 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 c74193802..0192304eb 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 @@ -398,15 +398,15 @@ test("real entry: available older upgrade snapshot restores and migrates before // 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", + "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','0054-ssh-presets') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "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');", + "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; 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','0054-ssh-presets'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "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';", + "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_recent_destinations(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, last_connect_at TEXT NOT NULL); CREATE TABLE ssh_presets(id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, description TEXT, node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, destination TEXT NOT NULL, alias TEXT, authentication TEXT NOT NULL, key_home TEXT REFERENCES nodes(id) ON DELETE SET NULL, fingerprints TEXT, created_at TEXT NOT NULL, updated_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';", ); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before 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')", + "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','0054-ssh-presets')", ) .all(), ).toEqual([]); @@ -430,7 +430,7 @@ test("real entry: available older upgrade snapshot restores and migrates before 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", + "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','0054-ssh-presets') ORDER BY name", ) .all(), ).toEqual([ @@ -441,6 +441,7 @@ test("real entry: available older upgrade snapshot restores and migrates before { name: "0051-ssh-request-destination" }, { name: "0052-subshell-key-home" }, { name: "0053-drop-ssh-grants" }, + { name: "0054-ssh-presets" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( @@ -448,8 +449,8 @@ test("real entry: available older upgrade snapshot restores and migrates before .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 sqlite_schema WHERE name='ssh_recent_destinations'").get()).toEqual({ + name: "ssh_recent_destinations", }); expect(migrated.query("SELECT name FROM pragma_table_info('subshells') WHERE name='ssh'").get()).toEqual({ name: "ssh", diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 2e730c77b..772fb930f 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -52,6 +52,7 @@ import * as sshRelayIdentityMigration from "@/db/migrations/0049-ssh-relay-ident 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 sshPresetsMigration from "@/db/migrations/0054-ssh-presets.js"; import * as dropSshGrantsMigration from "./migrations/0053-drop-ssh-grants.js"; /** @@ -161,6 +162,7 @@ export async function runMigrations(): Promise { // carries the A it launched with. "0052-subshell-key-home": subshellKeyHomeMigration, "0053-drop-ssh-grants": dropSshGrantsMigration, + "0054-ssh-presets": sshPresetsMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0054-ssh-presets.ts b/apps/server/api/src/db/migrations/0054-ssh-presets.ts new file mode 100644 index 000000000..0b380635a --- /dev/null +++ b/apps/server/api/src/db/migrations/0054-ssh-presets.ts @@ -0,0 +1,39 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + // Fresh app migrations precede better-auth's user table. There cannot be + // ledger rows yet, and even an empty DELETE resolves its foreign key. + const saved = await sql<{ + count: number; + }>`SELECT count(*) AS count FROM ssh_saved_hosts WHERE saved_at IS NOT NULL`.execute(db); + if (saved.rows[0].count > 0) await sql`DELETE FROM ssh_saved_hosts WHERE saved_at IS NOT NULL`.execute(db); + await db.schema.alterTable("ssh_saved_hosts").dropColumn("saved_at").execute(); + await db.schema.alterTable("ssh_saved_hosts").renameTo("ssh_recent_destinations").execute(); + await db.schema + .createTable("ssh_presets") + .addColumn("id", "text", (c) => c.primaryKey()) + .addColumn("user_id", "text", (c) => c.notNull().references("user.id").onDelete("cascade")) + .addColumn("name", "text", (c) => c.notNull()) + .addColumn("description", "text") + .addColumn("node_id", "text", (c) => c.references("nodes.id").onDelete("set null")) + .addColumn("destination", "text", (c) => c.notNull()) + .addColumn("alias", "text") + .addColumn("resolution_alias", "text") + .addColumn("authentication", "text", (c) => c.notNull()) + .addColumn("key_home", "text", (c) => c.references("nodes.id").onDelete("set null")) + .addColumn("fingerprints", "text") + .addColumn("created_at", "text", (c) => c.notNull()) + .addColumn("updated_at", "text", (c) => c.notNull()) + .execute(); + await sql`CREATE UNIQUE INDEX idx_ssh_presets_owner_name ON ssh_presets(user_id, name COLLATE NOCASE)`.execute(db); +} + +export async function down(db: Kysely): Promise { + // DESTRUCTIVE both directions, deliberately (the 0029 drafts precedent): + // up() discarded the saved_at rows, and down() drops every SSH preset this + // schema added. The pre-0054 schema cannot express a named preset, so a + // rollback restores the shape, never the data. + await db.schema.dropTable("ssh_presets").execute(); + await db.schema.alterTable("ssh_recent_destinations").renameTo("ssh_saved_hosts").execute(); + await db.schema.alterTable("ssh_saved_hosts").addColumn("saved_at", "text").execute(); +} 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 index e76257d4c..20e7c3959 100644 --- 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 @@ -8,7 +8,7 @@ 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"; +import { sshCanonicalDestination } from "@/services/ssh-destination.js"; interface MigrationDatabase { [table: string]: Record; 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 index 31ef4ada5..3474472c3 100644 --- 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 @@ -50,7 +50,7 @@ test("fresh migration chain contains trust and no SSH permission tables", async 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).toContain("ssh_recent_destinations"); expect(tables).not.toContain("ssh_key_grants"); expect(tables).not.toContain("ssh_grant_requests"); }); diff --git a/apps/server/api/src/db/migrations/__tests__/0054-ssh-presets.test.ts b/apps/server/api/src/db/migrations/__tests__/0054-ssh-presets.test.ts new file mode 100644 index 000000000..48fefb4ff --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0054-ssh-presets.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as migration from "@/db/migrations/0054-ssh-presets.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +test("SSH preset migration discards saved destinations, retains recents and agent presets", async () => { + const db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(":memory:") }) }); + try { + await sql`CREATE TABLE user (id TEXT PRIMARY KEY)`.execute(db); + await sql`CREATE TABLE nodes (id TEXT PRIMARY KEY)`.execute(db); + await sql`CREATE TABLE presets (id TEXT PRIMARY KEY, name TEXT)`.execute(db); + await sql`CREATE TABLE ssh_saved_hosts (id TEXT PRIMARY KEY, owner_user_id TEXT, destination TEXT, alias TEXT, node_id TEXT, saved_at TEXT, last_connect_at TEXT)`.execute( + db, + ); + await sql`INSERT INTO user VALUES ('u'), ('other')`.execute(db); + await sql`INSERT INTO nodes VALUES ('n')`.execute(db); + await sql`INSERT INTO presets VALUES ('agent', 'keep')`.execute(db); + await sql`INSERT INTO ssh_saved_hosts VALUES ('saved', 'u', 'saved:22', NULL, 'n', 'now', 'now'), ('recent', 'u', 'recent:22', 'r', 'n', NULL, 'now')`.execute( + db, + ); + await migration.up(db); + expect((await sql`SELECT * FROM ssh_recent_destinations`.execute(db)).rows).toEqual([ + { id: "recent", owner_user_id: "u", destination: "recent:22", alias: "r", node_id: "n", last_connect_at: "now" }, + ]); + expect((await sql`SELECT * FROM ssh_presets`.execute(db)).rows).toEqual([]); + expect((await sql`SELECT * FROM presets`.execute(db)).rows).toEqual([{ id: "agent", name: "keep" }]); + await sql`INSERT INTO ssh_presets VALUES ('p', 'u', 'Work', NULL, 'n', 'host:22', NULL, 'original-alias', 'connecting', NULL, NULL, 'now', 'now')`.execute( + db, + ); + await expect( + sql`INSERT INTO ssh_presets VALUES ('collision', 'u', 'work', NULL, 'n', 'host:22', NULL, 'original-alias', 'connecting', NULL, NULL, 'now', 'now')`.execute( + db, + ), + ).rejects.toThrow("UNIQUE"); + await sql`INSERT INTO ssh_presets VALUES ('other-p', 'other', 'work', NULL, 'n', 'host:22', NULL, 'original-alias', 'connecting', NULL, NULL, 'now', 'now')`.execute( + db, + ); + await sql`DELETE FROM nodes WHERE id='n'`.execute(db); + expect( + (await sql<{ node_id: string | null }>`SELECT node_id FROM ssh_presets`.execute(db)).rows.every( + (row) => row.node_id === null, + ), + ).toBe(true); + } finally { + await db.destroy(); + } +}); 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-recent-destinations.repository.test.ts similarity index 57% rename from apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts rename to apps/server/api/src/db/repositories/__tests__/ssh-recent-destinations.repository.test.ts index a9814f282..9d192e5bf 100644 --- a/apps/server/api/src/db/repositories/__tests__/ssh-saved-hosts.repository.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/ssh-recent-destinations.repository.test.ts @@ -2,7 +2,7 @@ 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 { SshRecentDestinationsRepository } from "@/db/repositories/ssh-recent-destinations.repository.js"; import { UsersRepository } from "@/db/repositories/users.repository.js"; /** @@ -12,13 +12,13 @@ import { UsersRepository } from "@/db/repositories/users.repository.js"; * - 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). + * - recency (`last_connect_at`) rides EVERY launch; the row is the most recent + * successful connection, and its node follows that launch; + * - the list reads never carry another owner's rows (the ownership axis, + * docs/security.md §3). */ -const repo = new SshSavedHostsRepository(db); +const repo = new SshRecentDestinationsRepository(db); const emails: string[] = []; const inserted: string[] = []; let ownerA = ""; @@ -44,11 +44,11 @@ beforeAll(async () => { }); afterAll(async () => { - for (const id of inserted) await db.deleteFrom("sshSavedHosts").where("id", "=", id).execute(); + for (const id of inserted) await db.deleteFrom("sshRecentDestinations").where("id", "=", id).execute(); for (const mail of emails) await deleteUserByEmailOrId(mail); }); -describe("ssh-saved-hosts repository", () => { +describe("ssh-recent-destinations 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({ @@ -59,7 +59,7 @@ describe("ssh-saved-hosts repository", () => { ...at("2026-10-07T10:00:00.000Z"), }); const row = await db - .selectFrom("sshSavedHosts") + .selectFrom("sshRecentDestinations") .selectAll() .where("destination", "=", destination) .executeTakeFirstOrThrow(); @@ -67,11 +67,14 @@ describe("ssh-saved-hosts repository", () => { 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(); + const rows = await db + .selectFrom("sshRecentDestinations") + .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 @@ -84,46 +87,26 @@ describe("ssh-saved-hosts repository", () => { 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(); + const rows = await db + .selectFrom("sshRecentDestinations") + .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 () => { + it("listRecent reads every destination 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({ + await repo.touch({ ownerUserId: ownerA, destination: `${tag}-old.test:22`, nodeId: "n", ...at("2026-10-07T08:00:00.000Z"), }); - await repo.markSaved({ + await repo.touch({ ownerUserId: ownerA, destination: `${tag}-new.test:22`, nodeId: "n", @@ -135,17 +118,11 @@ describe("ssh-saved-hosts repository", () => { 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)); + inserted.push(...recent.map((r) => r.id)); }); it("listRecent caps at 20 by default and honors an explicit limit", async () => { @@ -158,24 +135,13 @@ describe("ssh-saved-hosts repository", () => { ...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(); + const rows = await db + .selectFrom("sshRecentDestinations") + .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/ssh-host-pins.repository.ts b/apps/server/api/src/db/repositories/ssh-host-pins.repository.ts index 8b4830f69..a971c59bf 100644 --- a/apps/server/api/src/db/repositories/ssh-host-pins.repository.ts +++ b/apps/server/api/src/db/repositories/ssh-host-pins.repository.ts @@ -16,7 +16,7 @@ import type { SshHostPinTable } from "@/db/types/ssh-host-pins.db-types.js"; * 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 + * Every read and write is OWNER-SCOPED at the WHERE clause, the recent-destinations * posture verbatim: a foreign row is absent, never "forbidden". */ export class SshHostPinsRepository extends BaseRepository { diff --git a/apps/server/api/src/db/repositories/ssh-presets.repository.ts b/apps/server/api/src/db/repositories/ssh-presets.repository.ts new file mode 100644 index 000000000..b5a52e4ed --- /dev/null +++ b/apps/server/api/src/db/repositories/ssh-presets.repository.ts @@ -0,0 +1,28 @@ +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { SshPresetTable } from "@/db/types/ssh-presets.db-types.js"; + +/** + * Saved SSH connection presets. This repository holds database calls only: it + * scopes LIST by owner, but `findById`/`update`/`delete` ask by id alone, and + * the handler compares `userId` after the read and renders a foreign row as + * the same 404 an absent one gets (the ownership axis, docs/security.md §3). + * `update` takes the WHOLE row as the service composes it, partial only so + * the caller can omit what it is not deciding about. + */ +export class SshPresetsRepository extends BaseRepository { + async listByUser(userId: string) { + return this.db.selectFrom("sshPresets").selectAll().where("userId", "=", userId).orderBy("name").execute(); + } + async findById(id: string) { + return this.db.selectFrom("sshPresets").selectAll().where("id", "=", id).executeTakeFirst(); + } + async create(row: SshPresetTable) { + return this.db.insertInto("sshPresets").values(row).returningAll().executeTakeFirstOrThrow(); + } + async update(id: string, row: Partial) { + return this.db.updateTable("sshPresets").set(row).where("id", "=", id).returningAll().executeTakeFirstOrThrow(); + } + async delete(id: string) { + await this.db.deleteFrom("sshPresets").where("id", "=", id).execute(); + } +} diff --git a/apps/server/api/src/db/repositories/ssh-recent-destinations.repository.ts b/apps/server/api/src/db/repositories/ssh-recent-destinations.repository.ts new file mode 100644 index 000000000..f396a12e3 --- /dev/null +++ b/apps/server/api/src/db/repositories/ssh-recent-destinations.repository.ts @@ -0,0 +1,59 @@ +import { sql } from "kysely"; +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { SshRecentDestinationTable } from "@/db/types/ssh-recent-destinations.db-types.js"; + +/** Per-owner connection history, keyed by the canonical resolved destination. */ +export type NewSshRecentDestination = SshRecentDestinationTable; + +/** 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 SshRecentDestinationsRepository extends BaseRepository { + /** Record a successful launch, keeping an existing display alias when none is supplied. */ + async touch(write: LedgerWrite): Promise { + await this.db + .insertInto("sshRecentDestinations") + .values({ + id: crypto.randomUUID(), + ownerUserId: write.ownerUserId, + destination: write.destination, + alias: write.alias ?? null, + nodeId: write.nodeId, + 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_recent_destinations.alias)`, + }), + ) + .execute(); + } + + /** The owner's recent destinations, newest first and capped. */ + async listRecent(ownerUserId: string, limit = RECENT_LIMIT): Promise { + return await this.db + .selectFrom("sshRecentDestinations") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .orderBy("lastConnectAt", "desc") + .limit(limit) + .execute(); + } +} 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 deleted file mode 100644 index afb28185b..000000000 --- a/apps/server/api/src/db/repositories/ssh-saved-hosts.repository.ts +++ /dev/null @@ -1,141 +0,0 @@ -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/types/index.ts b/apps/server/api/src/db/types/index.ts index 76cf6cc09..35891e747 100644 --- a/apps/server/api/src/db/types/index.ts +++ b/apps/server/api/src/db/types/index.ts @@ -18,7 +18,8 @@ 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 { SshPresetTable } from "@/db/types/ssh-presets.db-types.js"; +import type { SshRecentDestinationTable } from "@/db/types/ssh-recent-destinations.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"; @@ -35,6 +36,7 @@ export interface Database { auditEvents: AuditEventsTable; authProviders: AuthProviderTable; presets: PresetTable; + sshPresets: SshPresetTable; prompts: PromptTable; promptStacks: PromptStackTable; promptStackItems: PromptStackItemTable; @@ -59,6 +61,6 @@ export interface Database { identities: IdentityTable; notificationsSubscriptions: NotificationSubscriptionTable; deviceTokens: DeviceTokenTable; - sshSavedHosts: SshSavedHostTable; + sshRecentDestinations: SshRecentDestinationTable; sshHostPins: SshHostPinTable; } diff --git a/apps/server/api/src/db/types/ssh-presets.db-types.ts b/apps/server/api/src/db/types/ssh-presets.db-types.ts new file mode 100644 index 000000000..bbaf43cae --- /dev/null +++ b/apps/server/api/src/db/types/ssh-presets.db-types.ts @@ -0,0 +1,40 @@ +/** The four authentication modes an SSH preset can record. Fixed set, so it's a union, not a string. */ +export type SshPresetAuthentication = "connecting" | "remote" | "local-key" | "password"; + +/** + * A saved SSH connection preset: the connecting node, the canonical resolved + * destination, the authentication preference, and (for remote keys) the chosen + * node's public-key fingerprints. It stores NO credential: private keys, + * passphrases, native approvals and passwords are never persisted here. + */ +export interface SshPresetTable { + /** Preset id (uuid) */ + id: string; + /** Owning user; foreign rows read as 404 (docs/security.md §3) */ + userId: string; + /** Display name, unique per owner under a case-insensitive index */ + name: string; + /** Optional longer description shown beside the preset */ + description: string | null; + /** + * Connecting node id; set null by FK when that node is deleted, which leaves + * the preset unreadable for launch until the owner picks a node again. + */ + nodeId: string | null; + /** Canonical resolved destination (`host:port` / `user@host:port`) */ + destination: string; + /** Display-only alias from the resolved snapshot; never used to re-resolve */ + alias: string | null; + /** Original resolution token; the saved destination constrains its current resolution. */ + resolutionAlias: string | null; + /** How a connection authenticates; none of these four modes stores a secret */ + authentication: SshPresetAuthentication; + /** Node providing the selected keys for `remote` authentication; null otherwise */ + keyHome: string | null; + /** JSON-encoded array of selected `SHA256:` public-key fingerprints (remote only); null otherwise */ + fingerprints: string | null; + /** ISO 8601 creation timestamp (DB default) */ + createdAt: string; + /** ISO 8601 last-update timestamp */ + updatedAt: string; +} diff --git a/apps/server/api/src/db/types/ssh-recent-destinations.db-types.ts b/apps/server/api/src/db/types/ssh-recent-destinations.db-types.ts new file mode 100644 index 000000000..1461ec5c6 --- /dev/null +++ b/apps/server/api/src/db/types/ssh-recent-destinations.db-types.ts @@ -0,0 +1,15 @@ +/** One owner's most recent successful connection to a canonical destination. */ +export interface SshRecentDestinationTable { + /** Row id (uuid) */ + id: string; + /** Owning user; the ledger is never read across owners */ + ownerUserId: string; + /** Canonical `host:port` / `user@host:port`; with the owner, the upsert key */ + destination: string; + /** Display label from the resolved snapshot; a bare-host relaunch keeps it standing */ + alias: string | null; + /** Node of the MOST RECENT successful launch — the row follows it */ + nodeId: string; + /** ISO 8601 of that launch; drives the newest-first list and the cap */ + lastConnectAt: string; +} diff --git a/apps/server/api/src/services/__tests__/ssh-local-key.service.test.ts b/apps/server/api/src/services/__tests__/ssh-local-key.service.test.ts index 501acc4b4..fb3d8a450 100644 --- a/apps/server/api/src/services/__tests__/ssh-local-key.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-local-key.service.test.ts @@ -192,3 +192,24 @@ it("maps a stale-sequence bridge refusal to the ended-request code, not a 500", expect(thrown).toBeInstanceOf(ApiError); expect((thrown as ApiError).code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); }); + +it.each([{ host: "changed.test" }, { user: "root" }, { port: 2222 }])( + "blocks preset redirection before host discovery or retaining a preparation: %j", + async (change) => { + let discoveries = 0; + setLocalSshKeyDepsForTests({ + ...fakeDeps, + resolve: async () => ({ ok: true, value: { accepted: true, snapshot: { ...snapshot, ...change } } }), + hostKey: async () => { + discoveries++; + return { lines: [pin] }; + }, + }); + await expect(prepareLocalSshKey("u1", "s1", "n1", "host", "alice@host.example:22")).rejects.toThrow( + "different destination", + ); + expect(discoveries).toBe(0); + expect(captures).toBe(0); + expect(sent).toHaveLength(0); + }, +); 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 index 8a10271d2..6e0bc60b3 100644 --- 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 @@ -13,8 +13,8 @@ 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 { sshCanonicalDestination } from "@/services/ssh-destination.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"; diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index b2ff1452a..dc9229979 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 = "0053-drop-ssh-grants"; +export const LATEST_BACKUP_MIGRATION = "0054-ssh-presets"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/services/ssh-destination-verification.service.ts b/apps/server/api/src/services/ssh-destination-verification.service.ts index d458e822b..1d238d861 100644 --- a/apps/server/api/src/services/ssh-destination-verification.service.ts +++ b/apps/server/api/src/services/ssh-destination-verification.service.ts @@ -1,7 +1,7 @@ import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; import { isSshKnownHostsPinLine } from "@internal/subshell-protocol"; -import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; import { SshRpcError, sshHostKey } from "@/services/nodes/ssh-rpc.js"; +import { sshCanonicalDestination, sshExpectedDestinationProblem } from "@/services/ssh-destination.js"; import { captureHostPin, hostKeyFingerprints, @@ -17,6 +17,7 @@ interface Request { node: string; keyHome: string; destination: string; + expectedDestination?: string; } interface Challenge extends Request { canonical: string; @@ -44,11 +45,13 @@ function fail(message: string): never { async function resolve(request: Request) { const keyGate = await deps.gate(request.viewerId, request.keyHome); if (!keyGate.ok) refuse(keyGate.refusal); - const answer = await deps.resolve(request.viewerId, request.node, request.destination); + const answer = await deps.resolve(request.viewerId, request.node, request.destination, request.expectedDestination); if (!answer.ok) refuse(answer.refusal); if (!answer.value.accepted) fail("This destination’s SSH configuration isn’t supported. Check the connecting node’s settings."); const snapshot = answer.value.snapshot; + const problem = sshExpectedDestinationProblem(snapshot, request.expectedDestination); + if (problem) throwApiError({ code: BackendErrorCodes.CONFIG_INVALID, message: problem, doNotLog: true }); // A direct scan cannot follow jump routes or emulate HostKeyAlias. Never scan a different path silently. if (snapshot.proxyJumps.length || snapshot.hostKeyAlias) fail( diff --git a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts b/apps/server/api/src/services/ssh-destination.ts similarity index 56% rename from apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts rename to apps/server/api/src/services/ssh-destination.ts index 43a828e32..b043dd94e 100644 --- a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts +++ b/apps/server/api/src/services/ssh-destination.ts @@ -1,36 +1,5 @@ /** - * 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}: + * Spell the canonical destination that keys {@link SshRecentDestinationTable}: * `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. @@ -78,3 +47,10 @@ export function parseSshCanonicalDestination(text: string): SshDestinationTriple if (port < 1 || port > 65_535) return null; return { host, port, user }; } + +/** A preset may reuse alias settings only while they still resolve to its saved destination. */ +export function sshExpectedDestinationProblem(snapshot: SshDestinationTriple, expected?: string): string | null { + if (expected === undefined) return null; + if (parseSshCanonicalDestination(expected) && sshCanonicalDestination(snapshot) === expected) return null; + return "This SSH configuration now resolves to a different destination than the preset saved. Edit the preset’s destination to approve the new address."; +} diff --git a/apps/server/api/src/services/ssh-host-pins.service.ts b/apps/server/api/src/services/ssh-host-pins.service.ts index 8cab0dc42..ab936c98e 100644 --- a/apps/server/api/src/services/ssh-host-pins.service.ts +++ b/apps/server/api/src/services/ssh-host-pins.service.ts @@ -4,9 +4,9 @@ import { isSshKnownHostsPinLine, type NodeSshHostKeyResult } from "@internal/sub 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 { parseSshCanonicalDestination } from "@/services/ssh-destination.js"; import { logger } from "@/utils/logger.js"; /** diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index 762d09005..731435195 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -26,15 +26,15 @@ 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 { SshRecentDestinationsRepository } from "@/db/repositories/ssh-recent-destinations.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 { SshRecentDestinationTable } from "@/db/types/ssh-recent-destinations.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 { sshCanonicalDestination, sshExpectedDestinationProblem } from "@/services/ssh-destination.js"; import { closeLocalSshKey, openLocalSshKey } from "@/services/ssh-local-key.service.js"; import { gateSshNode } from "@/services/ssh-policy.service.js"; import { prepareRelayLeg } from "@/services/ssh-relay-launch.service.js"; @@ -62,7 +62,7 @@ import { logger } from "@/utils/logger.js"; * 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 + * `launch` and SSH preset saves 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 @@ -73,7 +73,7 @@ import { logger } from "@/utils/logger.js"; * 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 + * Preset CRUD audits nothing (the prompts precedent: a preference row * is not an event). */ @@ -358,13 +358,19 @@ export async function sshResolveDestination( viewerId: string, nodeId: string, destination: string, + expectedDestination?: 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 resolved = await nodeResolve(gate.value.row, destination); + if (resolved.ok && resolved.value.accepted) { + const problem = sshExpectedDestinationProblem(resolved.value.snapshot, expectedDestination); + if (problem) return codedRefusal(400, BackendErrorCodes.CONFIG_INVALID, problem); + } + return resolved; } const UNSAFE_DESTINATION_COPY = @@ -384,6 +390,8 @@ export async function sshLaunch(args: { viewerId: string; nodeId: string; destination: string; + /** Canonical destination saved by a preset; checked before trust or launch effects. */ + expectedDestination?: string; name?: string; /** Optional key machine; current launch access on both machines authorizes relay use. */ keyHomeNodeId?: string; @@ -433,6 +441,8 @@ export async function sshLaunch(args: { if (!resolved.ok) return resolved; if (!resolved.value.accepted) return refusedRefusal(resolved.value); const snapshot = resolved.value.snapshot; // parseNodeSshResolveOutcome already narrowed + rebuilt it + const destinationProblem = sshExpectedDestinationProblem(snapshot, args.expectedDestination); + if (destinationProblem) return codedRefusal(400, BackendErrorCodes.CONFIG_INVALID, destinationProblem); if (args.keyHomeNodeId !== undefined) { const refusal = relaySnapshotRefusal(snapshot); if (refusal) return refusal; @@ -546,10 +556,10 @@ export async function sshLaunch(args: { // 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) + await new SshRecentDestinationsRepository(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}`); + logger.withError(err).warn(`ssh recent-destination 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 @@ -569,43 +579,6 @@ export async function sshLaunch(args: { 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; @@ -622,18 +595,13 @@ export async function listNodeAgentIdentities(args: { } /** 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[]; +export async function sshRecentDestinationsView(viewerId: string): Promise<{ + recent: SshRecentDestinationTable[]; 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 }; + const repo = new SshRecentDestinationsRepository(db); + const [recent, defaultNodeId] = await Promise.all([repo.listRecent(viewerId), getSshDefaultNode(viewerId)]); + return { recent, defaultNodeId }; } /* ------------------------------------------------------------------ */ diff --git a/apps/server/api/src/services/ssh-local-key.service.ts b/apps/server/api/src/services/ssh-local-key.service.ts index 697d8b613..7d62510fc 100644 --- a/apps/server/api/src/services/ssh-local-key.service.ts +++ b/apps/server/api/src/services/ssh-local-key.service.ts @@ -8,9 +8,9 @@ import { type SshKeyBridgeCommand, } from "@internal/subshell-protocol"; import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; -import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; import { NodeRpcError, sendCommand } from "@/services/nodes/node-rpc.js"; import { mapRpcError, SshRpcError, sshHostKey } from "@/services/nodes/ssh-rpc.js"; +import { sshCanonicalDestination, sshExpectedDestinationProblem } from "@/services/ssh-destination.js"; import { captureHostPin, hostKeyFingerprints, @@ -115,16 +115,29 @@ async function gate(entry: LocalKeySession) { } /** Resolve and capture public destination identity before the native approval and file picker. */ -export async function prepareLocalSshKey(viewerId: string, sessionId: string, nodeId: string, destination: string) { +export async function prepareLocalSshKey( + viewerId: string, + sessionId: string, + nodeId: string, + destination: string, + expectedDestination?: string, +) { if ( sessions.size >= MAX_PENDING_SESSIONS || [...sessions.values()].filter((entry) => entry.viewerId === viewerId).length >= MAX_SESSIONS_PER_VIEWER ) fail("Too many key selections are waiting. Close one or wait for it to expire."); - const answer = await deps.resolve(viewerId, nodeId, destination); - if (!answer.ok || !answer.value.accepted) + const answer = await deps.resolve(viewerId, nodeId, destination, expectedDestination); + if (!answer.ok) { + if ("code" in answer.refusal && answer.refusal.code === BackendErrorCodes.CONFIG_INVALID) + throwApiError({ code: answer.refusal.code, message: answer.refusal.message, doNotLog: true }); + fail("The destination could not be resolved. Check the connecting node and SSH destination."); + } + if (!answer.value.accepted) fail("The destination could not be resolved. Check the connecting node and SSH destination."); const snapshot = answer.value.snapshot; + const problem = sshExpectedDestinationProblem(snapshot, expectedDestination); + if (problem) throwApiError({ code: BackendErrorCodes.CONFIG_INVALID, message: problem, doNotLog: true }); if (relaySnapshotRefusal(snapshot) || !snapshot.user) fail("A key from your machine needs a destination with an explicit username and no jump hosts or host-key alias."); const canonical = sshCanonicalDestination(snapshot); diff --git a/apps/server/api/src/services/ssh-presets.service.ts b/apps/server/api/src/services/ssh-presets.service.ts new file mode 100644 index 000000000..2de0f2db9 --- /dev/null +++ b/apps/server/api/src/services/ssh-presets.service.ts @@ -0,0 +1,128 @@ +import type { Static } from "elysia"; +import { loadNodeGate } from "@/api/nodes/node-gate.js"; +import { throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { sshPresetView, type UpdateSshPresetSchema } from "@/api/ssh-presets.js"; +import { db } from "@/db/index.js"; +import { SshPresetsRepository } from "@/db/repositories/ssh-presets.repository.js"; +import type { SshPresetTable } from "@/db/types/ssh-presets.db-types.js"; +import { nodeCanLaunchOn } from "@/lib/node-access.js"; +import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import { sshCanonicalDestination } from "@/services/ssh-destination.js"; +import { sshResolveDestination } from "@/services/ssh-launch.service.js"; + +/** A refusal of an SSH preset write; `.status` is what the error handler maps to HTTP. */ +export class SshPresetError extends Error { + constructor( + readonly code: string, + message: string, + readonly status = 400, + ) { + super(message); + } +} + +/** + * Create or update an SSH preset. The saved destination is always the CANONICAL + * address, so a create (or any change to node/destination/resolution token) + * re-resolves on the connecting node and refuses with the resolve outcome + * rather than writing an unverified destination; an unchanged destination is + * trusted and never re-resolved. Optional fields follow the PUT convention: + * omitted keeps what is stored, explicit null clears. Returns the view, or + * `{ refusal }` when the human's confirmation gate must answer first. + */ +export async function saveSshPreset( + userId: string, + body: Static, + existing?: SshPresetTable, +) { + const name = body.name.trim(); + if (!name) throw new SshPresetError("bad_request", "Enter a preset name."); + if (/\p{Cc}/u.test(body.name)) + throw new SshPresetError("bad_request", "Remove control characters from the preset name."); + if (body.authentication !== "remote" && (body.keyHome != null || (body.fingerprints?.length ?? 0) > 0)) { + throw new SshPresetError("bad_request", "Only remote key authentication can store a key node or fingerprints"); + } + if (body.authentication === "remote") { + if (!body.fingerprints?.length) + throw new SshPresetError("bad_request", "Select at least one SSH key from the remote node"); + if (!body.keyHome || body.keyHome === body.nodeId) + throw new SshPresetError("bad_request", "Choose a different node to provide the SSH key"); + await validateNodeAccess(userId, body.keyHome); + } + let destination = body.destination; + // PUT omits a field it is not deciding about, so an absent alias keeps the + // stored one (null clears it), the same rule the agent PUT body follows. + // An alias trimmed away to nothing says the same thing as null: no alias. + let alias = existing?.alias ?? null; + if (body.alias !== undefined) alias = body.alias === null ? null : body.alias.trim() || null; + // TWO different "unchanged" facts. The stored token must keep resolving to + // the stored ADDRESS whenever the destination text is untouched, even on a + // machine switch, which then re-resolves on the new node under the same + // expectedDestination guard. Skipping the resolve entirely (the condition + // below) additionally needs the node to be the same one. + const destinationTextUnchanged = !!existing && existing.destination === body.destination; + const resolutionAlias = + body.resolutionAlias ?? + (destinationTextUnchanged ? (existing.resolutionAlias ?? body.destination) : body.destination); + if ( + !existing || + existing.nodeId !== body.nodeId || + existing.destination !== body.destination || + existing.resolutionAlias !== resolutionAlias + ) { + const resolved = await sshResolveDestination( + userId, + body.nodeId, + resolutionAlias, + body.expectedDestination ?? (destinationTextUnchanged ? existing.destination : undefined), + ); + if (!resolved.ok) return throwCodedRefusal(resolved.refusal); + if (!resolved.value.accepted) return { refusal: resolved.value }; + destination = sshCanonicalDestination(resolved.value.snapshot); + // A create has nothing stored, so the resolution's own alias is the only answer. + if (body.alias === undefined && !existing) alias = resolved.value.snapshot.alias; + } else { + await validateNodeAccess(userId, body.nodeId); + } + // An alias may expand beyond the launch field's limit. Refuse before a + // write so the saved row can still round-trip and launch through that field. + if (destination.length > 253) + throw new SshPresetError( + "bad_request", + "The resolved SSH destination is too long. Use a shorter host or user name so the full address stays within 253 characters.", + ); + const now = new Date().toISOString(); + const row = { + id: existing?.id ?? crypto.randomUUID(), + userId, + name, + description: body.description !== undefined ? body.description : (existing?.description ?? null), + nodeId: body.nodeId, + destination, + resolutionAlias, + alias, + authentication: body.authentication, + keyHome: body.authentication === "remote" ? (body.keyHome ?? null) : null, + fingerprints: + body.authentication === "remote" && body.fingerprints != null ? JSON.stringify(body.fingerprints) : null, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + try { + const repo = new SshPresetsRepository(db); + return sshPresetView(existing ? await repo.update(existing.id, row) : await repo.create(row)); + } catch (err) { + if (err instanceof Error && err.message.includes("UNIQUE")) + throw new SshPresetError("duplicate", "You already have an SSH preset with that name", 409); + throw err; + } +} + +/** Saving a hint needs launch permission; transient readiness belongs to launch. */ +async function validateNodeAccess(userId: string, nodeId: string) { + const gate = await loadNodeGate(userId, nodeId); + if (!gate) throw new SshPresetError("not_found", "Node not found", 404); + if (!nodeCanLaunchOn(gate.row.kind, gate.access, gate.granted, false, await serverSubshellsEnabled(db))) { + throw new SshPresetError("forbidden", "You need permission to launch subshells on this node", 403); + } +} diff --git a/apps/server/api/src/services/ssh-setup-here.service.ts b/apps/server/api/src/services/ssh-setup-here.service.ts index af63b9df9..18318d703 100644 --- a/apps/server/api/src/services/ssh-setup-here.service.ts +++ b/apps/server/api/src/services/ssh-setup-here.service.ts @@ -9,10 +9,10 @@ import { 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 { sshCanonicalDestination } from "@/services/ssh-destination.js"; import { composeSshLaunch, gateSshNode, diff --git a/apps/server/web/AGENTS.md b/apps/server/web/AGENTS.md index 97262e88b..907aa219e 100644 --- a/apps/server/web/AGENTS.md +++ b/apps/server/web/AGENTS.md @@ -436,8 +436,11 @@ and before launching. Node pages manage SSH through their settings card. When a relay connection has no trusted destination identity, its launch refusal opens a one-time confirmation in the connection flow. Cancellation saves nothing; acceptance submits the server-issued token and retries the captured launch once. -Changed identities never use that prompt. SSH settings manages saved destinations, -not host keys. Verification details can reveal the public fingerprint without +Changed identities never use that prompt. Settings → Presets has Agent and SSH +tabs; the SSH tab manages saved connection choices, not host keys. Applying a +preset clears any local key selection. Saving a local-key preset retains only +the authentication choice, never credentials or approval. Recent destinations +stay in the launch suggestions; `/settings/ssh` redirects to `/presets?type=ssh`. Verification details can reveal the public fingerprint without requiring a user to paste or manage key material. Old `/connect` links open the same subshell dialog through `/new?kind=ssh`. Failed reads must stay distinct from diff --git a/apps/server/web/docs/connect-page.md b/apps/server/web/docs/connect-page.md index 0b26c52d1..220ab51a4 100644 --- a/apps/server/web/docs/connect-page.md +++ b/apps/server/web/docs/connect-page.md @@ -4,9 +4,30 @@ 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 +Destination suggestions include 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. +destination. The server resolves it on the connecting machine before launching. SSH presets +fill all connection choices and retain the resolved destination when saved; +the original SSH token retains alias-specific routing and key settings. The server +checks every resolved snapshot against the saved canonical destination before +saving a clone, preparing a local key, verifying trust, or launching. Changing +an alias’s destination blocks the connection until the user edits the destination. The preset picker clears any +previous local key selection. + +The Presets header’s **New preset** menu offers **New agent preset** and +**New SSH preset** from either tab. Both empty libraries use the shared +`EmptyState` with a type-specific heading and create button. The SSH editor +asks for a name and connection choices, with no description field. Authentication +controls expand beneath their selected radio option: the remote node picker +keeps its accessible label but hides the redundant visible label. Local-key +and password explanations say that credentials are supplied when creating a +new SSH subshell; the preset saves only the authentication preference. +Name and destination validation use the same problem functions for submit gating +and inline feedback. Show input errors after blur, never while the caret is in +that field. Destination checks cover explicit user/host/port syntax and length; +alias existence and SSH configuration remain server-side checks. The API trims +preset names, rejects blanks and control characters, and checks uniqueness after +normalization. The form displays the server’s refusal and any blocked SSH settings so users know what to change. diff --git a/apps/server/web/docs/launch-form-picker.md b/apps/server/web/docs/launch-form-picker.md index 7bf08581d..95dbed620 100644 --- a/apps/server/web/docs/launch-form-picker.md +++ b/apps/server/web/docs/launch-form-picker.md @@ -171,7 +171,7 @@ 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. +Named SSH presets live under Presets → SSH. The legacy `/settings/ssh` route redirects to `/presets?type=ssh`. The SSH preset picker fills connection settings without launching and clears any selected local key. Save as preset requires a name, saves the canonical resolved destination before launch, and retains that preset when launch fails; an unchanged retry reuses it. Recent destinations remain suggestions through `/api/ssh/recent-destinations`. Remote presets restore the key source and fingerprints with fresh launch validation. Local-key presets store only the authentication choice, never key material, files, passphrases, or approval. Config alias suggestions keep the alias as the launch token and show an optional user@host:port preview beneath it. Discovery returns previews only for explicit literal values in simple configs; Match, Include, canonicalization, missing diff --git a/apps/server/web/docs/settings-navigation.md b/apps/server/web/docs/settings-navigation.md index a0669eea6..bae64ee61 100644 --- a/apps/server/web/docs/settings-navigation.md +++ b/apps/server/web/docs/settings-navigation.md @@ -16,3 +16,7 @@ and that reads `GET /api/users`, which is still instance-wide. Two of them are read-only: `/settings` is where an admin CHANGES the instance, while Status and `/settings/logs` are where they see what it currently IS and what has happened to it. + +Personal SSH connection settings live under **Presets → SSH**. There is no +Settings → SSH navigation entry; `/settings/ssh` redirects to +`/presets?type=ssh` for existing links. 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 157dac278..b6dff6d31 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 { Fingerprint, ServerCog, Settings, SlidersHorizontal } from "lucide-react"; +import { 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)", () => { @@ -24,16 +24,9 @@ describe("sidebar nav icons (spec 2026-09-02 §3, 2026-09-11 §3.1)", () => { 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("SSH presets replace the personal SSH settings page", () => { + expect(visibleNavItems(false).some((i) => i.to === "/settings/ssh")).toBe(false); + expect(visibleNavItems(false).some((i) => i.to === "/presets")).toBe(true); }); it("no two visible icons are the same — group headers included", () => { 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 51b6593af..613b35a97 100644 --- a/apps/server/web/src/components/__tests__/sidebar-nav.test.ts +++ b/apps/server/web/src/components/__tests__/sidebar-nav.test.ts @@ -36,10 +36,7 @@ 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); - // `/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"]); + expect(paths).toEqual(["/", "/workspaces", "/nodes", "/presets", "/prompts"]); } }); @@ -51,7 +48,6 @@ describe("visibleNavItems", () => { "/nodes", "/presets", "/prompts", - "/settings/ssh", ...GROUP_PAGES, ]); }); 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 index d21239eb3..b595db3b1 100644 --- a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx +++ b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx @@ -67,7 +67,8 @@ interface WireOpts { settings?: unknown; ledger?: unknown; aliases?: string[]; - launch?: () => Response; + launch?: () => Response | Promise; + presets?: () => Response | Promise; } /** Mock the whole /connect surface and record every call with its body. */ @@ -111,13 +112,21 @@ function mockFetch(opts: WireOpts = {}) { ); if (path === "/api/ssh/identities") return Promise.resolve(json({ identities: [{ fingerprint: "SHA256:AAAA", comment: "Work key" }] })); - if (path === "/api/ssh/saved-hosts" && method === "GET") { + if (path === "/api/ssh/recent-destinations" && 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/presets") { + if (method === "POST") + return Promise.resolve( + json({ + ...JSON.parse(String(init?.body)), + id: "ssh-preset", + destination: "resolved.example:22", + createdAt: "t", + updatedAt: "t", + }), + ); + return Promise.resolve(opts.presets?.() ?? json([])); } if (path === "/api/ssh/preferences") return Promise.resolve(json({ defaultNodeId: "a1" })); if (path === "/api/ssh/aliases") { @@ -140,7 +149,7 @@ interface Rendered { async function renderPanel( opts: WireOpts, - initial?: { node?: string; keyHome?: string; destination?: string; fingerprints?: string[] }, + initial?: { preset?: string; node?: string; keyHome?: string; destination?: string; fingerprints?: string[] }, ): Promise { const { calls, restore } = mockFetch(opts); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -406,18 +415,8 @@ describe("machine disclosure (contract 1)", () => { }); describe("destination field (contract 2)", () => { - it("feeds the grouped rows: Saved, Recent, and the picked machine's config", async () => { + it("feeds the grouped rows: 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", @@ -441,10 +440,8 @@ describe("destination field (contract 2)", () => { 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(); @@ -484,7 +481,7 @@ describe("destination field (contract 2)", () => { } }); - it("a config-alias pick launches the token, and the save sends it as the alias", async () => { + it("saving a config alias preserves alias settings under the saved canonical guard", async () => { const { calls, restore } = await renderPanel({ nodes: [HOST_A], aliases: ["workbox"] }); try { await waitFor(() => expect(machineInput().value).toBe("mac mini")); @@ -492,12 +489,24 @@ describe("destination field (contract 2)", () => { await settle(); fireEvent.click(await screen.findByRole("button", { name: "workbox" })); await settle(); - fireEvent.click(screen.getByLabelText("Remember this destination")); + fireEvent.click(screen.getByRole("checkbox", { name: "Save as preset" })); + fireEvent.change(screen.getByLabelText("Preset name"), { target: { value: "Work" } }); 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" }); + expect(calls.find((c) => c.url === "/api/ssh/launch")?.body).toEqual({ + node: "a1", + destination: "workbox", + expectedDestination: "resolved.example:22", + }); + await waitFor(() => expect(calls.some((c) => c.method === "POST" && c.url === "/api/presets")).toBe(true)); + const put = calls.find((c) => c.method === "POST" && c.url === "/api/presets"); + expect(put?.body).toMatchObject({ + kind: "ssh", + name: "Work", + nodeId: "a1", + destination: "workbox", + alias: "workbox", + authentication: "connecting", + }); } finally { restore(); } @@ -518,7 +527,8 @@ describe("connect and the ledger (contracts 3, 6)", () => { return Promise.resolve( json({ machines: [{ node: HOST_A, canConnect: true, canConfigure: true, blockers: [] }] }), ); - if (url.pathname === "/api/ssh/saved-hosts") + if (url.pathname === "/api/presets") return Promise.resolve(json([])); + if (url.pathname === "/api/ssh/recent-destinations") return Promise.resolve(json({ saved: [], recent: [], defaultNodeId: null })); return Promise.resolve(json({})); }) as typeof fetch; @@ -556,14 +566,14 @@ describe("connect and the ledger (contracts 3, 6)", () => { } }); - it("Remember starts unchecked; a checked launch PUTs the saved row with no alias, unchecked PUTs nothing", async () => { + it("Save as preset starts unchecked and creates only when selected", async () => { const off = await renderPanel({ nodes: [HOST_A] }); try { - expect((screen.getByLabelText("Remember this destination") as HTMLInputElement).checked).toBe(false); + expect(screen.getByRole("checkbox", { name: "Save as preset" }).getAttribute("aria-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([]); + expect(off.calls.filter((c) => c.method === "POST" && c.url === "/api/presets")).toEqual([]); } finally { off.restore(); } @@ -571,13 +581,12 @@ describe("connect and the ledger (contracts 3, 6)", () => { const on = await renderPanel({ nodes: [HOST_A] }); try { await commitTyped("box.example"); - fireEvent.click(screen.getByLabelText("Remember this destination")); + fireEvent.click(screen.getByRole("checkbox", { name: "Save as preset" })); + fireEvent.change(screen.getByLabelText("Preset name"), { target: { value: "Work" } }); 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" }); + await waitFor(() => expect(on.calls.some((c) => c.method === "POST" && c.url === "/api/presets")).toBe(true)); + const put = on.calls.find((c) => c.method === "POST" && c.url === "/api/presets"); + expect(put?.body).toMatchObject({ kind: "ssh", name: "Work", nodeId: "a1", destination: "box.example" }); } finally { on.restore(); } @@ -708,3 +717,49 @@ describe("relay launch with current launch access", () => { } }); }); + +it("a delayed URL preset cannot overwrite an edited draft during or after a pending launch", async () => { + let releasePresets!: (response: Response) => void; + let releaseLaunch!: (response: Response) => void; + const presets = new Promise((resolve) => { + releasePresets = resolve; + }); + const pendingLaunch = new Promise((resolve) => { + releaseLaunch = resolve; + }); + const { calls, restore } = await renderPanel( + { nodes: [HOST_A], presets: () => presets, launch: () => pendingLaunch }, + { preset: "slow", node: "a1" }, + ); + try { + await commitTyped("my-edited-host"); + fireEvent.click(screen.getByRole("button", { name: "Start SSH subshell" })); + await waitFor(() => expect(calls.some((call) => call.url === "/api/ssh/launch")).toBe(true)); + releasePresets( + json([ + { + id: "slow", + kind: "ssh", + name: "Late preset", + nodeId: "a1", + destination: "late-host:22", + authentication: "local-key", + keyHome: null, + fingerprints: [], + }, + ]), + ); + await settle(); + await settle(); + expect(destinationInput().value).toBe("my-edited-host"); + expect(calls.find((call) => call.url === "/api/ssh/launch")?.body).toEqual({ + node: "a1", + destination: "my-edited-host", + }); + releaseLaunch(json({ code: "SSH_RELAY_OPEN_FAILED", message: "Try again" }, 409)); + await screen.findByText("Try again"); + expect(destinationInput().value).toBe("my-edited-host"); + } 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 index dcb4bc2e7..578088452 100644 --- a/apps/server/web/src/components/connect/__tests__/destination-options.test.ts +++ b/apps/server/web/src/components/connect/__tests__/destination-options.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { SshSavedHost } from "@/lib/ssh"; -import { buildDestinationOptions, configGroupLabel, RECENT_GROUP, SAVED_GROUP } from "../destination-options"; +import type { SshRecentDestination } from "@/lib/ssh"; +import { buildDestinationOptions, configGroupLabel, RECENT_GROUP } from "../destination-options"; /** * The destination field's list as pure data: the fed groups and their @@ -9,52 +9,36 @@ import { buildDestinationOptions, configGroupLabel, RECENT_GROUP, SAVED_GROUP } * WIRE (the destination token). The alias token rides only config rows - * §7's alias-is-display rule. */ -const row = (over: Partial): SshSavedHost => ({ +const row = (over: Partial): SshRecentDestination => ({ 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", () => { + it("groups 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 })], + recent: [row({ id: "s1", alias: "web" })], 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.map((o) => o.group)).toEqual([RECENT_GROUP, configGroupLabel("mac mini")]); + // A recent 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"); + expect(options[1].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" }); + expect(candidates.get("recent: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, @@ -66,7 +50,6 @@ describe("buildDestinationOptions", () => { 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", @@ -79,7 +62,6 @@ describe("buildDestinationOptions", () => { it("no machine picked means no config group, and no aliases leak into it", () => { const { options } = buildDestinationOptions({ - saved: [], recent: [], aliases: ["workbox"], machineName: null, @@ -91,7 +73,6 @@ describe("buildDestinationOptions", () => { it("displays and searches configured destinations while still launching the alias", () => { const { options, candidates } = buildDestinationOptions({ - saved: [], recent: [], aliases: ["demo", "dynamic"], destinations: [{ alias: "demo", destination: "developer@127.0.0.1:22222" }], @@ -104,18 +85,16 @@ it("displays and searches configured destinations while still launching the alia expect(options[1]?.reason).toBe("Destination determined when connecting"); }); -it("shows the concrete destination beneath saved and recent aliases", () => { +it("shows the concrete destination beneath recent aliases", () => { const destination = "developer@127.0.0.1:22222"; const { options, candidates } = buildDestinationOptions({ - saved: [row({ id: "saved", alias: "saved-demo", destination })], recent: [row({ id: "recent", alias: "subshell-demo", destination }), row({ id: "plain", destination })], aliases: [], machineName: "Server", typed: "", }); expect(options[0]?.reason).toBe(destination); - expect(options[1]?.reason).toBe(destination); - expect(options[2]?.label).toBe(destination); - expect(options[2]?.reason).toBeUndefined(); + expect(options[1]?.label).toBe(destination); + expect(options[1]?.reason).toBeUndefined(); expect(candidates.get("recent:recent")).toEqual({ destination }); }); 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 deleted file mode 100644 index cd868fa3e..000000000 --- a/apps/server/web/src/components/connect/__tests__/saved-hosts-section.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -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 saved 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 index 02ef0bb9c..063c0eaad 100644 --- a/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts +++ b/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts @@ -174,3 +174,11 @@ it("preserves a local key unlock failure instead of the generic launch fallback" const message = "Couldn't unlock that SSH key. Check its passphrase and try again."; expect(sshLaunchRefusal(new Error(message), null, true)).toEqual({ field: "keys", text: message }); }); + +it("shows a preset's changed-resolution refusal at the destination field", () => { + const message = + "This SSH configuration now resolves to a different destination than the preset saved. Edit the preset’s destination to approve the new address."; + expect( + sshLaunchRefusal(new ApiError(400, "short", { code: "CONFIG_INVALID", body: { message } }), machine({})), + ).toEqual({ field: "destination", text: message }); +}); 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 index fed3e6287..ba8c9ead1 100644 --- a/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx +++ b/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx @@ -63,6 +63,10 @@ async function setup({ machines, readyError, savedError: false, + presetError: false, + preset422: false, + launchError: false, + presets: [] as unknown[], enableError: false, aliasesError: false, identities: [{ fingerprint: "SHA256:AAAA", comment: "Work key" }], @@ -76,6 +80,23 @@ async function setup({ 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/presets") { + if (method === "GET") return json(state.presets); + if (state.presetError) return json({ message: "Preset save failed" }, 409); + if (state.preset422) + return json({ outcome: { accepted: false, code: "unsupported_setting", settings: ["ProxyCommand"] } }, 422); + const row = { + ...JSON.parse(String(init?.body)), + id: "saved-preset", + destination: "user@canonical:22", + createdAt: "t", + updatedAt: "t", + }; + state.presets.push(row); + return json(row, 201); + } + if (path === "/api/ssh/launch" && state.launchError) + return json({ code: "SSH_RELAY_OPEN_FAILED", message: "Launch failed" }, 409); if (path === "/api/settings/public") return json({ allowNodeEnrollment: state.enrollmentAllowed, @@ -85,7 +106,7 @@ async function setup({ }); if (path === "/api/ssh/readiness") return state.readyError ? json({ message: "read failed" }, 503) : json({ machines: state.machines }); - if (path === "/api/ssh/saved-hosts") + if (path === "/api/ssh/recent-destinations") return state.savedError ? json({ message: "saved read failed" }, 503) : json({ saved: [], recent: [], defaultNodeId: null }); @@ -193,14 +214,9 @@ it("opens directly on the connection form", async () => { expect(screen.getByRole("combobox", { name: "Connect from" })).toBeTruthy(); expect(screen.getByRole("radio", { name: "Use the connecting node’s keys" })).toBeTruthy(); expect(screen.queryByRole("button", { name: "Continue" })).toBeNull(); - fireEvent.click(screen.getByLabelText("Remember this destination")); - await settle(); await click("Start SSH subshell"); expect(calls.find((c) => c.path === "/api/ssh/launch")?.body).toEqual({ node: "desk", destination: "target" }); - expect(calls.find((c) => c.path === "/api/ssh/saved-hosts" && c.method === "PUT")?.body).toEqual({ - node: "desk", - destination: "target", - }); + expect(calls.filter((c) => c.path === "/api/presets" && c.method === "POST")).toHaveLength(0); }); it("enables an owned node only when starting the subshell", async () => { const { calls } = await setup({ @@ -292,7 +308,7 @@ it("blocks vanished keys and unavailable SSH agents", async () => { it("retains draft choices after failed readiness reads", async () => { const { state, client } = await setup({ initial: { node: "desk", destination: "keep-me" } }); await task(); - fireEvent.click(screen.getByLabelText("Remember this destination")); + fireEvent.click(screen.getByRole("checkbox", { name: "Save as preset" })); state.readyError = true; await act(async () => { await client.invalidateQueries({ queryKey: ["ssh-readiness"] }); @@ -300,7 +316,7 @@ it("retains draft choices after failed readiness reads", async () => { await settle(); expect(disabled()).toBe(true); expect((screen.getByLabelText("SSH destination") as HTMLInputElement).value).toBe("keep-me"); - expect((screen.getByLabelText("Remember this destination") as HTMLInputElement).checked).toBe(true); + expect(screen.getByRole("checkbox", { name: "Save as preset" }).getAttribute("aria-checked")).toBe("true"); }); it("holds launch and navigation until the parent attaches the subshell", async () => { let finish!: () => void; @@ -594,3 +610,54 @@ for (const action of ["cancel", "unmount"] as const) { } }); } + +it("saves before launching and reuses the saved preset after a launch failure", async () => { + const { state, calls } = await setup({ initial: { node: "desk", destination: "target" } }); + await task(); + state.launchError = true; + fireEvent.click(screen.getByRole("checkbox", { name: "Save as preset" })); + expect(disabled()).toBe(true); + fireEvent.change(screen.getByLabelText("Preset name"), { target: { value: "Build" } }); + await task(); + await click("Start SSH subshell"); + await screen.findByText("Launch failed"); + const writes = calls.filter((c) => c.method === "POST"); + expect(writes.map((c) => c.path)).toEqual(["/api/presets", "/api/ssh/launch"]); + expect(writes[1].body).toMatchObject({ destination: "target", expectedDestination: "user@canonical:22" }); + state.launchError = false; + await click("Start SSH subshell"); + expect(calls.filter((c) => c.path === "/api/presets" && c.method === "POST")).toHaveLength(1); + expect(calls.filter((c) => c.path === "/api/ssh/launch")).toHaveLength(2); +}); +it("routes a 422 during save-before-launch through the cause copy under the save heading", async () => { + const { state, calls } = await setup({ initial: { node: "desk", destination: "target" } }); + await task(); + state.preset422 = true; + fireEvent.click(screen.getByRole("checkbox", { name: "Save as preset" })); + fireEvent.change(screen.getByLabelText("Preset name"), { target: { value: "Build" } }); + await task(); + await click("Start SSH subshell"); + // The act that failed was the save: heading names the save, and the body is + // the mapped cause, not a raw API 422 slice of the outcome JSON. + await screen.findByText("Couldn’t save preset"); + await screen.findByText(/uses settings Subshell doesn't support: ProxyCommand/); + expect(document.body.textContent?.includes("API 422") ?? false).toBe(false); + expect(calls.some((c) => c.path === "/api/ssh/launch")).toBe(false); +}); + +it("keeps a failed save open without launching", async () => { + const { state, calls } = await setup({ initial: { node: "desk", destination: "target" } }); + await task(); + state.presetError = true; + fireEvent.click(screen.getByRole("checkbox", { name: "Save as preset" })); + fireEvent.change(screen.getByLabelText("Preset name"), { target: { value: "Build" } }); + await task(); + await click("Start SSH subshell"); + await screen.findByText(/Preset save failed/); + // The heading names the act that failed. "Connection failed" above a + // sentence saying the connection never started is the bug this pins out. + expect(screen.getByText("Couldn’t save preset")).toBeDefined(); + expect(screen.queryByText("Connection failed") === null).toBe(true); + expect(calls.some((c) => c.path === "/api/ssh/launch")).toBe(false); + expect((screen.getByLabelText("Preset name") as HTMLInputElement).value).toBe("Build"); +}); diff --git a/apps/server/web/src/components/connect/__tests__/verify-destination.test.tsx b/apps/server/web/src/components/connect/__tests__/verify-destination.test.tsx index 85c9175f9..038897ae1 100644 --- a/apps/server/web/src/components/connect/__tests__/verify-destination.test.tsx +++ b/apps/server/web/src/components/connect/__tests__/verify-destination.test.tsx @@ -10,7 +10,8 @@ afterEach(() => { }); const request: SshLaunchRequest = { node: "connecting", - destination: "user@host:22", + destination: "original-alias", + expectedDestination: "user@host:22", keyHome: "keys", fingerprints: ["SHA256:key"], }; @@ -58,6 +59,12 @@ test("confirmation accepts only the server-issued check before retrying the same }), ).toEqual(result); expect(launches).toBe(2); + expect(calls[0]?.body).toEqual({ + node: "connecting", + keyHome: "keys", + destination: "original-alias", + expectedDestination: "user@host:22", + }); expect(calls[1]).toEqual({ path: "/api/ssh/verify-destination/accept", body: { token: "bound-check" } }); }); test("a changed identity never offers first-connection acceptance", async () => { diff --git a/apps/server/web/src/components/connect/connect-panel.tsx b/apps/server/web/src/components/connect/connect-panel.tsx index ee2f91d17..8c1d725bd 100644 --- a/apps/server/web/src/components/connect/connect-panel.tsx +++ b/apps/server/web/src/components/connect/connect-panel.tsx @@ -1,5 +1,5 @@ import type { Node } from "@internal/node-admin"; -import { Button, errMessage, Label } from "@internal/node-admin"; +import { ApiError, Button, errMessage, 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"; @@ -8,6 +8,7 @@ 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 { SaveSshPresetFields, SshPresetPicker } from "@/components/connect/ssh-preset-controls"; import { type SshRefusalCopy, sshLaunchRefusal } from "@/components/connect/ssh-refusal"; import { type SshInitialChoices, @@ -27,20 +28,23 @@ import { Field, FieldGroup } from "@/components/ui/field"; import { RequiredMark } from "@/components/ui/required-mark"; import { useLaunchSsh, - useSaveSshHost, useSetDefaultNode, useSetSshEnabled, useSshAliases, useSshNodeRoster, useSshReadiness, - useSshSavedHosts, + useSshRecentDestinations, } from "@/hooks/use-ssh"; +import { useSaveSshPreset } from "@/hooks/use-ssh-presets"; import { isDesktop } from "@/lib/desktop"; import { fieldErrorToned, makeForm, useSubmitDisabled } from "@/lib/form"; import { nodeOptionLabel } from "@/lib/node-label"; import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone"; import { sshCanEnableOnStart } from "@/lib/ssh"; +import { sshDestinationProblem } from "@/lib/ssh-input-validation"; import { launchWithLocalKey } from "@/lib/ssh-local-key"; +import { sshDraftFromPreset, sshPresetPayload } from "@/lib/ssh-preset"; +import type { SshPreset } from "@/types/preset"; /** Shared SSH launch form for new subshells, workspace additions and splits. * A picked suggestion retains its canonical destination; editing it submits @@ -81,7 +85,7 @@ export function ConnectPanel({ 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 { data: ledger, isPending: ledgerPending } = useSshRecentDestinations(); const launch = useLaunchSsh(); const browserKey = useBrowserKeySelection(); const localKeyAbort = useRef(null); @@ -99,12 +103,18 @@ export function ConnectPanel({ onPendingChange?.(submitting); return () => onPendingChange?.(false); }, [submitting, onPendingChange]); - const save = useSaveSshHost(); + const savePreset = useSaveSshPreset(); + const savedAttempt = useRef<{ signature: string; preset: SshPreset } | null>(null); const setDefault = useSetDefaultNode(); const [localDraft, setLocalDraft] = useState(() => sshSessionDraft(initial)); const draft = controlledDraft ?? localDraft; - const setDraft = onDraftChange ?? setLocalDraft; + const writeDraft = onDraftChange ?? setLocalDraft; + // Manual choices release a pending URL preset so a late response cannot replace this draft. + const setDraft = useCallback( + (next: SshSessionDraft) => writeDraft({ ...next, initialPresetId: undefined }), + [writeDraft], + ); const signingScope = JSON.stringify([draft.authentication, draft.nodeId, sshDestination(draft)]); const previousSigningScope = useRef(signingScope); useEffect(() => { @@ -117,19 +127,38 @@ export function ConnectPanel({ // discard it; approval binds the imported key to the final destination. if (draft.authentication !== "local-key") browserKey.clear(); }, [draft.authentication, browserKey.clear]); + const applyPreset = useCallback( + (preset: SshPreset) => { + localKeyAbort.current?.abort(); + browserKey.clear(); + savedAttempt.current = null; + setWizard(true); + setRefusal(null); + setDraft(sshDraftFromPreset(preset)); + }, + [browserKey.clear, setDraft], + ); 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 setKeyHome = (keyHome: string) => + setDraft({ ...draft, keyHome, authentication: keyHome ? "remote" : "connecting" }); + const setDest = (destination: SshSessionDraft["destination"]) => + setDraft({ ...draft, destination, expectedDestination: undefined, resolutionAlias: undefined }); 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 presetPicker = ( + + ); const targets = nodes ?? []; const selectedNode: Node | null = (nodes ?? []).find((n) => n.id === nodeId) ?? null; @@ -161,7 +190,6 @@ export function ConnectPanel({ const aliases = aliasesQ.data?.aliases ?? []; const { options: destinationOptions, candidates } = buildDestinationOptions({ - saved: ledger?.saved ?? [], recent: ledger?.recent ?? [], aliases, destinations: aliasesQ.data?.destinations, @@ -175,11 +203,11 @@ export function ConnectPanel({ // (decision 2). Keyed on the loaded node list, never on `targets` (a new // array every render). useEffect(() => { - if (nodeId !== "" || machines === null) return; + if (nodeId !== "" || machines === null || draft.presetApplied || draft.initialPresetId) 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]); + }, [nodeId, machines, defaultNodeId, setNodeId, draft.presetApplied, draft.initialPresetId]); const keyOptions: ComboboxOption[] = [ { value: "connecting", label: "Keys on the connecting machine" }, @@ -253,12 +281,14 @@ export function ConnectPanel({ if (!canSubmit || submitGuard.current) return; if (draft.authentication === "local-key" && !isDesktop() && browserKey.problem()) return; submitGuard.current = true; + if (draft.initialPresetId) setDraft(draft); const attempt = new AbortController(); localKeyAbort.current = attempt; const cancelled = () => draft.authentication === "local-key" && attempt.signal.aborted; setSubmitting(true); setRefusal(null); let launchedId: string | null = null; + let savingPreset = false; let preparing = enableOnStart || enableKeysOnStart; try { const nodesToEnable = [...(enableKeysOnStart ? [keyHome] : []), ...(enableOnStart ? [nodeId] : [])]; @@ -294,10 +324,25 @@ export function ConnectPanel({ } if (cancelled()) return; preparing = false; + let expectedDestination = draft.expectedDestination; + if (draft.saveAsPreset) { + savingPreset = true; + const payload = sshPresetPayload(draft, draft.presetName ?? ""); + const signature = JSON.stringify(payload); + const preset = + savedAttempt.current?.signature === signature + ? savedAttempt.current.preset + : await savePreset.mutateAsync({ payload }); + savedAttempt.current = { signature, preset }; + expectedDestination = preset.destination; + savingPreset = false; + } + if (cancelled()) return; const launchRequest = { ...(draft.authentication === "password" ? { authentication: "password" as const } : {}), node: nodeId, destination, + ...(expectedDestination ? { expectedDestination } : {}), ...(keyHome ? { keyHome, fingerprints } : {}), }; const created = @@ -334,6 +379,7 @@ export function ConnectPanel({ ...(draft.authentication === "password" ? { authentication: "password" as const } : {}), node: nodeId, destination, + ...(expectedDestination ? { expectedDestination } : {}), ...(keyHome ? { keyHome, fingerprints } : {}), }, launch.mutateAsync, @@ -342,34 +388,36 @@ export function ConnectPanel({ if (draft.authentication === "local-key") browserKey.clear(); 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) { if (cancelled()) return; setRefusal( - launchedId + savingPreset && !(err instanceof ApiError && err.status === 422) ? { field: "destination", - text: - draft.authentication === "local-key" - ? errMessage( - err, - "The SSH subshell was created, but temporary key authentication did not finish. Check its terminal before starting another connection.", - ) - : "SSH subshell was created but could not be attached. Add it from the existing subshell list instead.", + title: "Couldn’t save preset", + text: errMessage(err, "Couldn’t save this preset. Your connection hasn’t started."), } - : preparing - ? { field: "machine", text: errMessage(err, "Couldn’t enable SSH and start the subshell.") } - : sshLaunchRefusal(err, selectedNode, draft.authentication === "local-key"), + : launchedId + ? { + field: "destination", + text: + draft.authentication === "local-key" + ? errMessage( + err, + "The SSH subshell was created, but temporary key authentication did not finish. Check its terminal before starting another connection.", + ) + : "SSH subshell was created but could not be attached. Add it from the existing subshell list instead.", + } + : preparing + ? { field: "machine", text: errMessage(err, "Couldn’t enable SSH and start the subshell.") } + : (() => { + const mapped = sshLaunchRefusal(err, selectedNode, draft.authentication === "local-key"); + // A 422 during the save step gets the launch mapper's cause, + // but the act that failed was still the save. + return savingPreset && mapped ? { ...mapped, title: "Couldn’t save preset" } : mapped; + })(), ); } finally { if (localKeyAbort.current === attempt) { @@ -407,6 +455,7 @@ export function ConnectPanel({ if (wizard) return (
+ {presetPicker}
); @@ -439,10 +489,15 @@ export function ConnectPanel({ return (
+ {presetPicker} - + )}

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

@@ -566,21 +621,7 @@ export function ConnectPanel({
-
- setRemember(e.target.checked)} - className="h-4 w-4 rounded border border-input bg-background accent-primary" - /> - -
-

- Adds this destination to your saved suggestions when you start the subshell. It uses the address or SSH alias - as its label, so you don’t need to name it. -

+ - - ); -} diff --git a/apps/server/web/src/components/connect/ssh-destination-step.tsx b/apps/server/web/src/components/connect/ssh-destination-step.tsx index 31e9ee778..0a3a92865 100644 --- a/apps/server/web/src/components/connect/ssh-destination-step.tsx +++ b/apps/server/web/src/components/connect/ssh-destination-step.tsx @@ -1,9 +1,11 @@ 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 { type SshSessionDraft, sshDestination } from "@/components/connect/ssh-session-draft"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; -import { useSshAliases, useSshSavedHosts } from "@/hooks/use-ssh"; +import { RequiredMark } from "@/components/ui/required-mark"; +import { useSshAliases, useSshRecentDestinations } from "@/hooks/use-ssh"; +import { sshDestinationProblem } from "@/lib/ssh-input-validation"; /** Reuses the click-dismiss destination picker and its canonical suggestion state. */ export function SshDestinationStep({ @@ -17,10 +19,9 @@ export function SshDestinationStep({ nodeName?: string; ready: boolean; }) { - const ledger = useSshSavedHosts(); + const ledger = useSshRecentDestinations(); const aliases = useSshAliases(ready ? draft.nodeId : null); const { options, candidates } = buildDestinationOptions({ - saved: ledger.data?.saved ?? [], recent: ledger.data?.recent ?? [], aliases: aliases.data?.aliases ?? [], destinations: aliases.data?.destinations, @@ -31,40 +32,43 @@ export function SshDestinationStep({ return ( - SSH destination + + SSH destination + + onChange({ ...draft, destination: { typed, pick: null, pickId: null } })} + onTextChange={(typed) => + onChange({ + ...draft, + expectedDestination: undefined, + resolutionAlias: undefined, + 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 } }); + if (pick) + onChange({ + ...draft, + expectedDestination: undefined, + resolutionAlias: undefined, + 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. -

- - onChange({ ...draft, remember: event.target.checked })} - /> - Remember this destination - -

- Adds this destination to your saved suggestions when you start the subshell. It uses the address or SSH alias - as its label, so you don’t need to name it. + Enter the destination you want to access, for example deploy@example.com:22, or choose a recent destination.

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

- {ledger.isError && "Couldn’t load saved and recent destinations. "} + {ledger.isError && "Couldn’t load recent destinations. "} {aliases.isError && "Couldn’t load aliases from the SSH configuration. "} You can still type a destination.

diff --git a/apps/server/web/src/components/connect/ssh-machine-step.tsx b/apps/server/web/src/components/connect/ssh-machine-step.tsx index 1bf12bdb2..ee3489259 100644 --- a/apps/server/web/src/components/connect/ssh-machine-step.tsx +++ b/apps/server/web/src/components/connect/ssh-machine-step.tsx @@ -12,6 +12,7 @@ export function SshMachineStep({ value, onChange, label, + hideLabel = false, description, notice, children, @@ -22,6 +23,7 @@ export function SshMachineStep({ value: string; onChange: (id: string) => void; label: string; + hideLabel?: boolean; description?: string; notice?: string; children?: ReactNode; @@ -31,7 +33,9 @@ export function SshMachineStep({ return ( - {label} + + {label} + void; + disabled: boolean; + /** Local key selection also releases automatic application, while manual selection stays available. */ + initialEligible?: boolean; +}) { + const query = useSshPresets(); + const initial = query.data?.find((p) => p.id === initialId); + const consumed = useRef(false); + useEffect(() => { + if (consumed.current) return; + // Busy or edited forms release the URL intent permanently, including while the query is pending. + if (disabled || !initialEligible || !initialId) { + consumed.current = true; + return; + } + if (initial) { + consumed.current = true; + onApply(initial); + } + }, [initial, initialId, disabled, initialEligible, onApply]); + if (query.isPending || query.isError) return ; + return ( + <> + {initialId && !initial && ( +

+ This SSH preset is no longer available. Choose another preset or enter the connection details. +

+ )} + {!!query.data?.length && ( + + SSH preset + ({ + value: p.id, + label: p.name, + searchText: p.destination, + disabled, + reason: disabled ? "Wait for the current connection attempt to finish." : undefined, + }))} + onValueChange={(id) => { + const preset = query.data.find((p) => p.id === id); + if (preset && !disabled) onApply(preset); + }} + /> + + )} + + ); +} + +export function SaveSshPresetFields({ + draft, + onChange, + busy, +}: { + draft: SshSessionDraft; + onChange: (draft: SshSessionDraft) => void; + busy: boolean; +}) { + return ( + + + onChange({ ...draft, saveAsPreset: checked === true })} + /> + Save as preset + + {draft.saveAsPreset && ( + + onChange({ ...draft, presetName })} + /> +

+ Saves these connection choices before starting. Keys from your machine and passwords aren’t saved. +

+
+ )} +
+ ); +} diff --git a/apps/server/web/src/components/connect/ssh-refusal.ts b/apps/server/web/src/components/connect/ssh-refusal.ts index c5957a67b..dd5fbd377 100644 --- a/apps/server/web/src/components/connect/ssh-refusal.ts +++ b/apps/server/web/src/components/connect/ssh-refusal.ts @@ -30,6 +30,8 @@ export type RefusalField = "destination" | "machine" | "keys"; export interface SshRefusalCopy { text: string; field: RefusalField; + /** Names the act that failed, so a heading can say which one; the wizard's default is "Connection failed". */ + title?: string; } /** What the panel knows about the connecting machine at submit time. */ @@ -37,7 +39,7 @@ 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 { +export 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."; @@ -60,6 +62,13 @@ export function sshLaunchRefusal( // They aren't API responses and must not disappear into the launch fallback. if (localKey && err instanceof Error && !(err instanceof ApiError)) return { field: "keys", text: err.message }; if (err instanceof ApiError) { + if (err.code === "CONFIG_INVALID") { + const message = + err.body && typeof err.body === "object" && "message" in err.body && typeof err.body.message === "string" + ? err.body.message + : err.message; + return { field: "destination", text: message }; + } if ( err.code?.startsWith("SSH_RELAY_") || err.code?.startsWith("SSH_HOST_PIN_") || diff --git a/apps/server/web/src/components/connect/ssh-session-draft.ts b/apps/server/web/src/components/connect/ssh-session-draft.ts index 4a65eb8f2..6a06b181a 100644 --- a/apps/server/web/src/components/connect/ssh-session-draft.ts +++ b/apps/server/web/src/components/connect/ssh-session-draft.ts @@ -1,22 +1,35 @@ import type { DestinationCandidate } from "@/components/connect/destination-options"; import { type SshAgentIdentity, sshSelectionError } from "@/lib/ssh"; +import { sshDestinationProblem, sshPresetNameProblem } from "@/lib/ssh-input-validation"; -export type SshInitialChoices = { node?: string; destination?: string; keyHome?: string; fingerprints?: string[] }; +export type SshInitialChoices = { + preset?: string; + 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; /** Authentication preference only; credentials are entered in the terminal. */ - authentication?: "password" | "local-key"; + authentication?: "connecting" | "remote" | "password" | "local-key"; /** 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; + initialPresetId?: string; + /** Reuse alias-scoped settings while requiring the preset’s saved address. Destination edits clear both. */ + resolutionAlias?: string; + expectedDestination?: string; + /** Prevent default-node seeding from replacing a preset’s missing node. */ + presetApplied?: boolean; + saveAsPreset?: boolean; + presetName?: string; } export function sshSessionDraft(initial?: SshInitialChoices): SshSessionDraft { @@ -29,12 +42,12 @@ export function sshSessionDraft(initial?: SshInitialChoices): SshSessionDraft { pickId: null, typed: initial?.destination ?? "", }, - remember: false, + initialPresetId: initial?.preset, }; } export function sshDestination(draft: SshSessionDraft): string { - return draft.destination.pick?.destination ?? draft.destination.typed.trim(); + return draft.resolutionAlias ?? draft.destination.pick?.destination ?? draft.destination.typed.trim(); } export function sshKeySelectionProblem(fingerprints: string[], identities?: SshAgentIdentity[]): string | null { @@ -53,8 +66,12 @@ export function sshKeySourceProblem(draft: SshSessionDraft): string | null { } export function sshDraftProblems(draft: SshSessionDraft): Record { + const nameProblem = draft.saveAsPreset ? sshPresetNameProblem(draft.presetName ?? "") : null; + if (nameProblem) return { name: nameProblem }; + if (draft.authentication === "remote" && !draft.keyHome) return { keys: "Choose a node to supply keys." }; if (!draft.nodeId) return { node: "Choose a node to connect from first." }; - if (!sshDestination(draft)) return { destination: "Enter an SSH destination." }; + const destinationProblem = sshDestinationProblem(sshDestination(draft)); + if (destinationProblem) return { destination: destinationProblem }; const error = sshKeySourceProblem(draft) || (draft.keyHome ? sshKeySelectionProblem(draft.selections[draft.keyHome] ?? []) : null); diff --git a/apps/server/web/src/components/connect/ssh-wizard.tsx b/apps/server/web/src/components/connect/ssh-wizard.tsx index 65620f3a5..c49d89073 100644 --- a/apps/server/web/src/components/connect/ssh-wizard.tsx +++ b/apps/server/web/src/components/connect/ssh-wizard.tsx @@ -1,10 +1,11 @@ import { Button } from "@internal/node-admin"; import { CircleAlert } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { BrowserKeyFields, type BrowserKeySelection } from "@/components/connect/browser-key-fields"; import { SshDestinationStep } from "@/components/connect/ssh-destination-step"; import { SshKeyStep } from "@/components/connect/ssh-key-step"; import { SshMachineSetup, SshMachineStep } from "@/components/connect/ssh-machine-step"; +import { SaveSshPresetFields } from "@/components/connect/ssh-preset-controls"; import { type SshSessionDraft, sshDestination, @@ -18,6 +19,7 @@ import { useSshNodeRoster } from "@/hooks/use-ssh"; import { isDesktop } from "@/lib/desktop"; import { makeForm, useSubmitDisabled } from "@/lib/form"; import { type SshMachineReadiness, sshCanEnableOnStart } from "@/lib/ssh"; +import { sshDestinationProblem, sshPresetNameProblem } from "@/lib/ssh-input-validation"; export interface SshWizardProps { /** Shared with the connection form and retained by the outer dialog. */ @@ -40,6 +42,10 @@ export interface SshWizardProps { /** A pane already exists; attachment failure must not create a second one. */ launchBlocked?: boolean; refusal?: string; + /** Names the act a refusal refers to, so the heading isn't always "Connection failed". */ + refusalTitle?: string; + /** Saving choices does not select a local key, enable SSH, or launch. */ + presetEditor?: { name: string; unchangedDestination?: boolean }; } export function SshWizard({ @@ -54,16 +60,18 @@ export function SshWizard({ busy, launchBlocked, refusal, + refusalTitle, + presetEditor, }: SshWizardProps) { const errorRef = useRef(null); useEffect(() => { if (refusal) errorRef.current?.focus(); }, [refusal]); - const [useRemote, setUseRemote] = useState(!!draft.keyHome); + const useRemote = draft.authentication === "remote" || (!draft.authentication && !!draft.keyHome); const machine = machines.find((item) => item.node.id === draft.nodeId); const keyMachine = machines.find((item) => item.node.id === draft.keyHome); const remote = useRemote; - const enableKeysOnStart = remote && sshCanEnableOnStart(keyMachine); + const enableKeysOnStart = !presetEditor && remote && sshCanEnableOnStart(keyMachine); const keyMachines = machines.filter((item) => item.node.canLaunch); const noOtherKeyNodes = !readiness.isPending && !readiness.isError && !keyMachines.some((item) => item.node.id !== draft.nodeId); @@ -71,34 +79,52 @@ export function SshWizard({ const fingerprints = draft.selections[draft.keyHome] ?? []; const roster = useSshNodeRoster(remote && !keyProblem && keyMachine?.canConnect ? draft.keyHome : null); const patch = (value: Partial) => onDraftChange({ ...draft, ...value }); - const problems = (): Record => { - if (draft.authentication === "local-key" && !isDesktop()) { + const problems = (values?: { draft: SshSessionDraft; presetName: string }): Record => { + const value = values?.draft ?? draft; + const name = value.presetName ?? presetEditor?.name ?? ""; + const remote = value.authentication === "remote" || (!value.authentication && !!value.keyHome); + const nameProblem = presetEditor || value.saveAsPreset ? sshPresetNameProblem(name) : null; + if (nameProblem) return { draft: nameProblem }; + if (!presetEditor && value.authentication === "local-key" && !isDesktop()) { const problem = browserKey.problem(); if (problem) return { draft: problem }; } - if (!draft.nodeId) return { draft: "Choose a node." }; - if (!sshDestination(draft)) return { draft: "Enter an SSH destination." }; + if (!value.nodeId) return { draft: "Choose a node." }; + const destinationProblem = sshDestinationProblem(sshDestination(value)); + if (destinationProblem) return { draft: destinationProblem }; if (remote) { - const error = !draft.keyHome ? "Choose a node to supply keys." : keyProblem; + const error = !value.keyHome ? "Choose a node to supply keys." : sshKeySourceProblem(value); if (error) return { draft: error }; + if (presetEditor) { + const selectionError = sshKeySelectionProblem(value.selections[value.keyHome] ?? []); + if (selectionError) return { draft: selectionError }; + } } return {}; }; - const enableOnStart = sshCanEnableOnStart(machine); + const enableOnStart = !presetEditor && sshCanEnableOnStart(machine); const blocked = busy || readiness.isPending || readiness.isError || - (!machine?.canConnect && !enableOnStart) || + (!!presetEditor && (!machine?.node.canLaunch || (remote && !keyMachine?.node.canLaunch))) || + (!machine?.canConnect && !enableOnStart && !presetEditor?.unchangedDestination) || !!launchBlocked || - (remote && + (!presetEditor && + remote && !enableKeysOnStart && (!keyMachine?.canConnect || roster.isPending || roster.isError || sshKeySelectionProblem(fingerprints, roster.data?.identities) !== null)); const form = makeForm({ - defaultValues: { draft, remote, keyName: browserKey.name, trusted: browserKey.trusted }, + defaultValues: { + draft, + remote, + presetName: presetEditor?.name ?? "", + keyName: browserKey.name, + trusted: browserKey.trusted, + }, validator: problems, onSubmit: async () => { if (blocked || Object.keys(problems()).length) return; @@ -107,11 +133,12 @@ export function SshWizard({ }); useEffect(() => { form.setFieldValue("draft", draft); + form.setFieldValue("presetName", presetEditor?.name ?? ""); form.setFieldValue("remote", remote); form.setFieldValue("keyName", browserKey.name); form.setFieldValue("trusted", browserKey.trusted); void form.validate("change"); - }, [draft, remote, form, browserKey.name, browserKey.trusted]); + }, [draft, remote, form, browserKey.name, browserKey.trusted, presetEditor?.name]); const disabled = useSubmitDisabled(form, blocked); return (
@@ -143,9 +170,8 @@ export function SshWizard({ aria-labelledby="ssh-authentication-heading" value={draft.authentication ?? (useRemote ? "remote" : "connecting")} onValueChange={(value) => { - setUseRemote(value === "remote"); patch({ - authentication: value === "password" || value === "local-key" ? value : undefined, + authentication: value as SshSessionDraft["authentication"], ...(value !== "remote" ? { keyHome: "" } : {}), }); }} @@ -155,11 +181,15 @@ export function SshWizard({ id="ssh-keys-connecting" value="connecting" aria-labelledby="ssh-keys-connecting-label" - aria-describedby={!draft.authentication && !remote ? "ssh-keys-node-name" : undefined} + aria-describedby={ + (!draft.authentication || draft.authentication === "connecting") && !remote + ? "ssh-keys-node-name" + : undefined + } /> Use the connecting node’s keys - {!draft.authentication && !remote && ( + {(!draft.authentication || draft.authentication === "connecting") && !remote && ( -
+
Use another node’s keys @@ -201,6 +231,51 @@ export function SshWizard({ . )} + {remote && (!noOtherKeyNodes || keyProblem || keyMachine) && ( + + {!noOtherKeyNodes && ( + { + patch({ keyHome, authentication: "remote" }); + }} + exclude={draft.nodeId} + id="ssh-keys-machine" + label="Use keys from" + hideLabel + notice={ + enableKeysOnStart + ? "Starting this subshell will enable SSH on the node supplying keys. You’ll then choose which keys to use before connecting." + : undefined + } + > + {keyMachine && !keyProblem && !keyMachine.canConnect && !enableKeysOnStart && ( + + )} + + )} + {keyProblem && ( +

+ {keyProblem} +

+ )} + {keyMachine?.canConnect && !keyProblem && ( + + patch({ selections: { ...draft.selections, [draft.keyHome]: selection } }) + } + /> + )} +
+ )}
@@ -210,18 +285,23 @@ export function SshWizard({ aria-labelledby="ssh-auth-local-key-label" aria-describedby={ draft.authentication === "local-key" - ? isDesktop() + ? isDesktop() || presetEditor ? "ssh-auth-local-key-description" : "ssh-browser-key-risk" : undefined } /> -
+
Use a key from my machine {draft.authentication === "local-key" && - (isDesktop() ? ( + (presetEditor ? ( +

+ Choose a key from your machine when creating a new SSH subshell. Your key isn’t saved in the + preset. +

+ ) : isDesktop() ? (

Choose a key when you start. It stays on your machine and is released after one authentication attempt or 90 seconds. @@ -242,63 +322,27 @@ export function SshWizard({ Enter a password when connecting {draft.authentication === "password" && ( - Enter your password in the terminal when SSH asks for it. Subshell won’t save it. + {presetEditor + ? "When creating a new SSH subshell with this preset, enter your password in the terminal when SSH asks for it. The preset won’t save your password." + : "Enter your password in the terminal when SSH asks for it. Subshell won’t save it."} )} - {remote && (!noOtherKeyNodes || keyProblem || keyMachine) && ( - - {!noOtherKeyNodes && ( - { - setUseRemote(true); - patch({ keyHome }); - }} - exclude={draft.nodeId} - id="ssh-keys-machine" - label="Use keys from" - notice={ - enableKeysOnStart - ? "Starting this subshell will enable SSH on the node supplying keys. You’ll then choose which keys to use before connecting." - : undefined - } - > - {keyMachine && !keyProblem && !keyMachine.canConnect && !enableKeysOnStart && ( - - )} - - )} - {keyProblem && ( -

- {keyProblem} -

- )} - {keyMachine?.canConnect && !keyProblem && ( - patch({ selections: { ...draft.selections, [draft.keyHome]: selection } })} - /> - )} - - )} + {!presetEditor && } {refusal && (
diff --git a/apps/server/web/src/components/connect/verify-destination.tsx b/apps/server/web/src/components/connect/verify-destination.tsx index 212fa5653..2c95610b2 100644 --- a/apps/server/web/src/components/connect/verify-destination.tsx +++ b/apps/server/web/src/components/connect/verify-destination.tsx @@ -17,6 +17,7 @@ export async function launchWithDestinationVerification( node: request.node, keyHome: request.keyHome, destination: request.destination, + ...(request.expectedDestination ? { expectedDestination: request.expectedDestination } : {}), }, ); const accepted = await confirmAction({ diff --git a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx new file mode 100644 index 000000000..be3a6ce26 --- /dev/null +++ b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx @@ -0,0 +1,212 @@ +import { afterEach, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SshPresetEditor } from "@/components/presets/ssh-preset-editor"; +import type { SshPreset } from "@/types/preset"; + +function disabled() { + const button = screen.getByRole("button", { name: "Save preset" }) as HTMLButtonElement; + return button.disabled || button.getAttribute("aria-disabled") === "true"; +} +const original = globalThis.fetch; +afterEach(() => { + cleanup(); + globalThis.fetch = original; +}); +const preset: SshPreset = { + kind: "ssh", + id: "p", + name: "My build", + description: null, + destination: "user@build:22", + alias: "build", + resolutionAlias: "private-build", + nodeId: "desk", + authentication: "local-key", + keyHome: null, + fingerprints: [], + createdAt: "t", + updatedAt: "t", +}; +async function setup(online = true) { + const writes: { path: string; body: Record }[] = []; + let closed = false; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const path = new URL(String(input), "http://localhost").pathname; + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)); + writes.push({ path, body }); + return Response.json({ ...preset, ...body }); + } + if (path === "/api/ssh/readiness") + return Response.json({ + machines: [ + { + node: { id: "desk", name: "Desktop", kind: "agent", canLaunch: true, sshEnabled: true }, + canConnect: online, + blockers: [], + canConfigure: true, + }, + ], + }); + if (path === "/api/ssh/recent-destinations") return Response.json({ recent: [], defaultNodeId: null }); + if (path === "/api/ssh/aliases") return Response.json({ aliases: [] }); + return Response.json([]); + }) as typeof fetch; + const root = createRootRoute(); + const route = createRoute({ + getParentRoute: () => root, + path: "/", + component: () => ( + { + closed = true; + }} + /> + ), + }); + const router = createRouter({ + routeTree: root.addChildren([route]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); + render( + + + , + ); + await screen.findByRole("dialog", { name: "Edit SSH preset" }); + return { writes, closed: () => closed }; +} +it("saves the local-key radio choice without requiring a file or approval", async () => { + const { writes, closed } = await setup(); + expect(screen.getByRole("radio", { name: "Use a key from my machine" }).getAttribute("aria-checked")).toBe("true"); + const localKeyChoice = screen.getByRole("radio", { name: "Use a key from my machine" }); + expect(document.getElementById(localKeyChoice.getAttribute("aria-describedby") ?? "")?.textContent).toContain( + "Choose a key from your machine when creating a new SSH subshell", + ); + expect(screen.queryByRole("button", { name: "Choose key…" })).toBeNull(); + expect(screen.queryByRole("checkbox", { name: "I trust this website and its operator" })).toBeNull(); + await waitFor(() => expect(disabled()).toBe(false)); + fireEvent.click(screen.getByRole("button", { name: "Save preset" })); + await waitFor(() => expect(closed()).toBe(true)); + expect(writes).toEqual([ + { + path: "/api/presets/p", + body: { + name: "My build", + description: null, + nodeId: "desk", + destination: "user@build:22", + resolutionAlias: "private-build", + expectedDestination: "user@build:22", + alias: "build", + authentication: "local-key", + keyHome: null, + fingerprints: [], + }, + }, + ]); +}); +it("allows unrelated edits while the saved connecting node is offline", async () => { + const { writes } = await setup(false); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Renamed" } }); + await waitFor(() => expect(disabled()).toBe(false)); + fireEvent.click(screen.getByRole("button", { name: "Save preset" })); + await waitFor(() => expect(writes[0]?.body.name).toBe("Renamed")); +}); +it("requires a name and refuses a changed destination while the node is offline", async () => { + await setup(false); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "" } }); + await waitFor(() => expect(disabled()).toBe(true)); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Renamed" } }); + fireEvent.change(screen.getByLabelText("SSH destination"), { target: { value: "other:22" } }); + await waitFor(() => expect(disabled()).toBe(true)); +}); + +it("releases the saved destination guard and alias on a deliberate destination edit", async () => { + const { writes } = await setup(); + fireEvent.change(screen.getByLabelText("SSH destination"), { target: { value: "new-build" } }); + await waitFor(() => expect(disabled()).toBe(false)); + fireEvent.click(screen.getByRole("button", { name: "Save preset" })); + await waitFor(() => expect(writes).toHaveLength(1)); + expect(writes[0]?.body.destination).toBe("new-build"); + expect(writes[0]?.body.expectedDestination).toBeUndefined(); + expect(writes[0]?.body.resolutionAlias).toBeUndefined(); +}); + +it("shows name and destination problems on blur and blocks saving until corrected", async () => { + const { writes } = await setup(); + const name = screen.getByLabelText("Name"); + fireEvent.focus(name); + fireEvent.change(name, { target: { value: " " } }); + expect(screen.queryByText("Enter a preset name.") === null).toBe(true); + fireEvent.blur(name); + expect(screen.getByText("Enter a preset name.")).toBeDefined(); + expect(name.getAttribute("aria-invalid")).toBe("true"); + await waitFor(() => expect(disabled()).toBe(true)); + fireEvent.focus(name); + expect(screen.queryByText("Enter a preset name.") === null).toBe(true); + fireEvent.change(name, { target: { value: "Valid name" } }); + fireEvent.blur(name); + const destination = screen.getByLabelText("SSH destination"); + fireEvent.focus(destination); + fireEvent.change(destination, { target: { value: "user@host:70000" } }); + expect(screen.queryByText("Enter a port between 1 and 65535.") === null).toBe(true); + fireEvent.blur(destination); + expect(screen.getByText("Enter a port between 1 and 65535.")).toBeDefined(); + expect(destination.getAttribute("aria-invalid")).toBe("true"); + await waitFor(() => expect(disabled()).toBe(true)); + fireEvent.click(screen.getByRole("button", { name: "Save preset" })); + expect(writes).toHaveLength(0); + fireEvent.change(destination, { target: { value: "user@host:22" } }); + await waitFor(() => expect(disabled()).toBe(false)); +}); + +it("renders a resolve refusal as its cause and remedy, not the raw 422 body", async () => { + await setup(); + fireEvent.blur(screen.getByLabelText("Name")); + const previousFetch = globalThis.fetch; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + if (init?.method === "PUT") + return Response.json( + { outcome: { accepted: false, code: "unsupported_setting", settings: ["ProxyCommand"] } }, + { status: 422 }, + ); + return previousFetch(input as RequestInfo, init); + }) as typeof fetch; + await waitFor(() => expect(disabled()).toBe(false)); + fireEvent.click(screen.getByRole("button", { name: "Save preset" })); + const cause = await screen.findByText(/uses settings Subshell doesn't support: ProxyCommand/); + expect(cause.textContent ?? "").toContain("connecting machine"); + expect(document.body.textContent?.includes("API 422") ?? false).toBe(false); +}); + +it("places a duplicate-name refusal beside the name and clears it when edited", async () => { + await setup(); + fireEvent.blur(screen.getByLabelText("Name")); + const previousFetch = globalThis.fetch; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + if (init?.method === "PUT") + return Response.json( + { code: "duplicate", message: "You already have an SSH preset with that name" }, + { status: 409 }, + ); + return previousFetch(input as RequestInfo, init); + }) as typeof fetch; + await waitFor(() => expect(disabled()).toBe(false)); + fireEvent.click(screen.getByRole("button", { name: "Save preset" })); + const message = await screen.findByText("You already have an SSH preset with that name. Choose another name."); + const name = screen.getByLabelText("Name"); + expect(name.getAttribute("aria-describedby")).toBe(message.id); + fireEvent.change(name, { target: { value: "Different name" } }); + await waitFor(() => expect(screen.queryByText(message.textContent ?? "") === null).toBe(true)); +}); diff --git a/apps/server/web/src/components/presets/agent-preset-library.tsx b/apps/server/web/src/components/presets/agent-preset-library.tsx new file mode 100644 index 000000000..d3f2a9599 --- /dev/null +++ b/apps/server/web/src/components/presets/agent-preset-library.tsx @@ -0,0 +1,215 @@ +import { apiFetch, Button, Card, CardContent, CardHeader, confirmAction, errMessage } from "@internal/node-admin"; +import { useNavigate } from "@tanstack/react-router"; +import { Copy, Pencil, SlidersHorizontal, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { EmptyState } from "@/components/empty-state"; +import { ErrorBanner } from "@/components/error-banner"; +import { LoadingCard } from "@/components/loading-card"; +import { PluginIcon } from "@/components/plugin-icon"; +import { CreatePresetDialog } from "@/components/presets/create-preset-dialog"; +import { PresetTableRow } from "@/components/presets/preset-table-row"; +import { useInstancePlugins } from "@/hooks/use-instance-plugins"; +import { useInvalidatePresets, usePresets } from "@/hooks/use-presets"; +import { presetFormFromRow, suggestCloneName } from "@/lib/preset-form"; +import type { PresetRow } from "@/types/preset"; + +/** Presets under their agent's header, agents in catalog order. Rows whose + * harness is not (or no longer) in the catalog group last, keyed by id, + * rather than vanishing with the plugin view that failed to name them. */ +function groupByHarness(presets: PresetRow[], pluginOrder: string[]): [string, PresetRow[]][] { + const byHarness = new Map(); + for (const p of presets) { + const bucket = byHarness.get(p.harnessId); + if (bucket) bucket.push(p); + else byHarness.set(p.harnessId, [p]); + } + const ordered: [string, PresetRow[]][] = []; + for (const id of pluginOrder) { + const bucket = byHarness.get(id); + if (bucket) { + ordered.push([id, bucket]); + byHarness.delete(id); + } + } + for (const [id, bucket] of byHarness) ordered.push([id, bucket]); + return ordered; +} + +/** The Agent tab: one card per harness, rows with the launch-command row. */ +export function AgentPresetLibrary({ + creating, + onCreate, + onCreatedClose, +}: { + creating: boolean; + onCreate: () => void; + onCreatedClose: () => void; +}) { + const navigate = useNavigate(); + const invalidate = useInvalidatePresets(); + const { data: presets, isLoading, isError, refetch } = usePresets(); + const { data: pluginData } = useInstancePlugins(); + const plugins = pluginData?.plugins ?? []; + + // The row whose Clone dialog is open; the dialog mounts only while set + // (clone-dialog posture), so every open re-seeds from the then-current row. + const [cloneSource, setCloneSource] = useState(null); + // The delete failure belongs on the page — with the row menu closed and no + // dialog up, anywhere else would render a failed delete nowhere at all. + const [listError, setListError] = useState(null); + + const groups = useMemo( + () => + groupByHarness( + presets ?? [], + plugins.map((p) => p.id), + ), + [presets, plugins], + ); + + async function deletePreset(id: string) { + const ok = await confirmAction({ + title: "Delete this preset?", + confirmLabel: "Delete", + danger: true, + }); + if (!ok) return; + try { + setListError(null); + await apiFetch(`/api/presets/${id}`, { method: "DELETE" }); + await invalidate(); + } catch (err) { + setListError(errMessage(err, "Failed to delete preset")); + } + } + + return ( + <> + {listError &&

{listError}

} + + {/* A dialog rather than a card that pushes the list down the page + (user report 2026-09-11): creating a preset is a decision with an + end, and the list behind it is the thing being added to. Same shape + as every other "new X" in the app — the launch dialog, the + workspace dialog, Add a node. The create dialog and the inline one + in the launch form are the SAME component, unlocked here. */} + {creating && !next && onCreatedClose()} />} + + {cloneSource && ( + !next && setCloneSource(null)} + initialForm={{ + ...presetFormFromRow(cloneSource), + name: suggestCloneName(presets ?? [], cloneSource), + }} + /> + )} + + {isLoading && } + + {/* A failed list load is not an empty account: say so, and offer the + retry the old silent blank denied. */} + {isError && ( + void refetch()} + > + Retry + + } + /> + )} + + {!isLoading && !isError && presets?.length === 0 && !creating && ( + + )} + + {/* Rows grouped under their agent (icon + plugin name, fallback + harnessId — the same reading grammar everywhere the catalog might + not know a row's harness). The binary for the launch-command + preview comes from the plugin row; an unknown harness falls back to + its id so the row still renders something honest. */} + {groups.length > 0 && ( +
+ {groups.map(([harnessId, rows]) => { + const plugin = plugins.find((p) => p.id === harnessId); + return ( + + {/* A real heading, not styled text: the e2e group-header probe + queries by role because a presetless row's launch-command + preview renders the bare harness id — text matching would + collide. PageHeader carries the h1; these are the h2s. */} + + +

{plugin?.name ?? harnessId}

+
+ +
+ + + + + + + + + + + {rows.map((p) => ( + void navigate({ to: "/presets/$id", params: { id: p.id } }), + }, + { + label: "Clone preset", + icon: Copy, + onSelect: () => setCloneSource(p), + }, + { + label: "Delete preset", + icon: Trash2, + destructive: true, + onSelect: () => void deletePreset(p.id), + }, + ]} + /> + ))} + +
+ Name + + Auto-restart + + Cross-agent use + + Actions +
+
+
+
+ ); + })} +
+ )} + + ); +} diff --git a/apps/server/web/src/components/presets/ssh-preset-editor.tsx b/apps/server/web/src/components/presets/ssh-preset-editor.tsx new file mode 100644 index 000000000..10d26c858 --- /dev/null +++ b/apps/server/web/src/components/presets/ssh-preset-editor.tsx @@ -0,0 +1,115 @@ +import { ApiError, errMessage } from "@internal/node-admin"; +import { useState } from "react"; +import { useBrowserKeySelection } from "@/components/connect/browser-key-fields"; +import { isSshResolveRefusal, refusalFromOutcome } from "@/components/connect/ssh-refusal"; +import { sshSessionDraft } from "@/components/connect/ssh-session-draft"; +import { SshWizard } from "@/components/connect/ssh-wizard"; +import { SshPresetNameField } from "@/components/presets/ssh-preset-name-field"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + formDialogOpenChange, +} from "@/components/ui/dialog"; +import { useSshReadiness } from "@/hooks/use-ssh"; +import { useSaveSshPreset } from "@/hooks/use-ssh-presets"; +import { sshDraftFromPreset, sshPresetPayload } from "@/lib/ssh-preset"; +import type { SshPreset } from "@/types/preset"; + +/** + * A refused save in words. The resolve refusal (422) carries the outcome in + * the body, not an error sentence, so read the cause there rather than + * rendering an `API 422:` JSON slice; everything else keeps the generic + * message. Same doctrine as `ssh-refusal.ts` for the launch panel. + */ +function presetSaveRefusal(error: unknown): string { + if (error instanceof ApiError && error.status === 422) { + const outcome = + typeof error.body === "object" && error.body !== null ? (error.body as { outcome?: unknown }).outcome : undefined; + if (isSshResolveRefusal(outcome)) return refusalFromOutcome(outcome); + } + return errMessage(error, "Couldn’t save this preset. Check the connection details and try again."); +} + +/** A fresh mount is one editing session, including clones. No credential is requested here. */ +export function SshPresetEditor({ + preset, + cloneName, + onClose, +}: { + preset?: SshPreset; + cloneName?: string; + onClose: () => void; +}) { + const [draft, setDraft] = useState(() => ({ + ...(preset ? sshDraftFromPreset(preset) : sshSessionDraft()), + presetName: cloneName ?? preset?.name ?? "", + })); + const name = draft.presetName ?? ""; + const readiness = useSshReadiness(); + const browserKey = useBrowserKeySelection(); + const save = useSaveSshPreset(); + const duplicateName = save.error instanceof ApiError && save.error.code === "duplicate"; + const editing = !!preset && cloneName === undefined; + return ( + { + if (!open && !save.isPending) onClose(); + })} + > + + + + {cloneName !== undefined ? "Clone SSH preset" : editing ? "Edit SSH preset" : "Create SSH preset"} + + + Save connection choices for next time. Saving doesn’t start a connection or approve a destination’s + identity. + + + { + save.reset(); + setDraft({ ...draft, presetName }); + }} + /> + setDraft({ ...next, presetName: next.presetName ?? "" })} + browserKey={browserKey} + machines={readiness.data?.machines ?? []} + readiness={readiness} + busy={save.isPending} + onCancel={onClose} + onLeave={onClose} + presetEditor={{ + name, + unchangedDestination: + editing && draft.nodeId === preset.nodeId && draft.destination.typed === preset.destination, + }} + refusal={save.error && !duplicateName ? presetSaveRefusal(save.error) : undefined} + onStart={async () => { + try { + const payload = sshPresetPayload(draft, name, preset?.description ?? ""); + if (preset && draft.destination.typed === preset.destination) payload.alias = preset.alias; + await save.mutateAsync({ id: editing ? preset.id : undefined, payload }); + onClose(); + } catch { + /* The mutation keeps the error beside the form. */ + } + }} + /> + + + ); +} diff --git a/apps/server/web/src/components/presets/ssh-preset-library.tsx b/apps/server/web/src/components/presets/ssh-preset-library.tsx new file mode 100644 index 000000000..767e4e5ea --- /dev/null +++ b/apps/server/web/src/components/presets/ssh-preset-library.tsx @@ -0,0 +1,162 @@ +import { apiFetch, Card, CardContent, CardHeader, CardTitle, confirmAction, errMessage } from "@internal/node-admin"; +import { useNavigate } from "@tanstack/react-router"; +import { Copy, Pencil, Play, Terminal, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { ActionsMenu } from "@/components/actions-menu"; +import { EmptyState } from "@/components/empty-state"; +import { SshPresetEditor } from "@/components/presets/ssh-preset-editor"; +import { SshQueryStatus } from "@/components/ssh/query-status"; +import { useNodes } from "@/hooks/use-nodes"; +import { useInvalidatePresets } from "@/hooks/use-presets"; +import { useSshPresets } from "@/hooks/use-ssh-presets"; +import { SSH_PRESET_NAME_MAX } from "@/lib/ssh-input-validation"; +import { SSH_AUTHENTICATION_LABELS } from "@/lib/ssh-preset"; +import type { SshPreset } from "@/types/preset"; + +/** SSH presets share library actions with agent presets, but show connection facts. */ +export function SshPresetLibrary({ + creating, + onCreate, + onCreatedClose, +}: { + creating: boolean; + onCreate: () => void; + onCreatedClose: () => void; +}) { + const query = useSshPresets(); + const nodes = useNodes(); + const invalidate = useInvalidatePresets(); + const navigate = useNavigate(); + const [editor, setEditor] = useState<{ preset: SshPreset; cloneName?: string } | null>(null); + const [error, setError] = useState(null); + function clone(preset: SshPreset) { + // Case folding mirrors the server's ASCII NOCASE index, same as + // suggestCloneName's set on the Agent tab. + const names = new Set(query.data?.map((p) => p.name.toLowerCase())); + const withSuffix = (suffix: string) => preset.name.slice(0, SSH_PRESET_NAME_MAX - suffix.length) + suffix; + let name = withSuffix(" (copy)"); + for (let index = 2; names.has(name.toLowerCase()); index++) name = withSuffix(` (copy ${index})`); + setEditor({ preset, cloneName: name }); + } + async function remove(preset: SshPreset) { + if ( + !(await confirmAction({ + title: "Delete this preset?", + description: `Delete “${preset.name}”? Existing subshells will keep running.`, + confirmLabel: "Delete", + danger: true, + })) + ) + return; + try { + setError(null); + await apiFetch(`/api/presets/${encodeURIComponent(preset.id)}`, { method: "DELETE" }); + await invalidate(); + } catch (err) { + setError(errMessage(err, "Couldn’t delete this preset. Try again.")); + } + } + return ( + <> + {creating && } + {editor && setEditor(null)} />} + {error && ( +

+ {error} +

+ )} + + {query.isSuccess && !query.data.length && !creating && ( + + )} + {query.isSuccess && query.data.length > 0 && ( + + + SSH presets + + +
+ + + + + + + + + + + + {query.data.map((preset) => ( + + {/* The name opens the editor, same act as the menu's Edit. Not a + disclosure: this row already prints the preset's four facts, so + there is nothing the name could expand that Edit doesn't show. */} + + + + + + + ))} + +
+ Name + + Destination + + Connect from + + Authentication + + Actions +
+ + + {preset.destination} + + {nodes.data + ? (nodes.data.nodes.find((node) => node.id === preset.nodeId)?.name ?? "Node unavailable") + : ""} + + {SSH_AUTHENTICATION_LABELS[preset.authentication]} + + void navigate({ to: "/new", search: { kind: "ssh", preset: preset.id } }), + }, + { label: "Edit", icon: Pencil, onSelect: () => setEditor({ preset }) }, + { label: "Clone preset", icon: Copy, onSelect: () => clone(preset) }, + { + label: "Delete preset", + icon: Trash2, + destructive: true, + onSelect: () => void remove(preset), + }, + ]} + /> +
+
+
+
+ )} + + ); +} diff --git a/apps/server/web/src/components/presets/ssh-preset-name-field.tsx b/apps/server/web/src/components/presets/ssh-preset-name-field.tsx new file mode 100644 index 000000000..3d2a2829b --- /dev/null +++ b/apps/server/web/src/components/presets/ssh-preset-name-field.tsx @@ -0,0 +1,63 @@ +import { cn, Input } from "@internal/node-admin"; +import { useState } from "react"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { RequiredMark } from "@/components/ui/required-mark"; +import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone"; +import { SSH_PRESET_NAME_MAX, sshPresetNameProblem } from "@/lib/ssh-input-validation"; + +export function SshPresetNameField({ + id, + label, + value, + onChange, + disabled, + serverError, +}: { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + disabled?: boolean; + serverError?: string; +}) { + const [touched, setTouched] = useState(false); + const [focused, setFocused] = useState(false); + // Gold means "nothing typed yet" (ruling 2026-09-30): a caret-leave on an + // empty field is a requirement gap, not a refused value. A refused value or + // the server's refusal of the submit stays red. + const clientProblem = !focused && touched ? sshPresetNameProblem(value) : null; + const gap = clientProblem !== null && !value.trim(); + const error = serverError || clientProblem; + return ( + + + {label} + + + onChange(event.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => { + setTouched(true); + setFocused(false); + }} + /> + {error && ( + + )} + + ); +} diff --git a/apps/server/web/src/components/sidebar/sidebar-nav.ts b/apps/server/web/src/components/sidebar/sidebar-nav.ts index db9872550..5d95cbcf0 100644 --- a/apps/server/web/src/components/sidebar/sidebar-nav.ts +++ b/apps/server/web/src/components/sidebar/sidebar-nav.ts @@ -2,7 +2,6 @@ import { Activity, ArrowUpCircle, DatabaseBackup, - Fingerprint, KeyRound, LayoutDashboard, type LucideIcon, @@ -73,11 +72,6 @@ const NAV_ENTRIES: NavEntry[] = [ { to: "/presets", label: "Presets", icon: SlidersHorizontal, short: "Preset" }, // Beside Presets: both are saved launch material, one is settings, one is text. { to: "/prompts", label: "Prompts", icon: MessageSquareText, short: "Prompts" }, - // The owner's SSH relay ledger (spec 2026-10-08 §8): per-owner surface, - // so it sits in the personal group even though its path is under - // /settings - General lights only on an exact match, so a member's page - // never illuminates the admin group (sidebar-nav's own comment). - { to: "/settings/ssh", label: "SSH", icon: Fingerprint, short: "SSH" }, ], }, // On the label (spec 2026-09-11 §2.1). The single entry here used to read diff --git a/apps/server/web/src/components/ssh/__tests__/query-states.test.tsx b/apps/server/web/src/components/ssh/__tests__/query-states.test.tsx index cc0ab0f1a..c9ffe524a 100644 --- a/apps/server/web/src/components/ssh/__tests__/query-states.test.tsx +++ b/apps/server/web/src/components/ssh/__tests__/query-states.test.tsx @@ -8,7 +8,9 @@ import { RouterProvider, } from "@tanstack/react-router"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { SshSettingsPage } from "@/routes/settings_.ssh"; +import { SshPresetLibrary } from "@/components/presets/ssh-preset-library"; + +const SshSettingsPage = () => {}} creating={false} onCreatedClose={() => {}} />; const original = globalThis.fetch; afterEach(() => { @@ -16,7 +18,7 @@ afterEach(() => { globalThis.fetch = original; }); -test("loading and failed reads never claim an empty SSH settings ledger; retry recovers", async () => { +test("loading and failed reads never claim an empty SSH preset library; retry recovers", async () => { let respond!: () => void; const gate = new Promise((resolve) => { respond = resolve; @@ -27,7 +29,7 @@ test("loading and failed reads never claim an empty SSH settings ledger; retry r const path = String(input); if (path === "/api/nodes") return new Response(JSON.stringify({ nodes: [] })); if (failing) return new Response(JSON.stringify({ message: "Unavailable" }), { status: 503 }); - return new Response(JSON.stringify({ requests: [], grants: [], pins: [] })); + return new Response(JSON.stringify([])); }) as typeof fetch; const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const root = createRootRoute(); @@ -42,13 +44,13 @@ test("loading and failed reads never claim an empty SSH settings ledger; retry r , ); - expect(await screen.findByText("Loading saved destinations…")).toBeTruthy(); - expect(screen.queryByText("No saved destinations yet.")).toBeNull(); + expect(await screen.findByText("Loading SSH presets…")).toBeTruthy(); + expect(screen.queryByText("No SSH presets yet")).toBeNull(); expect(screen.queryByText("No recent destinations yet.")).toBeNull(); respond(); - expect(await screen.findByText("Could not load saved destinations.")).toBeTruthy(); - expect(screen.queryByText("No saved destinations yet.")).toBeNull(); + expect(await screen.findByText("Could not load SSH presets.")).toBeTruthy(); + expect(screen.queryByText("No SSH presets yet")).toBeNull(); failing = false; for (const button of screen.getAllByRole("button", { name: "Retry" })) fireEvent.click(button); - expect(await screen.findByText("No saved destinations yet.")).toBeTruthy(); + expect(await screen.findByText("No SSH presets yet")).toBeTruthy(); }); diff --git a/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx index 3d9fd4d13..0716166bc 100644 --- a/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx +++ b/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx @@ -45,7 +45,7 @@ function api() { }, ], } - : path === "/api/ssh/saved-hosts" + : path === "/api/ssh/recent-destinations" ? { saved: [], recent: [], defaultNodeId: "local" } : path === "/api/ssh/aliases" ? { aliases: [] } diff --git a/apps/server/web/src/hooks/__tests__/use-ssh.test.tsx b/apps/server/web/src/hooks/__tests__/use-ssh.test.tsx index e48096316..f141add0b 100644 --- a/apps/server/web/src/hooks/__tests__/use-ssh.test.tsx +++ b/apps/server/web/src/hooks/__tests__/use-ssh.test.tsx @@ -2,14 +2,14 @@ import { afterEach, describe, expect, it } from "bun:test"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; -import { useLaunchSsh, useSshAliases, useSshSavedHosts } from "@/hooks/use-ssh"; +import { useLaunchSsh, useSshAliases, useSshRecentDestinations } from "@/hooks/use-ssh"; /** * The keys and URLs this file states, asserted on the WIRE the way * `use-instance-plugins.test.tsx` asserts its invalidations — a hook that * stops refreshing cannot pass by invalidating some other key. * - * Three facts: the ledger fetches once under `["ssh-saved-hosts"]`; aliases + * Three facts: the ledger fetches once under `["ssh-recent-destinations"]`; aliases * are per-machine and gated (a null picker asks the node nothing); and a * successful launch refetches the ledger ONLY — the recency row moved, the * machine's config did not. @@ -46,15 +46,15 @@ const savedHosts = { saved: [], recent: [], defaultNodeId: null }; afterEach(cleanup); -describe("useSshSavedHosts", () => { - it("fetches the ledger once at /api/ssh/saved-hosts", async () => { - const { calls, restore } = mockFetch({ "GET /api/ssh/saved-hosts": () => json(savedHosts) }); +describe("useSshRecentDestinations", () => { + it("fetches the ledger once at /api/ssh/recent-destinations", async () => { + const { calls, restore } = mockFetch({ "GET /api/ssh/recent-destinations": () => json(savedHosts) }); try { const { wrapper } = makeWrapper(); - const { result } = renderHook(() => useSshSavedHosts(), { wrapper }); + const { result } = renderHook(() => useSshRecentDestinations(), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(calls.filter((c) => c.url === "/api/ssh/saved-hosts")).toEqual([ - { method: "GET", url: "/api/ssh/saved-hosts" }, + expect(calls.filter((c) => c.url === "/api/ssh/recent-destinations")).toEqual([ + { method: "GET", url: "/api/ssh/recent-destinations" }, ]); } finally { restore(); @@ -95,7 +95,7 @@ describe("useSshAliases", () => { describe("useLaunchSsh", () => { it("refetches the ledger and nothing else on success", async () => { const { calls, restore } = mockFetch({ - "GET /api/ssh/saved-hosts": () => json(savedHosts), + "GET /api/ssh/recent-destinations": () => json(savedHosts), "GET /api/ssh/aliases": () => json({ aliases: [], includeCycle: false, truncated: false }), "POST /api/ssh/launch": () => json({ subshell: { id: "s1" } }, 201), }); @@ -103,7 +103,7 @@ describe("useLaunchSsh", () => { const { wrapper } = makeWrapper(); // Both lists mounted as observers: only the ledger may move. const { result } = renderHook( - () => ({ launch: useLaunchSsh(), hosts: useSshSavedHosts(), aliases: useSshAliases("n1") }), + () => ({ launch: useLaunchSsh(), hosts: useSshRecentDestinations(), aliases: useSshAliases("n1") }), { wrapper, }, @@ -122,7 +122,7 @@ describe("useLaunchSsh", () => { function counts(calls: Call[]): { saved: number; aliases: number } { return { - saved: calls.filter((c) => c.url === "/api/ssh/saved-hosts").length, + saved: calls.filter((c) => c.url === "/api/ssh/recent-destinations").length, aliases: calls.filter((c) => c.url.startsWith("/api/ssh/aliases")).length, }; } diff --git a/apps/server/web/src/hooks/use-ssh-presets.ts b/apps/server/web/src/hooks/use-ssh-presets.ts new file mode 100644 index 000000000..f4542fb6a --- /dev/null +++ b/apps/server/web/src/hooks/use-ssh-presets.ts @@ -0,0 +1,35 @@ +import { apiFetch, apiPost } from "@internal/node-admin"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { PRESETS_QUERY_KEY } from "@/hooks/use-presets"; +import type { SshPreset, SshPresetPayload } from "@/types/preset"; + +export const SSH_PRESETS_QUERY_KEY = [...PRESETS_QUERY_KEY, "ssh"] as const; + +/** SSH choices never share the legacy agent-only cache entry. */ +export function useSshPresets() { + return useQuery({ + queryKey: SSH_PRESETS_QUERY_KEY, + queryFn: ({ signal }) => apiFetch("/api/presets?kind=ssh", { signal }), + }); +} + +/** Create or edit only explicitly projected connection settings. */ +export function useSaveSshPreset() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ id, payload }: { id?: string; payload: SshPresetPayload }) => { + if (!id) return apiPost("/api/presets", payload); + const { kind: _kind, ...update } = payload; + return apiFetch(`/api/presets/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify(update), + }); + }, + onSuccess: (row) => { + client.setQueryData(SSH_PRESETS_QUERY_KEY, (rows) => + rows ? [...rows.filter((p) => p.id !== row.id), row] : [row], + ); + void client.invalidateQueries({ queryKey: PRESETS_QUERY_KEY }); + }, + }); +} diff --git a/apps/server/web/src/hooks/use-ssh.ts b/apps/server/web/src/hooks/use-ssh.ts index 5ef2715fc..c3dd59ca4 100644 --- a/apps/server/web/src/hooks/use-ssh.ts +++ b/apps/server/web/src/hooks/use-ssh.ts @@ -6,32 +6,17 @@ import type { SshHostPin, SshLaunchRequest, SshLaunchResponse, - SshSavedHost, - SshSavedHostsView, - SshSaveHostRequest, + SshRecentDestinationsView, } from "@/lib/ssh"; -/** - * The SSH launcher's data (spec 2026-10-07 §7), following `use-prompts.ts`: - * one key per list, one URL per key, mutations invalidate rather than patch. - * - * The saved-hosts view carries the ledger AND the default-machine preference, - * so every mutation that can move either one invalidates `["ssh-saved-hosts"]` - * and nothing else: a launch touched the recency row server-side, a save or - * delete moves the ledger, the preference PATCH moves `defaultNodeId`. The - * aliases are the MACHINE's config, which none of these acts edits, so no - * mutation invalidates them — the machine disclosure refetches them only when - * the chosen machine changes. - */ - -export const SSH_SAVED_HOSTS_QUERY_KEY = ["ssh-saved-hosts"] as const; +export const SSH_RECENT_DESTINATIONS_QUERY_KEY = ["ssh-recent-destinations"] as const; export const sshAliasesQueryKey = (nodeId: string) => ["ssh-aliases", nodeId] as const; -/** The owner's saved + recent destinations and their default connecting machine. */ -export function useSshSavedHosts() { +/** The owner's recent destinations and their default connecting machine. */ +export function useSshRecentDestinations() { return useQuery({ - queryKey: SSH_SAVED_HOSTS_QUERY_KEY, - queryFn: () => apiFetch("/api/ssh/saved-hosts"), + queryKey: SSH_RECENT_DESTINATIONS_QUERY_KEY, + queryFn: () => apiFetch("/api/ssh/recent-destinations"), }); } @@ -49,35 +34,9 @@ export function useSshAliases(nodeId: string | null) { } /** Invalidate the ledger after an act that moved it (the one rule the mutations share). */ -function useInvalidateSavedHosts(): () => Promise { +function useInvalidateRecentDestinations(): () => Promise { const queryClient = useQueryClient(); - return () => queryClient.invalidateQueries({ queryKey: SSH_SAVED_HOSTS_QUERY_KEY }); -} - -/** - * `PUT /api/ssh/saved-hosts` — resolves the destination first, so a - * refusal-shaped outcome answers 422 `{outcome}` (an `ApiError` with status - * 422) and stores nothing. Both callers treat it best-effort: the connect - * panel's Remember act and the Recent-star act do not render that refusal, - * because a row already exists from the launch's server-side recency touch, - * so a failed save costs only the `savedAt` pin, never the destination. - */ -export function useSaveSshHost() { - const invalidate = useInvalidateSavedHosts(); - return useMutation({ - mutationFn: (draft: SshSaveHostRequest) => - apiFetch("/api/ssh/saved-hosts", { method: "PUT", body: JSON.stringify(draft) }), - onSuccess: () => void invalidate(), - }); -} - -/** `DELETE /api/ssh/saved-hosts/:id` — 204 no body; a foreign row is the same 404 as an absent one. */ -export function useDeleteSshHost() { - const invalidate = useInvalidateSavedHosts(); - return useMutation({ - mutationFn: (id: string) => apiFetch(`/api/ssh/saved-hosts/${encodeURIComponent(id)}`, { method: "DELETE" }), - onSuccess: () => void invalidate(), - }); + return () => queryClient.invalidateQueries({ queryKey: SSH_RECENT_DESTINATIONS_QUERY_KEY }); } /** @@ -87,7 +46,7 @@ export function useDeleteSshHost() { * there is the picker defaulting to the wrong machine. */ export function useSetDefaultNode() { - const invalidate = useInvalidateSavedHosts(); + const invalidate = useInvalidateRecentDestinations(); return useMutation({ mutationFn: (defaultNodeId: string | null) => apiFetch<{ defaultNodeId: string | null }>("/api/ssh/preferences", { @@ -105,7 +64,7 @@ export function useSetDefaultNode() { * untouched by the act. */ export function useLaunchSsh() { - const invalidate = useInvalidateSavedHosts(); + const invalidate = useInvalidateRecentDestinations(); return useMutation({ mutationFn: (draft: SshLaunchRequest) => apiPost("/api/ssh/launch", draft), onSuccess: () => void invalidate(), diff --git a/apps/server/web/src/lib/__tests__/api.test.ts b/apps/server/web/src/lib/__tests__/api.test.ts index a0401a3fd..af7f17a4c 100644 --- a/apps/server/web/src/lib/__tests__/api.test.ts +++ b/apps/server/web/src/lib/__tests__/api.test.ts @@ -28,10 +28,12 @@ describe("apiFetch error classes", () => { }); it("204 No Content resolves undefined — a body-less success is not a parse failure", async () => { - // The ssh saved-host DELETE answers 204 with NO body (a 204 may not carry - // one); parsing one is a throw, so the helper must return before it tries. + // Helper contract, not a live route: a 204 may not carry a body, and + // parsing one is a throw, so the helper must resolve undefined before it + // tries. No SSH route answers 204 today; any future body-less success + // rides this path. globalThis.fetch = (async () => new Response(null, { status: 204 })) as unknown as typeof fetch; - await expect(apiFetch("/api/ssh/saved-hosts/x")).resolves.toBeUndefined(); + await expect(apiFetch("/api/example-204")).resolves.toBeUndefined(); }); it("isNetworkError matches only NetworkError (plain errors stay fail-fast)", () => { diff --git a/apps/server/web/src/lib/__tests__/ssh-input-validation.test.ts b/apps/server/web/src/lib/__tests__/ssh-input-validation.test.ts new file mode 100644 index 000000000..5ead55629 --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh-input-validation.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from "bun:test"; +import { sshDestinationProblem, sshPresetNameProblem } from "@/lib/ssh-input-validation"; + +it("accepts SSH aliases, explicit users and ports, and bracketed IPv6", () => { + for (const value of ["build", "user@build", "user@build:2222", "[::1]", "user@[2001:db8::1]:65535"]) + expect(sshDestinationProblem(value)).toBeNull(); +}); +it("rejects incomplete destinations, unsafe tokens, invalid ports, and oversized input", () => { + for (const value of [ + "", + "@host", + "user@", + "user@@host", + "-host", + "user@-host", + "host name", + "host\nname", + "host:0", + "host:65536", + "host:abc", + "host:", + "[::1", + "[::1]oops", + "[::1]:", + "::1", + "a".repeat(254), + ]) + expect(sshDestinationProblem(value)).not.toBeNull(); +}); +it("validates names after whitespace checks without rejecting ordinary Unicode", () => { + for (const name of ["", " ", "\t", "name\nline", "a".repeat(121)]) + expect(sshPresetNameProblem(name)).not.toBeNull(); + for (const name of [" Build ", "レビュー", "a".repeat(120)]) expect(sshPresetNameProblem(name)).toBeNull(); +}); diff --git a/apps/server/web/src/lib/__tests__/ssh-local-key.test.ts b/apps/server/web/src/lib/__tests__/ssh-local-key.test.ts index 3b72b742f..44d717514 100644 --- a/apps/server/web/src/lib/__tests__/ssh-local-key.test.ts +++ b/apps/server/web/src/lib/__tests__/ssh-local-key.test.ts @@ -40,9 +40,10 @@ it("routes only public replies and closes both sides after a single signature", }) as typeof fetch; let launched: string | undefined; const result = await launchWithLocalKey( - { node: "n", destination: "alice@host" }, + { node: "n", destination: "original-alias", expectedDestination: "alice@host:22" }, async (request) => { expect(request.localKeyId).toBe("request"); + expect(request.expectedDestination).toBe("alice@host:22"); return { subshell: { id: "pane" } }; }, (id) => { @@ -52,6 +53,7 @@ it("routes only public replies and closes both sides after a single signature", ); expect(result?.subshell.id).toBe("pane"); expect(launched).toBe("pane"); + expect(bodies).toContainEqual({ node: "n", destination: "original-alias", expectedDestination: "alice@host:22" }); expect(bodies).toContainEqual({ reply: { seq: 0, payload: "Dg==" } }); expect(JSON.stringify(bodies)).not.toContain("native-handle"); expect(calls).toContain("desktop_ssh_key_close"); diff --git a/apps/server/web/src/lib/__tests__/ssh-preset.test.ts b/apps/server/web/src/lib/__tests__/ssh-preset.test.ts new file mode 100644 index 000000000..1fcc354e6 --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh-preset.test.ts @@ -0,0 +1,72 @@ +import { expect, it } from "bun:test"; +import { sshDraftFromPreset, sshPresetPayload } from "@/lib/ssh-preset"; +import type { SshPreset } from "@/types/preset"; + +const preset: SshPreset = { + kind: "ssh", + id: "p", + name: "Build", + description: null, + nodeId: "desk", + destination: "user@host:22", + alias: "build", + authentication: "remote", + keyHome: "keys", + fingerprints: ["SHA256:one"], + createdAt: "t", + updatedAt: "t", +}; +it("restores only the selected remote node's fingerprints into a fresh draft", () => { + const draft = sshDraftFromPreset(preset); + expect(draft).toMatchObject({ + nodeId: "desk", + authentication: "remote", + keyHome: "keys", + selections: { keys: ["SHA256:one"] }, + }); + expect(draft.saveAsPreset).toBeUndefined(); + draft.selections.keys.push("other"); + expect(preset.fingerprints).toEqual(["SHA256:one"]); +}); +it("projects the local-key preference without key, credential, or stale remote state", () => { + const draft = { + ...sshDraftFromPreset(preset), + authentication: "local-key" as const, + localKeyId: "secret", + filename: "id_ed25519", + passphrase: "secret", + }; + expect(sshPresetPayload(draft, " Local ")).toEqual({ + kind: "ssh", + name: "Local", + description: null, + nodeId: "desk", + destination: "user@host:22", + expectedDestination: "user@host:22", + alias: null, + authentication: "local-key", + keyHome: null, + fingerprints: [], + }); +}); +it.each(["connecting", "remote", "local-key", "password"] as const)( + "round trips %s authentication", + (authentication) => { + const payload = sshPresetPayload(sshDraftFromPreset({ ...preset, authentication }), "Build"); + expect(payload.authentication).toBe(authentication); + expect(payload.fingerprints).toEqual(authentication === "remote" ? preset.fingerprints : []); + }, +); +it("retains a missing node as a choice the user must complete", () => { + expect(sshDraftFromPreset({ ...preset, nodeId: null }).nodeId).toBe(""); +}); + +it("reuses the resolution alias with a canonical guard when applying and cloning", () => { + const draft = sshDraftFromPreset({ ...preset, resolutionAlias: "private-build" }); + expect(draft.destination.typed).toBe(preset.destination); + expect(sshPresetPayload(draft, "Clone")).toMatchObject({ + destination: preset.destination, + resolutionAlias: "private-build", + expectedDestination: preset.destination, + }); +}); diff --git a/apps/server/web/src/lib/ssh-connect-search.ts b/apps/server/web/src/lib/ssh-connect-search.ts index 91f7f183c..4c239a685 100644 --- a/apps/server/web/src/lib/ssh-connect-search.ts +++ b/apps/server/web/src/lib/ssh-connect-search.ts @@ -1,5 +1,6 @@ /** Connection choices carried through approval; URL values never trigger a launch. */ export function sshConnectSearch(search: Record): { + preset?: string; node?: string; destination?: string; keyHome?: string; @@ -7,6 +8,7 @@ export function sshConnectSearch(search: Record): { const text = (key: string, limit: number) => typeof search[key] === "string" && search[key].length <= limit ? search[key] : undefined; return { + preset: text("preset", 64), node: text("node", 64), destination: text("destination", 253), keyHome: text("keyHome", 64), diff --git a/apps/server/web/src/lib/ssh-input-validation.ts b/apps/server/web/src/lib/ssh-input-validation.ts new file mode 100644 index 000000000..237819e0f --- /dev/null +++ b/apps/server/web/src/lib/ssh-input-validation.ts @@ -0,0 +1,51 @@ +import { SSH_NAME_MAX_CHARS } from "@internal/subshell-protocol"; + +/** + * Mirror of the API's SSH preset `maxLength: 120`, shared by the validator and + * the field's `maxLength`. The field caps typing at this length, so the long + * branch below only answers for names set programmatically (a draft restored + * from elsewhere), where typing never passes through the input. + */ +export const SSH_PRESET_NAME_MAX = 120; + +export function sshPresetNameProblem(value: string): string | null { + if (!value.trim()) return "Enter a preset name."; + if (value.length > SSH_PRESET_NAME_MAX) return `Keep the preset name within ${SSH_PRESET_NAME_MAX} characters.`; + if (/\p{Cc}/u.test(value)) return "Remove control characters from the preset name."; + return null; +} + +/** Validate explicit input without guessing which aliases exist on the connecting node. */ +export function sshDestinationProblem(value: string): string | null { + if (!value) return "Enter an SSH destination."; + if (value.length > SSH_NAME_MAX_CHARS) return `Keep the SSH destination within ${SSH_NAME_MAX_CHARS} characters.`; + if (/\s|\p{Cc}/u.test(value)) return "Remove spaces and control characters from the SSH destination."; + const parts = value.split("@"); + if (parts.length > 2 || parts.some((part) => !part)) + return "Enter a hostname or SSH alias, optionally preceded by user@."; + const hostPort = parts[parts.length - 1]; + let host = hostPort; + let port: string | undefined; + if (hostPort.startsWith("[")) { + const close = hostPort.indexOf("]"); + if (close < 2) return "Put the IPv6 address inside brackets, for example [::1]:22."; + host = hostPort.slice(0, close + 1); + const tail = hostPort.slice(close + 1); + if (tail && !tail.startsWith(":")) return "Add a port after the closing bracket, for example [::1]:22."; + if (tail) port = tail.slice(1); + } else { + const colon = hostPort.indexOf(":"); + if (colon !== -1) { + host = hostPort.slice(0, colon); + port = hostPort.slice(colon + 1); + if (port.includes(":")) return "Put the IPv6 address inside brackets, for example [::1]:22."; + } + if (/[[\]]/.test(host)) return "Put the IPv6 address inside brackets, for example [::1]:22."; + } + if (!host) return "Enter a hostname or SSH alias before the port."; + if (host.startsWith("-") || value.startsWith("-")) + return "Enter a hostname or SSH alias that doesn’t start with a dash."; + if (port !== undefined && (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535)) + return "Enter a port between 1 and 65535."; + return null; +} diff --git a/apps/server/web/src/lib/ssh-local-key.ts b/apps/server/web/src/lib/ssh-local-key.ts index 18cd38b6e..ce3e5e2f1 100644 --- a/apps/server/web/src/lib/ssh-local-key.ts +++ b/apps/server/web/src/lib/ssh-local-key.ts @@ -52,6 +52,7 @@ export async function launchWithLocalKey( prepared = await apiPost("/api/ssh/local-key", { node: request.node, destination: request.destination, + ...(request.expectedDestination ? { expectedDestination: request.expectedDestination } : {}), }); check(); if (browser) { diff --git a/apps/server/web/src/lib/ssh-preset.ts b/apps/server/web/src/lib/ssh-preset.ts new file mode 100644 index 000000000..7f3e8abd6 --- /dev/null +++ b/apps/server/web/src/lib/ssh-preset.ts @@ -0,0 +1,41 @@ +import { type SshSessionDraft, sshDestination, sshSessionDraft } from "@/components/connect/ssh-session-draft"; +import type { SshPreset, SshPresetPayload } from "@/types/preset"; + +export const SSH_AUTHENTICATION_LABELS = { + connecting: "Connecting node’s keys", + remote: "Another node’s keys", + "local-key": "Key from my machine", + password: "Password", +} as const; + +/** Start from a clean draft so no previous key selection or save intent survives. */ +export function sshDraftFromPreset(preset: SshPreset): SshSessionDraft { + return { + ...sshSessionDraft({ node: preset.nodeId ?? "", destination: preset.destination }), + presetApplied: true, + expectedDestination: preset.destination, + resolutionAlias: preset.resolutionAlias ?? undefined, + authentication: preset.authentication, + keyHome: preset.authentication === "remote" ? (preset.keyHome ?? "") : "", + selections: + preset.authentication === "remote" && preset.keyHome ? { [preset.keyHome]: [...preset.fingerprints] } : {}, + }; +} + +/** Explicit projection prevents credentials and form-only state reaching persistence. */ +export function sshPresetPayload(draft: SshSessionDraft, name: string, description = ""): SshPresetPayload { + const authentication = draft.authentication ?? (draft.keyHome ? "remote" : "connecting"); + return { + kind: "ssh", + name: name.trim(), + description: description.trim() || null, + nodeId: draft.nodeId, + destination: draft.expectedDestination ?? sshDestination(draft), + ...(draft.resolutionAlias ? { resolutionAlias: draft.resolutionAlias } : {}), + ...(draft.expectedDestination ? { expectedDestination: draft.expectedDestination } : {}), + alias: draft.destination.pick?.aliasToken ?? null, + authentication, + keyHome: authentication === "remote" ? draft.keyHome : null, + fingerprints: authentication === "remote" ? (draft.selections[draft.keyHome] ?? []) : [], + }; +} diff --git a/apps/server/web/src/lib/ssh.ts b/apps/server/web/src/lib/ssh.ts index bd8e35060..4845c22f5 100644 --- a/apps/server/web/src/lib/ssh.ts +++ b/apps/server/web/src/lib/ssh.ts @@ -90,8 +90,8 @@ export type SshResolveOutcome = settings: string[]; }; -/** One saved/recency row as its owner sees it. */ -export interface SshSavedHost { +/** One recent destination row as its owner sees it. */ +export interface SshRecentDestination { /** Row id (uuid) */ id: string; /** Canonical host:port (or user@host:port), the resolved destination as key */ @@ -100,18 +100,14 @@ export interface SshSavedHost { alias: string | null; /** The connecting machine of the most recent launch to this destination */ nodeId: string; - /** ISO 8601 the human saved it; null = recency-only row */ - savedAt: string | null; /** ISO 8601 of the most recent launch to this destination */ lastConnectAt: string; } -/** The `GET /api/ssh/saved-hosts` body: the destination field's feed and the machine preference. */ -export interface SshSavedHostsView { - /** Rows a human saved, newest save first (max 20) */ - saved: SshSavedHost[]; - /** Most-recently-connected destinations, saved or not, newest first (max 20) */ - recent: SshSavedHost[]; +/** The `GET /api/ssh/recent-destinations` body: the destination field's feed and the machine preference. */ +export interface SshRecentDestinationsView { + /** Most-recently-connected destinations, newest first (max 20) */ + recent: SshRecentDestination[]; /** * The caller's default connecting machine, or null. A node deleted since * reads back as the stored id; the client decides. @@ -130,16 +126,6 @@ export interface SshAliasesView { truncated: boolean; } -/** `PUT /api/ssh/saved-hosts` body: the machine gates the resolve, the alias is a display override. */ -export interface SshSaveHostRequest { - /** Machine to resolve the destination on (the gate owner) */ - node: string; - /** Destination token (1..253): an alias from the machine's config or a concrete host */ - destination: string; - /** Display label override; absent keeps the resolved snapshot's own alias */ - alias?: string; -} - /** `POST /api/ssh/launch` body: name is the pane's display name, optional. */ export interface SshLaunchRequest { /** One-use native signing preparation, never key material. */ @@ -152,6 +138,8 @@ export interface SshLaunchRequest { node: string; /** Destination token (1..253), resolved on the machine before anything launches */ destination: string; + /** Preset’s canonical address, checked against current SSH configuration before effects. */ + expectedDestination?: string; /** Pane display name */ name?: string; } diff --git a/apps/server/web/src/routes/__tests__/presets-page.test.tsx b/apps/server/web/src/routes/__tests__/presets-page.test.tsx index 74b0b4ecb..4baaece95 100644 --- a/apps/server/web/src/routes/__tests__/presets-page.test.tsx +++ b/apps/server/web/src/routes/__tests__/presets-page.test.tsx @@ -182,25 +182,21 @@ describe("/presets page", () => { try { renderPage(); expect(await screen.findByRole("heading", { name: "Presets" })).toBeDefined(); - expect(screen.getByText("Saved launch settings, per agent")).toBeDefined(); + expect(screen.getByText("Saved launch settings for agents and SSH connections")).toBeDefined(); fireEvent.click(screen.getByRole("button", { name: "New preset" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "New agent preset" })); expect(await screen.findByRole("dialog", { name: "Create preset" })).toBeDefined(); } finally { restore(); } }); - it("empty account: the frozen empty state, naming the option of launching without one", async () => { + it("empty account: the agent empty state offers creation", async () => { const { restore } = mockFetch({ presets: [] }); try { renderPage(); - expect(await screen.findByText("No presets yet")).toBeDefined(); - expect( - screen.getByText( - "A preset is saved launch settings for one agent: env vars, flags, and whether its subshells restart themselves. You can always start a subshell without one.", - ), - ).toBeDefined(); - expect(screen.getByRole("button", { name: "Create your first preset" })).toBeDefined(); + expect(await screen.findByText("No agent presets yet")).toBeDefined(); + expect(screen.getByRole("button", { name: "Create your first agent preset" })).toBeDefined(); } finally { restore(); } diff --git a/apps/server/web/src/routes/__tests__/settings-ssh.test.tsx b/apps/server/web/src/routes/__tests__/settings-ssh.test.tsx index 37e590478..d8c3b32a3 100644 --- a/apps/server/web/src/routes/__tests__/settings-ssh.test.tsx +++ b/apps/server/web/src/routes/__tests__/settings-ssh.test.tsx @@ -1,57 +1,21 @@ -import { afterEach, expect, it } from "bun:test"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, - RouterProvider, -} from "@tanstack/react-router"; -import { cleanup, render, screen } from "@testing-library/react"; -import { Route, SshSettingsPage } from "../settings_.ssh"; +import { expect, it } from "bun:test"; +import { Route } from "../settings_.ssh"; -/** Personal SSH settings expose saved destinations. */ -afterEach(cleanup); - -it("declares the route with a component (the path binds in the generated tree)", () => { - expect(typeof Route.options.component).toBe("function"); -}); - -it("opens on the SSH heading and saved destinations", async () => { - const original = globalThis.fetch; - globalThis.fetch = (async (input: unknown) => { - const path = new URL(String(input), "http://localhost").pathname; - const body = - path === "/api/ssh/host-pins" - ? { pins: [] } - : path === "/api/ssh/saved-hosts" - ? { saved: [], recent: [], defaultNodeId: null } - : []; - return new Response(JSON.stringify(body), { status: 200 }); - }) as typeof globalThis.fetch; +it("redirects the old settings page to SSH presets", () => { + // The throw is the redirect (TanStack's `redirect` throws its descriptor). + // Capture it into a variable: a `throw` inside the `try` would be caught by + // the same `catch`, so a missing redirect would fail as a confusing match on + // `undefined` instead of naming the real problem. + let redirect: unknown = null; try { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const rootRoute = createRootRoute(); - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/settings/ssh", - component: SshSettingsPage, - }); - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute]), - history: createMemoryHistory({ initialEntries: ["/settings/ssh"] }), - defaultPreload: false, - }); - await router.load(); - render( - - - , - ); - expect(await screen.findByRole("heading", { name: "SSH" })).toBeDefined(); - expect(await screen.findByText("Saved destinations")).toBeDefined(); - expect(screen.queryByText("Saved host keys") === null).toBe(true); - } finally { - globalThis.fetch = original; + (Route.options.beforeLoad as () => void)(); + } catch (thrown) { + redirect = thrown; } + expect(redirect).not.toBeNull(); + expect((redirect as { options: unknown }).options).toMatchObject({ + to: "/presets", + search: { type: "ssh" }, + replace: true, + }); }); diff --git a/apps/server/web/src/routes/presets.tsx b/apps/server/web/src/routes/presets.tsx index dc179476c..0ffd3ab65 100644 --- a/apps/server/web/src/routes/presets.tsx +++ b/apps/server/web/src/routes/presets.tsx @@ -1,223 +1,86 @@ -import { apiFetch, Button, Card, CardContent, CardHeader, confirmAction, errMessage } from "@internal/node-admin"; +import { Button } from "@internal/node-admin"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { Copy, Pencil, Plus, SlidersHorizontal, Trash2 } from "lucide-react"; -import { useMemo, useState } from "react"; +import { Plus, SlidersHorizontal, Terminal } from "lucide-react"; +import { useState } from "react"; import { DashboardPage } from "@/components/dashboard-page"; -import { EmptyState } from "@/components/empty-state"; -import { ErrorBanner } from "@/components/error-banner"; -import { LoadingCard } from "@/components/loading-card"; import { PageHeader } from "@/components/page-header"; -import { PluginIcon } from "@/components/plugin-icon"; -import { CreatePresetDialog } from "@/components/presets/create-preset-dialog"; -import { PresetTableRow } from "@/components/presets/preset-table-row"; -import { useInstancePlugins } from "@/hooks/use-instance-plugins"; -import { useInvalidatePresets, usePresets } from "@/hooks/use-presets"; -import { presetFormFromRow, suggestCloneName } from "@/lib/preset-form"; -import type { PresetRow } from "@/types/preset"; +import { AgentPresetLibrary } from "@/components/presets/agent-preset-library"; +import { SshPresetLibrary } from "@/components/presets/ssh-preset-library"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Segmented } from "@/components/ui/segmented"; export const Route = createFileRoute("/presets")({ component: PresetsPage, + validateSearch: (search: Record): { type?: "ssh" } => (search.type === "ssh" ? { type: "ssh" } : {}), }); -/** Presets under their agent's header, agents in catalog order. Rows whose - * harness is not (or no longer) in the catalog group last, keyed by id, - * rather than vanishing with the plugin view that failed to name them. */ -function groupByHarness(presets: PresetRow[], pluginOrder: string[]): [string, PresetRow[]][] { - const byHarness = new Map(); - for (const p of presets) { - const bucket = byHarness.get(p.harnessId); - if (bucket) bucket.push(p); - else byHarness.set(p.harnessId, [p]); - } - const ordered: [string, PresetRow[]][] = []; - for (const id of pluginOrder) { - const bucket = byHarness.get(id); - if (bucket) { - ordered.push([id, bucket]); - byHarness.delete(id); - } - } - for (const [id, bucket] of byHarness) ordered.push([id, bucket]); - return ordered; -} - +/** URL state, the header menu, and the tab; each tab's library owns its + * rows, dialogs, and delete flow (the route stays composition only). */ function PresetsPage() { const navigate = useNavigate(); - const invalidate = useInvalidatePresets(); - const { data: presets, isLoading, isError, refetch } = usePresets(); - const { data: pluginData } = useInstancePlugins(); - const plugins = pluginData?.plugins ?? []; + const { type } = Route.useSearch(); + const active = type ?? "agent"; const [showCreate, setShowCreate] = useState(false); - // The row whose Clone dialog is open; the dialog mounts only while set - // (clone-dialog posture), so every open re-seeds from the then-current row. - const [cloneSource, setCloneSource] = useState(null); - // The delete failure belongs on the page — with the row menu closed and no - // dialog up, anywhere else would render a failed delete nowhere at all. - const [listError, setListError] = useState(null); - const groups = useMemo( - () => - groupByHarness( - presets ?? [], - plugins.map((p) => p.id), - ), - [presets, plugins], - ); - - async function deletePreset(id: string) { - const ok = await confirmAction({ - title: "Delete this preset?", - confirmLabel: "Delete", - danger: true, - }); - if (!ok) return; - try { - setListError(null); - await apiFetch(`/api/presets/${id}`, { method: "DELETE" }); - await invalidate(); - } catch (err) { - setListError(errMessage(err, "Failed to delete preset")); - } + function createPreset(kind: "agent" | "ssh") { + setShowCreate(true); + void navigate({ to: "/presets", search: kind === "ssh" ? { type: "ssh" } : {} }); } return ( setShowCreate(true)}> - New preset - + + }> + New preset + + + createPreset("agent")}> + New agent preset + + createPreset("ssh")}> + New SSH preset + + + } /> - {listError &&

{listError}

} - - {/* A dialog rather than a card that pushes the list down the page - (user report 2026-09-11): creating a preset is a decision with an - end, and the list behind it is the thing being added to. Same shape - as every other "new X" in the app — the launch dialog, the - workspace dialog, Add a node. The create dialog and the inline one - in the launch form are the SAME component, unlocked here. */} - {showCreate && !next && setShowCreate(false)} />} - - {cloneSource && ( - !next && setCloneSource(null)} - initialForm={{ - ...presetFormFromRow(cloneSource), - name: suggestCloneName(presets ?? [], cloneSource), - }} - /> - )} - - {isLoading && } - - {/* A failed list load is not an empty account: say so, and offer the - retry the old silent blank denied. */} - {isError && ( - void refetch()} - > - Retry - - } + { + setShowCreate(false); + void navigate({ to: "/presets", search: next === "ssh" ? { type: "ssh" } : {} }); + }} + /> + {active === "ssh" ? ( + createPreset("ssh")} + onCreatedClose={() => setShowCreate(false)} /> - )} - - {!isLoading && !isError && presets?.length === 0 && !showCreate && ( - setShowCreate(true)} + ) : ( + createPreset("agent")} + onCreatedClose={() => setShowCreate(false)} /> )} - - {/* Rows grouped under their agent (icon + plugin name, fallback - harnessId — the same reading grammar everywhere the catalog might - not know a row's harness). The binary for the launch-command - preview comes from the plugin row; an unknown harness falls back to - its id so the row still renders something honest. */} - {groups.length > 0 && ( -
- {groups.map(([harnessId, rows]) => { - const plugin = plugins.find((p) => p.id === harnessId); - return ( - - {/* A real heading, not styled text: the e2e group-header probe - queries by role because a presetless row's launch-command - preview renders the bare harness id — text matching would - collide. PageHeader carries the h1; these are the h2s. */} - - -

{plugin?.name ?? harnessId}

-
- -
- - - - - - - - - - - {rows.map((p) => ( - void navigate({ to: "/presets/$id", params: { id: p.id } }), - }, - { - label: "Clone preset", - icon: Copy, - onSelect: () => setCloneSource(p), - }, - { - label: "Delete preset", - icon: Trash2, - destructive: true, - onSelect: () => void deletePreset(p.id), - }, - ]} - /> - ))} - -
- Name - - Auto-restart - - Cross-agent use - - Actions -
-
-
-
- ); - })} -
- )}
); } diff --git a/apps/server/web/src/routes/settings_.ssh.tsx b/apps/server/web/src/routes/settings_.ssh.tsx index 1df54c518..f94e37935 100644 --- a/apps/server/web/src/routes/settings_.ssh.tsx +++ b/apps/server/web/src/routes/settings_.ssh.tsx @@ -1,24 +1,8 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { SavedHostsSection } from "@/components/connect/saved-hosts-section"; -import { DashboardPage } from "@/components/dashboard-page"; -import { PageHeader } from "@/components/page-header"; -import { sshConnectSearch } from "@/lib/ssh-connect-search"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +/** Old bookmarks lead to the SSH preset library. */ export const Route = createFileRoute("/settings_/ssh")({ - validateSearch: sshConnectSearch, - component: SshSettingsRoute, + beforeLoad: () => { + throw redirect({ to: "/presets", search: { type: "ssh" }, replace: true }); + }, }); - -/** Personal saved and recent destinations, scoped to the caller. */ -function SshSettingsRoute() { - return ; -} - -export function SshSettingsPage() { - return ( - - - - - ); -} diff --git a/apps/server/web/src/types/preset.ts b/apps/server/web/src/types/preset.ts index 8416b4216..c2a856ac6 100644 --- a/apps/server/web/src/types/preset.ts +++ b/apps/server/web/src/types/preset.ts @@ -4,7 +4,8 @@ * (apps/server/api/src/api/models.ts): it carries the fields the frontend uses * and omits `userId`. */ -export interface PresetRow { +export interface AgentPreset { + kind: "agent"; id: string; harnessId: string; name: string; @@ -28,3 +29,42 @@ export interface PresetRow { /** ISO 8601 update timestamp */ updatedAt: string; } + +/** Legacy agent consumers use the agent-only list endpoint. */ +export type PresetRow = Omit & { kind?: "agent" }; + +export type SshAuthentication = "connecting" | "remote" | "local-key" | "password"; + +/** Saved connection choices only. Credentials and approvals never belong here. */ +export interface SshPreset { + kind: "ssh"; + /** Preset id */ + id: string; + /** Display name, unique per owner */ + name: string; + /** Optional longer description */ + description: string | null; + /** Connecting node; null after that node's deletion */ + nodeId: string | null; + /** Canonical resolved destination (`host:port` / `user@host:port`), never a raw typed token */ + destination: string; + /** Display-only alias from the resolved snapshot; never used to re-resolve */ + alias: string | null; + /** Original SSH token, reused only under the saved canonical destination guard. */ + resolutionAlias?: string | null; + /** How the connection authenticates; the mode alone is saved, never a secret */ + authentication: SshAuthentication; + /** Node providing the selected keys for `remote` authentication; null otherwise */ + keyHome: string | null; + /** Selected public-key fingerprints (remote mode only); private keys stay on their machine */ + fingerprints: string[]; + /** ISO 8601 creation timestamp */ + createdAt: string; + /** ISO 8601 update timestamp */ + updatedAt: string; +} + +export type SshPresetPayload = Pick< + SshPreset, + "name" | "description" | "destination" | "alias" | "resolutionAlias" | "authentication" | "keyHome" | "fingerprints" +> & { kind: "ssh"; nodeId: string; expectedDestination?: string }; diff --git a/apps/website/components/feature-showcase.tsx b/apps/website/components/feature-showcase.tsx index a1daa3c1e..237d8215b 100644 --- a/apps/website/components/feature-showcase.tsx +++ b/apps/website/components/feature-showcase.tsx @@ -176,6 +176,30 @@ export function FeatureShowcase() {
+
+
+

SSH

+

+ Open a remote shell from your dashboard. +

+
+
+

+ Connect over SSH and work in the terminal alongside your agents. Use keys from a connected node, choose a + key from your own machine, or enter a password when connecting. Save frequently used connections as SSH + presets. +

+
+ Explore SSH connections +
+
+
diff --git a/docs/security.md b/docs/security.md index 4b60e64ae..e124688d7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1218,8 +1218,18 @@ Its saved metadata records that authentication was single-use. A later **Set up Subshell here** request refuses instead of falling back to the connecting node's credentials. Restart still requires a new SSH connection. -The connection form is **New subshell → SSH terminal**. **Settings → SSH** -manages saved and recent destinations; it has no host-key editor. Node Overview's +The connection form is **New subshell → SSH terminal**. **Settings → Presets → SSH** +manages saved connection choices; it has no host-key editor. SSH presets retain +the resolved destination, original SSH resolution token, and authentication choice. +The token preserves alias-scoped routing and key settings; an optional +`expectedDestination` constraint checks the resolved username, host, and port +before save/clone, local-key preparation, trust discovery/acceptance, or launch +effects. Preset application supplies its saved canonical destination, and a +deliberate destination edit releases that constraint. Another node’s selected +key fingerprints are saved and rechecked at launch; a key from the user’s +machine is selected afresh. Presets never store private keys, passphrases, +local key filenames, signing identifiers, or destination approvals. Selecting +a preset fills the form without starting a connection. Node Overview's **SSH → Allow SSH** switch controls outgoing connections and supplying agent keys. It does not configure `sshd` or grant incoming SSH access. @@ -1228,7 +1238,8 @@ keys. It does not configure `sshd` or grant incoming SSH access. | `/api/ssh/readiness`, `/aliases`, `/resolve`, `/identities` | Browser session required. Node discovery and operations follow the caller's node access and the applicable readiness gates; alias/configuration data and public agent identities are not anonymous information. | | `POST /api/ssh/launch` | Browser session and launch access to the connecting node; relays also require launch access to the key source. Both participants must pass SSH and readiness checks. | | `PUT /api/nodes/:id/ssh-enabled` | Browser session and node owner, or administrator for the built-in node. An edit share alone cannot enable SSH. The setting can be changed while a node is offline and is reconciled on reconnect. | -| `/api/ssh/saved-hosts` and `/preferences` | Browser session, scoped to the caller. Saved suggestions are separate from destination trust; forgetting a suggestion neither removes its host pin nor closes a session. | +| `/api/ssh/recent-destinations` and `/preferences` | Browser session, scoped to the caller. Recent suggestions and the default connecting node are separate from destination trust. | +| `/api/presets` with SSH kind | Browser session, scoped to the caller. Saving resolves the destination through the existing node gate and checks access to any key node; it does not enable SSH or approve trust. Launch rechecks access, readiness, fingerprints, and trust. Deleting a preset neither removes its host pin nor closes a subshell. | | `/api/ssh/verify-destination` and `/verify-destination/accept` | Browser session, current access checks, and the one-use confirmation binding described above. These are destination network probes and trust writes, not login attempts. | | `/api/ssh/host-pins` | Browser session, owner-scoped compatibility API for explicit recovery. Creation refuses a conflicting key; deletion is a deliberate trust reset. Removing the UI did not remove this API. | | Terminal WebSocket input and REST input/exec | SSH input requires the subshell owner's cookie identity. Edit grantees and all bearer actors are refused, including the pane's own token. Output still follows ordinary view grants. | @@ -2600,7 +2611,7 @@ Audit events are written, grouped by family: dialed out to which canonical `host:port` and the pane that came of it, and never a config value, path list, or key material (§13). It is written AFTER the pane spawned, success only, the same posture as `subshell.create`, and - saved-host CRUD audits nothing (the prompts precedent: a preference row is + SSH preset CRUD audits nothing (the prompts precedent: a preference row is not an event). - **The server process**: `server.config.update`, `server.restart`, `server.logging.update`, `server.autostart.update`, `server.update`, and diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index a90d3d059..5f85f282f 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -142,16 +142,15 @@ are changed. Host keys are generated under the test's temporary directory and trusted explicitly by the connecting process. The browser exercises key login, password prompts and retry, terminal I/O, -view-only sharing, saving distinct usernames on one host, forgetting and -re-saving destinations. Password checks -cover HTTP bodies, terminal history, saved-host responses, and database bytes; +view-only sharing, and saving named SSH presets for distinct usernames on one host. Password checks +cover HTTP bodies, terminal history, preset responses, and database bytes; Playwright failure traces can still contain fixture passwords typed into the terminal, so these are disposable test credentials. Specs 22 and 23-ssh-relay retain their host-process fixtures for terminal lifecycle and cross-node cryptographic relay coverage. -Spec 24 covers the browser’s first-connection confirmation and cancellation; +Spec 24 covers SSH preset CRUD, restoring authentication choices, save-before-launch retries, and the browser’s first-connection confirmation and cancellation; spec 23-ssh-relay checks real discovery and acceptance before relay authentication. Spec 25 runs the browser signer against a real sshd on the host (not Testcontainers): it needs `ssh`, `sshd` and `ssh-keygen` present, and it skips @@ -168,11 +167,11 @@ a disposable SSH agent, and a Testcontainers destination. Docker and the Playwright Chromium browser are required. The normal `E2E_PORT_*` overrides apply when another suite is running. -The script writes two cropped PNGs to `apps/docs/public/screenshots/`, using -real form state and a real first-connection refusal. It cancels confirmation -and checks that no destination trust was saved, then cleans up its processes -and temporary state. Review the images and update their intrinsic dimensions -in `guides/ssh.mdx` if the layout changes. Never point it at the live server. +The script writes one cropped PNG to `apps/docs/public/screenshots/`, from a +real first-connection refusal. It cancels confirmation and checks that no +destination trust was saved, then cleans up its processes and temporary state. +Review the image and update its intrinsic dimensions in `guides/ssh.mdx` if the +layout changes. Never point it at the live server. For the other documentation screenshots, run `bun e2e/capture-docs.ts` after building the SPA. It uses the same isolated stack with sample presets, prompts, diff --git a/e2e/capture-docs.ts b/e2e/capture-docs.ts index 72925e9fb..62fd77ef5 100644 --- a/e2e/capture-docs.ts +++ b/e2e/capture-docs.ts @@ -101,6 +101,7 @@ try { await expect(page.getByRole("button", { name: "Repository review", exact: true })).toBeVisible(); await capture(page, "preset-library", page.locator("main h1"), card.last()); await page.getByRole("button", { name: "New preset", exact: true }).click(); + await page.getByRole("menuitem", { name: "New agent preset", exact: true }).click(); await page.locator("#preset-harness").click(); await page.getByRole("option", { name: "Codex", exact: true }).click(); await page.locator("#preset-name").fill("Repository review"); diff --git a/e2e/capture-ssh-docs.ts b/e2e/capture-ssh-docs.ts index ba45063fa..71934e69a 100644 --- a/e2e/capture-ssh-docs.ts +++ b/e2e/capture-ssh-docs.ts @@ -99,17 +99,7 @@ try { await page.goto(`${BASE_URL}/connect`); await page.getByRole("combobox", { name: "Connect from", exact: true }).click(); await page.getByRole("option", { name: "Build node", exact: true }).click(); - await page.getByLabel("SSH destination", { exact: true }).fill("developer@build.example.com:22"); - await page.getByLabel("Remember this destination", { exact: true }).check(); - await page.getByRole("heading", { name: "New subshell", exact: true }).click(); - await capture( - page, - "ssh-connection.png", - page.getByRole("combobox", { name: "Connect from" }).locator(".."), - page.getByLabel("Remember this destination").locator("../.."), - ); await page.getByLabel("SSH destination", { exact: true }).fill("build"); - await page.getByLabel("Remember this destination", { exact: true }).uncheck(); await page.getByRole("radio", { name: "Use another node’s keys", exact: true }).check(); await page.getByRole("combobox", { name: "Use keys from", exact: true }).click(); await page.getByRole("option", { name: "Personal node", exact: true }).click(); diff --git a/e2e/tests/04-presets.spec.ts b/e2e/tests/04-presets.spec.ts index 686bafd92..f13f6bfb1 100644 --- a/e2e/tests/04-presets.spec.ts +++ b/e2e/tests/04-presets.spec.ts @@ -40,9 +40,10 @@ test("create a preset from the page, verify it via the API, delete it", async ({ await page.goto("/presets"); // A fresh user has zero presets: the page opens on its empty state, not a // list of Defaults. - await expect(page.getByText("No presets yet")).toBeVisible(); + await expect(page.getByText("No agent presets yet")).toBeVisible(); await page.getByRole("button", { name: "New preset" }).click(); + await page.getByRole("menuitem", { name: "New agent preset", exact: true }).click(); // Unlocked create dialog (the nested launch-form variant locks the agent). await expect(page.getByRole("heading", { name: "Create preset" })).toBeVisible(); // The harness trigger carries the stable id `#preset-harness` — locating by @@ -124,7 +125,7 @@ test("create a preset from the page, verify it via the API, delete it", async ({ await expect(page.getByText("Delete this preset?")).toBeVisible(); await page.getByRole("button", { name: "Delete" }).click(); await expect(page.getByText("E2E shell")).toHaveCount(0); - await expect(page.getByText("No presets yet")).toBeVisible(); + await expect(page.getByText("No agent presets yet")).toBeVisible(); }); test(`the launch form's "Save as preset" makes a preset from the form`, async ({ page }) => { @@ -186,6 +187,7 @@ test(`the launch form's "Save as preset" makes a preset from the form`, async ({ test("clone a preset from the row's action menu", async ({ page }) => { await page.goto("/presets"); await page.getByRole("button", { name: "New preset" }).click(); + await page.getByRole("menuitem", { name: "New agent preset", exact: true }).click(); await page.locator("#preset-harness").click(); await page.getByRole("option", { name: "pi", exact: true }).click(); await page.fill("#preset-name", "E2E clone source"); diff --git a/e2e/tests/23-connect-flow.spec.ts b/e2e/tests/23-connect-flow.spec.ts index 9e8ef545b..9b17ef662 100644 --- a/e2e/tests/23-connect-flow.spec.ts +++ b/e2e/tests/23-connect-flow.spec.ts @@ -450,9 +450,8 @@ test("password login prompts in the terminal, retries a wrong password, and save await openConnectPage(page); await page.getByLabel("SSH destination", { exact: true }).fill(destination); await page.getByRole("radio", { name: "Enter a password when connecting", exact: true }).check(); - await page - .getByRole("checkbox", { name: "Remember this destination", exact: true }) - .setChecked(user === "developer"); + await page.getByRole("checkbox", { name: "Save as preset", exact: true }).check(); + await page.getByLabel("Preset name", { exact: true }).fill(`${user} terminal`); await page.getByRole("button", { name: "Start SSH subshell", exact: true }).click(); await expect(page).toHaveURL(/\/subshells\/[a-zA-Z0-9_-]+$/, { timeout: READY_TIMEOUT }); const id = new URL(page.url()).pathname.split("/").pop(); @@ -479,7 +478,7 @@ test("password login prompts in the terminal, retries a wrong password, and save await expect.poll(readLog, { timeout: READY_TIMEOUT }).toContain("PASSWORD-9-OK"); expect(await readLog()).not.toContain(fx.password); expect(await readLog()).not.toContain("incorrect-fixture-password"); - const saved = await api.get("/api/ssh/saved-hosts"); + const saved = await api.get("/api/presets?kind=ssh"); expect(JSON.stringify(await saved.json())).not.toContain(fx.password); } finally { await api.post(`/api/subshells/${id}/terminate`); @@ -491,27 +490,25 @@ test("password login prompts in the terminal, retries a wrong password, and save const dbPath = path.join(instance.dir, file); if (existsSync(dbPath)) expect(readFileSync(dbPath).includes(Buffer.from(fx.password))).toBe(false); } - await page.goto(`${ORIGIN}/settings/ssh`); - const operatorDestination = `operator@${fx.host}:${fx.port}`; - await page.getByRole("button", { name: `Remember ${operatorDestination}`, exact: true }).click(); - await expect(page.getByRole("button", { name: `Forget ${operatorDestination}`, exact: true })).toBeVisible(); - const saved = await api.get("/api/ssh/saved-hosts"); - const ledger = await saved.json(); - expect(ledger.saved.map((row: { destination: string }) => row.destination).sort()).toEqual( + await page.goto(`${ORIGIN}/presets?type=ssh`); + const saved = await api.get("/api/presets?kind=ssh"); + const presets = await saved.json(); + expect(presets.map((row: { destination: string }) => row.destination).sort()).toEqual( ["developer", "operator"].map((user) => `${user}@${fx.host}:${fx.port}`).sort(), ); - for (const user of ["developer", "operator"]) { - const destination = `${user}@${fx.host}:${fx.port}`; - await expect(page.getByText(destination, { exact: true })).toBeVisible(); - await page.getByRole("button", { name: `Forget ${destination}`, exact: true }).click(); - await expect(page.getByText(destination, { exact: true })).toHaveCount(0); + for (const preset of presets) { + expect(preset.authentication).toBe("password"); + await expect(page.getByRole("button", { name: preset.name, exact: true })).toBeVisible(); + const removed = await api.delete(`/api/presets/${preset.id}`); + expect(removed.ok()).toBe(true); } }); -test("SSH settings has no manual host-key management", async ({ page }) => { +test("legacy SSH settings redirects to presets without manual host-key management", async ({ page }) => { await login(page, ADMIN.email, ADMIN.password); await page.goto(`${ORIGIN}/settings/ssh`); - await expect(page.getByText("Saved destinations", { exact: true })).toBeVisible(); + await expect(page).toHaveURL(/\/presets\?type=ssh$/); + await expect(page.getByText("No SSH presets yet", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Add host key", exact: true })).toHaveCount(0); await expect(page.getByText("Saved host keys", { exact: true })).toHaveCount(0); }); diff --git a/e2e/tests/23-disabled-controls.spec.ts b/e2e/tests/23-disabled-controls.spec.ts index e67b3905c..80675505f 100644 --- a/e2e/tests/23-disabled-controls.spec.ts +++ b/e2e/tests/23-disabled-controls.spec.ts @@ -117,6 +117,7 @@ test.describe("touch explanations", () => { test("a disabled submit explains missing input above its dialog", async ({ page }) => { await page.goto("/presets"); await page.getByRole("button", { name: "New preset", exact: true }).click(); + await page.getByRole("menuitem", { name: "New agent preset", exact: true }).click(); const submit = page.getByRole("dialog").getByRole("button", { name: "Create preset", exact: true }); await expect(submit).toBeDisabled(); await submit.hover(); diff --git a/e2e/tests/24-ssh-ux.spec.ts b/e2e/tests/24-ssh-ux.spec.ts index 291ec03c4..21b0976e3 100644 --- a/e2e/tests/24-ssh-ux.spec.ts +++ b/e2e/tests/24-ssh-ux.spec.ts @@ -50,7 +50,7 @@ async function wire(page: Page, ready = true) { })), }, }); - if (path.endsWith("/saved-hosts")) return route.fulfill({ json: { saved: [], recent: [], defaultNodeId: "desk" } }); + if (path.endsWith("/recent-destinations")) return route.fulfill({ json: { recent: [], defaultNodeId: "desk" } }); if (path.endsWith("/aliases")) return route.fulfill({ json: { aliases: [], includeCycle: false, truncated: false } }); if (path.endsWith("/launch")) { @@ -65,7 +65,35 @@ async function wire(page: Page, ready = true) { if (path.endsWith("/host-pins")) return route.fulfill({ json: { pins: [] } }); return route.continue(); }); - return { launches, enables, nodes }; + const presets: Record[] = []; + const saves: unknown[] = []; + // A predicate, not a glob: in URL globs `?` is a single-character wildcard. + await page.route( + (url) => url.pathname === "/api/presets" && url.searchParams.get("kind") === "ssh", + (route) => route.fulfill({ json: presets }), + ); + await page.route("**/api/presets", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + const payload = route.request().postDataJSON(); + saves.push(payload); + const preset = { ...payload, id: `ssh-${saves.length}` }; + presets.push(preset); + return route.fulfill({ json: preset }); + }); + await page.route("**/api/presets/*", async (route) => { + const id = new URL(route.request().url()).pathname.split("/").pop(); + const index = presets.findIndex((preset) => preset.id === id); + if (route.request().method() === "DELETE") { + presets.splice(index, 1); + return route.fulfill({ json: { ok: true } }); + } + if (route.request().method() === "PUT") { + presets[index] = { ...presets[index], ...route.request().postDataJSON() }; + return route.fulfill({ json: presets[index] }); + } + return route.continue(); + }); + return { launches, enables, nodes, presets, saves }; } test("connection form reveals remote keys inline and launches only the selected keys", async ({ page }) => { @@ -129,45 +157,72 @@ test("polls while waiting for the owner to enable SSH", async ({ page }) => { expect(enables).toHaveLength(0); }); -test("SSH settings keeps empty and populated cards usable at narrow and wide widths", async ({ page }) => { +test("legacy SSH settings opens the SSH preset library", async ({ page }) => { await wire(page); await page.goto("/settings/ssh"); - await expect(page.getByText("No saved destinations yet.")).toBeVisible(); - await expect(page.getByText("No recent destinations yet.")).toBeVisible(); - const longDestination = `developer@${"long-host-".repeat(12)}.example.com:2222`; - await page.route("**/api/ssh/saved-hosts", (route) => - route.fulfill({ - json: { - saved: [ - { - id: "saved", - destination: longDestination, - alias: "Production", - nodeId: "desk", - savedAt: "2026-10-01T00:00:00Z", - lastConnectAt: "2026-10-01T00:00:00Z", - }, - ], - recent: [], - defaultNodeId: "desk", - }, - }), - ); - await page.reload(); - await expect(page.getByText(longDestination, { exact: true })).toBeVisible(); - for (const width of [320, 1600]) { - await page.setViewportSize({ width, height: 1000 }); - for (const name of ["Forget Production"]) { - const button = page.getByRole("button", { name, exact: true }); - await expect(button).toBeVisible(); - const bounds = await button.boundingBox(); - if (!bounds) throw new Error(`${name} has no visible bounds`); - expect(bounds.x).toBeGreaterThanOrEqual(0); - expect(bounds.x + bounds.width).toBeLessThanOrEqual(width); - } - expect(await page.locator("main").evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true); - await page.screenshot({ path: `test-results/ssh-settings-${width}.png`, fullPage: true }); - } + await expect(page).toHaveURL(/\/presets\?type=ssh$/); + await expect(page.getByText("No SSH presets yet", { exact: false })).toBeVisible(); + await page.getByRole("button", { name: "New preset", exact: true }).click(); + await page.getByRole("menuitem", { name: "New SSH preset", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Create SSH preset", exact: true })).toBeVisible(); +}); + +test("saving before a failed SSH launch retains one preset across retry", async ({ page }) => { + const { saves, launches, presets } = await wire(page); + await page.goto("/connect"); + await page.getByLabel("SSH destination", { exact: true }).fill(destination); + await page.getByRole("checkbox", { name: "Save as preset", exact: true }).check(); + await expect(page.getByRole("button", { name: "Start SSH subshell", exact: true })).toBeDisabled(); + await page.getByLabel("Preset name", { exact: true }).fill("Build terminal"); + await page.getByRole("button", { name: "Start SSH subshell", exact: true }).click(); + await expect(page.getByText("Test connection stopped before launch.")).toBeVisible(); + expect(saves).toHaveLength(1); + expect(presets).toHaveLength(1); + await page.getByRole("button", { name: "Start SSH subshell", exact: true }).click(); + await expect.poll(() => launches.length).toBe(2); + expect(saves).toHaveLength(1); +}); + +test("SSH preset selection restores remote key choices without connecting", async ({ page }) => { + const { presets, launches } = await wire(page); + presets.push({ + id: "remote", + kind: "ssh", + name: "Remote build", + description: null, + nodeId: "desk", + destination, + alias: null, + authentication: "remote", + keyHome: "local", + fingerprints: [fingerprint], + }); + await page.goto("/new?kind=ssh&preset=remote"); + await expect(page.getByLabel("SSH destination", { exact: true })).toHaveValue(destination); + await expect(page.getByRole("radio", { name: "Use another node’s keys", exact: true })).toBeChecked(); + await expect(page.getByRole("checkbox", { name: "Work key", exact: true })).toBeChecked(); + expect(launches).toHaveLength(0); +}); + +test("local-key SSH presets restore the authentication choice and require a fresh key", async ({ page }) => { + const { presets, launches } = await wire(page); + presets.push({ + id: "local-key", + kind: "ssh", + name: "Personal build", + description: null, + nodeId: "desk", + destination, + alias: null, + authentication: "local-key", + keyHome: null, + fingerprints: [], + }); + await page.goto("/new?kind=ssh&preset=local-key"); + await expect(page.getByLabel("SSH destination", { exact: true })).toHaveValue(destination); + await expect(page.getByRole("radio", { name: "Use a key from my machine", exact: true })).toBeChecked(); + await expect(page.getByRole("button", { name: "Start SSH subshell", exact: true })).toBeDisabled(); + expect(launches).toHaveLength(0); }); test("unfamiliar destinations ask in the connection flow and cancellation saves nothing", async ({ page }) => { @@ -220,3 +275,55 @@ test("unfamiliar destinations ask in the connection flow and cancellation saves expect(accepted).toEqual([{ token: "account-bound-check" }]); expect(launches).toBe(3); // two attempts, with one retry after confirmation }); + +test("SSH presets can be created, edited, cloned, opened, and deleted", async ({ page }) => { + const { presets, launches } = await wire(page); + await page.goto("/presets?type=ssh"); + await page.getByRole("button", { name: "New preset", exact: true }).click(); + await page.getByRole("menuitem", { name: "New SSH preset", exact: true }).click(); + let dialog = page.getByRole("dialog", { name: "Create SSH preset", exact: true }); + await dialog.getByLabel("Name", { exact: true }).fill(" "); + await dialog.getByLabel("SSH destination", { exact: true }).fill("user@host:70000"); + await expect(dialog.getByText("Enter a preset name.", { exact: true })).toBeVisible(); + await dialog.getByLabel("Name", { exact: true }).fill("Build login"); + await expect(dialog.getByText("Enter a port between 1 and 65535.", { exact: true })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Save preset", exact: true })).toBeDisabled(); + expect(presets).toHaveLength(0); + await dialog.getByLabel("SSH destination", { exact: true }).fill(destination); + await dialog.getByRole("combobox", { name: "Connect from", exact: true }).click(); + await page.getByRole("option", { name: /Connecting desktop/ }).click(); + await dialog.getByRole("radio", { name: "Use a key from my machine", exact: true }).check(); + await page.screenshot({ path: "test-results/ssh-preset-editor.png", fullPage: true }); + await dialog.getByRole("button", { name: "Save preset", exact: true }).click(); + await expect(dialog).toHaveCount(0); + await page.screenshot({ path: "test-results/ssh-preset-library.png", fullPage: true }); + expect(presets[0].authentication).toBe("local-key"); + expect(launches).toHaveLength(0); + await page.getByRole("button", { name: "Build login", exact: true }).click(); + dialog = page.getByRole("dialog", { name: "Edit SSH preset", exact: true }); + await dialog.getByLabel("Name", { exact: true }).fill("Personal build"); + await dialog.getByRole("button", { name: "Save preset", exact: true }).click(); + await expect(dialog).toHaveCount(0); + await page.getByRole("button", { name: "Actions for Personal build", exact: true }).click(); + await page.getByRole("menuitem", { name: "Clone preset", exact: true }).click(); + dialog = page.getByRole("dialog", { name: "Clone SSH preset", exact: true }); + await expect(dialog.getByLabel("Name", { exact: true })).toHaveValue("Personal build (copy)"); + await dialog.getByRole("button", { name: "Save preset", exact: true }).click(); + await expect(dialog).toHaveCount(0); + expect(presets).toHaveLength(2); + await page.getByRole("button", { name: "Actions for Personal build (copy)", exact: true }).click(); + await page.getByRole("menuitem", { name: "Connect", exact: true }).click(); + await expect(page.getByLabel("SSH destination", { exact: true })).toHaveValue(destination); + await expect(page.getByRole("radio", { name: "Use a key from my machine", exact: true })).toBeChecked(); + expect(launches).toHaveLength(0); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.goto("/presets?type=ssh"); + await page.getByRole("button", { name: "Actions for Personal build (copy)", exact: true }).click(); + await page.getByRole("menuitem", { name: "Delete preset", exact: true }).click(); + await page + .getByRole("dialog", { name: "Delete this preset?", exact: true }) + .getByRole("button", { name: "Delete", exact: true }) + .click(); + await expect(page.getByRole("button", { name: "Personal build (copy)", exact: true })).toHaveCount(0); + expect(presets).toHaveLength(1); +}); diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index b4d5eace8..a2083f10f 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -165,7 +165,7 @@ export function registerTools(server: McpServer, deps: { api: ToolApi; own: Iden { title: "List presets", description: - "List saved presets and available harnesses. Pass a saved preset's id to create_subshell. A crossCommReady preset includes a node and working directory, so its id is enough to launch; its saved prompt is included too. A catalogOnly row has no saved preset. For an agent harness, ask a human to save a preset in the dashboard first. A plain terminal can launch with harness and no preset. Every row includes the harness plugin ID.", + "List saved agent presets and available harnesses; SSH presets are dashboard-only and never appear here. Pass a saved preset's id to create_subshell. A crossCommReady preset includes a node and working directory, so its id is enough to launch; its saved prompt is included too. A catalogOnly row has no saved preset. For an agent harness, ask a human to save a preset in the dashboard first. A plain terminal can launch with harness and no preset. Every row includes the harness plugin ID.", inputSchema: z.object({}), }, guard(() => listPresets(deps)), diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 91f1737b4..a8f058ab8 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -270,6 +270,7 @@ export { parseSshNodeCommandBody, redactSshSetupKeyLines, SSH_COMMAND_TYPES, + SSH_FINGERPRINT_PATTERN, SSH_RELAY_CLOSE_REASONS, type SshAgentIdentitiesCommand, type SshDiscoverAliasesCommand, diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index 7eadbdbdc..acb1f951b 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -413,9 +413,13 @@ function isIdStr(value: unknown): value is string { * emits, so the strict width refuses nothing real. Shape, not truth - whether * the digest matches a key is the responder's §5.4 enforcement. The alphabet * rule is also what stops control characters reaching a future audit row - * through a field that promises to be a fingerprint. + * through a field that promises to be a fingerprint. Exported as a source + * STRING so an API schema carries the SAME rule as the predicate below + * instead of restating it and drifting. */ -const SSH_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9_-]{43}$/; +export const SSH_FINGERPRINT_PATTERN = "^SHA256:[A-Za-z0-9_-]{43}$"; + +const SSH_FINGERPRINT_RE = new RegExp(SSH_FINGERPRINT_PATTERN); /** * Whether `value` is ONE fingerprint in the canonical display grammar. THE