This is the canonical project instructions file for Claude Code working in this repo.
ShellWatch is a Human-in-the-Loop platform for agent-driven SSH. It's passkey-first and passkey-only — no passwords anywhere — with an SSH-agent proxy that forwards signing requests end-to-end to a user's WebAuthn passkey. Every agent action surfaces in realtime notifications, persists in a tamper-evident audit log, and can be gated behind explicit human approval before it touches the remote host.
Operationally, it brokers terminal sessions between configured SSH targets, human users (via the web UI), and AI agents (via MCP):
- A browser-based terminal UI for interactive SSH sessions
- An MCP (Model Context Protocol) interface for programmatic session control
- A shared TerminalManager that both UI and MCP operate on
- Runtime: Node.js with TypeScript (strict mode)
- Backend: Fastify with plugins (
@fastify/websocket,@fastify/cors) — owns all server logic (API, WebSocket, MCP, SSH) - Frontend: SvelteKit (adapter-static, client-side SPA) with Svelte 5, xterm.js — routing, layouts, and build only; no SSR or server-side SvelteKit features (Fastify handles that)
- SSH: ssh2 library
- Terminal: xterm.js
- MCP: @modelcontextprotocol/sdk (streamable HTTP transport)
- Persistence: Drizzle ORM with SQLite (audit log, pending actions, key/passkey storage)
- Auth: WebAuthn passkeys (human login + SSH signing). OAuth2/OIDC is fully delegated to Ory Hydra (web UI, MCP, and agent all use mediated DCR + authorization_code + PKCE); ShellWatch is Hydra's passkey-gated login/consent provider and verifies opaque bearer tokens via introspection. No passwords, no API keys.
- Config: YAML with zod validation
- Testing: Vitest (unit + integration)
- Linting: ESLint (typescript-eslint + eslint-plugin-svelte)
- Formatting: Prettier (prettier-plugin-svelte)
- Package manager: pnpm
- Agent client: Go binary in
agent-client/(separate module, MIT-licensed)
- Use ES modules (
import/export), not CommonJS (require) - Destructure imports when possible:
import { foo } from 'bar' - TypeScript strict mode — no
anyunless absolutely necessary - Use
.jsextensions in relative import paths (required for Node16 module resolution) - Single-line commit messages with category prefix:
feat:,fix:,chore:,refactor:,docs: - Do not add "Generated with Claude Code" or similar AI attribution to commits or PRs
- Functions with 5+ parameters must use a typed parameter object instead of positional args. Export the params interface for callers.
- Every new source file must carry an
SPDX-License-Identifierheader on its first line (after the shebang, if any). UseLicenseRef-FSL-1.1-Apache-2.0for files at the repo root andMITfor anything underagent-client/. CI enforces this viapnpm spdx:check; runpnpm spdx:writeto add headers to files you've just created. Comment styles by extension:// …for.ts/.mjs/.js/.go,<!-- … -->for.svelte/.html,/* … */for.css,# …for.sh/Makefile. - Keep the wire contract in sync with the code.
docs/api/is the language-agnostic source of truth for every external interface, frozen ahead of the Go rewrite (#210/#225) and used as the parity oracle. Whenever you add, remove, or change the shape of an HTTP route (path, method, request/response body, status code), a WebSocket message, or an MCP tool, update the matching artifact in the same change: HTTP →docs/api/openapi.yaml; WebSocket →docs/api/websocket-protocol.md; MCP →docs/api/mcp-tools.md. Document the shape as-is (mismatches are catalogued under "Known inconsistencies" indocs/api/README.md— don't silently normalize; converge in code + spec together as a deliberate change). Validate withpnpm api:lint.
src/
index.ts # Entry point — starts Fastify, loads config
config/ # Config schema (zod) and YAML loader
server/ # Fastify app, HTTP routes, WebSocket handler
terminal/ # TerminalManager, OutputBuffer, transport interface
transport/ # SSH transport implementation (ssh2)
agent/ # AgentSession — per-agent session isolation
agent-socket/ # Agent socket transport (Go agent-client bridge)
mcp/ # MCP server and streamable HTTP transport
hydra/ # Ory Hydra integration: passkey login/consent providers, mediated DCR, bearer introspection, discovery
webauthn/ # WebAuthn passkey registration and authentication
pending-action/ # Pending-action store (human-in-the-loop approvals)
audit/ # Audit log (signing requests, session events)
db/ # Drizzle ORM schema and migrations
util/, utils/ # Shared helpers
test/
helpers/ # Test infrastructure (SSH server, app, MCP/WS clients)
integration/ # Integration tests by category
client/ # SvelteKit frontend app (adapter-static)
src/
app.html # HTML shell
app.css # Global styles (CSS variables, shared classes)
service-worker.ts
lib/
stores/ # Svelte stores (ws, endpoints, keys, webauthn, auth)
components/ # Reusable components (Terminal, Sidebar)
utils/ # Utilities (FIDO signing)
routes/
+layout.svelte # Root layout (sidebar + mobile nav)
+page.svelte # Terminal view (default route)
admin/ # Admin views
audit/ # Audit log view
auth/callback/ # OAuth redirect target — exchanges code for tokens
observer/ # Multi-session grid view
passkey-invite/ # Passkey invite flow
register/ # Initial admin registration
session/ # Session detail
sign/ # FIDO/SSH signing approval
settings/ # Settings with tab sub-routes
endpoints/ # SSH endpoint management
keys/ # SSH key listing
passkeys/ # WebAuthn passkey management
sessions/ # Authorized clients (Hydra consent sessions) + invalidate
notifications/ # Notification channel config
general/ # General settings
agent-client/ # Go agent binary (separate module, MIT)
drizzle/ # Drizzle migrations
docs/ # Architecture and design notes
config.sample.yaml # Sample SSH endpoint config
# Development
pnpm dev # Start server + client (UI + API + WebSocket + MCP) with hot reload
pnpm dev:server # Server-only hot reload
# Production
pnpm build # Build server (tsc) + client (SvelteKit) for production
pnpm start # Run production server (serves pre-built client)
# Build (individual)
pnpm build:server # Compile server TypeScript only
pnpm build:client # Build SvelteKit client (svelte-kit sync + vite build)
# Quality
pnpm typecheck # Type check without emitting
pnpm lint # Lint with ESLint (server + client + Svelte)
pnpm lint:fix # Auto-fix lint issues
pnpm format # Format with Prettier
pnpm format:check # Check formatting without writing
pnpm test # Run unit tests (excludes integration)
pnpm test:integration # Run integration tests
pnpm test:watch # Run tests in watch mode
pnpm test:coverage # Run tests with coverage report
# SPDX headers
pnpm spdx:check # Verify all source files have SPDX headers (CI gate)
pnpm spdx:write # Add missing SPDX headers
# API wire contract (docs/api/)
pnpm api:preview # Build a self-contained Redoc HTML from the OpenAPI spec and open it (local, no upload)
pnpm api:lint # Validate docs/api/openapi.yamlFor detailed architecture documentation including data flows, component responsibilities, and planned extensions, see docs/architecture.md.
Key concepts:
- TerminalManager — central session registry, source-agnostic. All paths converge here.
- AgentSession (
src/agent/) — session isolation per agent connection. Each agent (MCP or future SSH) only sees its own sessions. - Web UI — admin view, sees all sessions regardless of source via REST API + WebSocket.
- MCP — streamable HTTP at
/mcp. Per-client stateful transport with debounced notifications.
[Web UI] [MCP Agent] [SSH Agent (planned)]
| | |
| [AgentSession] [AgentSession]
| | |
└──────────┼──────────────┘
|
[TerminalManager]
|
[SSH Transport]
|
[Remote host]
Tests cover both individual components and the full system. Integration tests use in-process infrastructure (ssh2 Server, Fastify app, MCP client, WebSocket client) — no external services needed.
src/terminal/output-buffer.test.ts— buffer append, incremental reads, evictionsrc/terminal/terminal-manager.test.ts— lifecycle, events, idle cleanup (mock transport)src/config/loader.test.ts— valid/invalid configs, validation errorssrc/mcp/server.test.ts— MCP tools via InMemoryTransportsrc/mcp/http-transport.test.ts— streamable HTTP transport behaviorsrc/transport/keys.test.ts— SSH key handling
Integration tests spin up real infrastructure per test suite:
- In-process ssh2 Server — ed25519 key auth, PTY, echo shell, server-push, disconnect simulation
- ShellWatch Fastify app — on random port,
skipStaticFiles: truefor test isolation - MCP client —
StreamableHTTPClientTransportagainst the app - WebSocket client —
wslibrary with message buffering andwaitForMessagehelper
Test categories (src/test/integration/):
mcp-flow.test.ts— MCP client full lifecyclerest-api-flow.test.ts— REST API CRUD + error codesws-flow.test.ts— WebSocket attach, I/O, close, disconnect survivabilitycross-actor.test.ts— MCP↔WebSocket and HTTP↔MCP session visibilityssh-server-events.test.ts— server-initiated output/disconnect propagationerror-scenarios.test.ts— error handling across all actorsconcurrent-sessions.test.ts— independent I/O, mixed actor sessionsagent-forward.test.ts— agent-client SSH key forwardingagent-proxy.test.ts— agent-client proxy transporthydra-oauth.test.ts— Hydra OAuth surfaces (mediated DCR, bearer gate + introspection/revocation, discovery, session management)passkey-invite-flow.test.ts— passkey invite minting and redemptionpasskey-stepup-flow.test.ts— step-up auth for sensitive actions
- Unit tests go next to the source file:
foo.ts→foo.test.ts - Integration tests go in
src/test/integration/ - Use
createTestLog()for diagnostics — logs dump automatically on test failure - Always clean up sessions in
finallyblocks - Use
waitForMessage(type, timeout)for async WebSocket assertions
SSH endpoints are loaded from a YAML config file at startup:
servers:
- id: dev-box
label: Dev Box
host: dev.example.com
port: 22
username: ubuntu
privateKeyPath: ./keys/dev-box.pemConfig path is resolved from: CLI arg > SHELLWATCH_CONFIG env var > ./config.yaml
- Local-first persistence: SQLite via Drizzle is the only datastore ShellWatch owns — no external/remote database. (OAuth2/OIDC is delegated to Ory Hydra, a required external service; Hydra keeps its own SQLite store, by default in the same
./datafolder.) - Single-instance only: Several stores live in process memory (challenge store, pending-action store, passkey-invite slot). Running ShellWatch behind a load balancer or in cluster mode would silently break those flows — invite tokens minted on one worker won't resolve on another. The product is currently scoped to one process per deployment.
- Account-scoped: All operations are scoped to the calling account. Admin is a role, not a cross-account view; never imply cross-account session/output visibility.
- Shared core: UI and MCP must use the same TerminalManager — no parallel implementations
- Real-time sync: Session changes broadcast to all WebSocket clients immediately
- Passkey-first: No passwords, no API keys. WebAuthn passkeys for humans; every client (UI, MCP, agent) authenticates with a Hydra-issued OAuth bearer token whose
subis the account. - Human-in-the-loop: Agent actions can require human approval via second channel (Slack, webhook)
- Simple: Prefer straightforward code over abstractions. Ship function first, polish later.