claude-code

This commit is contained in:
ashutoshpythoncs@gmail.com
2026-03-31 18:58:05 +05:30
parent a2a44a5841
commit b564857c0b
2148 changed files with 564518 additions and 2 deletions

View File

@@ -0,0 +1,89 @@
/* eslint-disable eslint-plugin-n/no-unsupported-features/node-builtins */
import { errorMessage } from '../utils/errors.js'
import { jsonStringify } from '../utils/slowOperations.js'
import type { DirectConnectConfig } from './directConnectManager.js'
import { connectResponseSchema } from './types.js'
/**
* Errors thrown by createDirectConnectSession when the connection fails.
*/
export class DirectConnectError extends Error {
constructor(message: string) {
super(message)
this.name = 'DirectConnectError'
}
}
/**
* Create a session on a direct-connect server.
*
* Posts to `${serverUrl}/sessions`, validates the response, and returns
* a DirectConnectConfig ready for use by the REPL or headless runner.
*
* Throws DirectConnectError on network, HTTP, or response-parsing failures.
*/
export async function createDirectConnectSession({
serverUrl,
authToken,
cwd,
dangerouslySkipPermissions,
}: {
serverUrl: string
authToken?: string
cwd: string
dangerouslySkipPermissions?: boolean
}): Promise<{
config: DirectConnectConfig
workDir?: string
}> {
const headers: Record<string, string> = {
'content-type': 'application/json',
}
if (authToken) {
headers['authorization'] = `Bearer ${authToken}`
}
let resp: Response
try {
resp = await fetch(`${serverUrl}/sessions`, {
method: 'POST',
headers,
body: jsonStringify({
cwd,
...(dangerouslySkipPermissions && {
dangerously_skip_permissions: true,
}),
}),
})
} catch (err) {
throw new DirectConnectError(
`Failed to connect to server at ${serverUrl}: ${errorMessage(err)}`,
)
}
if (!resp.ok) {
throw new DirectConnectError(
`Failed to create session: ${resp.status} ${resp.statusText}`,
)
}
const result = connectResponseSchema().safeParse(await resp.json())
if (!result.success) {
throw new DirectConnectError(
`Invalid session response: ${result.error.message}`,
)
}
const data = result.data
return {
config: {
serverUrl,
sessionId: data.session_id,
wsUrl: data.ws_url,
authToken,
},
workDir: data.work_dir,
}
}

View File

@@ -0,0 +1,214 @@
/* eslint-disable eslint-plugin-n/no-unsupported-features/node-builtins */
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
import type {
SDKControlPermissionRequest,
StdoutMessage,
} from '../entrypoints/sdk/controlTypes.js'
import type { RemotePermissionResponse } from '../remote/RemoteSessionManager.js'
import { logForDebugging } from '../utils/debug.js'
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
import type { RemoteMessageContent } from '../utils/teleport/api.js'
export type DirectConnectConfig = {
serverUrl: string
sessionId: string
wsUrl: string
authToken?: string
}
export type DirectConnectCallbacks = {
onMessage: (message: SDKMessage) => void
onPermissionRequest: (
request: SDKControlPermissionRequest,
requestId: string,
) => void
onConnected?: () => void
onDisconnected?: () => void
onError?: (error: Error) => void
}
function isStdoutMessage(value: unknown): value is StdoutMessage {
return (
typeof value === 'object' &&
value !== null &&
'type' in value &&
typeof value.type === 'string'
)
}
export class DirectConnectSessionManager {
private ws: WebSocket | null = null
private config: DirectConnectConfig
private callbacks: DirectConnectCallbacks
constructor(config: DirectConnectConfig, callbacks: DirectConnectCallbacks) {
this.config = config
this.callbacks = callbacks
}
connect(): void {
const headers: Record<string, string> = {}
if (this.config.authToken) {
headers['authorization'] = `Bearer ${this.config.authToken}`
}
// Bun's WebSocket supports headers option but the DOM typings don't
this.ws = new WebSocket(this.config.wsUrl, {
headers,
} as unknown as string[])
this.ws.addEventListener('open', () => {
this.callbacks.onConnected?.()
})
this.ws.addEventListener('message', event => {
const data = typeof event.data === 'string' ? event.data : ''
const lines = data.split('\n').filter((l: string) => l.trim())
for (const line of lines) {
let raw: unknown
try {
raw = jsonParse(line)
} catch {
continue
}
if (!isStdoutMessage(raw)) {
continue
}
const parsed = raw
// Handle control requests (permission requests)
if (parsed.type === 'control_request') {
if (parsed.request.subtype === 'can_use_tool') {
this.callbacks.onPermissionRequest(
parsed.request,
parsed.request_id,
)
} else {
// Send an error response for unrecognized subtypes so the
// server doesn't hang waiting for a reply that never comes.
logForDebugging(
`[DirectConnect] Unsupported control request subtype: ${parsed.request.subtype}`,
)
this.sendErrorResponse(
parsed.request_id,
`Unsupported control request subtype: ${parsed.request.subtype}`,
)
}
continue
}
// Forward SDK messages (assistant, result, system, etc.)
if (
parsed.type !== 'control_response' &&
parsed.type !== 'keep_alive' &&
parsed.type !== 'control_cancel_request' &&
parsed.type !== 'streamlined_text' &&
parsed.type !== 'streamlined_tool_use_summary' &&
!(parsed.type === 'system' && parsed.subtype === 'post_turn_summary')
) {
this.callbacks.onMessage(parsed)
}
}
})
this.ws.addEventListener('close', () => {
this.callbacks.onDisconnected?.()
})
this.ws.addEventListener('error', () => {
this.callbacks.onError?.(new Error('WebSocket connection error'))
})
}
sendMessage(content: RemoteMessageContent): boolean {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return false
}
// Must match SDKUserMessage format expected by `--input-format stream-json`
const message = jsonStringify({
type: 'user',
message: {
role: 'user',
content: content,
},
parent_tool_use_id: null,
session_id: '',
})
this.ws.send(message)
return true
}
respondToPermissionRequest(
requestId: string,
result: RemotePermissionResponse,
): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return
}
// Must match SDKControlResponse format expected by StructuredIO
const response = jsonStringify({
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {
behavior: result.behavior,
...(result.behavior === 'allow'
? { updatedInput: result.updatedInput }
: { message: result.message }),
},
},
})
this.ws.send(response)
}
/**
* Send an interrupt signal to cancel the current request
*/
sendInterrupt(): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return
}
// Must match SDKControlRequest format expected by StructuredIO
const request = jsonStringify({
type: 'control_request',
request_id: crypto.randomUUID(),
request: {
subtype: 'interrupt',
},
})
this.ws.send(request)
}
private sendErrorResponse(requestId: string, error: string): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return
}
const response = jsonStringify({
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId,
error,
},
})
this.ws.send(response)
}
disconnect(): void {
if (this.ws) {
this.ws.close()
this.ws = null
}
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN
}
}

