From 6d08c9ad6a3f94ca20b4b4394298beca97b8a09a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 12:22:44 +0000 Subject: [PATCH 1/4] chore: update CHANGELOG.md for v1.0.1 --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 894acb7..a191ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## v1.0.1 (2026-05-05) + +## What's Changed + +- ci: drop develop-FF gate from release dispatches; require main by @rado0x54 in https://github.com/rado0x54/ShellWatch/pull/200 +- Preprod Release by @rado0x54 in https://github.com/rado0x54/ShellWatch/pull/201 +- chore: update agent CHANGELOG.md for agent/v1.0.0 by @github-actions[bot] in https://github.com/rado0x54/ShellWatch/pull/202 +- docs: align README and docs/ with current code by @rado0x54 in https://github.com/rado0x54/ShellWatch/pull/203 +- docs: refactor README — logo, tagline, requirements, dev/prod flow by @rado0x54 in https://github.com/rado0x54/ShellWatch/pull/204 +- chore: self-host Geist fonts; drop Google Fonts dependency by @rado0x54 in https://github.com/rado0x54/ShellWatch/pull/206 +- ci: bump homebrew-tap formula on agent release by @rado0x54 in https://github.com/rado0x54/ShellWatch/pull/205 +- Preprod Release by @rado0x54 in https://github.com/rado0x54/ShellWatch/pull/207 + +**Full Changelog**: https://github.com/rado0x54/ShellWatch/compare/v1.0.0...v1.0.1 + ## v1.0.0 (2026-05-03) ## What's Changed From 448f99679699c16ebd28f7116c21318e51c350ac Mon Sep 17 00:00:00 2001 From: Martin Riedel <1713643+rado0x54@users.noreply.github.com> Date: Sun, 17 May 2026 21:30:19 +0200 Subject: [PATCH 2/4] chore: misc UX polish across sidebar, observer and onboarding (#212) * fix: prevent wordmark cutoff in README by widening viewBox * feat: restrict Observer Mode sidebar entry to admin accounts * feat: add empty-state explainer to Observer Mode page * docs: add ready-to-copy sshd_config one-liner to Requirements * feat: show ready-to-copy echo+tee one-liner in onboarding sshd step * feat: replace Sign Out text with Docs, GitHub and Logout icons in sidebar footer * feat: collapsible account row in sidebar footer, with inline logout * feat: expand account row on hover, auto-collapse 3s after click * fix: add 1s grace period before hover-collapsing the account row * fix: shorten account row hover-collapse grace to 500ms * revert: show Observer Mode to all accounts; empty-state copy is the explainer * fix: grep-guard onboarding sshd one-liner to avoid duplicate appends * fix: only wire account menu keydown listener while menu is open * revert: drop grep-q guard from sshd one-liner in README and onboarding --- README.md | 8 + client/src/lib/components/Sidebar.svelte | 289 ++++++++++++++++++++--- client/src/routes/observer/+page.svelte | 37 +++ client/src/routes/register/+page.svelte | 6 +- design/shellwatch_wordmark-dark.svg | 6 +- design/shellwatch_wordmark-light.svg | 6 +- 6 files changed, 315 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5e94bcc..e982ba9 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,14 @@ ShellWatch is a Human-in-the-Loop platform for agent-driven SSH. Passkey-first a PubkeyAcceptedAlgorithms=+webauthn-sk-ecdsa-sha2-nistp256@openssh.com ``` + One-liner to append it and reload `sshd`: + + ```bash + echo 'PubkeyAcceptedAlgorithms=+webauthn-sk-ecdsa-sha2-nistp256@openssh.com' \ + | sudo tee -a /etc/ssh/sshd_config + sudo systemctl reload ssh # or: sudo systemctl reload sshd + ``` + - **Client (`ssh`):** OpenSSH **10.3+** — only when using the [SSH agent proxy](#ssh-agent-proxy). The PAM-from-inside-a-session path uses our [PAM module](https://github.com/rado0x54/pam-ssh-agent-webauthn) talking to `$SSH_AUTH_SOCK` directly, and plain ShellWatch sessions opened from the UI or MCP have no client-side OpenSSH requirement. ## Quick start diff --git a/client/src/lib/components/Sidebar.svelte b/client/src/lib/components/Sidebar.svelte index 1979dfd..272d26c 100644 --- a/client/src/lib/components/Sidebar.svelte +++ b/client/src/lib/components/Sidebar.svelte @@ -1,5 +1,6 @@ + + + @@ -464,21 +639,6 @@ box-shadow: 0 0 12px rgba(105, 246, 184, 0.6); } - .account-info { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) 0; - margin-bottom: var(--space-2); - } - - .account-details { - display: flex; - flex-direction: column; - gap: 0.15rem; - min-width: 0; - } - .account-name { font-size: var(--body-md); font-weight: 600; @@ -498,6 +658,7 @@ letter-spacing: 0.04em; color: var(--primary); font-weight: 500; + align-self: flex-start; } .badge-admin::before { @@ -508,11 +669,81 @@ display: inline-block; } - .btn-logout:hover { - color: var(--error); + .footer-row { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-3); + padding-top: var(--space-4); + border-top: 1px solid var(--outline-variant); + } + + .account-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px; + background: transparent; + border: none; + cursor: pointer; + transition: filter 0.15s; + flex-shrink: 0; + } + + .account-trigger:hover { + filter: brightness(1.15); + } + + .account-trigger:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } + + .account-details { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; + flex: 1; + } + + .footer-resources { + display: flex; + align-items: center; + gap: var(--space-1); + margin-left: auto; + } + + .icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: transparent; + border: none; + color: var(--on-surface-variant); + cursor: pointer; + text-decoration: none; + transition: + color 0.15s, + background 0.15s; + } + + .icon-btn:hover { + color: var(--on-surface); background: var(--surface-container); } + .icon-btn:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; + } + + .icon-btn-danger:hover { + color: var(--error); + } + @media (max-width: 768px) { .sidebar { width: 100%; diff --git a/client/src/routes/observer/+page.svelte b/client/src/routes/observer/+page.svelte index 255c8ef..551e700 100644 --- a/client/src/routes/observer/+page.svelte +++ b/client/src/routes/observer/+page.svelte @@ -157,6 +157,16 @@
{/each} + {#if $sessions.length === 0} +
+

No active sessions

+

+ Observer Mode shows a live read-only grid of every open session in your account — + UI, MCP agent, and SSH-agent proxy connections all appear here side by side. +

+

Open a session from the sidebar or have an agent start one, and it will show up here.

+
+ {/if} @@ -242,4 +252,31 @@ height: 100%; padding: 2px; } + + .observer-empty { + grid-column: 1 / -1; + grid-row: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + padding: var(--space-6); + text-align: center; + color: var(--on-surface-variant); + } + + .observer-empty h2 { + font-family: var(--font-display); + font-size: var(--title-md); + font-weight: 600; + letter-spacing: -0.01em; + color: var(--on-surface); + } + + .observer-empty p { + max-width: 56ch; + line-height: 1.55; + font-size: var(--body-md); + } diff --git a/client/src/routes/register/+page.svelte b/client/src/routes/register/+page.svelte index 2a1eec0..f4888d8 100644 --- a/client/src/routes/register/+page.svelte +++ b/client/src/routes/register/+page.svelte @@ -137,6 +137,7 @@ // (additional algorithms, multi-line config), surface it through the // register response and consume it here instead. const SSHD_CONFIG_LINE = "PubkeyAcceptedAlgorithms=+webauthn-sk-ecdsa-sha2-nistp256@openssh.com"; + const SSHD_CONFIG_ONE_LINER = `echo '${SSHD_CONFIG_LINE}' | sudo tee -a /etc/ssh/sshd_config`; async function copyToClipboard(text: string, btn: HTMLButtonElement) { const original = btn.innerHTML; @@ -356,11 +357,12 @@ 1. Enable WebAuthn keys in /etc/ssh/sshd_config (reload sshd after) - {SSHD_CONFIG_LINE} + {SSHD_CONFIG_ONE_LINER} diff --git a/design/shellwatch_wordmark-dark.svg b/design/shellwatch_wordmark-dark.svg index e970a09..bdd6916 100644 --- a/design/shellwatch_wordmark-dark.svg +++ b/design/shellwatch_wordmark-dark.svg @@ -1,12 +1,12 @@ - + ShellWatch - SHELLWATCH diff --git a/design/shellwatch_wordmark-light.svg b/design/shellwatch_wordmark-light.svg index ae13a15..ef26503 100644 --- a/design/shellwatch_wordmark-light.svg +++ b/design/shellwatch_wordmark-light.svg @@ -1,12 +1,12 @@ - + ShellWatch - SHELLWATCH From d2633b88ae6296d26c6afe3f38eda9d3810a98e1 Mon Sep 17 00:00:00 2001 From: Martin Riedel <1713643+rado0x54@users.noreply.github.com> Date: Sun, 17 May 2026 22:23:22 +0200 Subject: [PATCH 3/4] feat: per-endpoint SSH agent forwarding toggle (#213) * feat: per-endpoint SSH agent forwarding toggle * chore: move agent-forward toggle below description, shorten hint * chore: trim agent-forward hint to single sentence * fix: split block-level help class so description's inline hint isn't broken --- README.md | 2 +- client/src/lib/stores/account.ts | 14 - client/src/lib/stores/endpoints.ts | 2 + client/src/routes/admin/general/+page.svelte | 5 + .../routes/settings/endpoints/+page.svelte | 86 +- .../src/routes/settings/general/+page.svelte | 106 +- config.sample.yaml | 2 + drizzle/0007_endpoint_agent_forward.sql | 2 + drizzle/meta/0007_snapshot.json | 973 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/config/loader.test.ts | 25 + src/config/schema.ts | 5 + src/db/repositories/account-repo.ts | 5 +- src/db/repositories/endpoint-repo.ts | 14 +- src/db/schema.ts | 8 +- src/db/seed.ts | 1 + src/server/routes/accounts.test.ts | 1 - src/server/routes/accounts.ts | 42 +- src/server/routes/endpoints.ts | 35 +- src/terminal/terminal-manager.test.ts | 1 + src/test/helpers/app-server.ts | 1 + src/test/integration/agent-forward.test.ts | 2 +- src/test/integration/error-scenarios.test.ts | 1 + src/transport/create-factory.ts | 5 - src/transport/ssh-transport-factory.test.ts | 6 +- src/transport/ssh-transport-factory.ts | 4 +- 26 files changed, 1183 insertions(+), 172 deletions(-) create mode 100644 drizzle/0007_endpoint_agent_forward.sql create mode 100644 drizzle/meta/0007_snapshot.json diff --git a/README.md b/README.md index e982ba9..6fe9693 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ShellWatch is a Human-in-the-Loop platform for agent-driven SSH. Passkey-first a - **Passkey-only auth** — WebAuthn for UI login, agent enrollment, and SSH authentication via OpenSSH's [`webauthn-sk-ecdsa-sha2-nistp256@openssh.com`](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.u2f) signature algorithm - **End-to-end SSH-agent proxy** — local `ssh`/`scp`/`git` reach a passkey via ShellWatch with explicit browser approval per signature -- **Agent forwarding into sessions** — your passkey-backed SSH agent is forwarded into every ShellWatch session, so you can hop to additional hosts and enable SSH-agent-based PAM integration +- **Agent forwarding into sessions** — your passkey-backed SSH agent is forwarded into ShellWatch sessions (per-endpoint toggle), so you can hop to additional hosts and enable SSH-agent-based PAM integration - **PAM integration** — pair with [`pam-ssh-agent-webauthn`](https://github.com/rado0x54/pam-ssh-agent-webauthn) to gate `sudo` (or any PAM-aware step) behind a passkey approval surfaced through ShellWatch - **Human-in-the-loop for agents** — MCP agents request, humans approve; sensitive actions can require per-action consent - **Realtime notifications** — sign requests arrive as Web Push and in-UI toasts diff --git a/client/src/lib/stores/account.ts b/client/src/lib/stores/account.ts index 2636afa..5d0be0f 100644 --- a/client/src/lib/stores/account.ts +++ b/client/src/lib/stores/account.ts @@ -5,7 +5,6 @@ export interface AccountData { id: string; name: string; isAdmin: boolean; - agentForward: boolean; } export const account = writable(null); @@ -36,16 +35,3 @@ export async function updateAccountName(name: string): Promise { } await fetchAccount(); } - -export async function updateAgentForward(agentForward: boolean): Promise { - const res = await fetch("/api/auth/me", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agentForward }), - }); - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || "Failed to update agent forwarding"); - } - await fetchAccount(); -} diff --git a/client/src/lib/stores/endpoints.ts b/client/src/lib/stores/endpoints.ts index 6b0479a..a8caced 100644 --- a/client/src/lib/stores/endpoints.ts +++ b/client/src/lib/stores/endpoints.ts @@ -18,6 +18,7 @@ export interface Endpoint { port: number; username: string; userVerification: UserVerification; + agentForward: boolean; description: string | null; } @@ -35,6 +36,7 @@ export async function createEndpoint(body: { port: number; username: string; userVerification?: UserVerification; + agentForward?: boolean; description?: string | null; }): Promise { const res = await fetch("/api/endpoints", { diff --git a/client/src/routes/admin/general/+page.svelte b/client/src/routes/admin/general/+page.svelte index f4fd03b..5e1aeb7 100644 --- a/client/src/routes/admin/general/+page.svelte +++ b/client/src/routes/admin/general/+page.svelte @@ -13,6 +13,7 @@ interface SeedEndpoint { label: string; address: string; + agentForward: boolean; passkeyCredentialRef?: string; } @@ -53,6 +54,10 @@ for (const ep of endpoints) { lines.push(` - label: ${yamlStr(ep.label)}`); lines.push(` address: ${yamlStr(ep.address)}`); + // Only emit when off — default is true, keep YAML clean. + if (!ep.agentForward) { + lines.push(" agentForward: false"); + } if (ep.passkeyCredentialRef) { lines.push(` passkeyCredentialRef: ${yamlStr(ep.passkeyCredentialRef)}`); } diff --git a/client/src/routes/settings/endpoints/+page.svelte b/client/src/routes/settings/endpoints/+page.svelte index 7dad21d..8728b10 100644 --- a/client/src/routes/settings/endpoints/+page.svelte +++ b/client/src/routes/settings/endpoints/+page.svelte @@ -28,6 +28,7 @@ let formLabel = $state(""); let formAddress = $state(""); let formUserVerification = $state("required"); + let formAgentForward = $state(true); let formDescription = $state(""); let deleteTarget = $state(null); let deleting = $state(false); @@ -41,6 +42,7 @@ formLabel = ""; formAddress = ""; formUserVerification = "required"; + formAgentForward = true; formDescription = ""; } @@ -49,6 +51,7 @@ formLabel = ep.label; formAddress = formatEndpointAddress(ep); formUserVerification = ep.userVerification; + formAgentForward = ep.agentForward; formDescription = ep.description ?? ""; } @@ -80,6 +83,7 @@ port: parsed.port, username: parsed.username, userVerification: formUserVerification, + agentForward: formAgentForward, description, }); } else { @@ -89,6 +93,7 @@ port: parsed.port, username: parsed.username, userVerification: formUserVerification, + agentForward: formAgentForward, description, }); } @@ -134,7 +139,10 @@ {#snippet primary()} {ep.label} - {ep.userVerification} + UV: {ep.userVerification} + + forward: {ep.agentForward ? "on" : "off"} + {/snippet} {#snippet secondary()}{formatEndpointAddress(ep)}{/snippet} {#snippet actions()} @@ -232,6 +240,28 @@
{formDescription.length} / {ENDPOINT_DESCRIPTION_MAX_LENGTH}
+
+ +
+ + {formAgentForward ? "Enabled" : "Disabled"} +
+ + Forward SSH keys to this host so onward tools (ssh, git) can authenticate. + +
+ {#snippet actions()} - - {$account.agentForward ? "Enabled" : "Disabled"} - - - - Forward SSH keys to remote hosts so programs like ssh and git can authenticate onward. - - {#if agentForwardMessage} - {agentForwardMessage} - {/if} - -
Version
@@ -221,57 +170,4 @@ .message.success { color: var(--green, #4ade80); } - - .toggle-row { - display: flex; - align-items: center; - gap: 0.75rem; - } - - .toggle { - position: relative; - width: 40px; - height: 22px; - border-radius: 11px; - border: 1px solid var(--border); - background: var(--bg-primary); - cursor: pointer; - padding: 0; - transition: background-color 0.2s; - } - - .toggle.active { - background: var(--green, #4ade80); - border-color: var(--green, #4ade80); - } - - .toggle-knob { - position: absolute; - top: 2px; - left: 2px; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--text-muted); - transition: - transform 0.2s, - background-color 0.2s; - } - - .toggle.active .toggle-knob { - transform: translateX(18px); - background: white; - } - - .toggle-label { - font-size: 0.85rem; - color: var(--text-primary); - } - - .field-hint { - display: block; - font-size: 0.75rem; - color: var(--text-muted); - margin-top: 0.375rem; - } diff --git a/config.sample.yaml b/config.sample.yaml index 28f2584..dd5b3c1 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -40,12 +40,14 @@ keyDirectory: ./keys # Optional: seed endpoints for the admin account on first run (keys are assigned via UI) # Format: [user@]host[:port] — defaults: user=shellwatch, port=22 +# agentForward defaults to true; set to false for hosts that disallow forwarding. # seedAdminEndpoints: # - label: Dev Box # address: ubuntu@dev.example.com # # - label: Staging # address: deploy@staging.example.com:2222 +# agentForward: false # Security settings security: diff --git a/drizzle/0007_endpoint_agent_forward.sql b/drizzle/0007_endpoint_agent_forward.sql new file mode 100644 index 0000000..42104d9 --- /dev/null +++ b/drizzle/0007_endpoint_agent_forward.sql @@ -0,0 +1,2 @@ +ALTER TABLE `endpoints` ADD `agent_forward` integer DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE `accounts` DROP COLUMN `agent_forward`; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..72eee5a --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,973 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "833d8ef2-d304-4fca-a662-6c996f63b68d", + "prevId": "3ef48364-48b4-4c51-8686-c11af4722eb9", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "max_sessions": { + "name": "max_sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_account": { + "name": "admin_account", + "columns": { + "singleton": { + "name": "singleton", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "admin_account_account_id_accounts_id_fk": { + "name": "admin_account_account_id_accounts_id_fk", + "tableFrom": "admin_account", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "single_row": { + "name": "single_row", + "value": "\"admin_account\".\"singleton\" = 1" + } + } + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoints": { + "name": "endpoints", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": ["key_hash"], + "isUnique": true + } + }, + "foreignKeys": { + "api_keys_account_id_accounts_id_fk": { + "name": "api_keys_account_id_accounts_id_fk", + "tableFrom": "api_keys", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_session_lifecycle": { + "name": "audit_session_lifecycle", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_ip": { + "name": "source_ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_reason": { + "name": "mcp_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_client_name": { + "name": "mcp_client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_client_version": { + "name": "mcp_client_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_label": { + "name": "api_key_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_prefix": { + "name": "api_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_hostname": { + "name": "client_hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_os": { + "name": "client_os", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_session_lifecycle_account_created_idx": { + "name": "audit_session_lifecycle_account_created_idx", + "columns": ["account_id", "created_at", "session_id"], + "isUnique": false + }, + "audit_session_lifecycle_account_endpoint_created_idx": { + "name": "audit_session_lifecycle_account_endpoint_created_idx", + "columns": ["account_id", "endpoint_id", "created_at", "session_id"], + "isUnique": false + } + }, + "foreignKeys": { + "audit_session_lifecycle_account_id_accounts_id_fk": { + "name": "audit_session_lifecycle_account_id_accounts_id_fk", + "tableFrom": "audit_session_lifecycle", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "audit_session_lifecycle_status_chk": { + "name": "audit_session_lifecycle_status_chk", + "value": "\"audit_session_lifecycle\".\"status\" IN ('open','closed','error')" + }, + "audit_session_lifecycle_source_chk": { + "name": "audit_session_lifecycle_source_chk", + "value": "\"audit_session_lifecycle\".\"source\" IN ('ui','mcp','ssh')" + } + } + }, + "audit_signing_requests": { + "name": "audit_signing_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_ip": { + "name": "source_ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endpoint_label": { + "name": "endpoint_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endpoint_address": { + "name": "endpoint_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_reason": { + "name": "mcp_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_client_name": { + "name": "mcp_client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_client_version": { + "name": "mcp_client_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_label": { + "name": "api_key_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_prefix": { + "name": "api_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_hostname": { + "name": "client_hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_os": { + "name": "client_os", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "passkey_label": { + "name": "passkey_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_label": { + "name": "key_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_signing_requests_account_created_idx": { + "name": "audit_signing_requests_account_created_idx", + "columns": ["account_id", "created_at", "id"], + "isUnique": false + } + }, + "foreignKeys": { + "audit_signing_requests_account_id_accounts_id_fk": { + "name": "audit_signing_requests_account_id_accounts_id_fk", + "tableFrom": "audit_signing_requests", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "audit_signing_requests_type_chk": { + "name": "audit_signing_requests_type_chk", + "value": "\"audit_signing_requests\".\"type\" IN ('webauthn-sign','key-approve')" + }, + "audit_signing_requests_source_chk": { + "name": "audit_signing_requests_source_chk", + "value": "\"audit_signing_requests\".\"source\" IN ('endpoint-auth','agent-forwarding','agent-proxy')" + }, + "audit_signing_requests_outcome_chk": { + "name": "audit_signing_requests_outcome_chk", + "value": "\"audit_signing_requests\".\"outcome\" IS NULL OR \"audit_signing_requests\".\"outcome\" IN ('approved','denied','expired','cancelled')" + } + } + }, + "endpoints": { + "name": "endpoints", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'required'" + }, + "agent_forward": { + "name": "agent_forward", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "endpoints_account_id_accounts_id_fk": { + "name": "endpoints_account_id_accounts_id_fk", + "tableFrom": "endpoints", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "columns": ["endpoint"], + "isUnique": true + } + }, + "foreignKeys": { + "push_subscriptions_account_id_accounts_id_fk": { + "name": "push_subscriptions_account_id_accounts_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_keys": { + "name": "ssh_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'file'" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ssh_keys_fingerprint_unique": { + "name": "ssh_keys_fingerprint_unique", + "columns": ["fingerprint"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webauthn_credentials": { + "name": "webauthn_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key_openssh": { + "name": "public_key_openssh", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webauthn_credentials_credential_id_unique": { + "name": "webauthn_credentials_credential_id_unique", + "columns": ["credential_id"], + "isUnique": true + } + }, + "foreignKeys": { + "webauthn_credentials_account_id_accounts_id_fk": { + "name": "webauthn_credentials_account_id_accounts_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 61bfff8..68785a3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1777783781140, "tag": "0006_audit_signing_requests", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1779047498048, + "tag": "0007_endpoint_agent_forward", + "breakpoints": true } ] } diff --git a/src/config/loader.test.ts b/src/config/loader.test.ts index e7360ca..53bb145 100644 --- a/src/config/loader.test.ts +++ b/src/config/loader.test.ts @@ -128,6 +128,31 @@ seedAdminEndpoints: expect(() => loadConfig(configPath)).toThrow("Invalid config"); }); + it("defaults agentForward to true on seed endpoints", () => { + const dir = createTempDir(); + const configPath = writeConfig( + dir, + ` +server: + externalUrl: http://localhost:3000 +security: + rpId: localhost + trustedWebauthnOrigins: + - http://localhost +seedAdminEndpoints: + - label: Default + address: host.example.com + - label: NoForward + address: locked.example.com + agentForward: false +`, + ); + + const config = loadConfig(configPath); + expect(config.seedAdminEndpoints[0].agentForward).toBe(true); + expect(config.seedAdminEndpoints[1].agentForward).toBe(false); + }); + it("loads multiple endpoints with different address formats", () => { const dir = createTempDir(); const configPath = writeConfig( diff --git a/src/config/schema.ts b/src/config/schema.ts index 755591e..7234e96 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -8,6 +8,11 @@ export const SeedEndpointSchema = z.object({ .string() .min(1) .transform((addr) => parseEndpointAddress(addr)), + /** + * Whether to enable SSH agent forwarding on sessions to this endpoint. + * Defaults to true — opt out per endpoint when the host disallows forwarding. + */ + agentForward: z.boolean().default(true), }); /** Field-level defaults for optional security settings (rpId and trustedWebauthnOrigins are required) */ diff --git a/src/db/repositories/account-repo.ts b/src/db/repositories/account-repo.ts index ba2eea1..5d5bac2 100644 --- a/src/db/repositories/account-repo.ts +++ b/src/db/repositories/account-repo.ts @@ -9,7 +9,6 @@ export interface AccountInfo { isAdmin: boolean; enabled: boolean; maxSessions: number; - agentForward: boolean; lastUsedAt: string | null; createdAt: string; updatedAt: string; @@ -20,7 +19,7 @@ export interface AccountRepository { findAll(): Promise; update( id: string, - data: Partial>, + data: Partial>, ): Promise; /** Mark account as active. Writes are batched — call flushLastUsed() to persist. */ touchLastUsed(id: string): void; @@ -82,7 +81,7 @@ export class DrizzleAccountRepository implements AccountRepository { async update( id: string, - data: Partial>, + data: Partial>, ): Promise { this.db .update(accounts) diff --git a/src/db/repositories/endpoint-repo.ts b/src/db/repositories/endpoint-repo.ts index 30ebceb..8752605 100644 --- a/src/db/repositories/endpoint-repo.ts +++ b/src/db/repositories/endpoint-repo.ts @@ -27,6 +27,7 @@ export interface EndpointInfo { port: number; username: string; userVerification: UserVerification; + agentForward: boolean; description: string | null; } @@ -45,6 +46,7 @@ export interface EndpointRepository { port: number; username: string; userVerification?: UserVerification; + agentForward?: boolean; description?: string | null; }): Promise; update( @@ -56,6 +58,7 @@ export interface EndpointRepository { port: number; username: string; userVerification: UserVerification; + agentForward: boolean; description: string | null; }>, ): Promise; @@ -70,6 +73,7 @@ const ENDPOINT_COLUMNS = { port: endpoints.port, username: endpoints.username, userVerification: endpoints.userVerification, + agentForward: endpoints.agentForward, description: endpoints.description, } as const; @@ -101,6 +105,7 @@ export class DrizzleEndpointRepository implements EndpointRepository { port: number; username: string; userVerification?: UserVerification; + agentForward?: boolean; description?: string | null; }): Promise { const now = new Date().toISOString(); @@ -109,6 +114,7 @@ export class DrizzleEndpointRepository implements EndpointRepository { .values({ ...data, userVerification: data.userVerification ?? "required", + agentForward: data.agentForward ?? true, description: data.description ?? null, enabled: true, createdAt: now, @@ -126,6 +132,7 @@ export class DrizzleEndpointRepository implements EndpointRepository { port: number; username: string; userVerification: UserVerification; + agentForward: boolean; description: string | null; }>, ): Promise { @@ -150,9 +157,10 @@ export class InMemoryEndpointRepository implements EndpointRepository { constructor( initialEndpoints: Array< - Omit & { + Omit & { accountId?: string; userVerification?: UserVerification; + agentForward?: boolean; description?: string | null; } > = [], @@ -162,6 +170,7 @@ export class InMemoryEndpointRepository implements EndpointRepository { ...e, accountId: e.accountId ?? this.defaultAccountId, userVerification: e.userVerification ?? "required", + agentForward: e.agentForward ?? true, description: e.description ?? null, })); } @@ -182,11 +191,13 @@ export class InMemoryEndpointRepository implements EndpointRepository { port: number; username: string; userVerification?: UserVerification; + agentForward?: boolean; description?: string | null; }): Promise { this.store.push({ ...data, userVerification: data.userVerification ?? "required", + agentForward: data.agentForward ?? true, description: data.description ?? null, }); } @@ -200,6 +211,7 @@ export class InMemoryEndpointRepository implements EndpointRepository { port: number; username: string; userVerification: UserVerification; + agentForward: boolean; description: string | null; }>, ): Promise { diff --git a/src/db/schema.ts b/src/db/schema.ts index b6158b1..adfd95a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -10,7 +10,6 @@ export const accounts = sqliteTable("accounts", { enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), maxSessions: integer("max_sessions").notNull().default(5), - agentForward: integer("agent_forward", { mode: "boolean" }).notNull().default(false), lastUsedAt: text("last_used_at"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), @@ -87,6 +86,13 @@ export const endpoints = sqliteTable("endpoints", { * host / authenticator can't provide UV. */ userVerification: text("user_verification").notNull().default("required"), + /** + * Whether to enable SSH agent forwarding when opening a session to this + * endpoint. Defaults to enabled — some hosts disallow forwarding (e.g. + * AllowAgentForwarding no in sshd_config) and the user can disable it + * per-endpoint to avoid a forwarding-channel-rejected handshake. + */ + agentForward: integer("agent_forward", { mode: "boolean" }).notNull().default(true), /** * Optional free-form description (max 1000 chars). Surfaced to MCP agents in * the per-endpoint instructions so they have context about what runs on the diff --git a/src/db/seed.ts b/src/db/seed.ts index ab9f519..a685be5 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -105,6 +105,7 @@ export function seedFromConfig(db: ShellWatchDB, config: Config): SeedResult { host: ep.address.host, port: ep.address.port, username: ep.address.username, + agentForward: ep.agentForward, enabled: true, createdAt: now, updatedAt: now, diff --git a/src/server/routes/accounts.test.ts b/src/server/routes/accounts.test.ts index 051f2e3..7bd115e 100644 --- a/src/server/routes/accounts.test.ts +++ b/src/server/routes/accounts.test.ts @@ -16,7 +16,6 @@ function stubAccount(id: string, isAdmin: boolean): AccountInfo { isAdmin, enabled: true, maxSessions: 5, - agentForward: false, lastUsedAt: null, createdAt: now, updatedAt: now, diff --git a/src/server/routes/accounts.ts b/src/server/routes/accounts.ts index 817e5e8..f137582 100644 --- a/src/server/routes/accounts.ts +++ b/src/server/routes/accounts.ts @@ -34,33 +34,26 @@ export function registerAccountRoutes(params: AccountRoutesParams) { id: account.id, name: account.name, isAdmin: account.isAdmin, - agentForward: account.agentForward, }; }); - app.put<{ Body: { name?: string; agentForward?: boolean } }>( - "/api/auth/me", - async (request, reply) => { - const accountId = request.accountId; - const { name, agentForward } = request.body; - const updates: Partial<{ name: string; agentForward: boolean }> = {}; - if (name !== undefined) { - const trimmed = name.trim(); - if (!trimmed) { - reply.status(400); - return { error: "Name cannot be empty" }; - } - updates.name = trimmed; - } - if (agentForward !== undefined) { - updates.agentForward = agentForward; - } - if (Object.keys(updates).length > 0) { - await accountRepo.update(accountId, updates); + app.put<{ Body: { name?: string } }>("/api/auth/me", async (request, reply) => { + const accountId = request.accountId; + const { name } = request.body; + const updates: Partial<{ name: string }> = {}; + if (name !== undefined) { + const trimmed = name.trim(); + if (!trimmed) { + reply.status(400); + return { error: "Name cannot be empty" }; } - return { status: "updated" }; - }, - ); + updates.name = trimmed; + } + if (Object.keys(updates).length > 0) { + await accountRepo.update(accountId, updates); + } + return { status: "updated" }; + }); // --- Account Management (admin only) --- @@ -77,7 +70,6 @@ export function registerAccountRoutes(params: AccountRoutesParams) { isAdmin: a.isAdmin, enabled: a.enabled, maxSessions: a.maxSessions, - agentForward: a.agentForward, lastUsedAt: a.lastUsedAt, createdAt: a.createdAt, })), @@ -162,6 +154,7 @@ export function registerAccountRoutes(params: AccountRoutesParams) { host: endpointsTable.host, port: endpointsTable.port, username: endpointsTable.username, + agentForward: endpointsTable.agentForward, }) .from(endpointsTable) .where(eq(endpointsTable.accountId, adminId)) @@ -192,6 +185,7 @@ export function registerAccountRoutes(params: AccountRoutesParams) { host: ep.host, port: ep.port, }), + agentForward: ep.agentForward, })); return { passkeys: seedPasskeys, endpoints: seedEndpoints }; diff --git a/src/server/routes/endpoints.ts b/src/server/routes/endpoints.ts index 6c21059..4040883 100644 --- a/src/server/routes/endpoints.ts +++ b/src/server/routes/endpoints.ts @@ -31,15 +31,18 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) { app.get("/api/endpoints", async (request) => { const all = await endpointRepo.findAllForAccount(request.accountId); return { - endpoints: all.map(({ id, label, host, port, username, userVerification, description }) => ({ - id, - label, - host, - port, - username, - userVerification, - description, - })), + endpoints: all.map( + ({ id, label, host, port, username, userVerification, agentForward, description }) => ({ + id, + label, + host, + port, + username, + userVerification, + agentForward, + description, + }), + ), }; }); @@ -50,6 +53,7 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) { port?: number; username?: string; userVerification?: string; + agentForward?: boolean; description?: string | null; }; }>("/api/endpoints", async (request, reply) => { @@ -61,6 +65,13 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) { error: `userVerification must be one of: ${USER_VERIFICATION_VALUES.join(", ")}`, }; } + if ( + request.body.agentForward !== undefined && + typeof request.body.agentForward !== "boolean" + ) { + reply.status(400); + return { error: "agentForward must be a boolean" }; + } const desc = normalizeDescription(request.body.description); if (!desc.ok) { reply.status(400); @@ -77,6 +88,7 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) { port: request.body.port ?? 22, username: request.body.username ?? "shellwatch", userVerification: uv as UserVerification | undefined, + agentForward: request.body.agentForward, description: desc.value, }); return { status: "created", id }; @@ -95,6 +107,7 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) { port?: number; username?: string; userVerification?: string; + agentForward?: boolean; description?: string | null; }; }>("/api/endpoints/:id", async (request, reply) => { @@ -106,6 +119,10 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) { error: `userVerification must be one of: ${USER_VERIFICATION_VALUES.join(", ")}`, }; } + if (body.agentForward !== undefined && typeof body.agentForward !== "boolean") { + reply.status(400); + return { error: "agentForward must be a boolean" }; + } const descriptionPatch: Partial<{ description: string | null }> = {}; if (body.description !== undefined) { const desc = normalizeDescription(body.description); diff --git a/src/terminal/terminal-manager.test.ts b/src/terminal/terminal-manager.test.ts index 65572ae..7f98433 100644 --- a/src/terminal/terminal-manager.test.ts +++ b/src/terminal/terminal-manager.test.ts @@ -22,6 +22,7 @@ const testEndpoint: EndpointInfo = { port: 22, username: "testuser", userVerification: "required", + agentForward: true, description: null, }; diff --git a/src/test/helpers/app-server.ts b/src/test/helpers/app-server.ts index afed877..fb90729 100644 --- a/src/test/helpers/app-server.ts +++ b/src/test/helpers/app-server.ts @@ -104,6 +104,7 @@ export async function startTestApp( { label: "Test Server", address: { username: "testuser", host: sshServer.host, port: sshServer.port }, + agentForward: true, }, ], security: { cookieSecret: testCookieSecret }, diff --git a/src/test/integration/agent-forward.test.ts b/src/test/integration/agent-forward.test.ts index 493c32e..b75e7a1 100644 --- a/src/test/integration/agent-forward.test.ts +++ b/src/test/integration/agent-forward.test.ts @@ -60,6 +60,7 @@ describe("SSH Agent Forwarding", () => { host: sshServer.host, port: sshServer.port, username: "testuser", + agentForward, }, ]); const keyRepo = new InMemorySshKeyRepository([ @@ -69,7 +70,6 @@ describe("SSH Agent Forwarding", () => { const factory = new SshTransportFactory(keyRepo, keyProvider, { rpId: "localhost", - getAgentForward: async () => agentForward, isAdmin: () => true, createAgent: ({ fileKeys, agentForward: fwd }) => { const fileKeyEntries = fileKeys diff --git a/src/test/integration/error-scenarios.test.ts b/src/test/integration/error-scenarios.test.ts index 31f542d..9b49793 100644 --- a/src/test/integration/error-scenarios.test.ts +++ b/src/test/integration/error-scenarios.test.ts @@ -129,6 +129,7 @@ describe("Error Scenarios", () => { { label: "No Key", address: { username: "test", host: "localhost", port: 22 }, + agentForward: true, }, ], security: { cookieSecret: testSecret }, diff --git a/src/transport/create-factory.ts b/src/transport/create-factory.ts index b0a54f3..46bf807 100644 --- a/src/transport/create-factory.ts +++ b/src/transport/create-factory.ts @@ -43,11 +43,6 @@ export function createSshTransportFactoryFromConfig( findCredentialsForAccount: (accountId) => findCredentialsForAccount(db, accountId), isAdmin: (accountId) => accountRepo.isAdmin(accountId), - getAgentForward: async (accountId) => { - const account = await accountRepo.findById(accountId); - return account?.agentForward ?? false; - }, - createAgent: ({ endpoint, fileKeys, diff --git a/src/transport/ssh-transport-factory.test.ts b/src/transport/ssh-transport-factory.test.ts index 786bab1..c6517cb 100644 --- a/src/transport/ssh-transport-factory.test.ts +++ b/src/transport/ssh-transport-factory.test.ts @@ -24,6 +24,7 @@ const testEndpoint: EndpointInfo = { username: "user", accountId: "account-1", userVerification: "required", + agentForward: false, description: null, }; @@ -211,7 +212,7 @@ describe("SshTransportFactory", () => { expect(cleanup).toHaveBeenCalledOnce(); }); - it("passes agentForward=true when account has it enabled", async () => { + it("passes agentForward=true when the endpoint has it enabled", async () => { const mockTransport = createMockTransport(); const mockAgent = { sign: vi.fn() } as never; mockConnectSshWithAgent.mockResolvedValue(mockTransport); @@ -226,12 +227,11 @@ describe("SshTransportFactory", () => { createAgent, findCredentialsForAccount: () => [testCredential], isAdmin: () => false, - getAgentForward: async () => true, }, ); await factory.create({ - endpoint: testEndpoint, + endpoint: { ...testEndpoint, agentForward: true }, sessionId: "sess_test", trigger: { kind: "ui", sourceIp: "127.0.0.1" }, }); diff --git a/src/transport/ssh-transport-factory.ts b/src/transport/ssh-transport-factory.ts index 679a569..5a4eafc 100644 --- a/src/transport/ssh-transport-factory.ts +++ b/src/transport/ssh-transport-factory.ts @@ -43,8 +43,6 @@ export interface SshTransportFactoryOptions { createAgent: AgentFactory; findCredentialsForAccount?: CredentialsForAccountLookup; isAdmin?: AdminCheck; - /** Look up whether agent forwarding is enabled for a given account */ - getAgentForward?: (accountId: string) => Promise; } /** @@ -63,7 +61,7 @@ export class SshTransportFactory { async create(params: TransportFactoryParams): Promise { const { endpoint, sessionId, trigger } = params; - const agentForward = (await this.options.getAgentForward?.(endpoint.accountId)) ?? false; + const agentForward = endpoint.agentForward; const isAdmin = this.options.isAdmin?.(endpoint.accountId) ?? false; // Gather file keys (admin only) From e2033ef01401a621a3edb23898bd3ec193af8f9c Mon Sep 17 00:00:00 2001 From: Martin Riedel <1713643+rado0x54@users.noreply.github.com> Date: Mon, 18 May 2026 14:24:27 +0200 Subject: [PATCH 4/4] feat: virtual demo endpoints + Settings UX overhaul (closes #211) (#214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: virtual demoEndpoints config with per-account show toggle * feat: /demo/authorized-keys lookup + persisted passkey fingerprint * revert: /demo/authorized-keys lookup + persisted passkey fingerprint Demo auth is now handled inside the demo container (blanket-allow on a private docker network), no per-user pubkey lookup needed. See #211. * feat: trim onboarding, lift setup docs to a Settings tab, split SSH keys Onboarding drops Server Setup, Endpoints, MCP and Advanced steps; users land in the app right after passkey + notifications. Endpoints page no longer carries the UV hint-block, gains an Add Endpoint wizard that prepends a Server Setup step for first-time users, and renders the default shellwatch@ prefix as a gray adornment with a warning on blur. New /settings/setup tab consolidates the lifted help content. /settings/keys renamed to Passkeys; file-based SSH keys move to a separate admin-only Other SSH Keys tab. formatEndpointAddress always renders the username so the wire-level user is unambiguous in the UI. * chore: align primary actions across Settings pages Add Endpoint and Generate API Key now sit below the list, left-aligned, in the same .register-section pattern as Add passkey. Generate API Key opens a form modal (label + scopes) instead of an inline form; the existing key-display modal still shows the minted key. Demo Endpoints toggle moves to sit next to the section headline instead of the right edge. * feat: restructure Settings Setup tab into collapsible per-integration cards Replace the three-section Setup tab with five collapsible cards (SSH Server, Endpoint, MCP Client, ShellWatch Agent, ShellWatch PAM). Each card has a short explanation and minimum-step setup; Endpoint Setup adds a full field reference covering Label, Address, Description (with a callout on its role for MCP agents), UV, and SSH Agent Forwarding. MCP URL renders the actual origin; Agent install link points at the dedicated agent/v* release stream on GitHub; OpenSSH 10.3+ client requirement noted for the Agent flow. PAM step 3 spells out that the endpoint needs agent forwarding so SSH_AUTH_SOCK is forwarded. ServerSetupGuide placeholder shows a realistic example webauthn-sk one-liner. * feat: optional description on seed/demo endpoints; expose demo endpoints to MCP SeedEndpointSchema gains an optional description (max 1000 chars), which propagates to seedAdminEndpoints inserts and to the demo-endpoint synthesizer — so operator-configured context now surfaces in MCP's list_endpoints response. AgentSession and the shellwatch_manage_endpoints tool merge demo entries into the per-account list when accounts.showDemoEndpoints is on; read resolves demo:* ids via the synthesizer; create/update/delete reject demo ids with a clear error. AgentSession.createSession also resolves demo ids so MCP-driven session opens reach demo principals. * chore: address review feedback (#214) — empty-demo gate, integration coverage, signal-chip badge Hide the Demo Endpoints section + toggle on deployments with no demoEndpoints in config (new demoEndpointsAvailable field on /api/auth/me). Thread $account?.name into the wizard's ServerSetupGuide so the authorized_keys comment carries the real account, not 'user'. Sidebar gains a demo signal-chip below the endpoint label, reusing the global .badge convention. Doc comments on the showDemoEndpoints visibility-vs-auth semantics and on the seed-export side effect of formatEndpointAddress always emitting the username. Locks in the c228350 MCP demo wiring with 7 REST + 6 MCP integration tests. * test: cover demoEndpointsAvailable on GET /api/auth/me Two-case unit test for the field that gates the Settings → Endpoints demo section: false when the operator configured no demoEndpoints, true when at least one is present. Behavior was indirectly exercised in demo-endpoints-flow.test.ts; this nails it down in the most direct test surface. --- client/src/app.css | 7 + .../lib/components/ServerSetupGuide.svelte | 187 ++++ client/src/lib/components/Sidebar.svelte | 12 + client/src/lib/stores/account.ts | 16 + client/src/lib/stores/endpoints.ts | 2 + client/src/lib/utils/endpoint-address.ts | 9 +- client/src/routes/register/+page.svelte | 541 +--------- client/src/routes/settings/+layout.svelte | 29 +- .../src/routes/settings/api-keys/+page.svelte | 147 ++- .../routes/settings/endpoints/+page.svelte | 320 ++++-- client/src/routes/settings/keys/+page.svelte | 111 +- client/src/routes/settings/setup/+page.svelte | 367 +++++++ .../src/routes/settings/ssh-keys/+page.svelte | 178 ++++ config.sample.yaml | 21 + drizzle/0008_account_show_demo_endpoints.sql | 1 + drizzle/meta/0008_snapshot.json | 981 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/agent/agent-session.test.ts | 14 + src/agent/agent-session.ts | 32 +- src/config/index.ts | 2 + src/config/schema.ts | 20 + src/db/repositories/account-repo.ts | 5 +- src/db/schema.ts | 5 + src/db/seed.ts | 1 + src/demo-endpoints/index.test.ts | 96 ++ src/demo-endpoints/index.ts | Bin 0 -> 2556 bytes src/mcp/http-transport.test.ts | 2 + src/mcp/http-transport.ts | 25 +- src/mcp/server.test.ts | 174 +++- src/mcp/server.ts | 21 +- src/mcp/tools/endpoints.ts | 45 +- src/server/app.ts | 12 +- src/server/routes/accounts.test.ts | 48 +- src/server/routes/accounts.ts | 64 +- src/server/routes/endpoints.ts | 37 +- src/server/routes/sessions.ts | 16 +- src/test/helpers/test-config.ts | 1 + .../integration/demo-endpoints-flow.test.ts | 250 +++++ src/utils/endpoint-address.test.ts | 12 +- src/utils/endpoint-address.ts | 11 +- 40 files changed, 3018 insertions(+), 811 deletions(-) create mode 100644 client/src/lib/components/ServerSetupGuide.svelte create mode 100644 client/src/routes/settings/setup/+page.svelte create mode 100644 client/src/routes/settings/ssh-keys/+page.svelte create mode 100644 drizzle/0008_account_show_demo_endpoints.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 src/demo-endpoints/index.test.ts create mode 100644 src/demo-endpoints/index.ts create mode 100644 src/test/integration/demo-endpoints-flow.test.ts diff --git a/client/src/app.css b/client/src/app.css index 142caf9..7e683a8 100644 --- a/client/src/app.css +++ b/client/src/app.css @@ -326,6 +326,13 @@ pre, background: var(--warning, var(--secondary)); } +.badge-demo { + color: var(--accent); +} +.badge-demo::before { + background: var(--accent); +} + /* ------------------------------------------------------------------ * Status dots — small 6px squares, colored, glow when live * ------------------------------------------------------------------ */ diff --git a/client/src/lib/components/ServerSetupGuide.svelte b/client/src/lib/components/ServerSetupGuide.svelte new file mode 100644 index 0000000..84b6154 --- /dev/null +++ b/client/src/lib/components/ServerSetupGuide.svelte @@ -0,0 +1,187 @@ + + + + +

+ Two one-time steps on each server you want to reach. Requires + OpenSSH 8.4+. +

+ +
+ 1. Enable WebAuthn keys in /etc/ssh/sshd_config (reload sshd after) + {SSHD_CONFIG_ONE_LINER} + +
+ +{#if sshOneLiner && sshLine} +
+ 2. Add this passkey to ~/.ssh/authorized_keys + {sshOneLiner} + +
+{:else if passkey} +

+ This authenticator does not expose an SSH-compatible public key. You can still use it for login. To enable SSH, register a different passkey from Settings. +

+{:else} +
+ 2. Add a passkey to ~/.ssh/authorized_keys (example) + + echo 'webauthn-sk-ecdsa-sha2-nistp256@openssh.com + AAAAK3dlYmF1dGhuLXNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBExample…= + example_com-alice-yubikey5' >> ~/.ssh/authorized_keys + +
+

+ Replace the key body and comment with your own — copy the exact one-liner for a specific passkey + from Settings → Passkeys. +

+{/if} + + diff --git a/client/src/lib/components/Sidebar.svelte b/client/src/lib/components/Sidebar.svelte index 272d26c..d1b23e4 100644 --- a/client/src/lib/components/Sidebar.svelte +++ b/client/src/lib/components/Sidebar.svelte @@ -158,6 +158,9 @@
{ep.label} + {#if ep.isDemo} + demo + {/if}
:global(.btn) { flex-shrink: 0; } diff --git a/client/src/lib/stores/account.ts b/client/src/lib/stores/account.ts index 5d0be0f..1f3d96b 100644 --- a/client/src/lib/stores/account.ts +++ b/client/src/lib/stores/account.ts @@ -5,6 +5,9 @@ export interface AccountData { id: string; name: string; isAdmin: boolean; + showDemoEndpoints: boolean; + /** True when the operator has at least one demoEndpoints entry in config. */ + demoEndpointsAvailable: boolean; } export const account = writable(null); @@ -35,3 +38,16 @@ export async function updateAccountName(name: string): Promise { } await fetchAccount(); } + +export async function updateShowDemoEndpoints(showDemoEndpoints: boolean): Promise { + const res = await fetch("/api/auth/me", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ showDemoEndpoints }), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to update demo endpoint visibility"); + } + await fetchAccount(); +} diff --git a/client/src/lib/stores/endpoints.ts b/client/src/lib/stores/endpoints.ts index a8caced..7a4ec92 100644 --- a/client/src/lib/stores/endpoints.ts +++ b/client/src/lib/stores/endpoints.ts @@ -20,6 +20,8 @@ export interface Endpoint { userVerification: UserVerification; agentForward: boolean; description: string | null; + /** True if this is a virtual demo endpoint (read-only, from operator config). */ + isDemo: boolean; } export const endpoints = writable([]); diff --git a/client/src/lib/utils/endpoint-address.ts b/client/src/lib/utils/endpoint-address.ts index 28c4eaa..fdb0dcc 100644 --- a/client/src/lib/utils/endpoint-address.ts +++ b/client/src/lib/utils/endpoint-address.ts @@ -58,10 +58,13 @@ function parsePort(s: string): number { } /** - * Format an endpoint address, omitting defaults. + * Format an endpoint address. The username is always rendered so the display + * is unambiguous when the endpoint was created without a `user@` prefix and + * picked up the `shellwatch` default — surfacing the default explicitly + * matches what sshd actually sees on the wire. The port is still omitted + * when it's the SSH default (22). */ export function formatEndpointAddress(ep: EndpointAddress): string { - const userPart = ep.username !== DEFAULT_USERNAME ? `${ep.username}@` : ""; const portPart = ep.port !== DEFAULT_PORT ? `:${ep.port}` : ""; - return `${userPart}${ep.host}${portPart}`; + return `${ep.username}@${ep.host}${portPart}`; } diff --git a/client/src/routes/register/+page.svelte b/client/src/routes/register/+page.svelte index f4888d8..2618e73 100644 --- a/client/src/routes/register/+page.svelte +++ b/client/src/routes/register/+page.svelte @@ -5,9 +5,6 @@ import { resolve } from "$app/paths"; import { get } from "svelte/store"; import { selfRegistrationEnabled } from "$lib/stores/connection.js"; - import { createEndpoint, endpoints, fetchEndpoints } from "$lib/stores/endpoints.js"; - import { formatEndpointAddress, parseEndpointAddress } from "$lib/utils/endpoint-address.js"; - import { generateApiKey } from "$lib/stores/keys.js"; import { pushEnabled, pushLoading, @@ -20,23 +17,15 @@ import { credentials, fetchCredentials, registerAccount } from "$lib/stores/webauthn.js"; import Wordmark from "$lib/components/Wordmark.svelte"; - type StepId = - | "welcome" - | "passkey" - | "server-setup" - | "endpoints" - | "mcp" - | "notifications" - | "advanced"; + // The technical-setup steps (SSH server, endpoints, MCP key, advanced links) + // live in Settings → Setup now. Onboarding is a 3-step minimum-friction path + // that ends with the user landing in the app and seeing demo endpoints. + type StepId = "welcome" | "passkey" | "notifications"; const stepOrder: { id: StepId; label: string }[] = [ { id: "welcome", label: "Welcome" }, { id: "passkey", label: "Passkey" }, - { id: "server-setup", label: "Server" }, - { id: "endpoints", label: "Endpoints" }, - { id: "mcp", label: "MCP" }, { id: "notifications", label: "Notify" }, - { id: "advanced", label: "Done" }, ]; let isAdminSetup = $state(false); @@ -91,10 +80,9 @@ const result = await registerAccount(accountName.trim()); registeredCredentialId = result.credentialId; status = ""; - // Pull the freshly-stored credential row so we can show its - // authorized_keys entry on the server-setup step. Also pre-fetch - // endpoints so admin's seeded entries appear when they reach that step. - await Promise.all([fetchCredentials(), fetchEndpoints()]); + // Pull the freshly-stored credential row so the confirmation copy can + // surface its label. + await fetchCredentials(); next(); } catch (err) { error = (err as Error).message; @@ -103,128 +91,10 @@ loading = false; } - // --- Server setup step --- - // registerAccount returns the WebAuthn credentialId (base64url), not the - // row UUID — so match on credentialId, not the row's `id` field. const registeredCred = $derived( $credentials.find((c) => c.credentialId === registeredCredentialId) ?? null, ); - function sshComment(label: string): string { - const sanitize = (s: string) => - s - .toLowerCase() - .replace(/[^a-z0-9]/g, "_") - .replace(/_+/g, "_") - .replace(/^_|_$/g, ""); - const host = sanitize(window.location.hostname); - const name = sanitize(accountName.trim() || "user"); - const key = sanitize(label); - return `${host}-${name}-${key}`; - } - - const sshLine = $derived( - registeredCred?.authorizedKeysEntry - ? `${registeredCred.authorizedKeysEntry} ${sshComment(registeredCred.label)}` - : null, - ); - - const sshOneLiner = $derived(sshLine ? `echo '${sshLine}' >> ~/.ssh/authorized_keys` : null); - - // Single source for the sshd line — referenced from both the visible code - // block and the clipboard handler so they can't drift. Mirrors the value - // computed server-side in src/webauthn/ssh-key-format.ts; if that changes - // (additional algorithms, multi-line config), surface it through the - // register response and consume it here instead. - const SSHD_CONFIG_LINE = "PubkeyAcceptedAlgorithms=+webauthn-sk-ecdsa-sha2-nistp256@openssh.com"; - const SSHD_CONFIG_ONE_LINER = `echo '${SSHD_CONFIG_LINE}' | sudo tee -a /etc/ssh/sshd_config`; - - async function copyToClipboard(text: string, btn: HTMLButtonElement) { - const original = btn.innerHTML; - try { - await navigator.clipboard.writeText(text); - btn.innerHTML = "✓ Copied"; - } catch { - // Insecure context (HTTP non-localhost) or denied permission — surface - // the failure instead of flashing a false-positive "Copied" state. - btn.innerHTML = "Copy failed"; - } - setTimeout(() => { - btn.innerHTML = original; - }, 1500); - } - - // --- Endpoints step --- - let epLabel = $state(""); - let epAddress = $state(""); - - async function handleAddEndpoint() { - if (!epLabel || !epAddress) { - error = "Label and Address are required"; - return; - } - let parsed; - try { - parsed = parseEndpointAddress(epAddress); - } catch (err) { - error = (err as Error).message; - return; - } - loading = true; - error = ""; - try { - await createEndpoint({ - label: epLabel, - host: parsed.host, - port: parsed.port, - username: parsed.username, - }); - epLabel = ""; - epAddress = ""; - } catch (err) { - error = (err as Error).message; - } - loading = false; - } - - // --- MCP step --- - let apiKeyLabel = $state(""); - let generatedKey = $state(""); - let showApiKeyForm = $state(false); - - const mcpUrl = $derived(`${window.location.origin}/mcp`); - - // Sample config for non-OAuth MCP clients (HTTP-streaming with bearer auth). - // Inline the generated key directly so the user can copy the whole snippet - // and paste it into their MCP client without further substitution. - const mcpSampleConfig = $derived( - `{ - "mcpServers": { - "shellwatch": { - "type": "http", - "url": "${mcpUrl}", - "headers": { "Authorization": "Bearer ${generatedKey || ""}" } - } - } -}`, - ); - - async function handleGenerateApiKey() { - if (!apiKeyLabel) { - error = "Label is required"; - return; - } - loading = true; - error = ""; - try { - generatedKey = await generateApiKey(apiKeyLabel, ["mcp"]); - apiKeyLabel = ""; - } catch (err) { - error = (err as Error).message; - } - loading = false; - } - // --- Notifications step --- // Only probe the SW + push manager once. Re-firing on each back/forward // visit is harmless but wasteful — the user can't toggle from elsewhere @@ -345,161 +215,6 @@
{/if} - {:else if currentStep === "server-setup"} -

Use this passkey for SSH

-

- Two one-time steps on each server you want to reach. Requires - OpenSSH 8.4+. -

- - {#if sshOneLiner && sshLine} -
- 1. Enable WebAuthn keys in /etc/ssh/sshd_config (reload sshd after) - {SSHD_CONFIG_ONE_LINER} - -
- -
- 2. Add this passkey to ~/.ssh/authorized_keys - {sshOneLiner} - -
- {:else} -

- This authenticator does not expose an SSH-compatible public key. You can still use it for login. To enable SSH, register a different passkey from Settings. -

- {/if} - - - {:else if currentStep === "endpoints"} -

Add SSH Endpoints

-

- Configure the remote servers you want to manage. You can always add more later. -

- - {#if $endpoints.length > 0} -
- {#each $endpoints as ep (ep.id)} -
- {ep.label} - {formatEndpointAddress(ep)} -
- {/each} -
- {/if} - -
- - -
-
- -
- - {:else if currentStep === "mcp"} -

Connect AI agents

-

- Agents talk to via the Model Context Protocol. There are two ways to wire one up. -

- -
-
- Recommended - OAuth-capable agents -
-

- Point your agent at this URL. The MCP client (Claude Desktop, Cursor, etc.) redirects - through 's OAuth flow — you approve with a passkey, and mints a scoped - API key on the fly and injects it into the agent's session. No manual key handling. -

-
- {mcpUrl} - -
-
- - {#if generatedKey} - -
- API Key — copy now, shown only once - {generatedKey} - -
-
- Sample agent config -
{mcpSampleConfig}
- -
- {:else} -
- Use a static API key instead -

- For non-OAuth agents, generate a key with mcp scope and configure your client - with a bearer header. -

-
- - -
-
- {/if} - - {:else if currentStep === "notifications"}

Stay in the loop

@@ -545,46 +260,6 @@

{/if} - - {:else if currentStep === "advanced"} -

What's next

-

A few features worth knowing about.

- -
-
- shellwatch-agent -

- A local SSH agent that brokers signing through . Run - ssh user@host from your terminal — your passkey unlocks the connection, no private - key on disk. -

-
-
- pam-ssh-agent-webauthn -

- PAM module that gates remote actions (e.g. sudo) on a passkey signature - brokered through . Source at - github.com/rado0x54/pam-ssh-agent-webauthn. -

-
-
- Docs & guides -

- Setup walkthroughs and reference live at - docs.shellwatch.ai. -

-
-
-