mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
89
src/server/createDirectConnectSession.ts
Normal file
89
src/server/createDirectConnectSession.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
214
src/server/directConnectManager.ts
Normal file
214
src/server/directConnectManager.ts
Normal 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
58
src/server/types.ts
Normal 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>
|
||||
|
||||
76
src/server/web/__tests__/auth.test.ts
Normal file
76
src/server/web/__tests__/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
159
src/server/web/__tests__/session-manager.test.ts
Normal file
159
src/server/web/__tests__/session-manager.test.ts
Normal 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
188
src/server/web/admin.ts
Normal 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()">↻ 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
65
src/server/web/auth.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
220
src/server/web/auth/adapter.ts
Normal file
220
src/server/web/auth/adapter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
230
src/server/web/auth/apikey-auth.ts
Normal file
230
src/server/web/auth/apikey-auth.ts
Normal 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>`;
|
||||
276
src/server/web/auth/oauth-auth.ts
Normal file
276
src/server/web/auth/oauth-auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
81
src/server/web/auth/token-auth.ts
Normal file
81
src/server/web/auth/token-auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
326
src/server/web/pty-server.ts
Normal file
326
src/server/web/pty-server.ts
Normal 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 };
|
||||
5
src/server/web/public/favicon.svg
Normal file
5
src/server/web/public/favicon.svg
Normal 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 |
48
src/server/web/public/index.html
Normal file
48
src/server/web/public/index.html
Normal 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">▾</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>
|
||||
432
src/server/web/public/terminal.css
Normal file
432
src/server/web/public/terminal.css
Normal file
File diff suppressed because one or more lines are too long
8963
src/server/web/public/terminal.js
Normal file
8963
src/server/web/public/terminal.js
Normal file
File diff suppressed because one or more lines are too long
66
src/server/web/scrollback-buffer.ts
Normal file
66
src/server/web/scrollback-buffer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
328
src/server/web/session-manager.ts
Normal file
328
src/server/web/session-manager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
197
src/server/web/session-store.ts
Normal file
197
src/server/web/session-store.ts
Normal 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
243
src/server/web/styles.css
Normal 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
372
src/server/web/terminal.ts
Normal 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()
|
||||
})
|
||||
8
src/server/web/tsconfig.json
Normal file
8
src/server/web/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["terminal.ts", "styles.css"]
|
||||
}
|
||||
64
src/server/web/user-store.ts
Normal file
64
src/server/web/user-store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user