58
src/server/types.ts Normal file
View File

@@ -0,0 +1,58 @@
import type { ChildProcess } from 'child_process'
import { z } from 'zod/v4'
import { lazySchema } from '../utils/lazySchema.js'
export const connectResponseSchema = lazySchema(() =>
z.object({
session_id: z.string(),
ws_url: z.string(),
work_dir: z.string().optional(),
}),
)
export type ServerConfig = {
port: number
host: string
authToken: string
unix?: string
/** Idle timeout for detached sessions (ms). 0 = never expire. */
idleTimeoutMs?: number
/** Maximum number of concurrent sessions. */
maxSessions?: number
/** Default workspace directory for sessions that don't specify cwd. */
workspace?: string
}
export type SessionState =
| 'starting'
| 'running'
| 'detached'
| 'stopping'
| 'stopped'
export type SessionInfo = {
id: string
status: SessionState
createdAt: number
workDir: string
process: ChildProcess | null
sessionKey?: string
}
/**
* Stable session key → session metadata. Persisted to ~/.claude/server-sessions.json
* so sessions can be resumed across server restarts.
*/
export type SessionIndexEntry = {
/** Server-assigned session ID (matches the subprocess's claude session). */
sessionId: string
/** The claude transcript session ID for --resume. Same as sessionId for direct sessions. */
transcriptSessionId: string
cwd: string
permissionMode?: string
createdAt: number
lastActiveAt: number
}
export type SessionIndex = Record<string, SessionIndexEntry>

View File

@@ -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);
});
});

View File

@@ -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<typeof mock.fn>).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<typeof mock.fn>).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<typeof mock.fn>).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<typeof mock.fn>).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<typeof mock.fn>).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);
});
});

188
src/server/web/admin.ts Normal file
View File

