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 diff --git a/README.md b/README.md index 5e94bcc..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 @@ -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/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 1979dfd..d1b23e4 100644 --- a/client/src/lib/components/Sidebar.svelte +++ b/client/src/lib/components/Sidebar.svelte @@ -1,5 +1,6 @@ + + + @@ -330,6 +508,15 @@ white-space: nowrap; } + /* Reuse the global signal-chip convention (.badge + .badge-demo) below the + label rather than inline, so a long label still fits the full sidebar + width before ellipsizing. align-self pins the chip to the left edge of + the column; margin-left/0 drops the global .badge inline-spacing default. */ + .endpoint-info :global(.badge) { + margin-left: 0; + align-self: flex-start; + } + .endpoint-item > :global(.btn) { flex-shrink: 0; } @@ -464,21 +651,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 +670,7 @@ letter-spacing: 0.04em; color: var(--primary); font-weight: 500; + align-self: flex-start; } .badge-admin::before { @@ -508,11 +681,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/lib/stores/account.ts b/client/src/lib/stores/account.ts index 2636afa..1f3d96b 100644 --- a/client/src/lib/stores/account.ts +++ b/client/src/lib/stores/account.ts @@ -5,7 +5,9 @@ export interface AccountData { id: string; name: string; isAdmin: boolean; - agentForward: boolean; + showDemoEndpoints: boolean; + /** True when the operator has at least one demoEndpoints entry in config. */ + demoEndpointsAvailable: boolean; } export const account = writable(null); @@ -37,15 +39,15 @@ export async function updateAccountName(name: string): Promise { await fetchAccount(); } -export async function updateAgentForward(agentForward: boolean): Promise { +export async function updateShowDemoEndpoints(showDemoEndpoints: boolean): Promise { const res = await fetch("/api/auth/me", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agentForward }), + body: JSON.stringify({ showDemoEndpoints }), }); if (!res.ok) { const err = await res.json(); - throw new Error(err.error || "Failed to update agent forwarding"); + 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 6b0479a..7a4ec92 100644 --- a/client/src/lib/stores/endpoints.ts +++ b/client/src/lib/stores/endpoints.ts @@ -18,7 +18,10 @@ export interface Endpoint { port: number; username: string; 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([]); @@ -35,6 +38,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/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/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/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..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,127 +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"; - - 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 @@ -344,160 +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_LINE} - -
- -
- 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

@@ -543,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. -

-
-
-