diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..74c8466 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# Dependencies (rebuilt in container) +node_modules + +# Git metadata +.git +.github +.gitignore + +# Build output (rebuilt in container) +dist + +# Env files — never bake secrets into the image +.env +.env.* + +# Logs and debug +*.log +npm-debug.log* +bun-debug.log* + +# Test artifacts +coverage +.nyc_output + +# Editor / OS noise +.DS_Store +Thumbs.db +.vscode +.idea + +# Docker context itself +docker diff --git a/.gitignore b/.gitignore index f49c47c..7ae1f8b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ build/ .env.local .env.*.local +# Prompts +prompts/ + # OS files .DS_Store Thumbs.db diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 0000000..e278c3f --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1,35 @@ +# NOTE: Docker reads .dockerignore from the build context root. +# The canonical copy lives at /.dockerignore — keep both in sync. + +# Dependencies (rebuilt in container) +node_modules + +# Git metadata +.git +.github +.gitignore + +# Build output (rebuilt in container) +dist + +# Env files — never bake secrets into the image +.env +.env.* + +# Logs and debug +*.log +npm-debug.log* +bun-debug.log* + +# Test artifacts +coverage +.nyc_output + +# Editor / OS noise +.DS_Store +Thumbs.db +.vscode +.idea + +# Docker context itself +docker diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..9e72faf --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,83 @@ +# ───────────────────────────────────────────────────────────── +# Claude Web Terminal — Production Container +# ───────────────────────────────────────────────────────────── +# Multi-stage build: compiles node-pty native module and bundles +# the Claude CLI, then copies artifacts into a slim runtime image. +# +# Usage: +# docker build -f docker/Dockerfile -t claude-web . +# docker run -p 3000:3000 -e ANTHROPIC_API_KEY=sk-ant-... claude-web +# ───────────────────────────────────────────────────────────── + +# ── Stage 1: Build ──────────────────────────────────────────── +FROM oven/bun:1 AS builder + +WORKDIR /app + +# Build tools required to compile node-pty's native C++ addon +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +# Copy manifests first for layer caching +COPY package.json bun.lockb* ./ + +# Install all deps (triggers node-pty native compilation) +RUN bun install --frozen-lockfile 2>/dev/null || bun install + +# Copy source tree +COPY . . + +# Bundle the Claude CLI (produces dist/cli.mjs) +RUN bun run build:prod + +# ── Stage 2: Runtime ────────────────────────────────────────── +FROM oven/bun:1 AS runtime + +WORKDIR /app + +# curl for health checks; no build tools needed at runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user that PTY sessions will run under +RUN groupadd -r claude && useradd -r -g claude -m -d /home/claude claude + +# Compiled node_modules (includes native node-pty binary) +COPY --from=builder /app/node_modules ./node_modules + +# Bundled Claude CLI +COPY --from=builder /app/dist ./dist + +# PTY server source (bun runs TypeScript natively) +COPY --from=builder /app/src/server ./src/server + +# TypeScript config needed for bun's module resolution +COPY --from=builder /app/tsconfig.json ./tsconfig.json + +# Thin wrapper so the PTY server can exec `claude` as a subprocess +RUN printf '#!/bin/sh\nexec bun /app/dist/cli.mjs "$@"\n' \ + > /usr/local/bin/claude && chmod +x /usr/local/bin/claude + +# Entrypoint script +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Allow the claude user to write its config into its home dir +RUN chown -R claude:claude /home/claude + +# ── Defaults ────────────────────────────────────────────────── +ENV NODE_ENV=production \ + PORT=3000 \ + MAX_SESSIONS=5 \ + CLAUDE_BIN=claude + +EXPOSE 3000 + +USER claude + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:${PORT:-3000}/health || exit 1 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..ff16d69 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,28 @@ +services: + claude-web: + build: + context: .. + dockerfile: docker/Dockerfile + ports: + - "${PORT:-3000}:3000" + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - AUTH_TOKEN=${AUTH_TOKEN:-} + - MAX_SESSIONS=${MAX_SESSIONS:-5} + - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} + volumes: + # Persist Claude's config and session data across restarts + - claude-data:/home/claude/.claude + tmpfs: + # PTY processes write temp files here; no persistent storage needed + - /tmp:mode=1777 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + claude-data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..d95006d --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -e + +# ── Validate required env vars ──────────────────────────────── +if [ -z "$ANTHROPIC_API_KEY" ]; then + echo "ERROR: ANTHROPIC_API_KEY is not set." >&2 + echo "" >&2 + echo " docker run -p 3000:3000 -e ANTHROPIC_API_KEY=sk-ant-... claude-web" >&2 + echo "" >&2 + echo " Or via docker-compose with a .env file:" >&2 + echo " ANTHROPIC_API_KEY=sk-ant-... docker-compose up" >&2 + exit 1 +fi + +# The API key is forwarded to child PTY processes via process.env, +# so the claude CLI will pick it up automatically — no config file needed. + +echo "Claude Web Terminal starting on port ${PORT:-3000}..." +if [ -n "$AUTH_TOKEN" ]; then + echo " Auth token protection: enabled" +fi +if [ -n "$ALLOWED_ORIGINS" ]; then + echo " Allowed origins: $ALLOWED_ORIGINS" +fi +echo " Max sessions: ${MAX_SESSIONS:-5}" + +# Hand off to the PTY WebSocket server +exec bun /app/src/server/web/pty-server.ts diff --git a/package-lock.json b/package-lock.json index 23d9cc7..ee8f416 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "ignore": "^6.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", + "node-pty": "^1.1.0", "p-map": "^7.0.0", "picomatch": "^4.0.0", "proper-lockfile": "^4.1.2", @@ -2411,6 +2412,12 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -2451,6 +2458,16 @@ } } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", diff --git a/package.json b/package.json index 1840ae4..bc02317 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "build": "bun scripts/build-bundle.ts", "build:watch": "bun scripts/build-bundle.ts --watch", "build:prod": "bun scripts/build-bundle.ts --minify", + "build:web": "bun scripts/build-web.ts", + "build:web:watch": "bun scripts/build-web.ts --watch", + "build:web:prod": "bun scripts/build-web.ts --minify", "typecheck": "tsc --noEmit", "lint": "biome check src/", "lint:fix": "biome check --write src/", @@ -30,6 +33,7 @@ "@opentelemetry/sdk-logs": "^0.57.0", "@opentelemetry/sdk-metrics": "^1.30.0", "@opentelemetry/sdk-trace-base": "^1.30.0", + "auto-bind": "^5.0.1", "axios": "^1.7.0", "chalk": "^5.4.0", "chokidar": "^4.0.0", @@ -43,13 +47,13 @@ "ignore": "^6.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", + "node-pty": "^1.1.0", "p-map": "^7.0.0", "picomatch": "^4.0.0", "proper-lockfile": "^4.1.2", "qrcode": "^1.5.0", "react": "^19.0.0", "react-reconciler": "^0.31.0", - "auto-bind": "^5.0.1", "semver": "^7.6.0", "stack-utils": "^2.0.6", "strip-ansi": "^7.1.0", @@ -61,7 +65,13 @@ "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yaml": "^2.6.0", - "zod": "^3.24.0" + "zod": "^3.24.0", + "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-unicode11": "^0.8.0", + "@xterm/addon-webgl": "^0.18.0" }, "devDependencies": { "@biomejs/biome": "^1.9.0", @@ -82,5 +92,3 @@ }, "packageManager": "bun@1.1.0" } - - diff --git a/scripts/build-web.ts b/scripts/build-web.ts new file mode 100644 index 0000000..da758ce --- /dev/null +++ b/scripts/build-web.ts @@ -0,0 +1,58 @@ +// scripts/build-web.ts +// Bundles the browser-side terminal frontend. +// +// Usage: +// bun scripts/build-web.ts # dev build +// bun scripts/build-web.ts --watch # watch mode +// bun scripts/build-web.ts --minify # production (minified) + +import * as esbuild from 'esbuild' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dir: string = + (import.meta as any).dir ?? + (import.meta as any).dirname ?? + dirname(fileURLToPath(import.meta.url)) + +const ROOT = resolve(__dir, '..') +const ENTRY = resolve(ROOT, 'src/server/web/terminal.ts') +const OUT_DIR = resolve(ROOT, 'src/server/web/public') + +const watch = process.argv.includes('--watch') +const minify = process.argv.includes('--minify') + +const buildOptions: esbuild.BuildOptions = { + entryPoints: [ENTRY], + bundle: true, + platform: 'browser', + target: ['es2020', 'chrome90', 'firefox90', 'safari14'], + format: 'esm', + outdir: OUT_DIR, + // CSS imported from JS is auto-emitted alongside the JS output + loader: { '.css': 'css' }, + minify, + sourcemap: minify ? false : 'inline', + tsconfig: resolve(ROOT, 'src/server/web/tsconfig.json'), + logLevel: 'info', +} + +async function main() { + if (watch) { + const ctx = await esbuild.context(buildOptions) + await ctx.watch() + console.log('Watching src/server/web/terminal.ts...') + } else { + const start = Date.now() + const result = await esbuild.build(buildOptions) + if (result.errors.length > 0) { + process.exit(1) + } + console.log(`Web build complete in ${Date.now() - start}ms → ${OUT_DIR}`) + } +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/src/server/web/__tests__/auth.test.ts b/src/server/web/__tests__/auth.test.ts new file mode 100644 index 0000000..de0671e --- /dev/null +++ b/src/server/web/__tests__/auth.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { describe, it, afterEach } from "node:test"; +import type { IncomingMessage } from "node:http"; +import { ConnectionRateLimiter, validateAuthToken } from "../auth.js"; + +function mockReq(url: string, host = "localhost:3000"): IncomingMessage { + return { url, headers: { host } } as unknown as IncomingMessage; +} + +describe("validateAuthToken", () => { + const originalEnv = process.env.AUTH_TOKEN; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.AUTH_TOKEN; + } else { + process.env.AUTH_TOKEN = originalEnv; + } + }); + + it("allows all connections when AUTH_TOKEN is not set", () => { + delete process.env.AUTH_TOKEN; + assert.equal(validateAuthToken(mockReq("/ws")), true); + }); + + it("rejects connections without token when AUTH_TOKEN is set", () => { + process.env.AUTH_TOKEN = "secret123"; + assert.equal(validateAuthToken(mockReq("/ws")), false); + }); + + it("rejects connections with wrong token", () => { + process.env.AUTH_TOKEN = "secret123"; + assert.equal(validateAuthToken(mockReq("/ws?token=wrong")), false); + }); + + it("accepts connections with correct token", () => { + process.env.AUTH_TOKEN = "secret123"; + assert.equal(validateAuthToken(mockReq("/ws?token=secret123")), true); + }); +}); + +describe("ConnectionRateLimiter", () => { + it("allows connections under the limit", () => { + const limiter = new ConnectionRateLimiter(3, 60_000); + assert.equal(limiter.allow("1.2.3.4"), true); + assert.equal(limiter.allow("1.2.3.4"), true); + assert.equal(limiter.allow("1.2.3.4"), true); + }); + + it("blocks connections over the limit", () => { + const limiter = new ConnectionRateLimiter(2, 60_000); + assert.equal(limiter.allow("1.2.3.4"), true); + assert.equal(limiter.allow("1.2.3.4"), true); + assert.equal(limiter.allow("1.2.3.4"), false); + }); + + it("tracks IPs independently", () => { + const limiter = new ConnectionRateLimiter(1, 60_000); + assert.equal(limiter.allow("1.2.3.4"), true); + assert.equal(limiter.allow("5.6.7.8"), true); + assert.equal(limiter.allow("1.2.3.4"), false); + assert.equal(limiter.allow("5.6.7.8"), false); + }); + + it("cleans up stale entries", () => { + const limiter = new ConnectionRateLimiter(1, 1); // 1ms window + limiter.allow("1.2.3.4"); + // Wait for window to expire + const start = Date.now(); + while (Date.now() - start < 5) { + // busy wait 5ms + } + limiter.cleanup(); + assert.equal(limiter.allow("1.2.3.4"), true); + }); +}); diff --git a/src/server/web/__tests__/session-manager.test.ts b/src/server/web/__tests__/session-manager.test.ts new file mode 100644 index 0000000..2b1de2c --- /dev/null +++ b/src/server/web/__tests__/session-manager.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { describe, it, mock } from "node:test"; +import { EventEmitter } from "node:events"; +import { SessionManager } from "../session-manager.js"; +import type { IPty } from "node-pty"; +import type { WebSocket } from "ws"; + +// --- Mock factories --- + +function createMockPty(): IPty & { _dataHandler?: (d: string) => void; _exitHandler?: (e: { exitCode: number; signal: number }) => void } { + const mockPty = { + onData(handler: (data: string) => void) { + mockPty._dataHandler = handler; + return { dispose() {} }; + }, + onExit(handler: (e: { exitCode: number; signal: number }) => void) { + mockPty._exitHandler = handler; + return { dispose() {} }; + }, + write: mock.fn(), + resize: mock.fn(), + kill: mock.fn(), + pid: 12345, + cols: 80, + rows: 24, + process: "claude", + handleFlowControl: false, + pause: mock.fn(), + resume: mock.fn(), + clear: mock.fn(), + _dataHandler: undefined as ((d: string) => void) | undefined, + _exitHandler: undefined as ((e: { exitCode: number; signal: number }) => void) | undefined, + }; + return mockPty as unknown as IPty & { _dataHandler?: (d: string) => void; _exitHandler?: (e: { exitCode: number; signal: number }) => void }; +} + +function createMockWs(): WebSocket & EventEmitter { + const emitter = new EventEmitter(); + const ws = Object.assign(emitter, { + OPEN: 1, + CONNECTING: 0, + readyState: 1, + send: mock.fn(), + close: mock.fn(), + }); + return ws as unknown as WebSocket & EventEmitter; +} + +describe("SessionManager", () => { + it("creates a session and tracks it", () => { + const mockPty = createMockPty(); + const manager = new SessionManager(5, () => mockPty); + const ws = createMockWs(); + + const session = manager.create(ws); + assert.ok(session); + assert.equal(manager.activeCount, 1); + assert.ok(session.id); + assert.equal(session.ws, ws); + assert.equal(session.pty, mockPty); + }); + + it("enforces max sessions limit", () => { + const manager = new SessionManager(1, () => createMockPty()); + + const session1 = manager.create(createMockWs()); + assert.ok(session1); + + const ws2 = createMockWs(); + const session2 = manager.create(ws2); + assert.equal(session2, null); + assert.equal(manager.activeCount, 1); + }); + + it("forwards PTY data to WebSocket", () => { + const mockPty = createMockPty(); + const manager = new SessionManager(5, () => mockPty); + const ws = createMockWs(); + + manager.create(ws); + + // Simulate PTY output + mockPty._dataHandler?.("hello world"); + assert.equal((ws.send as ReturnType).mock.callCount(), 1); + }); + + it("forwards WebSocket input to PTY", () => { + const mockPty = createMockPty(); + const manager = new SessionManager(5, () => mockPty); + const ws = createMockWs(); + + manager.create(ws); + + // Simulate WebSocket input + ws.emit("message", Buffer.from("ls\n")); + assert.equal((mockPty.write as ReturnType).mock.callCount(), 1); + }); + + it("handles resize messages", () => { + const mockPty = createMockPty(); + const manager = new SessionManager(5, () => mockPty); + const ws = createMockWs(); + + manager.create(ws); + + ws.emit("message", JSON.stringify({ type: "resize", cols: 120, rows: 40 })); + assert.equal((mockPty.resize as ReturnType).mock.callCount(), 1); + }); + + it("handles ping messages with pong response", () => { + const mockPty = createMockPty(); + const manager = new SessionManager(5, () => mockPty); + const ws = createMockWs(); + + manager.create(ws); + + ws.emit("message", JSON.stringify({ type: "ping" })); + // Should have sent connected + pong + const calls = (ws.send as ReturnType).mock.calls; + const lastCall = calls[calls.length - 1]; + assert.ok(lastCall); + const parsed = JSON.parse(lastCall.arguments[0] as string); + assert.equal(parsed.type, "pong"); + }); + + it("cleans up session on WebSocket close", () => { + const mockPty = createMockPty(); + const manager = new SessionManager(5, () => mockPty); + const ws = createMockWs(); + + manager.create(ws); + assert.equal(manager.activeCount, 1); + + ws.emit("close"); + assert.equal(manager.activeCount, 0); + }); + + it("handles PTY spawn failure gracefully", () => { + const manager = new SessionManager(5, () => { + throw new Error("no pty available"); + }); + const ws = createMockWs(); + + const session = manager.create(ws); + assert.equal(session, null); + assert.equal((ws.close as ReturnType).mock.callCount(), 1); + }); + + it("destroyAll cleans up all sessions", () => { + const manager = new SessionManager(5, () => createMockPty()); + + manager.create(createMockWs()); + manager.create(createMockWs()); + assert.equal(manager.activeCount, 2); + + manager.destroyAll(); + assert.equal(manager.activeCount, 0); + }); +}); diff --git a/src/server/web/auth.ts b/src/server/web/auth.ts new file mode 100644 index 0000000..03d9224 --- /dev/null +++ b/src/server/web/auth.ts @@ -0,0 +1,65 @@ +import type { IncomingMessage } from "http"; + +/** + * Validates the auth token from a WebSocket upgrade request. + * If AUTH_TOKEN is not set, all connections are allowed. + */ +export function validateAuthToken(req: IncomingMessage): boolean { + const authToken = process.env.AUTH_TOKEN; + if (!authToken) { + return true; + } + + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const token = url.searchParams.get("token"); + return token === authToken; +} + +/** + * Simple per-IP rate limiter for new connections. + */ +export class ConnectionRateLimiter { + private attempts = new Map(); + private readonly maxPerWindow: number; + private readonly windowMs: number; + + constructor(maxPerWindow = 5, windowMs = 60_000) { + this.maxPerWindow = maxPerWindow; + this.windowMs = windowMs; + } + + /** + * Returns true if the connection should be allowed. + */ + allow(ip: string): boolean { + const now = Date.now(); + const timestamps = this.attempts.get(ip) ?? []; + + // Prune old entries + const recent = timestamps.filter((t) => now - t < this.windowMs); + + if (recent.length >= this.maxPerWindow) { + this.attempts.set(ip, recent); + return false; + } + + recent.push(now); + this.attempts.set(ip, recent); + return true; + } + + /** + * Periodically clean up stale entries. + */ + cleanup(): void { + const now = Date.now(); + for (const [ip, timestamps] of this.attempts) { + const recent = timestamps.filter((t) => now - t < this.windowMs); + if (recent.length === 0) { + this.attempts.delete(ip); + } else { + this.attempts.set(ip, recent); + } + } + } +} diff --git a/src/server/web/auth/adapter.ts b/src/server/web/auth/adapter.ts new file mode 100644 index 0000000..df5eed6 --- /dev/null +++ b/src/server/web/auth/adapter.ts @@ -0,0 +1,220 @@ +import { createHmac, createCipheriv, createDecipheriv, randomBytes } from "crypto"; +import type { IncomingMessage, ServerResponse } from "http"; +import type { Application, Request, Response, NextFunction } from "express"; + +// ── Public types ──────────────────────────────────────────────────────────── + +export interface AuthUser { + id: string; + email?: string; + name?: string; + isAdmin: boolean; + /** Decrypted Anthropic API key (only present for apikey auth provider) */ + apiKey?: string; +} + +export interface SessionData { + userId: string; + email?: string; + name?: string; + isAdmin: boolean; + /** AES-256-GCM encrypted Anthropic API key */ + encryptedApiKey?: string; + createdAt: number; + expiresAt: number; +} + +/** Augmented Express request with authenticated user attached. */ +export interface AuthenticatedRequest extends Request { + user: AuthUser; +} + +/** Auth adapter — pluggable authentication strategy. */ +export interface AuthAdapter { + /** + * Authenticate an IncomingMessage (HTTP or WebSocket upgrade). + * Returns null when the request is unauthenticated. + */ + authenticate(req: IncomingMessage): AuthUser | null; + + /** + * Register login/callback/logout routes on the Express app. + * Called once during server startup before any requests arrive. + */ + setupRoutes(app: Application): void; + + /** + * Express middleware that rejects unauthenticated requests. + * For browser clients it redirects to /auth/login; for API clients it + * returns 401 JSON. + */ + requireAuth(req: Request, res: Response, next: NextFunction): void; +} + +// ── Cookie helpers ────────────────────────────────────────────────────────── + +const COOKIE_NAME = "cc_session"; +const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 h + +function parseCookies(header: string): Record { + const out: Record = {}; + for (const part of header.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + const key = part.slice(0, eq).trim(); + const val = part.slice(eq + 1).trim(); + if (key) out[key] = decodeURIComponent(val); + } + return out; +} + +// ── SessionStore ──────────────────────────────────────────────────────────── + +/** + * In-memory server-side session store. + * + * Sessions are identified by a random UUID stored in a signed HttpOnly cookie. + * Sensitive values (API keys) are stored encrypted with AES-256-GCM. + */ +export class SessionStore { + private readonly sessions = new Map(); + /** 32-byte key derived from the session secret. */ + private readonly key: Buffer; + + constructor(secret: string) { + // Derive a stable 32-byte key so the same secret always produces the + // same key (important if the process restarts while cookies are live). + const hmac = createHmac("sha256", secret); + hmac.update("cc-session-key-v1"); + this.key = hmac.digest(); + + // Purge expired sessions every 5 minutes. + setInterval(() => this.cleanup(), 5 * 60_000).unref(); + } + + // ── CRUD ────────────────────────────────────────────────────────────────── + + create(data: Omit): string { + const id = crypto.randomUUID(); + this.sessions.set(id, { + ...data, + createdAt: Date.now(), + expiresAt: Date.now() + SESSION_TTL_MS, + }); + return id; + } + + get(id: string): SessionData | undefined { + const s = this.sessions.get(id); + if (!s) return undefined; + if (Date.now() > s.expiresAt) { + this.sessions.delete(id); + return undefined; + } + return s; + } + + delete(id: string): void { + this.sessions.delete(id); + } + + // ── Cookie signing ──────────────────────────────────────────────────────── + + sign(id: string): string { + const hmac = createHmac("sha256", this.key); + hmac.update(id); + return `${id}.${hmac.digest("base64url")}`; + } + + /** + * Verifies the HMAC and returns the raw session ID, or null on failure. + * Uses constant-time comparison to prevent timing attacks. + */ + unsign(signed: string): string | null { + const dot = signed.lastIndexOf("."); + if (dot === -1) return null; + const id = signed.slice(0, dot); + const provided = signed.slice(dot + 1); + + const hmac = createHmac("sha256", this.key); + hmac.update(id); + const expected = hmac.digest("base64url"); + + if (provided.length !== expected.length) return null; + let diff = 0; + for (let i = 0; i < provided.length; i++) { + diff |= provided.charCodeAt(i) ^ expected.charCodeAt(i); + } + return diff === 0 ? id : null; + } + + // ── Request / response helpers ──────────────────────────────────────────── + + /** Returns the session data for the current request, or null. */ + getFromRequest(req: IncomingMessage): SessionData | null { + const cookies = parseCookies(req.headers.cookie ?? ""); + const signed = cookies[COOKIE_NAME]; + if (!signed) return null; + const id = this.unsign(signed); + if (!id) return null; + return this.get(id) ?? null; + } + + /** Returns the raw session ID from the request cookie, or null. */ + getIdFromRequest(req: IncomingMessage): string | null { + const cookies = parseCookies(req.headers.cookie ?? ""); + const signed = cookies[COOKIE_NAME]; + if (!signed) return null; + return this.unsign(signed); + } + + setCookie(res: ServerResponse, sessionId: string): void { + const signed = this.sign(sessionId); + const maxAge = Math.floor(SESSION_TTL_MS / 1000); + res.setHeader("Set-Cookie", [ + `${COOKIE_NAME}=${encodeURIComponent(signed)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`, + ]); + } + + clearCookie(res: ServerResponse): void { + res.setHeader("Set-Cookie", [ + `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`, + ]); + } + + // ── Encryption ──────────────────────────────────────────────────────────── + + /** Encrypts a plaintext string with AES-256-GCM for session storage. */ + encrypt(plaintext: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", this.key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + // Layout: iv(12) | tag(16) | ciphertext + return Buffer.concat([iv, tag, ciphertext]).toString("base64url"); + } + + /** Decrypts a value produced by {@link encrypt}. Returns null on failure. */ + decrypt(encoded: string): string | null { + try { + const buf = Buffer.from(encoded, "base64url"); + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const ciphertext = buf.subarray(28); + const decipher = createDecipheriv("aes-256-gcm", this.key, iv); + decipher.setAuthTag(tag); + return decipher.update(ciphertext).toString("utf8") + decipher.final("utf8"); + } catch { + return null; + } + } + + // ── Internals ───────────────────────────────────────────────────────────── + + private cleanup(): void { + const now = Date.now(); + for (const [id, s] of this.sessions) { + if (now > s.expiresAt) this.sessions.delete(id); + } + } +} diff --git a/src/server/web/auth/oauth-auth.ts b/src/server/web/auth/oauth-auth.ts new file mode 100644 index 0000000..fdacfd6 --- /dev/null +++ b/src/server/web/auth/oauth-auth.ts @@ -0,0 +1,276 @@ +import { randomBytes } from "crypto"; +import { request as nodeRequest } from "https"; +import { request as nodeHttpRequest } from "http"; +import type { IncomingMessage } from "http"; +import type { Application, Request, Response, NextFunction } from "express"; +import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js"; +import { SessionStore } from "./adapter.js"; + +interface OIDCDiscovery { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; +} + +interface TokenResponse { + access_token: string; + id_token?: string; + error?: string; +} + +interface UserInfoResponse { + sub: string; + email?: string; + name?: string; + preferred_username?: string; +} + +/** Minimal JSON fetch over https/http. */ +function fetchJSON(url: string, opts?: { method?: string; body?: string; headers?: Record }): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const isHttps = parsed.protocol === "https:"; + const req = (isHttps ? nodeRequest : nodeHttpRequest)( + { + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname + parsed.search, + method: opts?.method ?? "GET", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + ...(opts?.headers ?? {}), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")) as T); + } catch (e) { + reject(e); + } + }); + }, + ); + req.on("error", reject); + if (opts?.body) req.write(opts.body); + req.end(); + }); +} + +/** + * OAuth2 / OIDC authentication adapter. + * + * Performs a server-side authorization code flow against any OIDC-compliant + * identity provider (Google, GitHub, Okta, Auth0, …). + * + * Required env vars: + * OAUTH_CLIENT_ID — client ID registered with the provider + * OAUTH_CLIENT_SECRET — client secret + * OAUTH_ISSUER — issuer base URL, e.g. https://accounts.google.com + * + * Optional: + * OAUTH_CALLBACK_URL — full redirect URI (default: http://localhost:3000/auth/callback) + * OAUTH_SCOPES — space-separated scopes (default: openid email profile) + * ADMIN_USERS — comma-separated user IDs or emails that get admin role + */ +export class OAuthAdapter implements AuthAdapter { + private readonly store: SessionStore; + private readonly clientId: string; + private readonly clientSecret: string; + private readonly issuer: string; + private readonly callbackUrl: string; + private readonly scopes: string; + private readonly adminUsers: ReadonlySet; + /** Cached OIDC discovery document. */ + private discovery: OIDCDiscovery | null = null; + /** In-flight state tokens to prevent CSRF. */ + private readonly pendingStates = new Map(); + + constructor(store: SessionStore) { + this.store = store; + this.clientId = process.env.OAUTH_CLIENT_ID ?? ""; + this.clientSecret = process.env.OAUTH_CLIENT_SECRET ?? ""; + this.issuer = (process.env.OAUTH_ISSUER ?? "").replace(/\/$/, ""); + this.callbackUrl = + process.env.OAUTH_CALLBACK_URL ?? "http://localhost:3000/auth/callback"; + this.scopes = process.env.OAUTH_SCOPES ?? "openid email profile"; + this.adminUsers = new Set( + (process.env.ADMIN_USERS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + + // Periodically prune stale state tokens (>10 min old). + setInterval(() => { + const cutoff = Date.now() - 10 * 60_000; + for (const [s, t] of this.pendingStates) { + if (t < cutoff) this.pendingStates.delete(s); + } + }, 5 * 60_000).unref(); + } + + authenticate(req: IncomingMessage): AuthUser | null { + const session = this.store.getFromRequest(req); + if (!session) return null; + return { + id: session.userId, + email: session.email, + name: session.name, + isAdmin: + session.isAdmin || + this.adminUsers.has(session.userId) || + (session.email ? this.adminUsers.has(session.email) : false), + }; + } + + setupRoutes(app: Application): void { + // GET /auth/login — redirect to the identity provider + app.get("/auth/login", async (_req, res) => { + try { + const disc = await this.getDiscovery(); + const state = randomBytes(16).toString("hex"); + this.pendingStates.set(state, Date.now()); + + const authUrl = new URL(disc.authorization_endpoint); + authUrl.searchParams.set("client_id", this.clientId); + authUrl.searchParams.set("redirect_uri", this.callbackUrl); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", this.scopes); + authUrl.searchParams.set("state", state); + + // Store state in a short-lived cookie for CSRF validation + res.setHeader("Set-Cookie", [ + `oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/auth; Max-Age=600`, + ]); + res.redirect(authUrl.toString()); + } catch (err) { + console.error("[oauth] Failed to initiate login:", err); + res.status(500).send("Authentication provider unavailable."); + } + }); + + // GET /auth/callback — exchange code for tokens, create session + app.get("/auth/callback", async (req, res) => { + const { code, state, error } = req.query as Record; + + if (error) { + console.warn("[oauth] Provider error:", error); + res.status(400).send(`Provider returned error: ${error}`); + return; + } + + if (!code || !state) { + res.status(400).send("Missing code or state."); + return; + } + + // Validate state against cookie + const storedState = this.extractStateCookie(req as unknown as IncomingMessage); + if (!storedState || storedState !== state || !this.pendingStates.has(state)) { + res.status(400).send("Invalid state parameter."); + return; + } + this.pendingStates.delete(state); + // Clear state cookie + res.setHeader("Set-Cookie", [ + `oauth_state=; HttpOnly; SameSite=Lax; Path=/auth; Max-Age=0`, + ]); + + try { + const disc = await this.getDiscovery(); + const tokens = await this.exchangeCode(disc.token_endpoint, code); + const userInfo = await this.getUserInfo(disc.userinfo_endpoint, tokens.access_token); + + const isAdmin = + this.adminUsers.has(userInfo.sub) || + (userInfo.email ? this.adminUsers.has(userInfo.email) : false); + + const sessionId = this.store.create({ + userId: userInfo.sub, + email: userInfo.email, + name: userInfo.name ?? userInfo.preferred_username, + isAdmin, + }); + + this.store.setCookie(res as unknown as import("http").ServerResponse, sessionId); + res.redirect("/"); + } catch (err) { + console.error("[oauth] Callback error:", err); + res.status(500).send("Authentication failed. Please try again."); + } + }); + + // POST /auth/logout — destroy session and redirect to login + app.post("/auth/logout", (req, res) => { + const id = this.store.getIdFromRequest(req as unknown as IncomingMessage); + if (id) this.store.delete(id); + this.store.clearCookie(res as unknown as import("http").ServerResponse); + res.redirect("/auth/login"); + }); + } + + requireAuth(req: Request, res: Response, next: NextFunction): void { + const user = this.authenticate(req as unknown as IncomingMessage); + if (!user) { + const accept = req.headers["accept"] ?? ""; + if (accept.includes("application/json")) { + res.status(401).json({ error: "Unauthorized" }); + } else { + res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`); + } + return; + } + (req as AuthenticatedRequest).user = user; + next(); + } + + // ── OIDC helpers ────────────────────────────────────────────────────────── + + private async getDiscovery(): Promise { + if (this.discovery) return this.discovery; + const url = `${this.issuer}/.well-known/openid-configuration`; + const doc = await fetchJSON(url); + this.discovery = doc; + return doc; + } + + private async exchangeCode(tokenEndpoint: string, code: string): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: this.callbackUrl, + client_id: this.clientId, + client_secret: this.clientSecret, + }).toString(); + + const result = await fetchJSON(tokenEndpoint, { + method: "POST", + body, + }); + + if (result.error) throw new Error(`Token exchange failed: ${result.error}`); + return result; + } + + private async getUserInfo(userinfoEndpoint: string, accessToken: string): Promise { + return fetchJSON(userinfoEndpoint, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + } + + private extractStateCookie(req: IncomingMessage): string | null { + const cookieHeader = req.headers.cookie ?? ""; + for (const part of cookieHeader.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === "oauth_state") { + return decodeURIComponent(part.slice(eq + 1).trim()); + } + } + return null; + } +} diff --git a/src/server/web/auth/token-auth.ts b/src/server/web/auth/token-auth.ts new file mode 100644 index 0000000..724f684 --- /dev/null +++ b/src/server/web/auth/token-auth.ts @@ -0,0 +1,81 @@ +import type { IncomingMessage } from "http"; +import type { Application, Request, Response, NextFunction } from "express"; +import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js"; + +/** + * Token auth — the original single-token mode. + * + * If `AUTH_TOKEN` is set, callers must supply a matching token via the + * `?token=` query parameter (WebSocket) or `Authorization: Bearer ` + * header (HTTP). When `AUTH_TOKEN` is unset every caller is admitted as the + * built-in "default" admin user. + * + * All callers share the same user identity. Use this provider for single- + * user or trusted-network deployments where you just want a simple password. + */ +export class TokenAuthAdapter implements AuthAdapter { + private readonly token: string | undefined; + private readonly adminUsers: ReadonlySet; + + constructor() { + this.token = process.env.AUTH_TOKEN; + this.adminUsers = new Set( + (process.env.ADMIN_USERS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + } + + authenticate(req: IncomingMessage): AuthUser | null { + if (!this.token) { + return { id: "default", isAdmin: true }; + } + if (this.extractToken(req) === this.token) { + return { + id: "default", + isAdmin: this.adminUsers.size === 0 || this.adminUsers.has("default"), + }; + } + return null; + } + + setupRoutes(_app: Application): void { + // Token auth needs no login/callback/logout routes. + } + + requireAuth(req: Request, res: Response, next: NextFunction): void { + const user = this.authenticate(req as unknown as IncomingMessage); + if (!user) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + (req as AuthenticatedRequest).user = user; + next(); + } + + // ── Internals ───────────────────────────────────────────────────────────── + + private extractToken(req: IncomingMessage): string | null { + // 1. ?token= query param (used by WebSocket clients and simple links) + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const qp = url.searchParams.get("token"); + if (qp) return qp; + + // 2. Authorization: Bearer + const auth = req.headers["authorization"]; + if (auth?.startsWith("Bearer ")) return auth.slice(7); + + // 3. Cookie cc_token (set by token-auth login page if any) + const cookieHeader = req.headers.cookie ?? ""; + for (const part of cookieHeader.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === "cc_token") { + return decodeURIComponent(part.slice(eq + 1).trim()); + } + } + + return null; + } +} diff --git a/src/server/web/pty-server.ts b/src/server/web/pty-server.ts new file mode 100644 index 0000000..592e1cd --- /dev/null +++ b/src/server/web/pty-server.ts @@ -0,0 +1,159 @@ +import express from "express"; +import { createServer } from "http"; +import path from "path"; +import { spawn } from "node-pty"; +import { WebSocketServer } from "ws"; +import { ConnectionRateLimiter, validateAuthToken } from "./auth.js"; +import { SessionManager } from "./session-manager.js"; + +// Configuration from environment +const PORT = parseInt(process.env.PORT ?? "3000", 10); +const HOST = process.env.HOST ?? "0.0.0.0"; +const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS ?? "10", 10); +const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") ?? []; +const SHELL = process.env.SHELL ?? "bash"; + +// Resolve the claude CLI binary +const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude"; + +const app = express(); +const server = createServer(app); + +// --- HTTP routes --- + +app.get("/health", (_req, res) => { + res.json({ + status: "ok", + activeSessions: sessionManager.activeCount, + maxSessions: MAX_SESSIONS, + }); +}); + +// Serve static frontend +const publicDir = path.join(import.meta.dirname, "public"); +app.use(express.static(publicDir)); + +app.get("/", (_req, res) => { + res.sendFile(path.join(publicDir, "index.html")); +}); + +// --- Session Manager --- + +const sessionManager = new SessionManager(MAX_SESSIONS, (cols, rows) => + spawn(CLAUDE_BIN, [], { + name: "xterm-256color", + cols, + rows, + cwd: process.env.WORK_DIR ?? process.cwd(), + env: { + ...process.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + }, + }), +); + +// --- WebSocket server --- + +const rateLimiter = new ConnectionRateLimiter(); + +// Clean up rate limiter every 5 minutes +const rateLimiterCleanup = setInterval(() => rateLimiter.cleanup(), 5 * 60_000); + +const wss = new WebSocketServer({ + server, + path: "/ws", + verifyClient: ({ req, origin }, callback) => { + // Origin check + if (ALLOWED_ORIGINS.length > 0 && !ALLOWED_ORIGINS.includes(origin)) { + console.warn(`Rejected connection from origin: ${origin}`); + callback(false, 403, "Forbidden origin"); + return; + } + + // Auth token check + if (!validateAuthToken(req)) { + console.warn("Rejected connection: invalid auth token"); + callback(false, 401, "Unauthorized"); + return; + } + + // Rate limit check + const ip = + (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ?? + req.socket.remoteAddress ?? + "unknown"; + if (!rateLimiter.allow(ip)) { + console.warn(`Rate limited connection from ${ip}`); + callback(false, 429, "Too many connections"); + return; + } + + callback(true); + }, +}); + +wss.on("connection", (ws, req) => { + const ip = + (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ?? + req.socket.remoteAddress ?? + "unknown"; + console.log(`New WebSocket connection from ${ip}`); + + if (sessionManager.isFull) { + ws.send( + JSON.stringify({ + type: "error", + message: "Max sessions reached. Try again later.", + }), + ); + ws.close(1013, "Max sessions reached"); + return; + } + + // Parse initial size from query params + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const cols = parseInt(url.searchParams.get("cols") ?? "80", 10); + const rows = parseInt(url.searchParams.get("rows") ?? "24", 10); + + const session = sessionManager.create(ws, cols, rows); + if (session) { + ws.send(JSON.stringify({ type: "connected", sessionId: session.id })); + } +}); + +// --- Graceful shutdown --- + +function shutdown() { + console.log("Shutting down..."); + clearInterval(rateLimiterCleanup); + sessionManager.destroyAll(); + wss.close(() => { + server.close(() => { + console.log("Server closed."); + process.exit(0); + }); + }); + + // Force exit after 10 seconds + setTimeout(() => { + console.error("Forced shutdown after timeout"); + process.exit(1); + }, 10_000); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +// --- Start --- + +server.listen(PORT, HOST, () => { + console.log(`PTY server listening on http://${HOST}:${PORT}`); + console.log(` WebSocket: ws://${HOST}:${PORT}/ws`); + console.log(` Max sessions: ${MAX_SESSIONS}`); + if (process.env.AUTH_TOKEN) { + console.log(" Auth: token required"); + } +}); + +export { app, server, sessionManager, wss }; diff --git a/src/server/web/public/favicon.svg b/src/server/web/public/favicon.svg new file mode 100644 index 0000000..c4a397a --- /dev/null +++ b/src/server/web/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/server/web/public/index.html b/src/server/web/public/index.html new file mode 100644 index 0000000..0194903 --- /dev/null +++ b/src/server/web/public/index.html @@ -0,0 +1,54 @@ + + + + + + + + Claude Code + + + + + + + + + + + + + + + +
+
+ + Claude Code + -- +
+
+ +
+
+ + + +
+ + +
+
+
Connecting to Claude Code...
+
+ + +
+
+
Connection lost. Reconnecting...
+
Retrying in 1s...
+
+ + + + diff --git a/src/server/web/public/terminal.css b/src/server/web/public/terminal.css new file mode 100644 index 0000000..5c2da35 --- /dev/null +++ b/src/server/web/public/terminal.css @@ -0,0 +1,275 @@ +/* Terminal Theme — CSS Custom Properties */ +:root { + --term-bg: #1a1b26; + --term-fg: #c0caf5; + --term-cursor: #c0caf5; + --term-selection: rgba(130, 170, 255, 0.3); + --term-black: #15161e; + --term-red: #f7768e; + --term-green: #9ece6a; + --term-yellow: #e0af68; + --term-blue: #7aa2f7; + --term-magenta: #bb9af7; + --term-cyan: #7dcfff; + --term-white: #a9b1d6; + --term-bright-black: #414868; + --term-bright-white: #c0caf5; + --bar-bg: #16161e; + --bar-fg: #565f89; + --bar-accent: #7aa2f7; + --overlay-bg: rgba(26, 27, 38, 0.92); +} + +@media (prefers-color-scheme: light) { + :root { + --term-bg: #f5f5f5; + --term-fg: #343b58; + --term-cursor: #343b58; + --term-selection: rgba(52, 59, 88, 0.2); + --term-black: #0f0f14; + --term-red: #8c4351; + --term-green: #485e30; + --term-yellow: #8f5e15; + --term-blue: #34548a; + --term-magenta: #5a4a78; + --term-cyan: #0f4b6e; + --term-white: #343b58; + --term-bright-black: #9699a3; + --term-bright-white: #343b58; + --bar-bg: #e8e8e8; + --bar-fg: #6c7086; + --bar-accent: #34548a; + --overlay-bg: rgba(245, 245, 245, 0.92); + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 100vw; + height: 100vh; + overflow: hidden; + background: var(--term-bg); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; +} + +/* Top bar */ +#top-bar { + display: flex; + align-items: center; + justify-content: space-between; + height: 32px; + padding: 0 12px; + background: var(--bar-bg); + color: var(--bar-fg); + font-size: 12px; + user-select: none; + -webkit-user-select: none; + transition: transform 0.2s ease; +} + +#top-bar.collapsed { + transform: translateY(-100%); + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; +} + +#top-bar .left { + display: flex; + align-items: center; + gap: 10px; +} + +#top-bar .right { + display: flex; + align-items: center; + gap: 10px; +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--term-green); + transition: background 0.3s; +} + +.status-dot.disconnected { + background: var(--term-red); +} + +.status-dot.connecting { + background: var(--term-yellow); + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +#latency { + color: var(--bar-fg); + font-variant-numeric: tabular-nums; +} + +#bar-btn { + background: none; + border: 1px solid var(--bar-fg); + color: var(--bar-fg); + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 11px; + transition: color 0.15s, border-color 0.15s; +} + +#bar-btn:hover { + color: var(--bar-accent); + border-color: var(--bar-accent); +} + +#toggle-bar { + position: fixed; + top: 4px; + right: 4px; + z-index: 20; + background: var(--bar-bg); + border: 1px solid var(--bar-fg); + color: var(--bar-fg); + width: 20px; + height: 20px; + border-radius: 3px; + cursor: pointer; + font-size: 10px; + line-height: 1; + display: none; + align-items: center; + justify-content: center; +} + +#top-bar.collapsed ~ #toggle-bar { + display: flex; +} + +/* Terminal container */ +#terminal-container { + width: 100%; + height: calc(100vh - 32px); + background: var(--term-bg); +} + +#top-bar.collapsed ~ #terminal-container { + height: 100vh; +} + +#terminal-container .xterm { + padding: 4px; +} + +/* Loading overlay */ +#loading-overlay { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--overlay-bg); + z-index: 100; + transition: opacity 0.3s ease; +} + +#loading-overlay.hidden { + opacity: 0; + pointer-events: none; +} + +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--term-bright-black); + border-top-color: var(--bar-accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +#loading-overlay .message { + color: var(--term-fg); + font-size: 14px; + opacity: 0.8; +} + +/* Reconnect overlay */ +#reconnect-overlay { + position: fixed; + inset: 0; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--overlay-bg); + z-index: 90; +} + +#reconnect-overlay.visible { + display: flex; +} + +#reconnect-overlay .message { + color: var(--term-fg); + font-size: 14px; + margin-bottom: 8px; +} + +#reconnect-overlay .sub { + color: var(--bar-fg); + font-size: 12px; +} + +/* Mobile: make terminal touch-friendly */ +@media (max-width: 600px) { + #top-bar { + height: 28px; + font-size: 11px; + padding: 0 8px; + } + + #terminal-container { + height: calc(100vh - 28px); + } + + #top-bar.collapsed ~ #terminal-container { + height: 100vh; + } +} + +/* Scrollbar styling for xterm */ +.xterm-viewport::-webkit-scrollbar { + width: 8px; +} + +.xterm-viewport::-webkit-scrollbar-track { + background: transparent; +} + +.xterm-viewport::-webkit-scrollbar-thumb { + background: var(--term-bright-black); + border-radius: 4px; +} + +.xterm-viewport::-webkit-scrollbar-thumb:hover { + background: var(--bar-fg); +} diff --git a/src/server/web/public/terminal.js b/src/server/web/public/terminal.js new file mode 100644 index 0000000..8965937 --- /dev/null +++ b/src/server/web/public/terminal.js @@ -0,0 +1,361 @@ +/** + * Claude Code — Terminal-in-Browser (xterm.js + WebSocket) + */ + +const RECONNECT_BASE_MS = 1000; +const RECONNECT_MAX_MS = 30000; +const PING_INTERVAL_MS = 5000; + +// DOM elements +const loadingOverlay = document.getElementById('loading-overlay'); +const reconnectOverlay = document.getElementById('reconnect-overlay'); +const reconnectSub = document.getElementById('reconnect-sub'); +const statusDot = document.getElementById('status-dot'); +const latencyEl = document.getElementById('latency'); +const barBtn = document.getElementById('bar-btn'); +const topBar = document.getElementById('top-bar'); +const toggleBar = document.getElementById('toggle-bar'); +const terminalContainer = document.getElementById('terminal-container'); + +// State +let ws = null; +let term = null; +let fitAddon = null; +let searchAddon = null; +let webglAddon = null; +let reconnectDelay = RECONNECT_BASE_MS; +let reconnectTimer = null; +let pingTimer = null; +let lastPingSent = 0; +let connected = false; + +// ── Theme ────────────────────────────────────────────────────────────── + +function getTheme() { + const s = getComputedStyle(document.documentElement); + const v = (prop) => s.getPropertyValue(prop).trim(); + return { + background: v('--term-bg'), + foreground: v('--term-fg'), + cursor: v('--term-cursor'), + selectionBackground: v('--term-selection'), + black: v('--term-black'), + red: v('--term-red'), + green: v('--term-green'), + yellow: v('--term-yellow'), + blue: v('--term-blue'), + magenta: v('--term-magenta'), + cyan: v('--term-cyan'), + white: v('--term-white'), + brightBlack: v('--term-bright-black'), + brightRed: v('--term-red'), + brightGreen: v('--term-green'), + brightYellow: v('--term-yellow'), + brightBlue: v('--term-blue'), + brightMagenta: v('--term-magenta'), + brightCyan: v('--term-cyan'), + brightWhite: v('--term-bright-white'), + }; +} + +// ── Terminal Init ────────────────────────────────────────────────────── + +function initTerminal() { + term = new Terminal({ + cursorBlink: true, + cursorStyle: 'block', + fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, 'Courier New', monospace", + fontSize: 14, + lineHeight: 1.2, + theme: getTheme(), + allowProposedApi: true, + scrollback: 10000, + convertEol: true, + }); + + // Addons + fitAddon = new FitAddon.FitAddon(); + term.loadAddon(fitAddon); + + const webLinksAddon = new WebLinksAddon.WebLinksAddon(); + term.loadAddon(webLinksAddon); + + searchAddon = new SearchAddon.SearchAddon(); + term.loadAddon(searchAddon); + + const unicode11Addon = new Unicode11Addon.Unicode11Addon(); + term.loadAddon(unicode11Addon); + term.unicode.activeVersion = '11'; + + // Open terminal + term.open(terminalContainer); + + // Try WebGL renderer, fall back to canvas + try { + webglAddon = new WebglAddon.WebglAddon(); + webglAddon.onContextLoss(() => { + webglAddon.dispose(); + webglAddon = null; + }); + term.loadAddon(webglAddon); + } catch (e) { + console.warn('WebGL renderer unavailable, using default canvas renderer'); + } + + fitAddon.fit(); + + // Resize handling + const resizeObserver = new ResizeObserver(() => { + if (fitAddon) { + fitAddon.fit(); + } + }); + resizeObserver.observe(terminalContainer); + + term.onResize(({ cols, rows }) => { + sendJSON({ type: 'resize', cols, rows }); + }); + + // Forward input to WebSocket + term.onData((data) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + }); + + // Binary data + term.onBinary((data) => { + if (ws && ws.readyState === WebSocket.OPEN) { + const buf = new Uint8Array(data.length); + for (let i = 0; i < data.length; i++) { + buf[i] = data.charCodeAt(i) & 0xff; + } + ws.send(buf.buffer); + } + }); + + // Keyboard intercepts + term.attachCustomKeyEventHandler((ev) => { + // Ctrl+Shift+F → search + if (ev.ctrlKey && ev.shiftKey && ev.key === 'F') { + if (ev.type === 'keydown') { + const query = prompt('Search terminal:'); + if (query) searchAddon.findNext(query); + } + return false; + } + // Ctrl+Shift+C → copy (Linux) + if (ev.ctrlKey && ev.shiftKey && ev.key === 'C') { + if (ev.type === 'keydown') { + const sel = term.getSelection(); + if (sel) navigator.clipboard.writeText(sel); + } + return false; + } + // Ctrl+Shift+V → paste (Linux) + if (ev.ctrlKey && ev.shiftKey && ev.key === 'V') { + if (ev.type === 'keydown') { + navigator.clipboard.readText().then((text) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(text); + } + }); + } + return false; + } + return true; + }); + + // Theme change listener + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + term.options.theme = getTheme(); + }); +} + +// ── WebSocket ────────────────────────────────────────────────────────── + +function getWSUrl() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = new URL(`${proto}//${location.host}/ws`); + // Auth token from URL param or localStorage + const params = new URLSearchParams(location.search); + const token = params.get('token') || localStorage.getItem('claude-terminal-token'); + if (token) { + url.searchParams.set('token', token); + // Persist for reconnection + localStorage.setItem('claude-terminal-token', token); + } + return url.toString(); +} + +function sendJSON(obj) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(obj)); + } +} + +function connect() { + setStatus('connecting'); + + ws = new WebSocket(getWSUrl()); + ws.binaryType = 'arraybuffer'; + + ws.addEventListener('open', () => { + connected = true; + reconnectDelay = RECONNECT_BASE_MS; + setStatus('connected'); + hideLoading(); + hideReconnect(); + + // Send initial size + if (fitAddon) { + fitAddon.fit(); + sendJSON({ type: 'resize', cols: term.cols, rows: term.rows }); + } + + // Start ping + startPing(); + }); + + ws.addEventListener('message', (ev) => { + if (ev.data instanceof ArrayBuffer) { + term.write(new Uint8Array(ev.data)); + } else if (typeof ev.data === 'string') { + // Could be JSON control message or plain text + try { + const msg = JSON.parse(ev.data); + handleControlMessage(msg); + } catch { + term.write(ev.data); + } + } + }); + + ws.addEventListener('close', () => { + onDisconnect(); + }); + + ws.addEventListener('error', () => { + // error fires before close, let close handle reconnect + }); +} + +function handleControlMessage(msg) { + if (msg.type === 'pong') { + const latency = Date.now() - lastPingSent; + latencyEl.textContent = `${latency}ms`; + } +} + +function onDisconnect() { + connected = false; + setStatus('disconnected'); + stopPing(); + + if (ws) { + ws = null; + } + + showReconnect(); + scheduleReconnect(); +} + +function scheduleReconnect() { + clearTimeout(reconnectTimer); + reconnectSub.textContent = `Retrying in ${Math.round(reconnectDelay / 1000)}s...`; + reconnectTimer = setTimeout(() => { + connect(); + }, reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS); +} + +function manualReconnect() { + clearTimeout(reconnectTimer); + reconnectDelay = RECONNECT_BASE_MS; + if (ws) { + ws.close(); + ws = null; + } + // Clear terminal for fresh session + if (term) term.clear(); + connect(); +} + +// ── Ping ─────────────────────────────────────────────────────────────── + +function startPing() { + stopPing(); + pingTimer = setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + lastPingSent = Date.now(); + sendJSON({ type: 'ping' }); + } + }, PING_INTERVAL_MS); +} + +function stopPing() { + clearInterval(pingTimer); + latencyEl.textContent = '--'; +} + +// ── UI Helpers ───────────────────────────────────────────────────────── + +function setStatus(state) { + statusDot.className = 'status-dot'; + if (state === 'disconnected') statusDot.classList.add('disconnected'); + if (state === 'connecting') statusDot.classList.add('connecting'); + + barBtn.textContent = connected ? 'Disconnect' : 'Reconnect'; +} + +function hideLoading() { + loadingOverlay.classList.add('hidden'); +} + +function showReconnect() { + reconnectOverlay.classList.add('visible'); +} + +function hideReconnect() { + reconnectOverlay.classList.remove('visible'); +} + +// ── Top Bar Toggle ───────────────────────────────────────────────────── + +function setupBarToggle() { + const isCollapsed = localStorage.getItem('claude-bar-collapsed') === 'true'; + if (isCollapsed) topBar.classList.add('collapsed'); + + toggleBar.addEventListener('click', () => { + topBar.classList.remove('collapsed'); + localStorage.setItem('claude-bar-collapsed', 'false'); + if (fitAddon) setTimeout(() => fitAddon.fit(), 200); + }); + + // Double-click top bar to collapse + topBar.addEventListener('dblclick', () => { + topBar.classList.add('collapsed'); + localStorage.setItem('claude-bar-collapsed', 'true'); + if (fitAddon) setTimeout(() => fitAddon.fit(), 200); + }); + + barBtn.addEventListener('click', () => { + if (connected) { + if (ws) ws.close(); + } else { + manualReconnect(); + } + }); +} + +// ── Boot ─────────────────────────────────────────────────────────────── + +document.addEventListener('DOMContentLoaded', () => { + initTerminal(); + setupBarToggle(); + connect(); + + // Focus terminal on click anywhere + document.addEventListener('click', () => term.focus()); + term.focus(); +}); diff --git a/src/server/web/scrollback-buffer.ts b/src/server/web/scrollback-buffer.ts new file mode 100644 index 0000000..9f6591f --- /dev/null +++ b/src/server/web/scrollback-buffer.ts @@ -0,0 +1,66 @@ +/** + * Circular byte buffer for PTY scrollback replay. + * Stores the last `capacity` bytes of PTY output; oldest bytes are silently + * discarded when the buffer is full. + */ +export class ScrollbackBuffer { + private buf: Buffer; + private writePos = 0; // next write position in the ring + private stored = 0; // bytes currently stored (≤ capacity) + readonly capacity: number; + + constructor(capacityBytes = 100 * 1024) { + this.capacity = capacityBytes; + this.buf = Buffer.allocUnsafe(capacityBytes); + } + + /** + * Append PTY output to the buffer. + * Uses 'binary' (latin1) encoding to preserve raw byte values from node-pty. + */ + write(data: string): void { + const src = Buffer.from(data, "binary"); + const len = src.length; + if (len === 0) return; + + if (len >= this.capacity) { + // Incoming chunk larger than the whole buffer — keep only the tail + src.copy(this.buf, 0, len - this.capacity); + this.writePos = 0; + this.stored = this.capacity; + return; + } + + const end = this.writePos + len; + if (end <= this.capacity) { + src.copy(this.buf, this.writePos); + } else { + // Wrap around the ring boundary + const tailLen = this.capacity - this.writePos; + src.copy(this.buf, this.writePos, 0, tailLen); + src.copy(this.buf, 0, tailLen); + } + this.writePos = end % this.capacity; + this.stored = Math.min(this.stored + len, this.capacity); + } + + /** + * Returns all buffered bytes in chronological order (oldest first). + * The returned Buffer is a copy and safe to send over a WebSocket. + */ + read(): Buffer { + if (this.stored === 0) return Buffer.alloc(0); + + if (this.stored < this.capacity) { + // Buffer hasn't wrapped yet — contiguous region starting at index 0 + return Buffer.from(this.buf.subarray(0, this.stored)); + } + + // Buffer is full and has wrapped — oldest data starts at writePos + const out = Buffer.allocUnsafe(this.capacity); + const tailLen = this.capacity - this.writePos; + this.buf.copy(out, 0, this.writePos); // tail (oldest) + this.buf.copy(out, tailLen, 0, this.writePos); // head (newest) + return out; + } +} diff --git a/src/server/web/session-manager.ts b/src/server/web/session-manager.ts new file mode 100644 index 0000000..ffa76f6 --- /dev/null +++ b/src/server/web/session-manager.ts @@ -0,0 +1,176 @@ +import type { IPty } from "node-pty"; +import type { WebSocket } from "ws"; + +export type Session = { + id: string; + ws: WebSocket; + pty: IPty; + createdAt: number; +}; + +export class SessionManager { + private sessions = new Map(); + private maxSessions: number; + private spawnPty: (cols: number, rows: number) => IPty; + + constructor( + maxSessions: number, + spawnPty: (cols: number, rows: number) => IPty, + ) { + this.maxSessions = maxSessions; + this.spawnPty = spawnPty; + } + + get activeCount(): number { + return this.sessions.size; + } + + get isFull(): boolean { + return this.sessions.size >= this.maxSessions; + } + + getSession(id: string): Session | undefined { + return this.sessions.get(id); + } + + /** + * Creates a new PTY session bound to the given WebSocket. + * Returns the session or null if at capacity. + */ + create(ws: WebSocket, cols = 80, rows = 24): Session | null { + if (this.isFull) { + return null; + } + + const id = crypto.randomUUID(); + let pty: IPty; + + try { + pty = this.spawnPty(cols, rows); + } catch (err) { + const message = + err instanceof Error ? err.message : "Unknown PTY spawn error"; + ws.send( + JSON.stringify({ type: "error", message: `PTY spawn failed: ${message}` }), + ); + ws.close(1011, "PTY spawn failure"); + return null; + } + + const session: Session = { id, ws, pty, createdAt: Date.now() }; + this.sessions.set(id, session); + + // PTY output -> WebSocket + pty.onData((data: string) => { + if (ws.readyState === ws.OPEN) { + ws.send(data); + } + }); + + // PTY exit -> clean up + pty.onExit(({ exitCode, signal }) => { + console.log( + `[session ${id}] PTY exited: code=${exitCode}, signal=${signal}`, + ); + this.sessions.delete(id); + if (ws.readyState === ws.OPEN) { + ws.send( + JSON.stringify({ + type: "exit", + exitCode, + signal, + }), + ); + ws.close(1000, "PTY exited"); + } + }); + + // WebSocket messages -> PTY stdin (or resize) + ws.on("message", (data: Buffer | string) => { + const str = data.toString(); + + // Try to parse as JSON for control messages + if (str.startsWith("{")) { + try { + const msg = JSON.parse(str) as Record; + if ( + msg.type === "resize" && + typeof msg.cols === "number" && + typeof msg.rows === "number" + ) { + pty.resize(msg.cols as number, msg.rows as number); + return; + } + if (msg.type === "ping") { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify({ type: "pong" })); + } + return; + } + } catch { + // Not JSON, treat as terminal input + } + } + + pty.write(str); + }); + + // WebSocket close -> kill PTY + ws.on("close", () => { + console.log(`[session ${id}] WebSocket closed`); + this.destroySession(id); + }); + + ws.on("error", (err) => { + console.error(`[session ${id}] WebSocket error:`, err.message); + this.destroySession(id); + }); + + console.log( + `[session ${id}] Created (active: ${this.sessions.size}/${this.maxSessions})`, + ); + return session; + } + + /** + * Gracefully destroys a session: SIGHUP, then SIGKILL after timeout. + */ + destroySession(id: string): void { + const session = this.sessions.get(id); + if (!session) return; + + this.sessions.delete(id); + const { pty, ws } = session; + + try { + pty.kill("SIGHUP"); + } catch { + // PTY may already be dead + } + + // Force kill after 5 seconds if still alive + const killTimer = setTimeout(() => { + try { + pty.kill("SIGKILL"); + } catch { + // Already dead + } + }, 5000); + + // If PTY exits before the timer, clear it + pty.onExit(() => clearTimeout(killTimer)); + + if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) { + ws.close(1000, "Session destroyed"); + } + } + + /** + * Destroys all sessions. Used during server shutdown. + */ + destroyAll(): void { + for (const id of [...this.sessions.keys()]) { + this.destroySession(id); + } + } +} diff --git a/src/server/web/session-store.ts b/src/server/web/session-store.ts new file mode 100644 index 0000000..7f7526b --- /dev/null +++ b/src/server/web/session-store.ts @@ -0,0 +1,171 @@ +import type { IPty } from "node-pty"; +import type { WebSocket } from "ws"; +import { ScrollbackBuffer } from "./scrollback-buffer.js"; + +const DEFAULT_GRACE_MS = 5 * 60_000; // 5 minutes +const DEFAULT_SCROLLBACK_BYTES = 100 * 1024; // 100 KB + +export type StoredSession = { + token: string; + pty: IPty; + scrollback: ScrollbackBuffer; + ws: WebSocket | null; + createdAt: Date; + lastActive: Date; + graceTimer: ReturnType | null; +}; + +export type SessionInfo = { + token: string; + created: string; + lastActive: string; + alive: boolean; +}; + +/** + * In-memory session store with TTL-based cleanup. + * + * Sessions survive WebSocket disconnects for `gracePeriodMs` before being + * permanently destroyed. This lets clients reconnect and resume their PTY + * without losing conversation state. + */ +export class SessionStore { + private sessions = new Map(); + private readonly gracePeriodMs: number; + private readonly scrollbackBytes: number; + + constructor( + gracePeriodMs = DEFAULT_GRACE_MS, + scrollbackBytes = DEFAULT_SCROLLBACK_BYTES, + ) { + this.gracePeriodMs = gracePeriodMs; + this.scrollbackBytes = scrollbackBytes; + } + + /** + * Register a newly spawned PTY under a fresh session token. + */ + register(pty: IPty): StoredSession { + const token = crypto.randomUUID(); + const session: StoredSession = { + token, + pty, + scrollback: new ScrollbackBuffer(this.scrollbackBytes), + ws: null, + createdAt: new Date(), + lastActive: new Date(), + graceTimer: null, + }; + this.sessions.set(token, session); + return session; + } + + get(token: string): StoredSession | undefined { + return this.sessions.get(token); + } + + /** + * Attach a new WebSocket to an existing session. + * Cancels any running grace timer. + * Returns null if the session does not exist. + */ + reattach(token: string, ws: WebSocket): StoredSession | null { + const session = this.sessions.get(token); + if (!session) return null; + + if (session.graceTimer) { + clearTimeout(session.graceTimer); + session.graceTimer = null; + } + session.ws = ws; + session.lastActive = new Date(); + return session; + } + + /** + * Detach the WebSocket from a session and start the grace period timer. + * After `gracePeriodMs` with no reconnect, `onExpire` is called and the + * session is destroyed. + */ + startGrace(token: string, onExpire: () => void): void { + const session = this.sessions.get(token); + if (!session) return; + + session.ws = null; + session.lastActive = new Date(); + + if (session.graceTimer) { + clearTimeout(session.graceTimer); + } + + const remainingSec = Math.round(this.gracePeriodMs / 1000); + console.log( + `[session ${token.slice(0, 8)}] Disconnected — grace period: ${remainingSec}s`, + ); + + session.graceTimer = setTimeout(() => { + session.graceTimer = null; + console.log( + `[session ${token.slice(0, 8)}] Grace period expired — cleaning up`, + ); + this.destroy(token); + onExpire(); + }, this.gracePeriodMs); + } + + /** + * Immediately kill the PTY and remove the session. + */ + destroy(token: string): void { + const session = this.sessions.get(token); + if (!session) return; + + this.sessions.delete(token); + + if (session.graceTimer) { + clearTimeout(session.graceTimer); + session.graceTimer = null; + } + + if ( + session.ws && + session.ws.readyState !== 2 /* CLOSING */ && + session.ws.readyState !== 3 /* CLOSED */ + ) { + session.ws.close(1000, "Session destroyed"); + } + + try { + session.pty.kill("SIGHUP"); + } catch { + // PTY may already be dead + } + setTimeout(() => { + try { + session.pty.kill("SIGKILL"); + } catch { + // Already dead + } + }, 5000); + } + + /** Returns summary info for all sessions (used by the REST API). */ + list(): SessionInfo[] { + return [...this.sessions.values()].map((s) => ({ + token: s.token, + created: s.createdAt.toISOString(), + lastActive: s.lastActive.toISOString(), + alive: s.ws !== null && s.ws.readyState === 1 /* OPEN */, + })); + } + + get size(): number { + return this.sessions.size; + } + + destroyAll(): void { + for (const token of [...this.sessions.keys()]) { + this.destroy(token); + } + } +} diff --git a/src/server/web/terminal.ts b/src/server/web/terminal.ts new file mode 100644 index 0000000..e447f9b --- /dev/null +++ b/src/server/web/terminal.ts @@ -0,0 +1,372 @@ +/** + * Claude Code — terminal-in-browser + * + * WebSocket protocol (matches src/server/web/session-manager.ts): + * + * Server → client (text, JSON): + * { type: "connected", sessionId: string } + * { type: "pong" } + * { type: "error", message: string } + * { type: "exit", exitCode: number, signal: number | undefined } + * + * Server → client (text, raw): + * PTY output — plain string, written directly to xterm + * + * Client → server (text): + * { type: "resize", cols: number, rows: number } + * { type: "ping" } + * raw terminal input string + */ + +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { WebLinksAddon } from '@xterm/addon-web-links' +import { SearchAddon } from '@xterm/addon-search' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { WebglAddon } from '@xterm/addon-webgl' +import '@xterm/xterm/css/xterm.css' +import './styles.css' + +// ── Types ──────────────────────────────────────────────────────────────────── + +type ServerMessage = + | { type: 'connected'; sessionId: string } + | { type: 'pong' } + | { type: 'error'; message: string } + | { type: 'exit'; exitCode: number; signal?: number } + +// ── Config ─────────────────────────────────────────────────────────────────── + +const RECONNECT_BASE_MS = 1_000 +const RECONNECT_MAX_MS = 30_000 +const PING_INTERVAL_MS = 5_000 + +// ── State ──────────────────────────────────────────────────────────────────── + +let ws: WebSocket | null = null +let term: Terminal +let fitAddon: FitAddon +let searchAddon: SearchAddon +let reconnectDelay = RECONNECT_BASE_MS +let reconnectTimer: ReturnType | null = null +let pingTimer: ReturnType | null = null +let lastPingSent = 0 +let connected = false + +// ── DOM refs ───────────────────────────────────────────────────────────────── + +const loadingOverlay = document.getElementById('loading-overlay')! +const reconnectOverlay = document.getElementById('reconnect-overlay')! +const reconnectSub = document.getElementById('reconnect-sub')! +const statusDot = document.getElementById('status-dot')! +const latencyEl = document.getElementById('latency')! +const barBtn = document.getElementById('bar-btn') as HTMLButtonElement +const topBar = document.getElementById('top-bar')! +const toggleBarBtn = document.getElementById('toggle-bar') as HTMLButtonElement +const terminalContainer = document.getElementById('terminal-container')! + +// ── Theme ──────────────────────────────────────────────────────────────────── + +function getTheme(): Terminal['options']['theme'] { + const s = getComputedStyle(document.documentElement) + const v = (prop: string) => s.getPropertyValue(prop).trim() + return { + background: v('--term-bg'), + foreground: v('--term-fg'), + cursor: v('--term-cursor'), + selectionBackground: v('--term-selection'), + black: v('--term-black'), + red: v('--term-red'), + green: v('--term-green'), + yellow: v('--term-yellow'), + blue: v('--term-blue'), + magenta: v('--term-magenta'), + cyan: v('--term-cyan'), + white: v('--term-white'), + brightBlack: v('--term-bright-black'), + brightRed: v('--term-red'), + brightGreen: v('--term-green'), + brightYellow: v('--term-yellow'), + brightBlue: v('--term-blue'), + brightMagenta: v('--term-magenta'), + brightCyan: v('--term-cyan'), + brightWhite: v('--term-bright-white'), + } +} + +// ── Terminal initialisation ────────────────────────────────────────────────── + +function initTerminal(): void { + term = new Terminal({ + cursorBlink: true, + cursorStyle: 'block', + fontFamily: + "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, 'Courier New', monospace", + fontSize: 14, + lineHeight: 1.2, + theme: getTheme(), + allowProposedApi: true, + scrollback: 10_000, + convertEol: true, + }) + + fitAddon = new FitAddon() + term.loadAddon(fitAddon) + term.loadAddon(new WebLinksAddon()) + + searchAddon = new SearchAddon() + term.loadAddon(searchAddon) + + const unicode11 = new Unicode11Addon() + term.loadAddon(unicode11) + term.unicode.activeVersion = '11' + + term.open(terminalContainer) + + // WebGL renderer with canvas fallback + try { + const webgl = new WebglAddon() + webgl.onContextLoss(() => webgl.dispose()) + term.loadAddon(webgl) + } catch { + // Canvas renderer is already active — no action needed + } + + fitAddon.fit() + + // Keep terminal fitted to container + const resizeObserver = new ResizeObserver(() => fitAddon.fit()) + resizeObserver.observe(terminalContainer) + + // Propagate resize to server + term.onResize(({ cols, rows }) => sendJSON({ type: 'resize', cols, rows })) + + // Forward all terminal input to PTY + term.onData((data) => { + if (ws?.readyState === WebSocket.OPEN) ws.send(data) + }) + + // Keyboard intercepts (return false = swallow; return true = pass through) + term.attachCustomKeyEventHandler((ev) => { + // Ctrl+Shift+F → in-terminal search + if (ev.ctrlKey && ev.shiftKey && ev.key === 'F') { + if (ev.type === 'keydown') { + const query = window.prompt('Search terminal:') + if (query) searchAddon.findNext(query, { caseSensitive: false, regex: false }) + } + return false + } + // Ctrl+Shift+C → copy selection (Linux convention) + if (ev.ctrlKey && ev.shiftKey && ev.key === 'C') { + if (ev.type === 'keydown') { + const sel = term.getSelection() + if (sel) navigator.clipboard.writeText(sel) + } + return false + } + // Ctrl+Shift+V → paste (Linux convention) + if (ev.ctrlKey && ev.shiftKey && ev.key === 'V') { + if (ev.type === 'keydown') { + navigator.clipboard.readText().then((text) => { + if (ws?.readyState === WebSocket.OPEN) ws.send(text) + }) + } + return false + } + return true + }) + + // Update theme when OS preference changes + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + term.options.theme = getTheme() + }) +} + +// ── WebSocket ──────────────────────────────────────────────────────────────── + +function getWsUrl(): string { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' + const url = new URL(`${proto}//${location.host}/ws`) + + // Auth token: URL param wins, then localStorage + const params = new URLSearchParams(location.search) + const token = params.get('token') ?? localStorage.getItem('claude-terminal-token') + if (token) { + url.searchParams.set('token', token) + localStorage.setItem('claude-terminal-token', token) + } + + // Pass current terminal dimensions so the PTY is spawned at the right size + url.searchParams.set('cols', String(term.cols)) + url.searchParams.set('rows', String(term.rows)) + + return url.toString() +} + +function sendJSON(msg: Record): void { + if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)) +} + +function connect(): void { + setStatus('connecting') + + ws = new WebSocket(getWsUrl()) + + ws.addEventListener('open', () => { + connected = true + reconnectDelay = RECONNECT_BASE_MS + setStatus('connected') + hideOverlay(loadingOverlay) + hideOverlay(reconnectOverlay) + // Re-sync size in case the window changed while connecting + fitAddon.fit() + sendJSON({ type: 'resize', cols: term.cols, rows: term.rows }) + startPing() + }) + + ws.addEventListener('message', ({ data }: MessageEvent) => { + // All messages from the server are strings. + // Try JSON control message first; fall back to raw PTY output. + if (data.startsWith('{')) { + try { + handleControlMessage(JSON.parse(data) as ServerMessage) + return + } catch { + // Not JSON — fall through to write as PTY output + } + } + term.write(data) + }) + + ws.addEventListener('close', onDisconnect) + ws.addEventListener('error', () => { + // 'error' always fires before 'close'; let onDisconnect handle reconnect + }) +} + +function handleControlMessage(msg: ServerMessage): void { + switch (msg.type) { + case 'connected': + // Session established — nothing extra needed + break + + case 'pong': + latencyEl.textContent = `${Date.now() - lastPingSent}ms` + break + + case 'error': + term.writeln(`\r\n\x1b[31m[error] ${msg.message}\x1b[0m`) + break + + case 'exit': + term.writeln( + `\r\n\x1b[33m[session ended — exit code ${msg.exitCode ?? 0}]\x1b[0m`, + ) + break + } +} + +function onDisconnect(): void { + connected = false + ws = null + setStatus('disconnected') + stopPing() + showOverlay(reconnectOverlay) + scheduleReconnect() +} + +function scheduleReconnect(): void { + if (reconnectTimer) clearTimeout(reconnectTimer) + reconnectSub.textContent = `Retrying in ${Math.round(reconnectDelay / 1_000)}s…` + reconnectTimer = setTimeout(() => connect(), reconnectDelay) + reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS) +} + +function manualReconnect(): void { + if (reconnectTimer) clearTimeout(reconnectTimer) + reconnectDelay = RECONNECT_BASE_MS + ws?.close() + ws = null + term.clear() + connect() +} + +// ── Ping / latency ─────────────────────────────────────────────────────────── + +function startPing(): void { + stopPing() + pingTimer = setInterval(() => { + if (ws?.readyState === WebSocket.OPEN) { + lastPingSent = Date.now() + sendJSON({ type: 'ping' }) + } + }, PING_INTERVAL_MS) +} + +function stopPing(): void { + if (pingTimer) clearInterval(pingTimer) + pingTimer = null + latencyEl.textContent = '--' +} + +// ── UI helpers ──────────────────────────────────────────────────────────────── + +function setStatus(state: 'connected' | 'connecting' | 'disconnected'): void { + statusDot.className = 'status-dot' + if (state !== 'connected') statusDot.classList.add(state) + barBtn.textContent = connected ? 'Disconnect' : 'Reconnect' +} + +function showOverlay(el: HTMLElement): void { + el.classList.remove('hidden') + el.classList.add('visible') +} + +function hideOverlay(el: HTMLElement): void { + el.classList.remove('visible') + el.classList.add('hidden') +} + +// ── Top bar collapse ────────────────────────────────────────────────────────── + +function setupBarToggle(): void { + const STORAGE_KEY = 'claude-bar-collapsed' + + if (localStorage.getItem(STORAGE_KEY) === 'true') { + topBar.classList.add('collapsed') + } + + // Show bar button re-expands it + toggleBarBtn.addEventListener('click', () => { + topBar.classList.remove('collapsed') + localStorage.setItem(STORAGE_KEY, 'false') + setTimeout(() => fitAddon.fit(), 200) + }) + + // Double-click bar to collapse + topBar.addEventListener('dblclick', () => { + topBar.classList.add('collapsed') + localStorage.setItem(STORAGE_KEY, 'true') + setTimeout(() => fitAddon.fit(), 200) + }) + + barBtn.addEventListener('click', () => { + if (connected) { + ws?.close() + } else { + manualReconnect() + } + }) +} + +// ── Boot ───────────────────────────────────────────────────────────────────── + +document.addEventListener('DOMContentLoaded', () => { + initTerminal() + setupBarToggle() + connect() + + // Keep terminal focused + document.addEventListener('click', () => term.focus()) + term.focus() +}) diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..b4491d6 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,3 @@ +NEXT_PUBLIC_API_URL=http://localhost:3001 +NEXT_PUBLIC_WS_URL=ws://localhost:3001 +ANTHROPIC_API_KEY= diff --git a/web/.eslintrc.json b/web/.eslintrc.json new file mode 100644 index 0000000..957cd15 --- /dev/null +++ b/web/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/web/app/api/chat/route.ts b/web/app/api/chat/route.ts new file mode 100644 index 0000000..ca13262 --- /dev/null +++ b/web/app/api/chat/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; + + const response = await fetch(`${apiUrl}/api/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(process.env.ANTHROPIC_API_KEY + ? { Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}` } + : {}), + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + return NextResponse.json( + { error: "Backend request failed" }, + { status: response.status } + ); + } + + // Stream the response through + return new NextResponse(response.body, { + headers: { + "Content-Type": response.headers.get("Content-Type") ?? "application/json", + }, + }); + } catch (error) { + console.error("Chat API error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..8cf7b9f --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,78 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 262.1 83.3% 57.8%; + --primary-foreground: 210 20% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 262.1 83.3% 57.8%; + --radius: 0.5rem; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 263.4 70% 50.4%; + --primary-foreground: 210 20% 98%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 263.4 70% 50.4%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-feature-settings: "rlig" 1, "calt" 1; + } +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + @apply bg-transparent; +} + +::-webkit-scrollbar-thumb { + @apply bg-surface-300 dark:bg-surface-700 rounded-full; +} + +::-webkit-scrollbar-thumb:hover { + @apply bg-surface-400 dark:bg-surface-600; +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..fdbe163 --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,56 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import localFont from "next/font/local"; +import "./globals.css"; +import { ThemeProvider } from "@/components/layout/ThemeProvider"; +import { ToastProvider } from "@/components/ui/ToastProvider"; + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", + display: "swap", +}); + +const jetbrainsMono = localFont({ + src: [ + { + path: "../public/fonts/JetBrainsMono-Regular.woff2", + weight: "400", + style: "normal", + }, + { + path: "../public/fonts/JetBrainsMono-Medium.woff2", + weight: "500", + style: "normal", + }, + ], + variable: "--font-jetbrains-mono", + display: "swap", + fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "monospace"], +}); + +export const metadata: Metadata = { + title: "Claude Code", + description: "Claude Code — AI-powered development assistant", + icons: { + icon: "/favicon.ico", + }, +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + {children} + + + + + ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 0000000..b954c2b --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,5 @@ +import { ChatLayout } from "@/components/chat/ChatLayout"; + +export default function Home() { + return ; +} diff --git a/web/components/chat/ChatInput.tsx b/web/components/chat/ChatInput.tsx new file mode 100644 index 0000000..475ac0a --- /dev/null +++ b/web/components/chat/ChatInput.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import { Send, Square, Paperclip } from "lucide-react"; +import { useChatStore } from "@/lib/store"; +import { streamChat } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { MAX_MESSAGE_LENGTH } from "@/lib/constants"; + +interface ChatInputProps { + conversationId: string; +} + +export function ChatInput({ conversationId }: ChatInputProps) { + const [input, setInput] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const textareaRef = useRef(null); + const abortRef = useRef(null); + + const { conversations, settings, addMessage, updateMessage } = useChatStore(); + const conversation = conversations.find((c) => c.id === conversationId); + + const handleSubmit = useCallback(async () => { + const text = input.trim(); + if (!text || isStreaming) return; + + setInput(""); + setIsStreaming(true); + + // Add user message + addMessage(conversationId, { + role: "user", + content: text, + status: "complete", + }); + + // Add placeholder assistant message + const assistantId = addMessage(conversationId, { + role: "assistant", + content: "", + status: "streaming", + }); + + const controller = new AbortController(); + abortRef.current = controller; + + const messages = [ + ...(conversation?.messages ?? []).map((m) => ({ + role: m.role, + content: m.content, + })), + { role: "user" as const, content: text }, + ]; + + let fullText = ""; + + try { + for await (const chunk of streamChat(messages, settings.model, controller.signal)) { + if (chunk.type === "text" && chunk.content) { + fullText += chunk.content; + updateMessage(conversationId, assistantId, { + content: fullText, + status: "streaming", + }); + } else if (chunk.type === "done") { + break; + } else if (chunk.type === "error") { + updateMessage(conversationId, assistantId, { + content: chunk.error ?? "An error occurred", + status: "error", + }); + return; + } + } + + updateMessage(conversationId, assistantId, { status: "complete" }); + } catch (err) { + if ((err as Error).name !== "AbortError") { + updateMessage(conversationId, assistantId, { + content: "Request failed. Please try again.", + status: "error", + }); + } else { + updateMessage(conversationId, assistantId, { status: "complete" }); + } + } finally { + setIsStreaming(false); + abortRef.current = null; + } + }, [input, isStreaming, conversationId, conversation, settings.model, addMessage, updateMessage]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + const handleStop = () => { + abortRef.current?.abort(); + }; + + const adjustHeight = () => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 200)}px`; + }; + + return ( +
+
+
+ + +