@@ -0,0 +1,188 @@
import { Router } from "express";
import { join } from "path";
import { readFileSync } from "fs";
import type { Request, Response } from "express";
import type { AuthenticatedRequest } from "./auth/adapter.js";
import type { SessionManager } from "./session-manager.js";
import type { UserStore } from "./user-store.js";
/**
* Admin dashboard routes.
*
* All routes under /admin require the requesting user to have isAdmin = true.
* The caller (pty-server.ts) is responsible for applying the auth middleware
* before mounting this router.
*/
function requireAdmin(req: Request, res: Response, next: () => void): void {
const user = (req as AuthenticatedRequest).user;
if (!user?.isAdmin) {
res.status(403).json({ error: "Forbidden" });
return;
}
next();
}
export function createAdminRouter(
sessionManager: SessionManager,
userStore: UserStore,
): Router {
const router = Router();
// All admin routes require admin role.
router.use(requireAdmin);
// ── Dashboard UI ──────────────────────────────────────────────────────────
router.get("/", (_req, res) => {
try {
const p = join(import.meta.dirname, "public/admin.html");
res.setHeader("Content-Type", "text/html");
res.send(readFileSync(p, "utf8"));
} catch {
res.setHeader("Content-Type", "text/html");
res.send(INLINE_ADMIN_HTML);
}
});
// ── API: all active sessions ──────────────────────────────────────────────
/**
* GET /admin/sessions
* Returns all active sessions across all users.
*/
router.get("/sessions", (_req, res) => {
const sessions = sessionManager.getAllSessions().map((s) => ({
id: s.token,
userId: s.userId,
createdAt: s.created,
ageMs: Date.now() - new Date(s.created).getTime(),
alive: s.alive,
}));
res.json({ sessions });
});
// ── API: all connected users ──────────────────────────────────────────────
/**
* GET /admin/users
* Returns all users that currently have at least one active session.
*/
router.get("/users", (_req, res) => {
res.json({ users: userStore.list() });
});
// ── API: force-kill a session ─────────────────────────────────────────────
/**
* DELETE /admin/sessions/:token
* Force-kills the specified session regardless of which user owns it.
*/
router.delete("/sessions/:token", (req, res) => {
const { token } = req.params;
const session = sessionManager.getSession(token);
if (!session) {
res.status(404).json({ error: "Session not found" });
return;
}
sessionManager.destroySession(token);
res.json({ ok: true, destroyed: token });
});
return router;
}
// ── Inline admin dashboard HTML ───────────────────────────────────────────────
const INLINE_ADMIN_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Claude Code — Admin</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0d1117; color: #e6edf3; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
.subtitle { color: #8b949e; font-size: 0.875rem; margin-bottom: 2rem; }
h2 { font-size: 1rem; margin: 1.5rem 0 0.75rem; color: #8b949e; text-transform: uppercase; letter-spacing: .05em; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th { text-align: left; padding: 0.4rem 0.75rem; border-bottom: 1px solid #21262d; color: #8b949e; font-weight: 500; }
td { padding: 0.4rem 0.75rem; border-bottom: 1px solid #161b22; }
tr:hover td { background: #161b22; }
button.kill {
background: #da3633; border: 1px solid #f85149; color: #fff;
padding: 0.2rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.8rem;
}
button.kill:hover { background: #f85149; }
.badge {
display: inline-block; padding: 0.15rem 0.5rem; border-radius: 9999px;
font-size: 0.75rem; background: #21262d; color: #8b949e;
}
.refresh { float: right; background: #21262d; border: 1px solid #30363d; color: #8b949e;
padding: 0.3rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
.refresh:hover { color: #e6edf3; }
#msg { margin-top: 1rem; font-size: 0.875rem; color: #3fb950; min-height: 1.2em; }
</style>
</head>
<body>
<h1>Admin Dashboard</h1>
<p class="subtitle">Claude Code — multi-user session management</p>
<button class="refresh" onclick="load()">&#8635; Refresh</button>
<h2>Connected Users</h2>
<table id="users-table">
<thead><tr><th>User ID</th><th>Email / Name</th><th>Sessions</th><th>First seen</th></tr></thead>
<tbody id="users-body"><tr><td colspan="4">Loading…</td></tr></tbody>
</table>
<h2>Active Sessions</h2>
<table id="sessions-table">
<thead><tr><th>Session ID</th><th>User ID</th><th>Age</th><th>Action</th></tr></thead>
<tbody id="sessions-body"><tr><td colspan="4">Loading…</td></tr></tbody>
</table>
<div id="msg"></div>
<script>
const msg = document.getElementById('msg');
function fmt(ms) {
if (ms < 60000) return Math.round(ms/1000) + 's';
if (ms < 3600000) return Math.round(ms/60000) + 'm';
return Math.round(ms/3600000) + 'h';
}
async function load() {
const [{ users }, { sessions }] = await Promise.all([
fetch('/admin/users').then(r => r.json()),
fetch('/admin/sessions').then(r => r.json()),
]);
const ub = document.getElementById('users-body');
ub.innerHTML = users.length === 0 ? '<tr><td colspan="4">No connected users</td></tr>' :
users.map(u => \`<tr>
<td><code>\${u.id}</code></td>
<td>\${u.email || u.name || '—'}</td>
<td><span class="badge">\${u.sessionCount}</span></td>
<td>\${new Date(u.firstSeenAt).toLocaleTimeString()}</td>
</tr>\`).join('');
const sb = document.getElementById('sessions-body');
sb.innerHTML = sessions.length === 0 ? '<tr><td colspan="4">No active sessions</td></tr>' :
sessions.map(s => \`<tr>
<td><code>\${s.id.slice(0,8)}…</code></td>
<td><code>\${s.userId}</code></td>
<td>\${fmt(s.ageMs)}</td>
<td><button class="kill" onclick="kill('\${s.id}')">Kill</button></td>
</tr>\`).join('');
}
async function kill(id) {
if (!confirm('Kill session ' + id.slice(0,8) + '…?')) return;
const r = await fetch('/admin/sessions/' + id, { method: 'DELETE' });
const j = await r.json();
msg.textContent = j.ok ? 'Session ' + id.slice(0,8) + '… destroyed.' : j.error;
load();
}
load();
setInterval(load, 10000);
</script>
</body>
</html>`;

65
src/server/web/auth.ts Normal file
View File

@@ -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<string, number[]>();
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);
}
}
}
}

View File

@@ -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<string, string> {
const out: Record<string, string> = {};
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<string, SessionData>();
/** 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<SessionData, "createdAt" | "expiresAt">): 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);
}
}
}

View File

@@ -0,0 +1,230 @@
import { createHash } from "crypto";
import { readFileSync } from "fs";
import { join } from "path";
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";
/**
* API-key authentication adapter.
*
* Each user provides their own Anthropic API key on the login page.
* The key is stored encrypted in the server-side session and is injected
* as `ANTHROPIC_API_KEY` into every PTY spawned for that user.
* The plaintext key is never sent to the browser after the login form POST.
*
* User identity is derived from the key itself (SHA-256 prefix), so two
* sessions using the same key share the same userId and home directory.
*
* Optional env vars:
* ADMIN_USERS — comma-separated user IDs (SHA-256 prefixes) or API-key
* prefixes that receive the admin role
*/
export class ApiKeyAdapter implements AuthAdapter {
private readonly store: SessionStore;
private readonly adminUsers: ReadonlySet<string>;
constructor(store: SessionStore) {
this.store = store;
this.adminUsers = new Set(
(process.env.ADMIN_USERS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
}
authenticate(req: IncomingMessage): AuthUser | null {
const session = this.store.getFromRequest(req);
if (!session || !session.encryptedApiKey) return null;
const apiKey = this.store.decrypt(session.encryptedApiKey);
if (!apiKey) return null;
return {
id: session.userId,
email: session.email,
name: session.name,
isAdmin:
session.isAdmin ||
this.adminUsers.has(session.userId),
apiKey,
};
}
setupRoutes(app: Application): void {
const loginHtml = this.loadLoginPage();
// GET /auth/login — serve the API key login form
app.get("/auth/login", (_req, res) => {
res.setHeader("Content-Type", "text/html");
res.send(loginHtml);
});
// POST /auth/login — validate key, create encrypted session
app.post(
"/auth/login",
// express.urlencoded is registered in pty-server.ts before setupRoutes
(req: Request, res: Response) => {
const apiKey = (req.body as Record<string, string>)?.api_key?.trim() ?? "";
if (!apiKey.startsWith("sk-ant-")) {
res.setHeader("Content-Type", "text/html");
res.status(400).send(
loginHtml.replace(
"<!--ERROR-->",
`<p class="error">Invalid API key format. Keys must start with <code>sk-ant-</code>.</p>`,
),
);
return;
}
const userId = deriveUserId(apiKey);
const isAdmin = this.adminUsers.has(userId);
const encryptedApiKey = this.store.encrypt(apiKey);
const sessionId = this.store.create({
userId,
isAdmin,
encryptedApiKey,
});
this.store.setCookie(res as unknown as import("http").ServerResponse, sessionId);
const next = (req.query as Record<string, string>)?.next;
res.redirect(next && next.startsWith("/") ? next : "/");
},
);
// POST /auth/logout — destroy session
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();
}
// ── Internals ─────────────────────────────────────────────────────────────
private loadLoginPage(): string {
// Serve from the public directory at build time; fall back to inline HTML.
try {
const p = join(import.meta.dirname, "../public/login.html");
return readFileSync(p, "utf8");
} catch {
return INLINE_LOGIN_HTML;
}
}
}
/**
* Derives a stable, opaque user ID from an API key.
* Uses the first 16 hex chars of SHA-256(key) — short enough to be readable,
* long enough to be unique.
*/
function deriveUserId(apiKey: string): string {
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
}
// Fallback inline login page used when public/login.html is not present.
const INLINE_LOGIN_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Claude Code — Sign In</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #0d1117;
color: #e6edf3;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 400px;
}
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
p.subtitle { color: #8b949e; font-size: 0.875rem; margin-bottom: 1.5rem; }
label { display: block; font-size: 0.875rem; margin-bottom: 0.4rem; color: #8b949e; }
input[type="password"] {
width: 100%;
padding: 0.5rem 0.75rem;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 0.9rem;
margin-bottom: 1rem;
}
input[type="password"]:focus {
outline: none;
border-color: #58a6ff;
}
button {
width: 100%;
padding: 0.6rem;
background: #238636;
border: 1px solid #2ea043;
border-radius: 6px;
color: #fff;
font-size: 0.95rem;
cursor: pointer;
}
button:hover { background: #2ea043; }
.error { color: #f85149; font-size: 0.875rem; margin-bottom: 1rem; }
code { background: #21262d; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.8rem; }
.hint { color: #8b949e; font-size: 0.75rem; margin-top: 1rem; }
.hint a { color: #58a6ff; }
</style>
</head>
<body>
<div class="card">
<h1>Claude Code</h1>
<p class="subtitle">Enter your Anthropic API key to start a session.</p>
<!--ERROR-->
<form method="POST" action="/auth/login">
<label for="api_key">Anthropic API Key</label>
<input
type="password"
id="api_key"
name="api_key"
placeholder="sk-ant-..."
autocomplete="off"
required
autofocus
>
<button type="submit">Sign In</button>
</form>
<p class="hint">
Your key is stored encrypted on the server and never sent to the browser.
Get a key at <a href="https://console.anthropic.com" target="_blank" rel="noopener">console.anthropic.com</a>.
</p>
</div>
</body>
</html>`;

View File

@@ -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<T>(url: string, opts?: { method?: string; body?: string; headers?: Record<string, string> }): Promise<T> {
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<string>;
/** Cached OIDC discovery document. */
private discovery: OIDCDiscovery | null = null;
/** In-flight state tokens to prevent CSRF. */
private readonly pendingStates = new Map<string, number>();
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<string, string | undefined>;
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<OIDCDiscovery> {
if (this.discovery) return this.discovery;
const url = `${this.issuer}/.well-known/openid-configuration`;
const doc = await fetchJSON<OIDCDiscovery>(url);
this.discovery = doc;
return doc;
}
private async exchangeCode(tokenEndpoint: string, code: string): Promise<TokenResponse> {
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<TokenResponse>(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<UserInfoResponse> {
return fetchJSON<UserInfoResponse>(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;
}
}

View File

@@ -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 <token>`
* 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<string>;
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 <token>
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;
}
}

View File

@@ -0,0 +1,326 @@
import express from "express";
import { createServer } from "http";
import { mkdirSync } from "fs";
import path from "path";
import { spawn } from "node-pty";
import { WebSocketServer } from "ws";
import type { IncomingMessage } from "http";
import { ConnectionRateLimiter } from "./auth.js";
import { SessionManager } from "./session-manager.js";
import { UserStore } from "./user-store.js";
import { createAdminRouter } from "./admin.js";
import { SessionStore } from "./auth/adapter.js";
import { TokenAuthAdapter } from "./auth/token-auth.js";
import { OAuthAdapter } from "./auth/oauth-auth.js";
import { ApiKeyAdapter } from "./auth/apikey-auth.js";
import type { AuthAdapter, AuthUser } from "./auth/adapter.js";
// ── Configuration ─────────────────────────────────────────────────────────────
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 MAX_SESSIONS_PER_USER = parseInt(process.env.MAX_SESSIONS_PER_USER ?? "3", 10);
const MAX_SESSIONS_PER_HOUR = parseInt(process.env.MAX_SESSIONS_PER_HOUR ?? "10", 10);
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") ?? [];
const GRACE_PERIOD_MS = parseInt(process.env.SESSION_GRACE_MS ?? String(5 * 60_000), 10);
const SCROLLBACK_BYTES = parseInt(process.env.SCROLLBACK_BYTES ?? String(100 * 1024), 10);
const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude";
const AUTH_PROVIDER = process.env.AUTH_PROVIDER ?? "token";
const SESSION_SECRET = process.env.SESSION_SECRET ?? crypto.randomUUID();
const USER_HOME_BASE = process.env.USER_HOME_BASE ?? "/home/claude/users";
// ── Auth adapter ──────────────────────────────────────────────────────────────
const sessionStore = new SessionStore(SESSION_SECRET);
let authAdapter: AuthAdapter;
switch (AUTH_PROVIDER) {
case "oauth":
authAdapter = new OAuthAdapter(sessionStore);
break;
case "apikey":
authAdapter = new ApiKeyAdapter(sessionStore);
break;
default:
authAdapter = new TokenAuthAdapter();
}
// ── Express app ───────────────────────────────────────────────────────────────
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
const server = createServer(app);
// Register auth routes (login, callback, logout) before static files so they
// take priority over any index.html fallback.
authAdapter.setupRoutes(app);
// ── User store ────────────────────────────────────────────────────────────────
const userStore = new UserStore();
// ── Session Manager ───────────────────────────────────────────────────────────
/** Returns the user-specific home directory, creating it if needed. */
function userHomeDir(userId: string): string {
const dir = path.join(USER_HOME_BASE, userId);
try {
mkdirSync(path.join(dir, ".claude"), { recursive: true });
} catch {
// Already exists or no permission — fail silently; PTY spawn will surface any real issue.
}
return dir;
}
const sessionManager = new SessionManager(
MAX_SESSIONS,
(cols, rows, user?: AuthUser) => {
const userId = user?.id ?? "default";
const home = userHomeDir(userId);
return spawn(CLAUDE_BIN, [], {
name: "xterm-256color",
cols,
rows,
cwd: process.env.WORK_DIR ?? home,
env: {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
HOME: home,
// Inject the user's own API key when using apikey auth provider.
...(user?.apiKey ? { ANTHROPIC_API_KEY: user.apiKey } : {}),
},
});
},
GRACE_PERIOD_MS,
SCROLLBACK_BYTES,
MAX_SESSIONS_PER_USER,
MAX_SESSIONS_PER_HOUR,
);
// ── HTTP routes ───────────────────────────────────────────────────────────────
app.get("/health", (_req, res) => {
res.json({
status: "ok",
activeSessions: sessionManager.activeCount,
maxSessions: MAX_SESSIONS,
authProvider: AUTH_PROVIDER,
});
});
/**
* GET /api/sessions — list the current user's sessions.
* Requires authentication. Admins see all sessions.
*/
app.get("/api/sessions", authAdapter.requireAuth.bind(authAdapter), (req, res) => {
const user = (req as express.Request & { user: AuthUser }).user;
const sessions = user.isAdmin
? sessionManager.getAllSessions()
: sessionManager.getUserSessions(user.id);
res.json(sessions);
});
/**
* DELETE /api/sessions/:token — kill a session.
* Users may only kill their own sessions; admins may kill any session.
*/
app.delete(
"/api/sessions/:token",
authAdapter.requireAuth.bind(authAdapter),
(req, res) => {
const { token } = req.params;
const user = (req as express.Request & { user: AuthUser }).user;
const session = sessionManager.getSession(token);
if (!session) {
res.status(404).json({ error: "Session not found" });
return;
}
if (!user.isAdmin && session.userId !== user.id) {
res.status(403).json({ error: "Forbidden" });
return;
}
sessionManager.destroySession(token);
res.status(204).end();
},
);
// Admin routes — protected by admin-role check inside the router.
app.use(
"/admin",
authAdapter.requireAuth.bind(authAdapter),
createAdminRouter(sessionManager, userStore),
);
// Static frontend (served last so auth/admin routes win).
const publicDir = path.join(import.meta.dirname, "public");
app.use(express.static(publicDir));
app.get("/", authAdapter.requireAuth.bind(authAdapter), (_req, res) => {
res.sendFile(path.join(publicDir, "index.html"));
});
// ── WebSocket server ──────────────────────────────────────────────────────────
/**
* Extend IncomingMessage to carry the authenticated user through from
* verifyClient to the connection handler without re-authenticating.
*/
interface AuthedRequest extends IncomingMessage {
_authUser?: AuthUser;
}
const rateLimiter = new ConnectionRateLimiter();
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;
}
// Authenticate the user
const user = authAdapter.authenticate(req as IncomingMessage);
if (!user) {
console.warn("Rejected WebSocket connection: unauthenticated");
callback(false, 401, "Unauthorized");
return;
}
// IP-level rate limit (guards against connection floods from a single IP)
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;
}
// Per-user rate limit (hourly new-session quota)
if (sessionManager.isUserRateLimited(user.id)) {
const retryAfter = sessionManager.retryAfterSeconds(user.id);
console.warn(`Per-user rate limit for ${user.id}`);
callback(false, 429, "Too Many Requests", { "Retry-After": String(retryAfter) });
return;
}
// Per-user concurrent session limit
if (sessionManager.isUserAtConcurrentLimit(user.id)) {
console.warn(`Concurrent session limit reached for ${user.id}`);
callback(false, 429, "Session limit reached");
return;
}
// Attach user to request for the connection handler
(req as AuthedRequest)._authUser = user;
callback(true);
},
});
wss.on("connection", (ws, req) => {
const user = (req as AuthedRequest)._authUser;
if (!user) {
// Should never happen — verifyClient already checked, but be safe.
ws.close(1008, "Unauthenticated");
return;
}
const ip =
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ??
req.socket.remoteAddress ??
"unknown";
console.log(`New WebSocket connection from ${ip} (user: ${user.id})`);
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 resumeToken = url.searchParams.get("resume");
// Try to resume an existing session owned by this user
if (resumeToken) {
const stored = sessionManager.getSession(resumeToken);
// Users may only resume their own sessions (admins can resume any)
if (stored && (user.isAdmin || stored.userId === user.id)) {
const resumed = sessionManager.resume(resumeToken, ws, cols, rows);
if (resumed) return;
}
console.log(
`[resume] Session ${resumeToken.slice(0, 8)}… not found or not owned — starting fresh`,
);
}
// Global capacity check
if (sessionManager.isFull) {
ws.send(JSON.stringify({ type: "error", message: "Max sessions reached. Try again later." }));
ws.close(1013, "Max sessions reached");
return;
}
const token = sessionManager.create(ws, cols, rows, user);
if (token) {
// Track the user in the user store
userStore.touch(user.id, { email: user.email, name: user.name });
// Release the user slot when this session ends
const stored = sessionManager.getSession(token);
if (stored) {
stored.pty.onExit(() => userStore.release(user.id));
}
ws.send(JSON.stringify({ type: "session", token }));
}
});
// ── Graceful shutdown ─────────────────────────────────────────────────────────
function shutdown() {
console.log("Shutting down...");
clearInterval(rateLimiterCleanup);
sessionManager.destroyAll();
wss.close(() => {
server.close(() => {
console.log("Server closed.");
process.exit(0);
});
});
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} (${MAX_SESSIONS_PER_USER} per user)`);
console.log(` Session grace period: ${GRACE_PERIOD_MS / 1000}s`);
console.log(` Scrollback buffer: ${Math.round(SCROLLBACK_BYTES / 1024)}KB per session`);
console.log(` Auth provider: ${AUTH_PROVIDER}`);
if (AUTH_PROVIDER === "token" && process.env.AUTH_TOKEN) {
console.log(" Auth: token required");
}
if (process.env.ADMIN_USERS) {
console.log(` Admins: ${process.env.ADMIN_USERS}`);
}
});
export { app, server, sessionManager, wss };

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#1a1b26"/>
<path d="M8 22l4-12h1.5L9.5 22H8zm7 0l4-12h1.5L16.5 22H15z" fill="#7aa2f7"/>
<rect x="7" y="23" width="18" height="2" rx="1" fill="#c0caf5"/>
</svg>

After

Width:  |  Height:  |  Size: 281 B

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#1a1b26" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f5" media="(prefers-color-scheme: light)">
<title>Claude Code</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<!-- terminal.css is emitted by esbuild: xterm.css + styles.css bundled together -->
<link rel="stylesheet" href="terminal.css">
</head>
<body>
<!-- Top bar -->
<div id="top-bar">
<div class="left">
<span id="status-dot" class="status-dot connecting"></span>
<span>Claude Code</span>
<span id="latency">--</span>
</div>
<div class="right">
<button id="bar-btn">Reconnect</button>
</div>
</div>
<!-- Re-expand button shown when top bar is collapsed -->
<button id="toggle-bar" title="Show top bar">&#9662;</button>
<!-- Terminal mount point -->
<div id="terminal-container"></div>
<!-- Loading overlay (initial connect) -->
<div id="loading-overlay">
<div class="spinner"></div>
<div class="overlay-msg">Connecting to Claude Code…</div>
</div>
<!-- Reconnect overlay (shown on disconnect) -->
<div id="reconnect-overlay">
<div class="spinner"></div>
<div class="overlay-msg">Connection lost. Reconnecting…</div>
<div class="overlay-sub" id="reconnect-sub">Retrying in 1s…</div>
</div>
<!-- terminal.js is the esbuild bundle of terminal.ts + all xterm addons -->
<script type="module" src="terminal.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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;
}
}

View File

@@ -0,0 +1,328 @@
import type { IPty } from "node-pty";
import type { WebSocket } from "ws";
import { SessionStore } from "./session-store.js";
import type { AuthUser } from "./auth/adapter.js";
export type { SessionInfo } from "./session-store.js";
// ── Per-user hourly rate limiter ─────────────────────────────────────────────
/**
* Tracks new-session creations per user within a rolling 1-hour window.
*
* `allow(userId)` is a non-destructive peek so callers can check eligibility
* before committing. `record(userId)` commits an attempt (call only on
* successful creation).
*/
export class UserHourlyRateLimiter {
private readonly attempts = new Map<string, number[]>();
private readonly maxPerHour: number;
constructor(maxPerHour: number) {
this.maxPerHour = maxPerHour;
setInterval(() => this.cleanup(), 5 * 60_000).unref();
}
allow(userId: string): boolean {
return this.recent(userId).length < this.maxPerHour;
}
record(userId: string): void {
const r = this.recent(userId);
r.push(Date.now());
this.attempts.set(userId, r);
}
/** Seconds until the oldest attempt in the window falls off (for Retry-After). */
retryAfterSeconds(userId: string): number {
const r = this.recent(userId);
if (r.length === 0) return 0;
return Math.ceil((Math.min(...r) + 3_600_000 - Date.now()) / 1000);
}
private recent(userId: string): number[] {
const cutoff = Date.now() - 3_600_000;
const filtered = (this.attempts.get(userId) ?? []).filter((t) => t > cutoff);
this.attempts.set(userId, filtered);
return filtered;
}
private cleanup(): void {
const cutoff = Date.now() - 3_600_000;
for (const [id, ts] of this.attempts) {
const r = ts.filter((t) => t > cutoff);
if (r.length === 0) this.attempts.delete(id);
else this.attempts.set(id, r);
}
}
}
// ── SessionManager ────────────────────────────────────────────────────────────
export class SessionManager {
private store: SessionStore;
private maxSessions: number;
private maxSessionsPerUser: number;
private spawnPty: (cols: number, rows: number, user?: AuthUser) => IPty;
private rateLimiter: UserHourlyRateLimiter;
// Tracks which sessions have already had their PTY event listeners wired,
// so we don't double-register on reconnect.
private wiredPtys = new Set<string>();
constructor(
maxSessions: number,
spawnPty: (cols: number, rows: number, user?: AuthUser) => IPty,
gracePeriodMs?: number,
scrollbackBytes?: number,
maxSessionsPerUser?: number,
maxSessionsPerHour?: number,
) {
this.maxSessions = maxSessions;
this.maxSessionsPerUser = maxSessionsPerUser ?? maxSessions;
this.spawnPty = spawnPty;
this.store = new SessionStore(gracePeriodMs, scrollbackBytes);
this.rateLimiter = new UserHourlyRateLimiter(maxSessionsPerHour ?? 100);
}
get activeCount(): number {
return this.store.size;
}
get isFull(): boolean {
return this.store.size >= this.maxSessions;
}
getSession(token: string) {
return this.store.get(token);
}
listSessions() {
return this.store.list();
}
/** All sessions in the shape expected by the admin dashboard. */
getAllSessions(): Array<{ id: string; userId: string; createdAt: number }> {
return this.store.getAll().map((s) => ({
id: s.token,
userId: s.userId,
createdAt: s.createdAt.getTime(),
}));
}
/** Sessions owned by a specific user — used by the per-user API. */
getUserSessions(userId: string) {
return this.store.listByUser(userId);
}
isUserAtConcurrentLimit(userId: string): boolean {
return this.store.countByUser(userId) >= this.maxSessionsPerUser;
}
isUserRateLimited(userId: string): boolean {
return !this.rateLimiter.allow(userId);
}
retryAfterSeconds(userId: string): number {
return this.rateLimiter.retryAfterSeconds(userId);
}
/**
* Spawns a new PTY, registers it in the session store, and wires up all
* event plumbing between the PTY and the WebSocket.
*
* Returns the session token, or null if at capacity or PTY spawn fails.
* When `user` is provided the session is associated with that user and
* per-user limits are enforced.
*/
create(ws: WebSocket, cols = 80, rows = 24, user?: AuthUser): string | null {
if (this.isFull) return null;
const userId = user?.id ?? "default";
if (this.isUserAtConcurrentLimit(userId)) {
ws.send(
JSON.stringify({
type: "error",
message: `Session limit reached for your account (max ${this.maxSessionsPerUser}).`,
}),
);
ws.close(1013, "Per-user session limit reached");
return null;
}
if (this.isUserRateLimited(userId)) {
ws.send(
JSON.stringify({
type: "error",
message: "Too many sessions created recently. Please wait before starting a new session.",
}),
);
ws.close(1013, "Rate limited");
return null;
}
let pty: IPty;
try {
pty = this.spawnPty(cols, rows, user);
} 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;
}
// Record the creation only after a successful spawn.
this.rateLimiter.record(userId);
const session = this.store.register(pty, userId);
session.ws = ws;
const { token } = session;
this.wirePtyEvents(token, pty);
this.wireWsEvents(token, ws, pty);
console.log(
`[session ${token.slice(0, 8)}] Created for user ${userId} (active: ${this.store.size}/${this.maxSessions})`,
);
return token;
}
/**
* Attaches a new WebSocket to an existing session identified by `token`.
*
* - Cancels the grace timer
* - Sends `{ type: "resumed", token }` to the client
* - Replays the scrollback buffer so the user sees their conversation
* - Resizes the PTY to the client's current terminal dimensions
*
* Returns true if the session was found, false otherwise.
*/
resume(token: string, ws: WebSocket, cols: number, rows: number): boolean {
const session = this.store.reattach(token, ws);
if (!session) return false;
console.log(
`[session ${token.slice(0, 8)}] Resumed (active: ${this.store.size}/${this.maxSessions})`,
);
// Tell the client it's a resumed session BEFORE sending scrollback bytes.
// The client uses this to clear the terminal first.
ws.send(JSON.stringify({ type: "resumed", token }));
// Replay buffered output
const scrollback = session.scrollback.read();
if (scrollback.length > 0) {
ws.send(scrollback);
}
// Sync PTY dimensions to the reconnected client
try {
session.pty.resize(cols, rows);
} catch {
// PTY may have exited
}
this.wireWsEvents(token, ws, session.pty);
return true;
}
/**
* Wire PTY → scrollback + WebSocket.
* Called once per session lifetime (idempotent via `wiredPtys` guard).
*/
private wirePtyEvents(token: string, pty: IPty): void {
if (this.wiredPtys.has(token)) return;
this.wiredPtys.add(token);
const session = this.store.get(token);
if (!session) return;
pty.onData((data: string) => {
// Always capture to scrollback for future replay
session.scrollback.write(data);
// Forward to the currently attached WebSocket, if any
const ws = session.ws;
if (ws && ws.readyState === 1 /* OPEN */) {
ws.send(data);
}
});
pty.onExit(({ exitCode, signal }) => {
this.wiredPtys.delete(token);
console.log(
`[session ${token.slice(0, 8)}] PTY exited: code=${exitCode}, signal=${signal}`,
);
const ws = session.ws;
if (ws && ws.readyState === 1 /* OPEN */) {
ws.send(JSON.stringify({ type: "exit", exitCode, signal }));
ws.close(1000, "PTY exited");
}
this.store.destroy(token);
});
}
/**
* Wire WebSocket → PTY (input, resize, ping).
* On close/error, start the grace period instead of immediately destroying
* the session — this keeps the PTY alive for reconnection.
* Called once per WebSocket connection (safe to call again on reconnect).
*/
private wireWsEvents(token: string, ws: WebSocket, pty: IPty): void {
ws.on("message", (data: Buffer | string) => {
const str = data.toString();
if (str.startsWith("{")) {
try {
const msg = JSON.parse(str) as Record<string, unknown>;
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 === 1 /* OPEN */) {
ws.send(JSON.stringify({ type: "pong" }));
}
return;
}
} catch {
// Not JSON — treat as terminal input
}
}
pty.write(str);
});
const handleClose = () => {
console.log(`[session ${token.slice(0, 8)}] WebSocket closed`);
const session = this.store.get(token);
// Only start grace if this WS is still the one attached to the session
if (session && session.ws === ws) {
this.store.startGrace(token, () => {
/* logged inside startGrace */
});
}
};
ws.on("close", handleClose);
ws.on("error", (err) => {
console.error(`[session ${token.slice(0, 8)}] WebSocket error:`, err.message);
handleClose();
});
}
/**
* Force-kill a session immediately (used by the REST API).
*/
destroySession(token: string): void {
this.store.destroy(token);
}
destroyAll(): void {
this.store.destroyAll();
}
}

View File

@@ -0,0 +1,197 @@
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;
/** ID of the user who owns this session. */
userId: string;
pty: IPty;
scrollback: ScrollbackBuffer;
ws: WebSocket | null;
createdAt: Date;
lastActive: Date;
graceTimer: ReturnType<typeof setTimeout> | null;
};
export type SessionInfo = {
token: string;
/** ID of the user who owns this session. */
userId: 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<string, StoredSession>();
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.
* @param userId - ID of the owning user (defaults to "default" for single-user deployments).
*/
register(pty: IPty, userId = "default"): StoredSession {
const token = crypto.randomUUID();
const session: StoredSession = {
token,
userId,
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,
userId: s.userId,
created: s.createdAt.toISOString(),
lastActive: s.lastActive.toISOString(),
alive: s.ws !== null && s.ws.readyState === 1 /* OPEN */,
}));
}
/** Returns summary info for sessions owned by a specific user. */
listByUser(userId: string): SessionInfo[] {
return this.list().filter((s) => s.userId === userId);
}
/** How many sessions are owned by the given user. */
countByUser(userId: string): number {
let n = 0;
for (const s of this.sessions.values()) {
if (s.userId === userId) n++;
}
return n;
}
/** Returns all raw StoredSession objects (used internally by SessionManager). */
getAll(): StoredSession[] {
return [...this.sessions.values()];
}
get size(): number {
return this.sessions.size;
}
destroyAll(): void {
for (const token of [...this.sessions.keys()]) {
this.destroy(token);
}
}
}

243
src/server/web/styles.css Normal file
View File

@@ -0,0 +1,243 @@
/* ── CSS custom properties (theme tokens) ───────────────────────────────── */
: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);
}
}
/* ── Reset ──────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
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;
}
#top-bar.collapsed {
display: none;
}
#top-bar .left,
#top-bar .right {
display: flex;
align-items: center;
gap: 10px;
}
/* Re-expand button — only visible when bar is collapsed */
#toggle-bar {
display: none;
position: fixed;
top: 4px;
right: 4px;
z-index: 20;
width: 20px;
height: 20px;
background: var(--bar-bg);
border: 1px solid var(--bar-fg);
border-radius: 3px;
color: var(--bar-fg);
cursor: pointer;
font-size: 10px;
align-items: center;
justify-content: center;
}
#top-bar.collapsed ~ #toggle-bar {
display: flex;
}
/* ── Status dot ─────────────────────────────────────────────────────────── */
.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 ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* ── Bar button ─────────────────────────────────────────────────────────── */
#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);
}
/* ── Terminal container ─────────────────────────────────────────────────── */
#terminal-container {
width: 100%;
height: calc(100vh - 32px);
background: var(--term-bg);
}
#top-bar.collapsed ~ #terminal-container {
height: 100vh;
}
/* Give xterm a little breathing room */
#terminal-container .xterm {
padding: 4px;
}
/* ── Overlays (loading + reconnect) ─────────────────────────────────────── */
#loading-overlay,
#reconnect-overlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
background: var(--overlay-bg);
z-index: 100;
}
#loading-overlay {
transition: opacity 0.3s ease;
}
#loading-overlay.hidden {
opacity: 0;
pointer-events: none;
}
#reconnect-overlay {
display: none;
}
#reconnect-overlay.visible {
display: flex;
}
.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); }
}
.overlay-msg {
color: var(--term-fg);
font-size: 14px;
opacity: 0.85;
}
.overlay-sub {
margin-top: 6px;
color: var(--bar-fg);
font-size: 12px;
}
/* ── Scrollbar ──────────────────────────────────────────────────────────── */
.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); }
/* ── Mobile ─────────────────────────────────────────────────────────────── */
@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;
}
}

372
src/server/web/terminal.ts Normal file
View File

@@ -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<typeof setTimeout> | null = null
let pingTimer: ReturnType<typeof setInterval> | 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<string, unknown>): 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<string>) => {
// 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()
})

View File

@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": []
},
"include": ["terminal.ts", "styles.css"]
}

View File

@@ -0,0 +1,64 @@
/**
* UserStore — tracks which users are connected and how many sessions each has.
*
* This is a lightweight in-memory view derived from the SessionManager; it
* does not persist across restarts. The admin dashboard and admin API read
* from this store to enumerate users and their activity.
*/
export interface UserRecord {
id: string;
email?: string;
name?: string;
firstSeenAt: number;
lastSeenAt: number;
sessionCount: number;
}
export class UserStore {
private readonly users = new Map<string, UserRecord>();
/**
* Called when a session is created for a user.
* Creates the user record if it doesn't exist yet; increments sessionCount.
*/
touch(userId: string, meta?: { email?: string; name?: string }): void {
const existing = this.users.get(userId);
if (existing) {
existing.lastSeenAt = Date.now();
existing.sessionCount += 1;
if (meta?.email && !existing.email) existing.email = meta.email;
if (meta?.name && !existing.name) existing.name = meta.name;
} else {
this.users.set(userId, {
id: userId,
email: meta?.email,
name: meta?.name,
firstSeenAt: Date.now(),
lastSeenAt: Date.now(),
sessionCount: 1,
});
}
}
/**
* Called when a session is destroyed for a user.
* Decrements sessionCount; removes the record when it reaches zero.
*/
release(userId: string): void {
const record = this.users.get(userId);
if (!record) return;
record.sessionCount = Math.max(0, record.sessionCount - 1);
if (record.sessionCount === 0) {
this.users.delete(userId);
}
}
/** Returns all currently connected users (sessionCount > 0). */
list(): UserRecord[] {
return [...this.users.values()];
}
get(userId: string): UserRecord | undefined {
return this.users.get(userId);
}
}