Skip to content

Architecture

ShellWatch is a single Node.js process. Everything — REST API, WebSocket, MCP, SSH agent proxy, static client, and the passkey login/consent pages — runs in one Fastify app on one port. Alongside it runs one supporting service: Ory Hydra, the OAuth2/OIDC authority that issues every access token (for the web UI, MCP clients, and the agent-client alike). Hydra stores its data in file SQLite under the same ./data directory ShellWatch uses — there’s no separate database server. ShellWatch is Hydra’s login + consent provider — Hydra never sees a credential; every login decision is delegated back to ShellWatch’s passkey gate. Hydra owns the login UI in the sense that the unauthenticated web UI is redirected into Hydra’s authorization flow, which redirects back to ShellWatch’s server-rendered passkey login page (there’s no separate SPA /login screen).

ShellWatch component architecture Browser Web UI MCP client Claude · Cursor · … Local SSH shellwatch-agent MCP path AgentSession Broker core TerminalManager — source-agnostic session bus Broker core SshTransport · SigningBridge · /sign/:id Remote sshd /ws /mcp /agent-proxy passkey-signed SSH

Across the top you have the three client surfaces — browser (/ws), MCP agent (/mcp), and the SSH agent proxy (/agent-proxy). They never talk to each other directly. They all converge on the TerminalManager, the central session registry that doesn’t know or care where a session was created.

The brain of the broker. Owns every active session, routes input to the SSH transport, buffers output, emits events (output, status-change, close). Source-agnostic — the same code path is used whether the input came from a browser keystroke, an MCP send_keys call, or an SSH agent operation.

Per-agent isolation layer. Each MCP client connection gets exactly one AgentSession, which can only see and operate on the sessions it created. The Web UI sees the sessions belonging to the logged-in account — including the ones that account’s own MCP agents created — and labels them by source (ui, mcp, ssh). It does not cross account boundaries: another user’s sessions are not visible.

The factory gathers all of the account’s available credentials (registered passkeys, plus — admin only — file-based SSH keys discovered in keyDirectory) and hands them to ssh2. There’s no per-endpoint binding to a specific credential: sshd selects whichever public key matches a line in the target’s authorized_keys during the standard SSH handshake. For a passkey, the signing step is browser-mediated via the SigningBridge and a /sign/:id page; for a file key, ssh2 signs locally with the on-disk private key, but a /sign/:id PendingAction is still raised for human approval before the connection opens. The transport itself is a thin wrapper — PTY allocation, resize, write, close, error.

Every human-in-the-loop action — passkey signature, agent-proxy operation — becomes a PendingAction in an in-memory store with a 60-second TTL. The NotificationDispatcher fans the action out to whatever channels are wired up:

  • WebSocketChannel — sends sign:request over WebSocket to every browser tab open for the target account.
  • PushChannel — Web Push to OS-level notifications when no tab is open.

The approver opens /sign/:id, sees the context (which endpoint, which agent, source IP, MCP client name/version, agent-client hostname/OS/version), and resolves the action — typically by completing a WebAuthn ceremony. The signature flows back through SigningBridge into ssh2.

A separate src/audit/ module subscribes to TerminalManager and PendingActionStore events and persists them append-only to two tables: audit_session_lifecycle (session opens and closes, by source) and audit_signing_requests (every PendingAction outcome — approved, denied, expired, or cancelled — with the full SignRequestContext snapshotted at decision time). Reads are keyset-paginated and never join to live tables, so a passkey rename or endpoint relabel doesn’t rewrite history.

The Web UI surfaces these at /audit/sessions and /audit/signings, scoped per account. Admin is a role on the account, not a global view across accounts. See Concepts → Audit log.

SQLite (better-sqlite3 in WAL mode) via Drizzle ORM, in ./data/shellwatch.db. Tables: accounts, admin_account, webauthn_credentials, ssh_keys, endpoints, audit_session_lifecycle, audit_signing_requests, push_subscriptions. Endpoints and passkeys are managed dynamically through the Web UI or REST API. OAuth clients, grants, and tokens live in Hydra’s own store — a separate file SQLite database (./data/hydra.sqlite) — and ShellWatch resolves bearer tokens against it by introspection (cached, 60 s default).

Here’s what happens when an MCP agent runs a command on a passkey-protected endpoint:

  1. The agent calls shellwatch_send_keys(sessionId, ["text:ls -la", "enter"]) over /mcp.
  2. The MCP server resolves the agent’s bearer token to an AgentSession, checks the session is owned by this agent, then calls TerminalManager.sendInput().
  3. If the session doesn’t exist yet (e.g. the agent calls create_session first), the SSH transport authenticates. The transport’s agent emits onSignRequestSigningBridge files a PendingAction → the dispatcher pings the approver’s browser and/or phone → approver taps their passkey on /sign/:id → assertion flows back as an SSH signature → ssh2 completes the handshake.
  4. Once the session is open, keystrokes flow through SshTransport.write() to the remote shell.
  5. Remote output flows back via the ssh2 stream → OutputBuffer.append()TerminalManager emits output.
  6. The MCP notification dispatcher debounces and fires output_available to the agent.
  7. The Web UI’s WebSocket handler also gets the event and pushes it to any attached browser tab as terminal:output — the human sees what the agent is doing in real time.
Node.js process (single)
├─ Fastify
│ ├─ / — SvelteKit static SPA
│ ├─ /api/* — REST API (sessions, endpoints, keys, accounts, audit)
│ ├─ /api/version — build SHA + ref (also embedded into the SPA shell)
│ ├─ /api/auth/* — anonymous first-registration bootstrap + session (OAuth client) management
│ ├─ /api/webauthn/* — passkey registration / management
│ ├─ /api/webauthn/stepup/* — step-up assertions for sensitive passkey ops
│ ├─ /api/webauthn/invite — issue a cross-device passkey-enrollment token
│ ├─ /api/passkey-invite/* — token-gated registration on the second device
│ ├─ /api/hydra/* — passkey login + consent providers for Hydra
│ ├─ /ws — WebSocket (terminal I/O + sign:request)
│ ├─ /mcp — MCP streamable HTTP
│ ├─ /agent-proxy — SSH agent WebSocket
│ ├─ /api/hydra/register — mediated DCR (policy-gated, provisions clients in Hydra)
│ ├─ /.well-known/oauth-* — OAuth discovery metadata
│ └─ /health
├─ TerminalManager + ssh2 connections
├─ SigningBridge + PendingActionStore (60s TTL)
├─ NotificationDispatcher (WS + Push)
├─ Audit log (session lifecycle + signing-request outcomes)
└─ SQLite (WAL mode) — ./data/shellwatch.db
Ory Hydra (sibling process / container)
├─ :4444 public — /oauth2/auth, /oauth2/token, /oauth2/revoke, JWKS
├─ :4445 admin — login/consent acceptance, client CRUD, introspection
│ (ShellWatch only — never internet-exposed)
└─ file SQLite — ./data/hydra.sqlite (no separate DB server)

The SvelteKit frontend is built with adapter-static — no SSR, no server-side routes, no Node runtime in the browser tier. Fastify serves the built assets directly.

For deeper detail (data flows, file layout, the ssh2 fork, etc.) see the project’s architecture doc on GitHub. For the broker’s runtime configuration (config.yaml) see the Self-hosting section.