diff --git a/package.json b/package.json index af895b37..d11cc7b0 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/mcp-session-lifecycle.test.ts && tsx src/mcp-integration.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/scripts/acceptance-21.ts b/scripts/acceptance-21.ts new file mode 100644 index 00000000..7e41e17e --- /dev/null +++ b/scripts/acceptance-21.ts @@ -0,0 +1,486 @@ +/** + * DevSpace 21-Point Acceptance Test Suite v2 — Strict PASS/SKIP/FAIL + * + * Changes from v1: + * - Strict PASS/SKIP/FAIL: no "skipped" counted as PASS. + * - Test 1: healthz must have all required fields (ok, sessions, pendingReservations, occupiedCapacity, maxSessions). + * - Test 6: maxSessions read from real response only — no ?? 64 fallback. + * - Test 8: Actually injects transport.close() failure and verifies other sessions survive. + * - Test 15: contextIgnorePaths must exist in diagnose config — no ?? [] fallback. + * - Test 19: diagnose must have required fields (shell, sessions, config, config.contextIgnorePaths). + * - Test 21: 7676 must be reachable — FAIL if not, no skip. + * + * Usage: + * node --import tsx scripts/acceptance-21.ts --port 7677 + */ + +import assert from "node:assert/strict"; + +const PORT = parseInt(process.argv.find((a, i) => process.argv[i - 1] === "--port") ?? "7677", 10); +const BASE = `http://localhost:${PORT}`; +const WORKSPACE_PATH = "C:\\Users\\Administrator\\.devspace\\upgrade-work\\devspace"; + +type TestStatus = "PASS" | "SKIP" | "FAIL"; + +interface TestResult { + testId: number; + name: string; + status: TestStatus; + detail: string; +} + +const results: TestResult[] = []; +let mcpSessionId: string | null = null; +let workspaceId: string | null = null; + +function log(msg: string): void { + console.log(` ${msg}`); +} + +async function healthz(): Promise { + const resp = await fetch(`${BASE}/healthz`); + return resp.json(); +} + +async function diagnose(): Promise { + const resp = await fetch(`${BASE}/devspace-runtime/diagnose`); + return resp.json(); +} + +async function mcpRequest(method: string, params: unknown, id: number = 1): Promise<{ status: number; body: any }> { + const headers: Record = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }; + if (mcpSessionId) { + headers["mcp-session-id"] = mcpSessionId; + } + + const resp = await fetch(`${BASE}/mcp`, { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + }); + + const text = await resp.text(); + let body: any; + try { + const lines = text.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + body = JSON.parse(line.slice(6)); + break; + } + } + if (!body) body = JSON.parse(text); + } catch { + body = { error: { message: text.slice(0, 200) } }; + } + + const sid = resp.headers.get("mcp-session-id"); + if (sid) mcpSessionId = sid; + + return { status: resp.status, body }; +} + +async function rawMcpRequest(method: string, params: unknown, sessionId?: string, id: number = 1): Promise<{ status: number; body: any; sessionId?: string }> { + const headers: Record = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }; + if (sessionId) headers["mcp-session-id"] = sessionId; + + const resp = await fetch(`${BASE}/mcp`, { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + }); + + const text = await resp.text(); + let body: any; + try { + const lines = text.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + body = JSON.parse(line.slice(6)); + break; + } + } + if (!body) body = JSON.parse(text); + } catch { + body = { error: { message: text.slice(0, 200) } }; + } + + const sid = resp.headers.get("mcp-session-id") ?? undefined; + return { status: resp.status, body, sessionId: sid }; +} + +async function initializeWithSid(): Promise { + const { status, body, sessionId } = await rawMcpRequest("initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "acceptance-test", version: "1.0" }, + }); + assert.strictEqual(status, 200, `initialize failed: ${status}`); + assert.ok(!body.error, `initialize error: ${JSON.stringify(body.error)}`); + assert.ok(sessionId, "no session ID returned"); + + await fetch(`${BASE}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "mcp-session-id": sessionId!, + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + }); + return sessionId!; +} + +async function initialize(): Promise { + mcpSessionId = await initializeWithSid(); +} + +async function openWorkspace(): Promise { + const { body } = await mcpRequest("tools/call", { + name: "open_workspace", + arguments: { path: WORKSPACE_PATH }, + }, 5); + assert.ok(!body.error, `open_workspace error: ${JSON.stringify(body.error)}`); + const result = body.result; + if (result?.workspaceId) { + return result.workspaceId; + } + const resultStr = JSON.stringify(result); + const match = resultStr.match(/ws_[a-f0-9-]+/); + if (match) { + return match[0]; + } + return ""; +} + +async function bash(command: string, id: number): Promise { + const args: any = { command }; + if (workspaceId) { + args.workspaceId = workspaceId; + } + const { body } = await mcpRequest("tools/call", { + name: "bash", + arguments: args, + }, id); + if (body.error) { + throw new Error(`MCP error ${body.error.code}: ${body.error.message}`); + } + return (body.result?.content?.[0]?.text ?? "").trim(); +} + +async function runTest(testId: number, name: string, fn: () => Promise): Promise { + try { + const detail = await fn(); + results.push({ testId, name, status: "PASS", detail }); + console.log(` [PASS] Test ${testId}: ${name} — ${detail}`); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + results.push({ testId, name, status: "FAIL", detail }); + console.error(` [FAIL] Test ${testId}: ${name} — ${detail}`); + } +} + +async function main(): Promise { + console.log(`\n=== DevSpace 21-Point Acceptance Test Suite v2 (Strict) ===`); + console.log(`Target: ${BASE}\n`); + + // ─── Test 1: healthz with strict field validation ─── + await runTest(1, "healthz strict fields", async () => { + const h = await healthz(); + assert.ok(h.ok === true, `healthz.ok must be true, got ${h.ok}`); + // Strict: all required fields must be present — no fallback. + assert.ok("sessions" in h, "healthz missing 'sessions' field"); + assert.ok("pendingReservations" in h, "healthz missing 'pendingReservations' field"); + assert.ok("occupiedCapacity" in h, "healthz missing 'occupiedCapacity' field"); + assert.ok("maxSessions" in h, "healthz missing 'maxSessions' field"); + assert.ok(typeof h.sessions === "number", `sessions must be number, got ${typeof h.sessions}`); + assert.ok(typeof h.pendingReservations === "number", `pendingReservations must be number`); + assert.ok(typeof h.occupiedCapacity === "number", `occupiedCapacity must be number`); + assert.ok(typeof h.maxSessions === "number", `maxSessions must be number`); + assert.ok(h.maxSessions > 0, `maxSessions must be > 0, got ${h.maxSessions}`); + return `ok=${h.ok}, sessions=${h.sessions}, pending=${h.pendingReservations}, capacity=${h.occupiedCapacity}, max=${h.maxSessions}`; + }); + + // ─── Test 21: 7676 healthy throughout — strict, no skip ─── + await runTest(21, "7676 healthy throughout", async () => { + const resp = await fetch("http://localhost:7676/healthz", { signal: AbortSignal.timeout(3000) }); + assert.ok(resp.ok, `7676 healthz must return ok, got ${resp.status}`); + const h = await resp.json(); + assert.ok(h.ok === true, `7676 healthz.ok must be true, got ${h.ok}`); + return `ok=${h.ok}, sessions=${h.sessions ?? 0}`; + }); + + // ─── Test 2: Initialize MCP session ─── + await runTest(2, "Initialize MCP session", async () => { + await initialize(); + assert.ok(mcpSessionId, "session ID should be set"); + return `sessionId=${mcpSessionId!.slice(0, 12)}...`; + }); + + // ─── Test 3: Multiple requests ─── + await runTest(3, "Multiple requests", async () => { + for (let i = 0; i < 3; i++) { + const { status, body } = await mcpRequest("tools/list", {}, i + 10); + assert.strictEqual(status, 200, `request ${i} failed with status ${status}`); + assert.ok(!body.error, `request ${i} error: ${JSON.stringify(body.error)}`); + } + return "3 sequential requests OK"; + }); + + // Open workspace FIRST (needed for bash tool) + workspaceId = await openWorkspace(); + log(`workspaceId: ${workspaceId}`); + + // ─── Test 9: PowerShell $_ ─── + await runTest(9, "PowerShell $_", async () => { + const output = await bash("1,2,3 | ForEach-Object { $_ * 2 }", 20); + assert.ok(output.includes("2"), `output should contain 2: ${output}`); + assert.ok(output.includes("4"), `output should contain 4: ${output}`); + assert.ok(output.includes("6"), `output should contain 6: ${output}`); + return `output: ${output.replace(/\r/g, "")}`; + }); + + // ─── Test 10: PowerShell $variable ─── + await runTest(10, "PowerShell $variable", async () => { + const output = await bash("$x = 42; Write-Output \"x=$x\"", 21); + assert.ok(output.includes("x=42"), `output should contain x=42: ${output}`); + return `output: ${output.replace(/\r/g, "")}`; + }); + + // ─── Test 11: PowerShell pipeline ─── + await runTest(11, "PowerShell pipeline", async () => { + const output = await bash("Get-Process node | Select-Object ProcessName", 22); + assert.ok(output.includes("node"), `output should contain 'node': ${output}`); + return `output: ${output.replace(/\r/g, "").slice(0, 60)}`; + }); + + // ─── Test 12: 50000 char output limited ─── + await runTest(12, "50000 char output limited", async () => { + const output = await bash("Write-Output ('x' * 50000)", 23); + assert.ok(output.length < 50000, `output should be truncated: length=${output.length}`); + const hasMarker = output.includes("[output truncated") || output.includes("truncated"); + return `original=50000, returned=${output.length}, marker=${hasMarker}`; + }); + + // ─── Test 13: open_workspace compressed ─── + await runTest(13, "open_workspace compressed", async () => { + const { body } = await mcpRequest("tools/call", { + name: "open_workspace", + arguments: { path: WORKSPACE_PATH }, + }, 24); + assert.ok(!body.error, `open_workspace error: ${JSON.stringify(body.error)}`); + const result = JSON.stringify(body.result ?? {}); + const hasReadHint = result.includes("read") || result.includes("workspace"); + return `size=${result.length}, hasReadHint=${hasReadHint}`; + }); + + // ─── Test 17: D:\ path access ─── + await runTest(17, "D:\\ path access", async () => { + const output = await bash("Test-Path D:\\", 25); + assert.ok(output.includes("True"), `D:\\ should be accessible: ${output}`); + return `output: ${output.replace(/\r/g, "")}`; + }); + + // ─── Test 19: Diagnose strict fields + smoke + costs ─── + await runTest(19, "Diagnose strict fields + smoke + costs", async () => { + const diag = await diagnose(); + // Strict: diagnose must have required fields — no fallback. + assert.ok("shell" in diag, `diagnose missing 'shell' field`); + assert.ok(diag.shell, `diagnose.shell must be non-empty, got ${diag.shell}`); + assert.ok("sessions" in diag, `diagnose missing 'sessions' field`); + assert.ok(diag.sessions !== null, `diagnose.sessions must not be null`); + assert.ok("config" in diag, `diagnose missing 'config' field`); + assert.ok(diag.config !== null, `diagnose.config must not be null`); + assert.ok("contextIgnorePaths" in diag.config, `diagnose.config missing 'contextIgnorePaths'`); + assert.ok(Array.isArray(diag.config.contextIgnorePaths), `contextIgnorePaths must be array`); + assert.ok("maxSessions" in diag.sessions, `diagnose.sessions missing 'maxSessions'`); + assert.ok(typeof diag.sessions.maxSessions === "number", `maxSessions must be number`); + + const smokeResp = await fetch(`${BASE}/devspace-runtime/smoke`); + assert.ok(smokeResp.ok, `smoke endpoint should work, got ${smokeResp.status}`); + const smoke = await smokeResp.json(); + assert.ok(smoke.ok === true, `smoke.ok must be true`); + + const costsResp = await fetch(`${BASE}/devspace-runtime/costs`); + assert.ok(costsResp.ok, `costs endpoint should work, got ${costsResp.status}`); + + return `shell=${diag.shell}, smoke=${smoke.ok}, maxSessions=${diag.sessions.maxSessions}`; + }); + + // ─── Test 4: lastActivity refresh ─── + await runTest(4, "lastActivity refresh", async () => { + await mcpRequest("tools/list", {}, 30); + const h = await healthz(); + assert.ok(h.sessions >= 1, `should have at least 1 session: ${h.sessions}`); + return `activeSessions=${h.sessions}`; + }); + + // ─── Test 14: Read AGENTS via read tool ─── + await runTest(14, "Read AGENTS via read tool", async () => { + const { body } = await mcpRequest("tools/call", { + name: "read", + arguments: { path: `${WORKSPACE_PATH}\\AGENTS.md` }, + }, 31); + assert.ok(!body.error, `read should not error: ${JSON.stringify(body.error)}`); + return "read succeeded"; + }); + + // ─── Test 15: contextIgnorePaths exists — strict, no fallback ─── + await runTest(15, "contextIgnorePaths exists (strict)", async () => { + const diag = await diagnose(); + assert.ok(diag.config, "diagnose.config must exist"); + assert.ok("contextIgnorePaths" in diag.config, "config.contextIgnorePaths must exist as a field"); + const paths = diag.config.contextIgnorePaths; + assert.ok(Array.isArray(paths), `contextIgnorePaths must be an array, got ${typeof paths}`); + // Must be a real array — even if empty, the field must exist. + return `paths=${JSON.stringify(paths)} (count=${paths.length})`; + }); + + // ─── Test 16: Ignored dirs still readable ─── + await runTest(16, "Ignored dirs still readable", async () => { + const { body } = await mcpRequest("tools/call", { + name: "read", + arguments: { path: `${WORKSPACE_PATH}\\package.json` }, + }, 32); + assert.ok(!body.error, `read should not error: ${JSON.stringify(body.error)}`); + return "read succeeded"; + }); + + // ─── Test 5: inFlight protection ─── + await runTest(5, "inFlight protection", async () => { + await mcpRequest("tools/list", {}, 33); + const h = await healthz(); + assert.ok(h.sessions >= 1, `sessions should still be active: ${h.sessions}`); + return `sessions still active=${h.sessions}`; + }); + + // ─── Test 6: Session limit config — strict, no ?? 64 fallback ─── + await runTest(6, "Session limit config (strict)", async () => { + const h = await healthz(); + assert.ok("maxSessions" in h, "healthz must have maxSessions field"); + const maxSessions = h.maxSessions; + assert.ok(typeof maxSessions === "number", `maxSessions must be a number, got ${typeof maxSessions}`); + assert.ok(maxSessions > 0, `maxSessions must be > 0, got ${maxSessions}`); + // Cross-check with diagnose + const diag = await diagnose(); + assert.ok(diag.sessions?.maxSessions, "diagnose.sessions.maxSessions must exist"); + assert.strictEqual(diag.sessions.maxSessions, maxSessions, `healthz and diagnose maxSessions mismatch`); + return `maxSessions=${maxSessions} (from healthz, confirmed by diagnose)`; + }); + + // ─── Test 7: Multiple sessions (closeAll) ─── + await runTest(7, "Multiple sessions (closeAll)", async () => { + const oldSid = mcpSessionId; + mcpSessionId = null; + await initialize(); + assert.ok(mcpSessionId !== oldSid, "should have a different session ID"); + return `second session=${mcpSessionId!.slice(0, 12)}...`; + }); + + // ─── Test 8: Close failure resilience — inject + registry close ─── + await runTest(8, "Close failure resilience (injected)", async () => { + // Create 3 independent sessions. + const sid1 = await initializeWithSid(); + const sid2 = await initializeWithSid(); + const sid3 = await initializeWithSid(); + log(`created sessions: ${sid1.slice(0, 8)}, ${sid2.slice(0, 8)}, ${sid3.slice(0, 8)}`); + + // Inject close failure for sid1 — its transport.close() will throw. + const injectResp = await fetch(`${BASE}/test/inject-close-failure`, { + method: "POST", + headers: { "Content-Type": "application/json", "mcp-session-id": sid1 }, + body: JSON.stringify({ sessionId: sid1 }), + }); + assert.ok(injectResp.ok, `inject-close-failure should return ok, got ${injectResp.status}`); + const injectBody = await injectResp.json(); + assert.ok(injectBody.ok, `inject should succeed: ${JSON.stringify(injectBody)}`); + log(`injected close failure for ${sid1.slice(0, 8)}...`); + + // Close sid1 via registry — triggers closeTransport which will fail. + const closeResp = await fetch(`${BASE}/test/close-registry-session`, { + method: "POST", + headers: { "Content-Type": "application/json", "mcp-session-id": sid1 }, + body: JSON.stringify({ sessionId: sid1 }), + }); + assert.ok(closeResp.ok, `close-registry-session should return ok, got ${closeResp.status}`); + const closeBody = await closeResp.json(); + log(`close result: closed=${closeBody.closed}, lastError=${closeBody.lastError}, remaining=${closeBody.remainingSessions}`); + + // Assert lastError contains "Injected close failure" + assert.ok(closeBody.closed, "sid1 should have been closed"); + assert.ok(closeBody.lastError, "lastError should be set after close failure"); + assert.ok( + closeBody.lastError.includes("Injected close failure"), + `lastError should contain "Injected close failure", got: ${closeBody.lastError}` + ); + + // Verify sid2 and sid3 still work — this proves close failure resilience. + const list2 = await rawMcpRequest("tools/list", {}, sid2, 40); + assert.strictEqual(list2.status, 200, `sid2 should still work after sid1 close failure: ${list2.status}`); + assert.ok(!list2.body.error, `sid2 tools/list error: ${JSON.stringify(list2.body.error)}`); + + const list3 = await rawMcpRequest("tools/list", {}, sid3, 41); + assert.strictEqual(list3.status, 200, `sid3 should still work after sid1 close failure: ${list3.status}`); + assert.ok(!list3.body.error, `sid3 tools/list error: ${JSON.stringify(list3.body.error)}`); + + // Verify server is still healthy. + const h = await healthz(); + assert.ok(h.ok, "server must still be healthy after close failure"); + + // Clean up sid2 and sid3. + await fetch(`${BASE}/mcp`, { method: "DELETE", headers: { "mcp-session-id": sid2 } }); + await fetch(`${BASE}/mcp`, { method: "DELETE", headers: { "mcp-session-id": sid3 } }); + + return `sid1 close failed (injected, lastError="${closeBody.lastError.slice(0, 40)}"), sid2+sid3 survived`; + }); + + // ─── Test 18: AGENTS realpath ─── + await runTest(18, "AGENTS realpath", async () => { + const { body } = await mcpRequest("tools/call", { + name: "open_workspace", + arguments: { path: WORKSPACE_PATH }, + }, 41); + assert.ok(!body.error, `open_workspace should succeed: ${JSON.stringify(body.error)}`); + return "workspace opened"; + }); + + // ─── Test 20: Port active ─── + await runTest(20, "Port active", async () => { + const h = await healthz(); + assert.ok(h.ok, "port should be active"); + assert.ok("maxSessions" in h, "healthz must have maxSessions"); + return `port=${PORT}, maxSessions=${h.maxSessions}`; + }); + + // ─── Summary ─── + console.log(""); + const passed = results.filter((r) => r.status === "PASS").length; + const skipped = results.filter((r) => r.status === "SKIP").length; + const failed = results.filter((r) => r.status === "FAIL").length; + console.log(`=== Acceptance v2: ${passed} PASS, ${skipped} SKIP, ${failed} FAIL (total ${results.length}) ===`); + + if (failed > 0 || skipped > 0) { + console.error(`\n\u2717 NOT all 21 strict PASS — ${failed} FAIL, ${skipped} SKIP\n`); + // List failures + for (const r of results.filter((r) => r.status !== "PASS")) { + console.error(` ${r.status}: Test ${r.testId}: ${r.name} — ${r.detail}`); + } + process.exit(1); + } else if (passed === 21) { + console.log(`\n\u2713 All 21 acceptance tests strictly passed (21/21)\n`); + } else { + console.error(`\n\u2717 Only ${passed}/21 passed — missing tests\n`); + process.exit(1); + } +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/src/advanced-tools.ts b/src/advanced-tools.ts new file mode 100644 index 00000000..cc7bbb2d --- /dev/null +++ b/src/advanced-tools.ts @@ -0,0 +1,743 @@ +/** + * Advanced tools for DevSpace MCP server. + * + * This module provides read_many, search_text, task guardrails, job management, + * and Git workflow tools. It is compiled by tsc into dist/advanced-tools.js. + */ + +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { relative, resolve, sep } from "node:path"; +import { promisify } from "node:util"; +import type { WorkspaceRegistry, WorkspaceReadPath, Workspace } from "./workspaces.js"; +import type { ProcessSessionManager, ProcessSnapshot } from "./process-sessions.js"; + +const execFileAsync = promisify(execFile); +const MAX_READ_FILES = 50; +const DEFAULT_READ_BUDGET = 120_000; +const MAX_READ_BUDGET = 500_000; +const DEFAULT_SEARCH_RESULTS = 100; +const MAX_SEARCH_RESULTS = 500; + +// ─── Public Types ────────────────────────────────────────────────────── + +export interface AdvancedGuardStore { + apply(workspaceId: string, root: string, input?: unknown): { + protectedPaths: string[]; + blockedCommandPatterns: string[]; + }; + assertPathAllowed(workspaceId: string, path: string): void; + assertCommandAllowed(workspaceId: string, command: string): void; + assertReadAllowed?(workspaceId: string, path: string): void; + summary(workspaceId: string): { protectedPaths: string[]; blockedCommandPatterns: string[] }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RegisterAppToolFn = (server: any, name: string, descriptor: any, handler: any) => void; + +export interface AdvancedToolsDependencies { + z: typeof import("zod/v4"); + registerAppTool: RegisterAppToolFn; + workspaces: WorkspaceRegistry; + processSessions: ProcessSessionManager; + guards: AdvancedGuardStore; +} + +// ─── Internal Types ──────────────────────────────────────────────────── + +interface ExecResult { + exitCode: number; + stdout: string; + stderr: string; + error?: unknown; +} + +interface ExecOptions { + cwd?: string; + timeoutMs?: number; + maxBuffer?: number; +} + +interface ProtectedPathEntry { + input: string; + absolutePath: string; + normalized: string; +} + +interface BlockedPatternEntry { + input: string; + regex: RegExp; +} + +interface GuardEntry { + root: string; + protectedPaths: ProtectedPathEntry[]; + blockedCommandPatterns: BlockedPatternEntry[]; +} + +interface GitState { + status: string; + head: string; + origin: string; + remote: string; + remoteName: string; + branch: string; + clean: boolean; + aligned: boolean; +} + +interface TextResult { + text: string; + truncated: boolean; +} + +// ─── Helpers ─────────────────────────────────────────────────────────── + +export const ADVANCED_TOOL_NAMES = [ + "read_many", + "search_text", + "apply_task_guardrails", + "start_job", + "job_status", + "job_cancel", + "git_preflight", + "git_stage_exact", + "git_commit", + "git_push", + "git_postflight", +]; + +function textResult(text: string, structuredContent: Record = {}): { + content: [{ type: "text"; text: string }]; + structuredContent: Record; +} { + return { + content: [{ type: "text", text }], + structuredContent, + }; +} + +function normalizedPath(path: string): string { + const normalized = resolve(path).replaceAll("/", sep); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function pathIsInside(path: string, root: string): boolean { + const relationship = relative(root, path); + return relationship === "" || (!relationship.startsWith("..") && relationship !== ".." && !resolve(relationship).startsWith(`..${sep}`)); +} + +function assertInsideRoot(path: string, root: string, label: string): string { + const absolutePath = resolve(path); + const absoluteRoot = resolve(root); + const relationship = relative(absoluteRoot, absolutePath); + if (relationship === ".." || relationship.startsWith(`..${sep}`) || resolve(relationship).startsWith(`..${sep}`)) { + throw new Error(`${label} is outside the workspace root.`); + } + return absolutePath; +} + +function compilePattern(pattern: string): RegExp { + try { + return new RegExp(pattern, "i"); + } catch (error) { + throw new Error(`Invalid blocked command pattern: ${pattern}. ${error instanceof Error ? error.message : String(error)}`); + } +} + +// ─── Guard Store ─────────────────────────────────────────────────────── + +export function createAdvancedGuardStore(): AdvancedGuardStore { + const entries = new Map(); + return { + apply(workspaceId: string, root: string, input: unknown = {}) { + const inputObj = input as { protectedPaths?: string[]; blockedCommandPatterns?: string[] }; + const absoluteRoot = resolve(root); + const protectedPaths = [...new Set(inputObj.protectedPaths ?? [])].map((path) => { + const absolutePath = assertInsideRoot(resolve(absoluteRoot, path), absoluteRoot, `Protected path ${path}`); + return { + input: path, + absolutePath, + normalized: normalizedPath(absolutePath), + }; + }); + const blockedCommandPatterns = [...new Set(inputObj.blockedCommandPatterns ?? [])].map((pattern) => ({ + input: pattern, + regex: compilePattern(pattern), + })); + const entry: GuardEntry = { root: absoluteRoot, protectedPaths, blockedCommandPatterns }; + entries.set(workspaceId, entry); + return { + protectedPaths: protectedPaths.map((item) => item.input), + blockedCommandPatterns: blockedCommandPatterns.map((item) => item.input), + }; + }, + assertPathAllowed(workspaceId: string, path: string) { + const entry = entries.get(workspaceId); + if (!entry) return; + const candidate = normalizedPath(assertInsideRoot(path, entry.root, "Path")); + const blocked = entry.protectedPaths.find((item) => { + return candidate === item.normalized || candidate.startsWith(`${item.normalized}${sep}`); + }); + if (blocked) { + throw new Error(`Path is protected by active task guardrails: ${blocked.input}`); + } + }, + assertCommandAllowed(workspaceId: string, command: string) { + const entry = entries.get(workspaceId); + if (!entry) return; + const blocked = entry.blockedCommandPatterns.find((item) => item.regex.test(command)); + if (blocked) { + throw new Error(`Command is blocked by active task guardrails: ${blocked.input}`); + } + }, + summary(workspaceId: string) { + const entry = entries.get(workspaceId); + return { + protectedPaths: entry?.protectedPaths.map((item) => item.input) ?? [], + blockedCommandPatterns: entry?.blockedCommandPatterns.map((item) => item.input) ?? [], + }; + }, + }; +} + +export function validateExactStagePaths(paths: unknown): string[] { + if (!Array.isArray(paths) || paths.length === 0) { + throw new Error("git_stage_exact requires one or more exact file paths."); + } + const result: string[] = []; + const seen = new Set(); + for (const rawPath of paths) { + const path = String(rawPath ?? "").trim(); + const normalized = path.replaceAll("\\", "/"); + if ( + !path || + path.startsWith("-") || + normalized === "." || + normalized === "./" || + normalized === "*" || + /[*?\[\]]/.test(path) + ) { + throw new Error(`Only exact file paths are allowed for staging: ${rawPath}`); + } + if (!seen.has(normalized)) { + seen.add(normalized); + result.push(path); + } + } + return result; +} + +function sliceLines(text: string, offset: number = 1, limit?: number): string { + const normalized = text.replaceAll("\r\n", "\n"); + const lines = normalized.split("\n"); + if (lines.at(-1) === "") lines.pop(); + const start = Math.max(0, offset - 1); + const end = limit === undefined ? lines.length : start + limit; + return lines.slice(start, end).join("\n"); +} + +function truncateText(text: string, maxCharacters: number): TextResult { + if (text.length <= maxCharacters) return { text, truncated: false }; + if (maxCharacters <= 0) return { text: "", truncated: true }; + const marker = "\n... content truncated ...\n"; + if (maxCharacters <= marker.length) { + return { text: text.slice(0, maxCharacters), truncated: true }; + } + const available = maxCharacters - marker.length; + const head = Math.ceil(available / 2); + const tail = Math.floor(available / 2); + return { + text: text.slice(0, head) + marker + text.slice(text.length - tail), + truncated: true, + }; +} + +// ─── Executable / Git Helpers ────────────────────────────────────────── + +async function runExecutable(file: string, args: string[], options: ExecOptions = {}): Promise { + try { + const result = await execFileAsync(file, args, { + cwd: options.cwd, + encoding: "utf8", + timeout: options.timeoutMs ?? 60_000, + maxBuffer: options.maxBuffer ?? 10 * 1024 * 1024, + windowsHide: true, + env: { + ...process.env, + NO_COLOR: "1", + TERM: "dumb", + PAGER: "cat", + GIT_PAGER: "cat", + GH_PAGER: "cat", + GIT_TERMINAL_PROMPT: "0", + GCM_INTERACTIVE: "Never", + }, + }); + return { + exitCode: 0, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; + } catch (error) { + const execError = error as { code?: number; stdout?: string; stderr?: string; message?: string }; + return { + exitCode: typeof execError.code === "number" ? execError.code : 1, + stdout: execError.stdout ?? "", + stderr: execError.stderr ?? execError.message ?? String(error), + error, + }; + } +} + +function assertExecutableSuccess(result: ExecResult, label: string): ExecResult { + if (result.exitCode === 0) return result; + const details = [result.stderr, result.stdout].filter(Boolean).join("\n").trim(); + throw new Error(`${label} failed with exit code ${result.exitCode}${details ? `:\n${details}` : "."}`); +} + +async function runGit(root: string, args: string[], label: string = `git ${args.join(" ")}`): Promise { + return assertExecutableSuccess(await runExecutable("git", args, { cwd: root, timeoutMs: 120_000 }), label); +} + +function parseRemoteSha(output: string): string { + const firstLine = output.trim().split(/\r?\n/, 1)[0] ?? ""; + return firstLine.split(/\s+/, 1)[0] ?? ""; +} + +async function collectGitState(root: string, remote: string, branch: string): Promise { + const ref = `refs/heads/${branch}`; + const [status, head, origin, remoteHead] = await Promise.all([ + runGit(root, ["status", "--short"], "git status"), + runGit(root, ["rev-parse", "HEAD"], "git rev-parse HEAD"), + runGit(root, ["rev-parse", `${remote}/${branch}`], `git rev-parse ${remote}/${branch}`), + runGit(root, ["ls-remote", remote, ref], `git ls-remote ${remote} ${ref}`), + ]); + const values = { + status: status.stdout.trimEnd(), + head: head.stdout.trim(), + origin: origin.stdout.trim(), + remote: parseRemoteSha(remoteHead.stdout), + remoteName: remote, + branch, + }; + return { + ...values, + clean: values.status.length === 0, + aligned: Boolean(values.head && values.head === values.origin && values.head === values.remote), + }; +} + +function safeGitToken(value: unknown, label: string): string { + const token = String(value ?? "").trim(); + if (!token || token.startsWith("-") || !/^[A-Za-z0-9._\/-]+$/.test(token)) { + throw new Error(`Invalid Git ${label}: ${value}`); + } + return token; +} + +function jobStructured(snapshot: ProcessSnapshot) { + return { + sessionId: snapshot.sessionId, + running: snapshot.running, + exitCode: snapshot.exitCode, + signal: snapshot.signal, + output: snapshot.output ?? "", + outputTruncated: Boolean(snapshot.outputTruncated), + wallTimeMs: snapshot.wallTimeMs ?? 0, + }; +} + +// ─── Tool Registration ───────────────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function registerAdvancedTools(server: any, dependencies: AdvancedToolsDependencies): void { + const { z, registerAppTool, workspaces, processSessions, guards } = dependencies; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const registerTool = (serverInstance: any, name: string, descriptor: any, handler: any) => + registerAppTool(serverInstance, name, { _meta: {}, ...descriptor }, handler); + const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }; + const writeAction = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }; + const shellAction = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }; + + registerTool(server, "read_many", { + title: "Read many files", + description: "Read multiple workspace files in one call. Use this instead of repeated read calls when a task lists several known files. Each file supports optional 1-indexed offset and line limit.", + inputSchema: { + workspaceId: z.string(), + files: z.array(z.object({ + path: z.string(), + offset: z.number().int().positive().optional(), + limit: z.number().int().positive().optional(), + })).min(1).max(MAX_READ_FILES), + maxTotalCharacters: z.number().int().positive().max(MAX_READ_BUDGET).optional(), + }, + outputSchema: { + result: z.string(), + files: z.array(z.object({ + path: z.string(), + content: z.string().optional(), + error: z.string().optional(), + truncated: z.boolean(), + })), + totalCharacters: z.number(), + truncated: z.boolean(), + }, + annotations: readOnly, + }, async ({ workspaceId, files, maxTotalCharacters }: { + workspaceId: string; + files: { path: string; offset?: number; limit?: number }[]; + maxTotalCharacters?: number; + }) => { + const workspace = workspaces.getWorkspace(workspaceId) as Workspace; + const budget = maxTotalCharacters ?? DEFAULT_READ_BUDGET; + let remaining = budget; + let anyTruncated = false; + const loaded = await Promise.all(files.map(async (file) => { + try { + const readPath = workspaces.resolveReadPath(workspace, file.path) as WorkspaceReadPath; + const raw = await readFile(readPath.absolutePath, "utf8"); + workspaces.markReadPathLoaded(workspace, readPath); + return { + path: file.path, + selected: sliceLines(raw, file.offset ?? 1, file.limit), + }; + } catch (error) { + return { path: file.path, error: error instanceof Error ? error.message : String(error) }; + } + })); + const outputs = loaded.map((file) => { + if ("error" in file && file.error) return { path: file.path, error: file.error, truncated: false }; + const selected = (file as { path: string; selected: string }).selected; + const clipped = truncateText(selected, Math.max(0, remaining)); + remaining = Math.max(0, remaining - clipped.text.length); + anyTruncated ||= clipped.truncated; + return { path: file.path, content: clipped.text, truncated: clipped.truncated }; + }); + const totalCharacters = outputs.reduce((sum, file) => sum + (file.content?.length ?? 0), 0); + const result = `Read ${outputs.filter((file) => !file.error).length}/${outputs.length} files (${totalCharacters} characters).`; + return textResult(result, { result, files: outputs, totalCharacters, truncated: anyTruncated }); + }); + + registerTool(server, "search_text", { + title: "Search text", + description: "Search file contents with ripgrep without a shell round trip. Defaults to fixed-string smart-case matching and returns a bounded number of line matches.", + inputSchema: { + workspaceId: z.string(), + query: z.string().min(1), + paths: z.array(z.string()).max(20).optional(), + glob: z.string().optional(), + regex: z.boolean().optional(), + caseSensitive: z.boolean().optional(), + maxResults: z.number().int().positive().max(MAX_SEARCH_RESULTS).optional(), + }, + outputSchema: { + result: z.string(), + matches: z.array(z.string()), + matchCount: z.number(), + truncated: z.boolean(), + }, + annotations: readOnly, + }, async ({ workspaceId, query, paths, glob, regex, caseSensitive, maxResults }: { + workspaceId: string; + query: string; + paths?: string[]; + glob?: string; + regex?: boolean; + caseSensitive?: boolean; + maxResults?: number; + }) => { + const workspace = workspaces.getWorkspace(workspaceId) as Workspace; + const targets = paths?.length ? paths : ["."]; + for (const path of targets) workspaces.resolvePath(workspace, path); + const args = ["--line-number", "--column", "--no-heading", "--color", "never"]; + if (!regex) args.push("--fixed-strings"); + args.push(caseSensitive ? "--case-sensitive" : "--smart-case"); + if (glob) args.push("--glob", glob); + args.push("--", query, ...targets); + const search = await runExecutable("rg", args, { cwd: workspace.root, timeoutMs: 60_000 }); + if (![0, 1].includes(search.exitCode)) assertExecutableSuccess(search, "ripgrep search"); + const allMatches = search.stdout.replaceAll("\r\n", "\n").split("\n").filter(Boolean); + const limit = maxResults ?? DEFAULT_SEARCH_RESULTS; + const matches = allMatches.slice(0, limit); + const truncated = allMatches.length > matches.length; + const result = `Found ${allMatches.length} match(es)${truncated ? `; returned the first ${matches.length}` : ""}.`; + return textResult(result, { result, matches, matchCount: allMatches.length, truncated }); + }); + + registerTool(server, "apply_task_guardrails", { + title: "Apply task guardrails", + description: "Set workspace-scoped protected paths and blocked command regular expressions for the current task. Active guardrails are enforced by edit, write, bash, start_job, and git_stage_exact. Call again to replace the current guardrails; pass empty arrays to clear them.", + inputSchema: { + workspaceId: z.string(), + protectedPaths: z.array(z.string()).max(100).optional(), + blockedCommandPatterns: z.array(z.string()).max(100).optional(), + }, + outputSchema: { + result: z.string(), + protectedPaths: z.array(z.string()), + blockedCommandPatterns: z.array(z.string()), + protectedPathCount: z.number(), + blockedCommandPatternCount: z.number(), + }, + annotations: writeAction, + }, async ({ workspaceId, protectedPaths, blockedCommandPatterns }: { + workspaceId: string; + protectedPaths?: string[]; + blockedCommandPatterns?: string[]; + }) => { + const workspace = workspaces.getWorkspace(workspaceId) as Workspace; + const applied = guards.apply(workspaceId, workspace.root, { protectedPaths, blockedCommandPatterns }); + const structuredContent = { + result: `Applied ${applied.protectedPaths.length} protected path(s) and ${applied.blockedCommandPatterns.length} blocked command pattern(s).`, + protectedPaths: applied.protectedPaths, + blockedCommandPatterns: applied.blockedCommandPatterns, + protectedPathCount: applied.protectedPaths.length, + blockedCommandPatternCount: applied.blockedCommandPatterns.length, + }; + return textResult(structuredContent.result, structuredContent); + }); + + registerTool(server, "start_job", { + title: "Start long job", + description: "Start a long-running test, build, doctor, or inspection command without waiting for the full process. Returns a sessionId when the process is still running; poll it with job_status.", + inputSchema: { + workspaceId: z.string(), + command: z.string().min(1), + workingDirectory: z.string().optional(), + yieldTimeMs: z.number().int().min(0).max(30_000).optional(), + maxOutputTokens: z.number().int().positive().max(100_000).optional(), + }, + outputSchema: { + result: z.string(), + sessionId: z.number().optional(), + running: z.boolean(), + exitCode: z.number().optional(), + signal: z.string().optional(), + output: z.string(), + outputTruncated: z.boolean(), + wallTimeMs: z.number(), + }, + annotations: shellAction, + }, async ({ workspaceId, command, workingDirectory, yieldTimeMs, maxOutputTokens }: { + workspaceId: string; + command: string; + workingDirectory?: string; + yieldTimeMs?: number; + maxOutputTokens?: number; + }) => { + const workspace = workspaces.getWorkspace(workspaceId) as Workspace; + guards.assertCommandAllowed(workspaceId, command); + const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory); + const snapshot = await processSessions.start({ + workspaceId, + workspaceRoot: workspace.root, + cwd, + command, + tty: false, + yieldTimeMs: yieldTimeMs ?? 0, + maxOutputTokens: maxOutputTokens ?? 2_000, + }); + const job = jobStructured(snapshot); + const result = job.running ? `Job started with sessionId ${job.sessionId}.` : `Job completed with exit code ${job.exitCode}.`; + return textResult([result, job.output].filter(Boolean).join("\n"), { result, ...job }); + }); + + registerTool(server, "job_status", { + title: "Poll job", + description: "Poll a process started by start_job. Output is incremental and consumed on each poll. The final response includes the real exit code.", + inputSchema: { + workspaceId: z.string(), + sessionId: z.number().int().positive(), + waitMs: z.number().int().min(0).max(110_000).optional(), + maxOutputTokens: z.number().int().positive().max(100_000).optional(), + }, + outputSchema: { + result: z.string(), + sessionId: z.number().optional(), + running: z.boolean(), + exitCode: z.number().optional(), + signal: z.string().optional(), + output: z.string(), + outputTruncated: z.boolean(), + wallTimeMs: z.number(), + }, + annotations: readOnly, + }, async ({ workspaceId, sessionId, waitMs, maxOutputTokens }: { + workspaceId: string; + sessionId: number; + waitMs?: number; + maxOutputTokens?: number; + }) => { + workspaces.getWorkspace(workspaceId); + const snapshot = await processSessions.write({ + workspaceId, + sessionId, + chars: "", + yieldTimeMs: waitMs ?? 5_000, + maxOutputTokens: maxOutputTokens ?? 2_000, + }); + const job = jobStructured(snapshot); + const result = job.running ? `Job ${sessionId} is still running.` : `Job ${sessionId} completed with exit code ${job.exitCode}.`; + return textResult([result, job.output].filter(Boolean).join("\n"), { result, ...job }); + }); + + registerTool(server, "job_cancel", { + title: "Cancel job", + description: "Terminate a process started by start_job and return its final available output.", + inputSchema: { + workspaceId: z.string(), + sessionId: z.number().int().positive(), + }, + outputSchema: { + result: z.string(), + sessionId: z.number().optional(), + running: z.boolean(), + exitCode: z.number().optional(), + signal: z.string().optional(), + output: z.string(), + outputTruncated: z.boolean(), + wallTimeMs: z.number(), + }, + annotations: shellAction, + }, async ({ workspaceId, sessionId }: { workspaceId: string; sessionId: number }) => { + workspaces.getWorkspace(workspaceId); + processSessions.terminate(workspaceId, sessionId); + const snapshot = await processSessions.write({ + workspaceId, + sessionId, + chars: "", + yieldTimeMs: 1_000, + maxOutputTokens: 2_000, + }); + const job = jobStructured(snapshot); + const result = job.running ? `Cancellation requested for job ${sessionId}.` : `Job ${sessionId} stopped.`; + return textResult([result, job.output].filter(Boolean).join("\n"), { result, ...job }); + }); + + const gitStateSchema = { + result: z.string(), + status: z.string(), + head: z.string(), + origin: z.string(), + remote: z.string(), + remoteName: z.string(), + branch: z.string(), + clean: z.boolean(), + aligned: z.boolean(), + }; + + const registerGitStateTool = (name: string, title: string, description: string) => { + registerTool(server, name, { + title, + description, + inputSchema: { + workspaceId: z.string(), + remote: z.string().optional(), + branch: z.string().optional(), + }, + outputSchema: gitStateSchema, + annotations: readOnly, + }, async ({ workspaceId, remote, branch }: { + workspaceId: string; + remote?: string; + branch?: string; + }) => { + const workspace = workspaces.getWorkspace(workspaceId) as Workspace; + const remoteName = safeGitToken(remote ?? "origin", "remote"); + const branchName = safeGitToken(branch ?? "master", "branch"); + const state = await collectGitState(workspace.root, remoteName, branchName); + const result = state.aligned + ? `HEAD, ${remoteName}/${branchName}, and remote ${branchName} are aligned at ${state.head}.` + : `Git refs are not aligned. HEAD=${state.head}, ${remoteName}/${branchName}=${state.origin}, remote=${state.remote || "missing"}.`; + return textResult([result, state.status ? `Status:\n${state.status}` : "Working tree is clean."].join("\n"), { result, ...state }); + }); + }; + + registerGitStateTool( + "git_preflight", + "Git preflight", + "Run git status, rev-parse HEAD, rev-parse remote tracking branch, and ls-remote concurrently. Use before modifying a protected branch workflow.", + ); + + registerTool(server, "git_stage_exact", { + title: "Stage exact files", + description: "Stage only the explicit file paths supplied. Wildcards, '.', -A, --all, protected paths, and paths outside the workspace are rejected.", + inputSchema: { + workspaceId: z.string(), + paths: z.array(z.string()).min(1).max(200), + }, + outputSchema: { + result: z.string(), + stagedPaths: z.array(z.string()), + }, + annotations: writeAction, + }, async ({ workspaceId, paths }: { workspaceId: string; paths: string[] }) => { + const workspace = workspaces.getWorkspace(workspaceId) as Workspace; + const exactPaths = validateExactStagePaths(paths); + for (const path of exactPaths) { + const absolutePath = workspaces.resolvePath(workspace, path); + guards.assertPathAllowed(workspaceId, absolutePath); + } + await runGit(workspace.root, ["add", "--", ...exactPaths], "git add exact paths"); + const staged = await runGit(workspace.root, ["diff", "--cached", "--name-only"], "git diff --cached --name-only"); + const stagedPaths = staged.stdout.replaceAll("\r\n", "\n").split("\n").filter(Boolean); + const result = `Staged ${stagedPaths.length} file(s): ${stagedPaths.join(", ") || "none"}.`; + return textResult(result, { result, stagedPaths }); + }); + + registerTool(server, "git_commit", { + title: "Create Git commit", + description: "Commit the currently staged files with one explicit message. This does not stage additional files.", + inputSchema: { + workspaceId: z.string(), + message: z.string().min(1).max(500), + }, + outputSchema: { + result: z.string(), + commit: z.string(), + output: z.string(), + }, + annotations: writeAction, + }, async ({ workspaceId, message }: { workspaceId: string; message: string }) => { + const workspace = workspaces.getWorkspace(workspaceId) as Workspace; + const commit = await runGit(workspace.root, ["commit", "-m", message], "git commit"); + const sha = (await runGit(workspace.root, ["rev-parse", "HEAD"], "git rev-parse HEAD")).stdout.trim(); + const output = [commit.stdout, commit.stderr].filter(Boolean).join("\n").trim(); + const result = `Created commit ${sha}.`; + return textResult([result, output].filter(Boolean).join("\n"), { result, commit: sha, output }); + }); + + registerTool(server, "git_push", { + title: "Push Git branch", + description: "Run a normal non-force git push for one explicit remote and branch. Force options are not available.", + inputSchema: { + workspaceId: z.string(), + remote: z.string().optional(), + branch: z.string().optional(), + }, + outputSchema: { + result: z.string(), + output: z.string(), + }, + annotations: shellAction, + }, async ({ workspaceId, remote, branch }: { + workspaceId: string; + remote?: string; + branch?: string; + }) => { + const workspace = workspaces.getWorkspace(workspaceId) as Workspace; + const remoteName = safeGitToken(remote ?? "origin", "remote"); + const branchName = safeGitToken(branch ?? "master", "branch"); + const pushed = await runGit(workspace.root, ["push", remoteName, branchName], "git push"); + const output = [pushed.stdout, pushed.stderr].filter(Boolean).join("\n").trim(); + const result = `Pushed ${branchName} to ${remoteName} without force.`; + return textResult([result, output].filter(Boolean).join("\n"), { result, output }); + }); + + registerGitStateTool( + "git_postflight", + "Git postflight", + "Verify git status, local HEAD, remote-tracking branch, and the remote branch after commits and push.", + ); +} diff --git a/src/config.ts b/src/config.ts index 4fc1bcbb..15667c0a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,8 +7,11 @@ import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user- export type ToolMode = "minimal" | "full" | "codex"; export type WidgetMode = "off" | "changes" | "full"; +export type ShellMode = "auto" | "bash" | "powershell" | "cmd"; + const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; +const DEFAULT_INLINE_OUTPUT_CHARACTERS = 12000; export interface ServerConfig { host: string; @@ -28,6 +31,9 @@ export interface ServerConfig { subagents: boolean; agentDir: string; logging: LoggingConfig; + shell: ShellMode; + inlineOutputCharacters: number; + contextIgnorePaths: string[]; } function parsePort(value: string | number | undefined): number { @@ -71,10 +77,25 @@ function parseAllowedHosts(value: string | string[] | undefined, derivedHosts: s return normalizeAllowedHosts(rawHosts, derivedHosts); } +/** + * Normalize allowed hosts. + * Customization: strip URL schemes (http://, https://) to get clean hostname. + * This allows config.json and env vars to contain full URLs which get normalized + * to just the hostname for comparison. + */ function normalizeAllowedHosts(rawHosts: string[], derivedHosts: string[]): string[] { const hosts = rawHosts.length > 0 ? rawHosts : derivedHosts; if (hosts.includes("*")) return ["*"]; - return Array.from(new Set(hosts.map((host) => host.trim()).filter(Boolean))); + const cleaned = hosts.map((host) => { + const trimmed = host.trim(); + if (!trimmed) return ""; + // Strip http:// or https:// scheme + const withoutScheme = trimmed.replace(/^https?:\/\//i, ""); + // Strip port and path — keep just hostname for Host header comparison + const hostname = withoutScheme.split(/[:/]/)[0]; + return hostname || trimmed; + }).filter(Boolean); + return Array.from(new Set(cleaned)); } function parseBoolean(value: string | undefined): boolean { @@ -92,6 +113,14 @@ function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { return "minimal"; } +function parseShellMode(env: NodeJS.ProcessEnv): ShellMode { + const mode = env.DEVSPACE_SHELL; + if (mode === "auto" || mode === "bash" || mode === "powershell" || mode === "cmd") return mode; + if (mode) throw new Error(`Invalid DEVSPACE_SHELL: ${mode}`); + // Windows default: powershell; other platforms: auto + return process.platform === "win32" ? "powershell" : "auto"; +} + function parseLogLevel(value: string | undefined): LogLevel { if (!value || value === "info") return "info"; if (["silent", "error", "warn", "debug"].includes(value)) return value as LogLevel; @@ -135,6 +164,36 @@ function parsePositiveInteger(value: string | undefined, fallback: number, name: return parsed; } +function parseInlineOutputCharacters(env: NodeJS.ProcessEnv, configValue: number | undefined): number { + const raw = env.DEVSPACE_INLINE_OUTPUT_CHARACTERS ?? configValue ?? DEFAULT_INLINE_OUTPUT_CHARACTERS; + const n = typeof raw === "number" ? raw : parseInt(String(raw), 10); + if (!Number.isFinite(n) || n < 100) return DEFAULT_INLINE_OUTPUT_CHARACTERS; + if (n > 200000) return 200000; + return n; +} + +function parseContextIgnorePaths(env: NodeJS.ProcessEnv, configValue: string[] | undefined): string[] { + const envPaths = env.DEVSPACE_CONTEXT_IGNORE_PATHS + ?.split(",") + .map((s) => s.trim()) + .filter(Boolean) ?? []; + const source = envPaths.length > 0 ? envPaths : (configValue ?? []); + // Validate: reject absolute paths, drive letters, parent traversal, empty, null bytes + const accepted = new Set(); + for (const raw of source) { + const input = String(raw ?? "").trim(); + if (!input || input.includes("\0")) continue; + if (input.startsWith("/") || input.startsWith("\\")) continue; + if (/^[a-zA-Z]:[\\/]/.test(input)) continue; + if (input === ".." || input.startsWith("../") || input.startsWith("..\\")) continue; + const normalized = input.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); + if (normalized && normalized !== ".." && !normalized.startsWith("../")) { + accepted.add(normalized); + } + } + return [...accepted].sort(); +} + function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { return { level: parseLogLevel(env.DEVSPACE_LOG_LEVEL), @@ -236,6 +295,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { : parseBoolean(env.DEVSPACE_SUBAGENTS), agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), logging: parseLoggingConfig(env), + shell: parseShellMode(env), + inlineOutputCharacters: parseInlineOutputCharacters(env, files.config.inlineOutputCharacters), + contextIgnorePaths: parseContextIgnorePaths(env, files.config.contextIgnorePaths), }; } diff --git a/src/context-ignore.ts b/src/context-ignore.ts new file mode 100644 index 00000000..554b09ff --- /dev/null +++ b/src/context-ignore.ts @@ -0,0 +1,162 @@ +/** + * Context ignore paths — PR #76. + * + * Validates and normalizes a list of directory names to skip during + * nested AGENTS.md / CLAUDE.md discovery. + * + * Rules: + * - Only workspace-relative directory names are allowed (e.g. "node_modules", "dist"). + * - Absolute paths are rejected. + * - Drive-letter paths (C:\, D:\) are rejected. + * - Parent traversal (..) is rejected. + * - Empty strings and NULL bytes are rejected. + * - Forward/back slashes are normalized. + * - Duplicates are removed. + * - This only affects nested context file discovery; read, shell, grep still work. + * - Pruning happens before entering a directory, not after scanning. + */ + +const NULL_BYTE = "\0"; + +export interface ContextIgnoreResult { + paths: string[]; + rejected: Array<{ input: string; reason: string }>; +} + +/** + * Parse ignore paths from a comma-separated env string. + */ +export function parseContextIgnoreEnv(envValue: string | undefined): string[] { + if (!envValue) return []; + return envValue + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Validate and normalize a list of ignore paths. + * Returns accepted paths and a list of rejected entries with reasons. + */ +export function validateContextIgnorePaths(inputs: string[]): ContextIgnoreResult { + const accepted = new Set(); + const rejected: Array<{ input: string; reason: string }> = []; + + for (const raw of inputs) { + const input = String(raw ?? "").trim(); + + if (input === "") { + rejected.push({ input: String(raw), reason: "empty path" }); + continue; + } + + if (input.includes(NULL_BYTE)) { + rejected.push({ input, reason: "contains NULL byte" }); + continue; + } + + // Reject absolute paths (Unix and Windows) + if (input.startsWith("/") || input.startsWith("\\")) { + rejected.push({ input, reason: "absolute path not allowed" }); + continue; + } + + // Reject drive-letter paths (C:\, D:\, etc.) + if (/^[a-zA-Z]:[\\/]/.test(input)) { + rejected.push({ input, reason: "drive-letter path not allowed" }); + continue; + } + + // Reject parent traversal + if (input === ".." || input.startsWith("../") || input.startsWith("..\\") || + input.includes("/../") || input.includes("\\..\\")) { + rejected.push({ input, reason: "parent traversal (..) not allowed" }); + continue; + } + + // Normalize slashes: strip leading/trailing slashes, normalize to forward + let normalized = input.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); + + // Re-check after normalization + if (normalized === ".." || normalized.startsWith("../")) { + rejected.push({ input, reason: "parent traversal (..) after normalization" }); + continue; + } + + if (normalized === "") { + rejected.push({ input, reason: "empty after normalization" }); + continue; + } + + accepted.add(normalized); + } + + return { + paths: [...accepted].sort(), + rejected, + }; +} + +/** + * Resolve final ignore paths from env and config. + * Env takes precedence (comma-separated), then config.json array. + */ +export function resolveContextIgnorePaths( + envValue: string | undefined, + configValue: string[] | undefined, +): string[] { + const envPaths = parseContextIgnoreEnv(envValue); + const source = envPaths.length > 0 ? envPaths : (configValue ?? []); + const result = validateContextIgnorePaths(source); + return result.paths; +} + +/** + * Check if a directory entry name should be skipped during context file discovery. + * Uses the pre-validated ignore set for O(1) lookup. + */ +export function shouldSkipForContext( + dirName: string, + ignorePaths: Set, + defaultSkipped: Set, +): boolean { + if (defaultSkipped.has(dirName)) return true; + if (ignorePaths.has(dirName)) return true; + // Also check normalized (forward-slash) form + const normalized = dirName.replace(/\\/g, "/"); + if (ignorePaths.has(normalized)) return true; + return false; +} + +/** + * Default candidate paths to check for existence before adding to ignore list. + * Only paths that actually exist AND are confirmed to not hold project rule files + * should be added to the final config. + */ +export const DEFAULT_IGNORE_CANDIDATES = [ + "node_modules", + "dist", + "build", + "coverage", + ".cache", + ".next", + "target", + "vendor", + "tmp", + "temp", +]; + +/** + * Paths that must NEVER be ignored (they may hold AGENTS.md or project rules). + */ +export const NEVER_IGNORE = new Set([ + "src", + "docs", + "tests", + "test", + "scripts", + ".github", + ".devspace", + ".", + "", +]); diff --git a/src/cost-stats.ts b/src/cost-stats.ts new file mode 100644 index 00000000..51c26f44 --- /dev/null +++ b/src/cost-stats.ts @@ -0,0 +1,229 @@ +/** + * Cost statistics — PR #69 (selective: E. 执行成本统计). + * + * Records per-tool execution metrics with bounded rolling storage. + * Does NOT record user file contents, only metadata. + * + * Tracked per tool invocation: + * - tool name + * - duration (ms) + * - returned characters (after truncation) + * - characters before truncation + * - whether truncation occurred + * - error count + * - retry count + * - approximate token count + * - session-level tool call counter + * + * Storage is bounded (default 1000 entries, rolling). + */ + +import { dirname, join } from "node:path"; +import { existsSync } from "node:fs"; + +export interface ToolCallRecord { + tool: string; + startTime: number; + durationMs: number; + returnedChars: number; + originalChars: number; + truncated: boolean; + error: boolean; + retries: number; + approxTokens: number; + sessionId?: string; +} + +export interface CostSummary { + totalCalls: number; + totalErrors: number; + totalRetries: number; + totalDurationMs: number; + totalReturnedChars: number; + totalOriginalChars: number; + totalTruncatedCalls: number; + approxTotalTokens: number; + perTool: Array<{ + tool: string; + calls: number; + errors: number; + avgDurationMs: number; + totalReturnedChars: number; + truncatedCalls: number; + }>; + recentCalls: ToolCallRecord[]; +} + +const DEFAULT_MAX_RECORDS = 1000; + +// Rough approximation: 1 token ≈ 4 characters for English, ≈ 2 for CJK. +// We use a blended 3.5 chars/token as a rough heuristic. +const CHARS_PER_TOKEN = 3.5; + +export class CostTracker { + private records: ToolCallRecord[] = []; + private readonly maxRecords: number; + private sessionCallCount = new Map(); + + constructor(maxRecords: number = DEFAULT_MAX_RECORDS) { + this.maxRecords = maxRecords; + } + + /** Record a completed tool call. */ + record(call: Omit & { approxTokens?: number }): void { + const approxTokens = call.approxTokens ?? Math.round(call.returnedChars / CHARS_PER_TOKEN); + const entry: ToolCallRecord = { ...call, approxTokens }; + this.records.push(entry); + if (this.records.length > this.maxRecords) { + this.records.shift(); + } + if (call.sessionId) { + this.sessionCallCount.set( + call.sessionId, + (this.sessionCallCount.get(call.sessionId) ?? 0) + 1, + ); + } + } + + /** Get the number of tool calls in a session. */ + getSessionCallCount(sessionId: string): number { + return this.sessionCallCount.get(sessionId) ?? 0; + } + + /** Get a summary of all recorded calls. */ + getSummary(): CostSummary { + const perToolMap = new Map(); + + let totalCalls = 0; + let totalErrors = 0; + let totalRetries = 0; + let totalDurationMs = 0; + let totalReturnedChars = 0; + let totalOriginalChars = 0; + let totalTruncatedCalls = 0; + let approxTotalTokens = 0; + + for (const r of this.records) { + totalCalls++; + totalErrors += r.error ? 1 : 0; + totalRetries += r.retries; + totalDurationMs += r.durationMs; + totalReturnedChars += r.returnedChars; + totalOriginalChars += r.originalChars; + totalTruncatedCalls += r.truncated ? 1 : 0; + approxTotalTokens += r.approxTokens; + + const existing = perToolMap.get(r.tool) ?? { + calls: 0, + errors: 0, + totalDurationMs: 0, + totalReturnedChars: 0, + truncatedCalls: 0, + }; + existing.calls++; + existing.errors += r.error ? 1 : 0; + existing.totalDurationMs += r.durationMs; + existing.totalReturnedChars += r.returnedChars; + existing.truncatedCalls += r.truncated ? 1 : 0; + perToolMap.set(r.tool, existing); + } + + const perTool = [...perToolMap.entries()] + .map(([tool, stats]) => ({ + tool, + calls: stats.calls, + errors: stats.errors, + avgDurationMs: stats.calls > 0 ? Math.round(stats.totalDurationMs / stats.calls) : 0, + totalReturnedChars: stats.totalReturnedChars, + truncatedCalls: stats.truncatedCalls, + })) + .sort((a, b) => b.calls - a.calls); + + return { + totalCalls, + totalErrors, + totalRetries, + totalDurationMs, + totalReturnedChars, + totalOriginalChars, + totalTruncatedCalls, + approxTotalTokens, + perTool, + recentCalls: this.records.slice(-20), + }; + } + + /** Clear all records (for testing or reset). */ + clear(): void { + this.records = []; + this.sessionCallCount.clear(); + } + + /** Current record count. */ + get size(): number { + return this.records.length; + } +} + +/** + * Safe PATH supplement — PR #69 (selective: F. 安全 PATH 补充). + * + * On Windows, adds known-safe and commonly-needed paths to PATH + * without overwriting the user's existing PATH. Only appends missing entries. + * + * Added paths: + * - Node.js directory + * - npm global bin + * - Git cmd directory + * - PowerShell (System32\WindowsPowerShell\v1.0) + * - Windows System32 + * + * Does NOT read or execute .zshrc, .zprofile, bash profile, or unknown startup scripts. + */ +export function supplementSafePath(currentPath: string): string { + if (process.platform !== "win32") return currentPath; + + // path (dirname, join) and fs (existsSync) imported at module level + const existing = currentPath.split(";").map((p) => p.toLowerCase().replace(/\\$/, "")); + + const candidates: string[] = []; + + // Node.js directory + const nodeDir = dirname(process.execPath); + candidates.push(nodeDir); + + // npm global bin + const npmGlobal = join(process.env.APPDATA ?? "", "npm"); + candidates.push(npmGlobal); + + // Git cmd (typical install locations) + const gitCandidates = [ + "C:\\Program Files\\Git\\cmd", + "C:\\Program Files (x86)\\Git\\cmd", + ]; + for (const gc of gitCandidates) candidates.push(gc); + + // PowerShell + candidates.push("C:\\Windows\\System32\\WindowsPowerShell\\v1.0"); + + // Windows System32 + candidates.push("C:\\Windows\\System32"); + + const toAdd: string[] = []; + for (const c of candidates) { + if (!c) continue; + const cl = c.toLowerCase().replace(/\\$/, ""); + if (existing.includes(cl)) continue; + if (!existsSync(c)) continue; + toAdd.push(c); + } + + if (toAdd.length === 0) return currentPath; + return currentPath + (currentPath.endsWith(";") ? "" : ";") + toAdd.join(";"); +} diff --git a/src/mcp-integration.test.ts b/src/mcp-integration.test.ts new file mode 100644 index 00000000..98c80388 --- /dev/null +++ b/src/mcp-integration.test.ts @@ -0,0 +1,568 @@ +/** + * MCP Integration Tests v3 — Real HTTP/MCP server tests with handshake protection. + * + * Test scenarios: + * 1. Single real connection: initialize -> tools/list -> open_workspace + * 2. handleRequest throws -> inFlight back to 0 + * 3. 100 concurrent initialize with barrier test + * 4. Max active sessions -> overflow gets 503, not timeout + * 5. Max idle sessions -> new initialize evicts oldest, stays max + * 6. 100 consecutive full flows -> no inFlight accumulation + */ + +import assert from "node:assert/strict"; +import { ChildProcess, spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const NODE = process.execPath; +const SERVER_DIR = "C:\\Users\\Administrator\\.devspace\\upgrade-work\\devspace"; +const TEST_PORT = 7681; +const MAX_SESSIONS = 8; + +interface TestResult { passed: boolean; message: string; data?: Record; } +const results: TestResult[] = []; +let serverProcess: ChildProcess | null = null; +let useBarrier = false; + +function log(msg: string): void { console.log(` ${msg}`); } + +async function startServer(barrier: boolean = false): Promise { + useBarrier = barrier; + const env: Record = { + ...process.env as Record, + NODE_ENV: "test", + DEVSPACE_TEST_BYPASS_AUTH: "1", + PORT: String(TEST_PORT), + DEVSPACE_MAX_SESSIONS: String(MAX_SESSIONS), + DEVSPACE_SESSION_IDLE_MS: "86400000", + DEVSPACE_SESSION_SWEEP_MS: "300000", + DEVSPACE_SESSION_HANDSHAKE_TIMEOUT_MS: "30000", + DEVSPACE_INLINE_OUTPUT_CHARACTERS: "12000", + DEVSPACE_SHELL: "powershell", + DEVSPACE_LOG_LEVEL: "warn", + DEVSPACE_LOG_FORMAT: "json", + DEVSPACE_ALLOWED_HOSTS: "*", + }; + if (barrier) env.DEVSPACE_TEST_RESERVATION_BARRIER = "1"; + await startServerWithEnv(env); +} + +async function startServerWithEnv(env: Record): Promise { + + return new Promise((resolve, reject) => { + serverProcess = spawn(NODE, ["--import", "tsx", "src/cli.ts", "serve"], { + cwd: SERVER_DIR, env, stdio: ["pipe", "pipe", "pipe"], + }); + let started = false; + const timeout = setTimeout(() => { if (!started) reject(new Error("Server start timeout")); }, 20000); + const checkReady = (line: string) => { + if ((line.includes("listening") || line.includes("started") || line.includes("ready")) && !started) { + started = true; clearTimeout(timeout); setTimeout(resolve, 1000); + } + }; + createInterface({ input: serverProcess.stdout! }).on("line", checkReady); + createInterface({ input: serverProcess.stderr! }).on("line", checkReady); + const poll = setInterval(async () => { + if (started) { clearInterval(poll); return; } + try { + const resp = await fetch(`http://localhost:${TEST_PORT}/healthz`); + if (resp.ok) { started = true; clearTimeout(timeout); clearInterval(poll); setTimeout(resolve, 1000); } + } catch {} + }, 1000); + }); +} + +async function stopServer(): Promise { + if (serverProcess) { + serverProcess.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 2000)); + if (!serverProcess.killed) serverProcess.kill("SIGKILL"); + serverProcess = null; + // Wait for port to be fully freed + for (let i = 0; i < 10; i++) { + try { + await fetch(`http://localhost:${TEST_PORT}/healthz`, { signal: AbortSignal.timeout(500) }); + // Port still in use — wait more + await new Promise((r) => setTimeout(r, 500)); + } catch { + break; // Port is free + } + } + } +} + +function parseSseBody(text: string): any { + for (const line of text.split("\n")) { + if (line.startsWith("data: ")) { try { return JSON.parse(line.slice(6)); } catch {} } + } + try { return JSON.parse(text); } catch { return { error: { message: text.slice(0, 200) } }; } +} + +async function mcpRequest(method: string, params: unknown, sessionId?: string, id: number = 1): Promise<{ status: number; body: any; sessionId?: string }> { + const headers: Record = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" }; + if (sessionId) headers["mcp-session-id"] = sessionId; + const resp = await fetch(`http://localhost:${TEST_PORT}/mcp`, { + method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + }); + const text = await resp.text(); + const json = parseSseBody(text); + const sid = resp.headers.get("mcp-session-id") ?? undefined; + return { status: resp.status, body: json, sessionId: sid }; +} + +async function initialize(id?: number): Promise { + const { status, body, sessionId } = await mcpRequest("initialize", { + protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test-client", version: "1.0" }, + }, undefined, id ?? 1); + assert.strictEqual(status, 200, `initialize failed: ${status}: ${JSON.stringify(body)}`); + assert.ok(!body.error, `initialize error: ${JSON.stringify(body.error)}`); + assert.ok(sessionId, "no session ID"); + await fetch(`http://localhost:${TEST_PORT}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "mcp-session-id": sessionId! }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + }); + return sessionId!; +} + +async function getHealthz(): Promise { + return await (await fetch(`http://localhost:${TEST_PORT}/healthz`)).json(); +} + +async function releaseBarrier(): Promise { + await fetch(`http://localhost:${TEST_PORT}/test/release-barrier`, { method: "POST" }); +} + +async function runTest(name: string, fn: () => Promise): Promise { + try { + await fn(); + results.push({ passed: true, message: name }); + console.log(` \u2713 ${name}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + results.push({ passed: false, message: name, data: { error: msg } }); + console.error(` \u2717 ${name}`); + console.error(` ${msg}`); + } +} + +// ─── Tests ───────────────────────────────────────────────────────────── + +async function test1_singleRealConnection(): Promise { + const sessionId = await initialize(); + log(`session: ${sessionId.slice(0, 8)}...`); + const listResp = await mcpRequest("tools/list", {}, sessionId, 2); + assert.strictEqual(listResp.status, 200, "tools/list should return 200"); + assert.ok(!listResp.body.error, `tools/list error: ${JSON.stringify(listResp.body.error)}`); + const tools = (listResp.body.result as { tools: unknown[] })?.tools; + assert.ok(Array.isArray(tools), "tools/list should return tools array"); + log(`tools count: ${tools!.length}`); + + const wsResp = await mcpRequest("tools/call", { + name: "open_workspace", arguments: { path: process.cwd() }, + }, sessionId, 3); + assert.strictEqual(wsResp.status, 200, "open_workspace should return 200"); + assert.ok(!wsResp.body.error, `open_workspace error: ${JSON.stringify(wsResp.body.error)}`); + log(`open_workspace result: ${JSON.stringify(wsResp.body.result).slice(0, 80)}...`); + + const h = await getHealthz(); + assert.strictEqual(h.sessions, 1, `should have 1 session, got ${h.sessions}`); + log(`sessions: ${h.sessions}, capacity: ${h.occupiedCapacity}`); +} + +async function test2_handleRequestException(): Promise { + const sessionId = await initialize(); + const resp = await mcpRequest("invalid/method", {}, sessionId, 99); + assert.ok(resp.body.error || resp.body.result, "should get a response"); + const listResp = await mcpRequest("tools/list", {}, sessionId, 100); + assert.strictEqual(listResp.status, 200, "session should still work after error"); + const h = await getHealthz(); + log(`sessions: ${h.sessions}, capacity: ${h.occupiedCapacity}`); +} + +async function test3_concurrentWithBarrier(): Promise { + // Restart with barrier enabled + await stopServer(); + await startServer(true); + await new Promise((r) => setTimeout(r, 1000)); + + // Send MAX_SESSIONS (8) concurrent initialize requests — they will block on barrier + const barrierPromises: Promise<{ status: number; sessionId?: string; error?: string }>[] = []; + for (let i = 0; i < MAX_SESSIONS; i++) { + const p = (async () => { + try { + const resp = await fetch(`http://localhost:${TEST_PORT}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" }, + body: JSON.stringify({ + jsonrpc: "2.0", id: i, method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: `barrier-${i}`, version: "1.0" } }, + }), + }); + const sid = resp.headers.get("mcp-session-id") ?? undefined; + return { status: resp.status, sessionId: sid }; + } catch (err) { + return { status: 0, error: err instanceof Error ? err.message : String(err) }; + } + })(); + barrierPromises.push(p); + } + + // Wait for all 8 to be blocked at the barrier (pendingReservations should be 8) + await new Promise((r) => setTimeout(r, 2000)); + + const h1 = await getHealthz(); + log(`barrier state: sessions=${h1.sessions}, reservations=${h1.pendingReservations}, capacity=${h1.occupiedCapacity}`); + assert.strictEqual(h1.pendingReservations, MAX_SESSIONS, `pendingReservations should be ${MAX_SESSIONS}, got ${h1.pendingReservations}`); + assert.strictEqual(h1.occupiedCapacity, MAX_SESSIONS, `occupiedCapacity should be ${MAX_SESSIONS}, got ${h1.occupiedCapacity}`); + + // Send 9th initialize — should get 503 immediately (all capacity in reservations) + const sw = Date.now(); + let status9 = 0; + try { + const resp9 = await fetch(`http://localhost:${TEST_PORT}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" }, + body: JSON.stringify({ + jsonrpc: "2.0", id: 999, method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "overflow", version: "1.0" } }, + }), + signal: AbortSignal.timeout(3000), + }); + status9 = resp9.status; + } catch (err) { + throw new Error(`9th initialize threw: ${err} — should have gotten 503`); + } + const elapsed9 = Date.now() - sw; + assert.strictEqual(status9, 503, `9th initialize should return 503, got ${status9}`); + assert.ok(elapsed9 < 2000, `9th initialize should respond within 2s, took ${elapsed9}ms`); + log(`9th initialize: 503 in ${elapsed9}ms`); + + // Release barrier + await releaseBarrier(); + log("barrier released"); + + // Wait for all 8 to complete + const barrierResults = await Promise.all(barrierPromises); + const successCount = barrierResults.filter((r) => r.status === 200).length; + log(`barrier results: ${successCount}/${MAX_SESSIONS} succeeded`); + + // For each successful session, send notifications/initialized and tools/list + for (const r of barrierResults) { + if (r.status === 200 && r.sessionId) { + // Send notifications/initialized + await fetch(`http://localhost:${TEST_PORT}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "mcp-session-id": r.sessionId }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + }); + // tools/list + const listResp = await mcpRequest("tools/list", {}, r.sessionId, 50); + assert.strictEqual(listResp.status, 200, `tools/list failed for session ${r.sessionId?.slice(0, 8)}`); + assert.ok(!listResp.body.error, `tools/list error: ${JSON.stringify(listResp.body.error)}`); + } + } + + const h2 = await getHealthz(); + log(`after barrier: sessions=${h2.sessions}, reservations=${h2.pendingReservations}, capacity=${h2.occupiedCapacity}`); + assert.strictEqual(h2.pendingReservations, 0, `pendingReservations should be 0, got ${h2.pendingReservations}`); + assert.ok(h2.occupiedCapacity <= MAX_SESSIONS, `capacity must be <= ${MAX_SESSIONS}, got ${h2.occupiedCapacity}`); + + // Now send 100 more concurrent initialize (without barrier) + await stopServer(); + await startServer(false); + await new Promise((r) => setTimeout(r, 1000)); + + // Phase 1: Send all 100 initialize concurrently, collect results. + // Do NOT send notifications/initialized yet — keep sessions in initializing + // state so they are protected from eviction while other initialize requests + // are still in flight. + let success100 = 0, reject100 = 0, timeout100 = 0; + const successSessionIds: string[] = []; + const promises100: Promise[] = []; + for (let i = 0; i < 100; i++) { + promises100.push((async () => { + try { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), 5000); + const resp = await fetch(`http://localhost:${TEST_PORT}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" }, + body: JSON.stringify({ + jsonrpc: "2.0", id: i, method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: `conc-${i}`, version: "1.0" } }, + }), + signal: controller.signal, + }); + clearTimeout(t); + if (resp.status === 200) { + success100++; + const sid = resp.headers.get("mcp-session-id"); + if (sid) successSessionIds.push(sid); + } else if (resp.status === 503) { + reject100++; + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") timeout100++; + } + })()); + } + await Promise.all(promises100); + + log(`100 concurrent phase 1: success=${success100}, 503=${reject100}, timeout=${timeout100}`); + assert.strictEqual(timeout100, 0, `no timeout, got ${timeout100}`); + assert.strictEqual(success100 + reject100, 100, `success+503=100, got ${success100}+${reject100}`); + + // Phase 2: All 100 initialize have settled. Now process successful sessions. + // Since no new initialize requests are in flight, sessions won't be evicted + // while we complete their handshake. + let unknown100 = 0; + for (const sid of successSessionIds) { + // Send notifications/initialized + await fetch(`http://localhost:${TEST_PORT}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "mcp-session-id": sid }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + }); + // tools/list + const listResp = await mcpRequest("tools/list", {}, sid, 50); + if (listResp.body.error?.message?.includes("Unknown MCP session")) { + unknown100++; + } + } + + log(`100 concurrent phase 2: processed ${successSessionIds.length} sessions, unknown=${unknown100}`); + assert.strictEqual(unknown100, 0, `no Unknown MCP session, got ${unknown100}`); + + const h3 = await getHealthz(); + log(`final: sessions=${h3.sessions}, reservations=${h3.pendingReservations}, capacity=${h3.occupiedCapacity}`); + assert.strictEqual(h3.pendingReservations, 0, `pendingReservations should be 0`); + assert.ok(h3.occupiedCapacity <= MAX_SESSIONS, `capacity <= ${MAX_SESSIONS}`); +} + +async function test4_deterministicRequestBarrier(): Promise { + // Restart with request barrier enabled + await stopServer(); + // Wait for port to be fully freed on Windows + await new Promise((r) => setTimeout(r, 2000)); + const env: Record = { + ...process.env as Record, + NODE_ENV: "test", + DEVSPACE_TEST_BYPASS_AUTH: "1", + PORT: String(TEST_PORT), + DEVSPACE_MAX_SESSIONS: String(MAX_SESSIONS), + DEVSPACE_SESSION_IDLE_MS: "86400000", + DEVSPACE_SESSION_SWEEP_MS: "300000", + DEVSPACE_SESSION_HANDSHAKE_TIMEOUT_MS: "30000", + DEVSPACE_INLINE_OUTPUT_CHARACTERS: "12000", + DEVSPACE_SHELL: "powershell", + DEVSPACE_LOG_LEVEL: "warn", + DEVSPACE_LOG_FORMAT: "json", + DEVSPACE_ALLOWED_HOSTS: "*", + DEVSPACE_TEST_REQUEST_BARRIER: "1", + }; + await startServerWithEnv(env); + await new Promise((r) => setTimeout(r, 1000)); + + // 1. Create and complete handshake for MAX_SESSIONS sessions + const sessionIds: string[] = []; + for (let i = 0; i < MAX_SESSIONS; i++) { + const sid = await initialize(i + 1); + sessionIds.push(sid); + } + const h1 = await getHealthz(); + log(`after ${MAX_SESSIONS} handshaked: sessions=${h1.sessions}, capacity=${h1.occupiedCapacity}`); + assert.strictEqual(h1.sessions, MAX_SESSIONS, `should have ${MAX_SESSIONS} sessions`); + + // 2. Send a real MCP request on each session — they will block on request barrier + const requestPromises = sessionIds.map((sid, i) => + mcpRequest("tools/list", {}, sid, 100 + i).then((resp) => ({ sid, resp })) + ); + + // 3. Wait for blockedRequests = MAX_SESSIONS + let blockedCount = 0; + for (let attempt = 0; attempt < 20; attempt++) { + await new Promise((r) => setTimeout(r, 200)); + const statusResp = await fetch(`http://localhost:${TEST_PORT}/test/request-barrier-status`); + const statusBody = await statusResp.json(); + blockedCount = statusBody.blockedRequests; + if (blockedCount >= MAX_SESSIONS) break; + } + log(`blockedRequests: ${blockedCount}`); + assert.strictEqual(blockedCount, MAX_SESSIONS, `blockedRequests should be ${MAX_SESSIONS}, got ${blockedCount}`); + + // 4. Confirm all sessions have inFlight=1 (via healthz — sessions should be MAX_SESSIONS) + const h2 = await getHealthz(); + assert.strictEqual(h2.sessions, MAX_SESSIONS, "sessions should still be active"); + + // 5. Send 9th initialize — must strictly return 503 within 2s + const sw = Date.now(); + let status9 = 0; + try { + const resp9 = await fetch(`http://localhost:${TEST_PORT}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" }, + body: JSON.stringify({ + jsonrpc: "2.0", id: 9999, method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "overflow", version: "1.0" } }, + }), + signal: AbortSignal.timeout(3000), + }); + status9 = resp9.status; + } catch (err) { + throw new Error(`9th initialize threw: ${err} — should have gotten 503`); + } + const elapsed9 = Date.now() - sw; + assert.strictEqual(status9, 503, `9th initialize must return 503, got ${status9}`); + assert.ok(elapsed9 < 2000, `9th initialize must respond within 2s, took ${elapsed9}ms`); + log(`9th initialize: 503 in ${elapsed9}ms (all sessions genuinely in-flight)`); + + // 6. Release request barrier + await fetch(`http://localhost:${TEST_PORT}/test/release-request-barrier`, { method: "POST" }); + log("request barrier released"); + + // 7. All 8 requests must complete successfully + const results8 = await Promise.all(requestPromises); + for (const { sid, resp } of results8) { + assert.strictEqual(resp.status, 200, `session ${sid?.slice(0, 8)} tools/list should return 200`); + assert.ok(!resp.body.error, `session ${sid?.slice(0, 8)} error: ${JSON.stringify(resp.body.error)}`); + } + log(`all ${MAX_SESSIONS} blocked requests completed successfully`); + + // 8. Verify inFlight back to 0 + const h3 = await getHealthz(); + assert.strictEqual(h3.sessions, MAX_SESSIONS, "sessions should still be active"); +} + +async function test5_idleSessionsEviction(): Promise { + await stopServer(); + await startServer(false); + await new Promise((r) => setTimeout(r, 1000)); + + for (let i = 0; i < MAX_SESSIONS; i++) { + await initialize(i + 1); + } + const h1 = await getHealthz(); + log(`after ${MAX_SESSIONS} idle: sessions=${h1.sessions}`); + assert.strictEqual(h1.sessions, MAX_SESSIONS); + + const newSid = await initialize(999); + log(`new session: ${newSid.slice(0, 8)}...`); + const h2 = await getHealthz(); + assert.strictEqual(h2.sessions, MAX_SESSIONS, `should still have ${MAX_SESSIONS}`); + log(`after eviction: sessions=${h2.sessions}`); +} + +async function test6_consecutiveFullFlows(): Promise { + await stopServer(); + await startServer(false); + await new Promise((r) => setTimeout(r, 1000)); + + for (let i = 0; i < 100; i++) { + const sid = await initialize(i + 1); + const listResp = await mcpRequest("tools/list", {}, sid, 2); + assert.strictEqual(listResp.status, 200, `round ${i}: tools/list failed`); + await fetch(`http://localhost:${TEST_PORT}/mcp`, { + method: "DELETE", headers: { "mcp-session-id": sid }, + }); + if (i % 10 === 9) { + const h = await getHealthz(); + log(`round ${i + 1}: sessions=${h.sessions}, capacity=${h.occupiedCapacity}`); + assert.ok(h.occupiedCapacity <= MAX_SESSIONS, `capacity must stay <= ${MAX_SESSIONS}`); + } + } + const h = await getHealthz(); + log(`final: sessions=${h.sessions}, capacity=${h.occupiedCapacity}`); + assert.ok(h.occupiedCapacity <= MAX_SESSIONS); +} + +async function test7_realCloseFailure(): Promise { + await stopServer(); + await startServer(false); + await new Promise((r) => setTimeout(r, 1000)); + + // 1. Create 3 sessions with completed handshakes + const sid1 = await initialize(1); + const sid2 = await initialize(2); + const sid3 = await initialize(3); + log(`created: ${sid1.slice(0, 8)}, ${sid2.slice(0, 8)}, ${sid3.slice(0, 8)}`); + + // 2. Inject close failure for sid1 + const injectResp = await fetch(`http://localhost:${TEST_PORT}/test/inject-close-failure`, { + method: "POST", + headers: { "Content-Type": "application/json", "mcp-session-id": sid1 }, + body: JSON.stringify({ sessionId: sid1 }), + }); + assert.ok(injectResp.ok, `inject should succeed: ${injectResp.status}`); + log(`injected close failure for ${sid1.slice(0, 8)}`); + + // 3. Close sid1 via registry (triggers transport.close which will fail) + const closeResp = await fetch(`http://localhost:${TEST_PORT}/test/close-registry-session`, { + method: "POST", + headers: { "Content-Type": "application/json", "mcp-session-id": sid1 }, + body: JSON.stringify({ sessionId: sid1 }), + }); + assert.ok(closeResp.ok, `close-registry-session should succeed: ${closeResp.status}`); + const closeBody = await closeResp.json(); + log(`close result: closed=${closeBody.closed}, lastError=${closeBody.lastError}, remaining=${closeBody.remainingSessions}`); + + // 4. Assert lastError contains "Injected close failure" + assert.ok(closeBody.closed, "sid1 should have been closed"); + assert.ok(closeBody.lastError, "lastError should be set"); + assert.ok( + closeBody.lastError.includes("Injected close failure"), + `lastError should contain "Injected close failure", got: ${closeBody.lastError}` + ); + + // 5. sid2 and sid3 must still work + const list2 = await mcpRequest("tools/list", {}, sid2, 200); + assert.strictEqual(list2.status, 200, `sid2 tools/list should return 200: ${list2.status}`); + assert.ok(!list2.body.error, `sid2 error: ${JSON.stringify(list2.body.error)}`); + + const list3 = await mcpRequest("tools/list", {}, sid3, 201); + assert.strictEqual(list3.status, 200, `sid3 tools/list should return 200: ${list3.status}`); + assert.ok(!list3.body.error, `sid3 error: ${JSON.stringify(list3.body.error)}`); + log(`sid2 and sid3 survived close failure`); + + // 6. Clean up sid2 and sid3 + await fetch(`http://localhost:${TEST_PORT}/mcp`, { method: "DELETE", headers: { "mcp-session-id": sid2 } }); + await fetch(`http://localhost:${TEST_PORT}/mcp`, { method: "DELETE", headers: { "mcp-session-id": sid3 } }); + await new Promise((r) => setTimeout(r, 500)); + + const h = await getHealthz(); + log(`final: sessions=${h.sessions}, capacity=${h.occupiedCapacity}`); + assert.ok(h.sessions <= 1, `sessions should be <= 1, got ${h.sessions}`); +} + +// ─── Main ────────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log("\n=== MCP Integration Tests v3 (Handshake Protection) ===\n"); + console.log(`Node: ${process.version}, Max sessions: ${MAX_SESSIONS}\n`); + + try { + await startServer(false); + log(`Server started on port ${TEST_PORT}`); + await runTest("Test 1: Single real connection (initialize, tools/list, open_workspace)", test1_singleRealConnection); + await runTest("Test 2: handleRequest exception, inFlight=0, session usable", test2_handleRequestException); + await runTest("Test 3: 100 concurrent initialize with barrier test", test3_concurrentWithBarrier); + await runTest("Test 4: Deterministic request barrier — 8 inFlight, 9th gets 503", test4_deterministicRequestBarrier); + await runTest("Test 5: Max idle sessions, new evicts oldest, stays max", test5_idleSessionsEviction); + await runTest("Test 6: 100 consecutive full flows, no inFlight accumulation", test6_consecutiveFullFlows); + await runTest("Test 7: Real close failure via registry, other sessions survive", test7_realCloseFailure); + } finally { + await stopServer(); + } + + console.log(""); + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + console.log(`=== Integration Tests: ${passed} passed, ${failed} failed ===`); + if (failed > 0) { console.error("\n\u2717 Some integration tests FAILED\n"); process.exit(1); } + else { console.log("\n\u2713 All integration tests passed\n"); } +} + +main().catch((err) => { console.error("Fatal error:", err); stopServer().finally(() => process.exit(1)); }); diff --git a/src/mcp-session-lifecycle.test.ts b/src/mcp-session-lifecycle.test.ts new file mode 100644 index 00000000..61ec598e --- /dev/null +++ b/src/mcp-session-lifecycle.test.ts @@ -0,0 +1,515 @@ +/** + * MCP Session Lifecycle Regression Tests + * + * Tests the fix for the inFlight counter leak caused by duplicate markActive calls. + * Also tests the atomic reservation API (tryReserveSlot/commitReservation/releaseReservation). + * Tests the initialization handshake protection (initializing flag, inFlight=1 during init). + */ + +import assert from "node:assert/strict"; +import { McpSessionRegistry } from "./mcp-session-registry.js"; +import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; + +function createMockTransport(): StreamableHTTPServerTransport { + const handlers: Record void)[]> = {}; + return { + close: async () => { + handlers["close"]?.forEach((h) => h()); + }, + on: (event: string, handler: (...args: unknown[]) => void) => { + if (!handlers[event]) handlers[event] = []; + handlers[event].push(handler); + }, + handleRequest: async () => {}, + sessionId: undefined, + } as unknown as StreamableHTTPServerTransport; +} + +function simulateExistingSessionRequest( + registry: McpSessionRegistry, + sessionId: string, + handler: () => Promise, +): Promise { + let activeSessionId: string | undefined; + return (async () => { + try { + const transport = registry.get(sessionId)?.transport; + if (!transport) throw new Error("Unknown session"); + registry.markActive(sessionId); + activeSessionId = sessionId; + await handler(); + } catch { + // error handling + } finally { + if (activeSessionId) { + registry.markIdle(activeSessionId); + } + } + })(); +} + +function test(name: string, fn: () => void | Promise): void { + Promise.resolve(fn()) + .then(() => console.log(` \u2713 ${name}`)) + .catch((err) => { + console.error(` \u2717 ${name}`); + console.error(` ${err.message}`); + process.exitCode = 1; + }); +} + +console.log("\n=== MCP Session Lifecycle Regression Tests ===\n"); + +// Test 1: Normal request — markActive once, markIdle once, inFlight=0 +test("Test 1: Normal request — markActive once, markIdle once, inFlight=0", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + const transport = createMockTransport(); + const sessionId = "test-1"; + assert.ok(registry.register(sessionId, transport) === true); + assert.strictEqual(registry.get(sessionId)?.inFlight, 0); + simulateExistingSessionRequest(registry, sessionId, async () => { + assert.strictEqual(registry.get(sessionId)?.inFlight, 1); + }).then(() => { + assert.strictEqual(registry.get(sessionId)?.inFlight, 0); + }); +}); + +// Test 2: handleRequest throws — finally still executes, inFlight=0 +test("Test 2: handleRequest throws — finally still executes, inFlight=0", async () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + const transport = createMockTransport(); + const sessionId = "test-2"; + registry.register(sessionId, transport); + await simulateExistingSessionRequest(registry, sessionId, async () => { + throw new Error("handleRequest simulated failure"); + }); + assert.strictEqual(registry.get(sessionId)?.inFlight, 0); +}); + +// Test 3: 100 consecutive sessions — count <= 64, new sessions can initialize +test("Test 3: 100 consecutive ChatGPT-style sessions — count \u2264 64", async () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + for (let i = 0; i < 100; i++) { + const sessionId = `chatgpt-session-${i}`; + const transport = createMockTransport(); + const registered = registry.register(sessionId, transport); + if (registered) { + await simulateExistingSessionRequest(registry, sessionId, async () => {}); + } + } + assert.ok(registry.size <= 64, `session count should be \u2264 64, got ${registry.size}`); + assert.ok(registry.get("chatgpt-session-99"), "latest session should be alive"); +}); + +// Test 4: 64 old idle sessions — new session evicts oldest, new session survives +test("Test 4: 64 idle sessions — new session evicts oldest, new survives", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + for (let i = 0; i < 64; i++) { + const transport = createMockTransport(); + assert.ok(registry.register(`old-${i}`, transport), `old-${i} should register`); + } + assert.strictEqual(registry.size, 64); + const newTransport = createMockTransport(); + const registered = registry.register("new-session", newTransport); + assert.ok(registered); + assert.strictEqual(registry.size, 64); + assert.ok(registry.get("new-session")); + assert.ok(!registry.get("old-0")); +}); + +// Test 5: 64 active sessions — new initialize gets 503, no timeout +test("Test 5: 64 active sessions — atCapacity=true, register rejected", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + for (let i = 0; i < 64; i++) { + const transport = createMockTransport(); + registry.register(`active-${i}`, transport); + registry.markActive(`active-${i}`); + } + assert.ok(registry.atCapacity); + const newTransport = createMockTransport(); + const registered = registry.register("rejected-session", newTransport); + assert.strictEqual(registered, false); + assert.strictEqual(registry.size, 64); +}); + +// Test 6: Full flow with handshake protection — initialize (inFlight=1) → markIdle (inFlight=0) → completeHandshake → tools/list → open_workspace +test("Test 6: Full MCP flow with handshake protection", async () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + + // Step 1: initialize using reservation API + const res = registry.tryReserveSlot(); + assert.ok(res, "reservation should be created"); + assert.strictEqual(registry.pendingReservations, 1); + + const transport = createMockTransport(); + const sessionId = "full-flow-session"; + const committed = registry.commitReservation(res!, sessionId, transport); + assert.ok(committed, "commit should succeed"); + assert.strictEqual(registry.pendingReservations, 0); + + // After commit: inFlight=1 (initialize still in progress), initializing=true + const session = registry.get(sessionId); + assert.ok(session, "session should exist"); + assert.strictEqual(session!.inFlight, 1, "inFlight=1 during initialize (commitReservation sets this)"); + assert.strictEqual(session!.initializing, true, "initializing=true during initialize"); + + // Step 2: markIdle (initialize request finished) — simulates server finally block + registry.markIdle(sessionId); + assert.strictEqual(registry.get(sessionId)?.inFlight, 0, "inFlight=0 after initialize completes"); + // Session is still initializing=true (notifications/initialized not yet received) + assert.strictEqual(registry.get(sessionId)?.initializing, true, "still initializing before notifications/initialized"); + + // Step 3: completeHandshake (notifications/initialized received) + const handshakeResult = registry.completeHandshake(sessionId); + assert.strictEqual(handshakeResult, true, "completeHandshake should succeed"); + assert.strictEqual(registry.get(sessionId)?.initializing, false, "initializing=false after handshake"); + assert.strictEqual(registry.get(sessionId)?.inFlight, 0, "inFlight=0 after handshake"); + + // Step 4: tools/list (normal existing session request) + await simulateExistingSessionRequest(registry, sessionId, async () => { + assert.strictEqual(registry.get(sessionId)?.inFlight, 1, "inFlight=1 during tools/list"); + }); + assert.strictEqual(registry.get(sessionId)?.inFlight, 0); + + // Step 5: open_workspace + await simulateExistingSessionRequest(registry, sessionId, async () => { + assert.strictEqual(registry.get(sessionId)?.inFlight, 1, "inFlight=1 during open_workspace"); + }); + assert.strictEqual(registry.get(sessionId)?.inFlight, 0); + assert.ok(registry.get(sessionId), "session should still exist"); +}); + +// Test 7 (regression): 70 consecutive requests on same session — inFlight stays 0 +test("Test 7 (regression): 70 consecutive requests, inFlight stays 0", async () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + const transport = createMockTransport(); + const sessionId = "regression-70"; + registry.register(sessionId, transport); + for (let i = 0; i < 70; i++) { + await simulateExistingSessionRequest(registry, sessionId, async () => {}); + } + assert.strictEqual(registry.get(sessionId)?.inFlight, 0); +}); + +// Test 8 (regression): Old bug simulation — verify double markActive is detected +test("Test 8 (regression): Double markActive + single markIdle leaves inFlight=1", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + const transport = createMockTransport(); + const sessionId = "old-bug-demo"; + registry.register(sessionId, transport); + registry.markActive(sessionId); + registry.markActive(sessionId); + registry.markIdle(sessionId); + assert.strictEqual(registry.get(sessionId)?.inFlight, 1, "OLD BUG: inFlight=1"); + registry.markIdle(sessionId); + registry.markActive(sessionId); + registry.markIdle(sessionId); + assert.strictEqual(registry.get(sessionId)?.inFlight, 0, "FIXED: inFlight=0"); +}); + +// ─── Reservation API Tests ───────────────────────────────────────────── + +test("Test 9: tryReserveSlot creates reservation, occupiedCapacity increases", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + assert.strictEqual(registry.occupiedCapacity, 0); + const res = registry.tryReserveSlot(); + assert.ok(res); + assert.strictEqual(registry.pendingReservations, 1); + assert.strictEqual(registry.occupiedCapacity, 1); + assert.strictEqual(registry.size, 0); +}); + +test("Test 10: commitReservation creates session with initializing=true, inFlight=1", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + const res = registry.tryReserveSlot(); + assert.ok(res); + const transport = createMockTransport(); + const committed = registry.commitReservation(res!, "sess-1", transport); + assert.strictEqual(committed, true); + assert.strictEqual(registry.pendingReservations, 0); + assert.strictEqual(registry.size, 1); + assert.strictEqual(registry.occupiedCapacity, 1); + // NEW: commitReservation sets initializing=true and inFlight=1 + const session = registry.get("sess-1"); + assert.ok(session); + assert.strictEqual(session!.inFlight, 1, "inFlight=1 during initialize"); + assert.strictEqual(session!.initializing, true, "initializing=true"); + assert.ok(session!.handshakeDeadline, "handshakeDeadline should be set"); +}); + +test("Test 11: releaseReservation frees slot without session", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + const res = registry.tryReserveSlot(); + assert.ok(res); + assert.strictEqual(registry.occupiedCapacity, 1); + registry.releaseReservation(res!); + assert.strictEqual(registry.pendingReservations, 0); + assert.strictEqual(registry.size, 0); + assert.strictEqual(registry.occupiedCapacity, 0); +}); + +test("Test 12: Double commit is rejected (returns false)", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + const res = registry.tryReserveSlot(); + assert.ok(res); + const t1 = createMockTransport(); + const t2 = createMockTransport(); + assert.ok(registry.commitReservation(res!, "sess-a", t1)); + assert.strictEqual(registry.commitReservation(res!, "sess-b", t2), false); + assert.strictEqual(registry.size, 1); +}); + +test("Test 13: Double release is a no-op", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + const res = registry.tryReserveSlot(); + assert.ok(res); + registry.releaseReservation(res!); + assert.strictEqual(registry.occupiedCapacity, 0); + registry.releaseReservation(res!); + assert.strictEqual(registry.occupiedCapacity, 0); +}); + +test("Test 14: 100 concurrent tryReserveSlot — at most 64 succeed", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + let successCount = 0; + let failCount = 0; + for (let i = 0; i < 100; i++) { + const res = registry.tryReserveSlot(); + if (res) { successCount++; } else { failCount++; } + } + assert.strictEqual(successCount, 64); + assert.strictEqual(failCount, 36); + assert.strictEqual(registry.occupiedCapacity, 64); + assert.ok(registry.atCapacity); +}); + +// Test 15: Handshake protection prevents eviction of initializing sessions +test("Test 15: 64 committed initializing sessions — tryReserveSlot cannot evict any", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + // Fill with 64 sessions via reservation+commit (all initializing=true, inFlight=1) + for (let i = 0; i < 64; i++) { + const res = registry.tryReserveSlot(); + assert.ok(res, `reservation ${i} should succeed`); + const transport = createMockTransport(); + assert.ok(registry.commitReservation(res!, `sess-${i}`, transport), `commit ${i}`); + } + assert.strictEqual(registry.size, 64); + assert.strictEqual(registry.pendingReservations, 0); + + // Now try to reserve more — should fail because all sessions are initializing (protected) + const res = registry.tryReserveSlot(); + assert.strictEqual(res, undefined, "tryReserveSlot should fail — all sessions are initializing"); + + // Complete handshakes for all sessions + for (let i = 0; i < 64; i++) { + // First markIdle (initialize request finished) + registry.markIdle(`sess-${i}`); + // Then completeHandshake + registry.completeHandshake(`sess-${i}`); + } + + // Now all sessions are idle and fully handshaked — tryReserveSlot can evict + for (let i = 0; i < 100; i++) { + const res2 = registry.tryReserveSlot(); + assert.ok(res2, `reservation new-${i} should succeed (evicts idle handshaked session)`); + const transport = createMockTransport(); + assert.ok(registry.commitReservation(res2!, `new-${i}`, transport), `commit new-${i}`); + // Complete handshake immediately to allow further eviction + registry.markIdle(`new-${i}`); + registry.completeHandshake(`new-${i}`); + assert.ok(registry.occupiedCapacity <= 64, `capacity must stay <= 64, got ${registry.occupiedCapacity}`); + } + assert.strictEqual(registry.size, 64); + assert.ok(registry.get("new-99")); + assert.ok(!registry.get("sess-0")); +}); + +// Test 16: Initialize fails — reservation released in finally, slot freed +test("Test 16: Initialize fails — reservation released in finally, slot freed", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + let initReservation: ReturnType | undefined; + let reservationCommitted = false; + try { + initReservation = registry.tryReserveSlot(); + assert.ok(initReservation); + assert.strictEqual(registry.pendingReservations, 1); + throw new Error("server.connect failed"); + } catch { + // error handling + } finally { + if (initReservation && !reservationCommitted) { + registry.releaseReservation(initReservation); + } + } + assert.strictEqual(registry.pendingReservations, 0); + assert.strictEqual(registry.occupiedCapacity, 0); + assert.strictEqual(registry.size, 0); +}); + +// Test 17: Handshake timeout cleanup +test("Test 17: Stale handshake cleanup — past deadline with inFlight=0 gets cleaned", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + handshakeTimeoutMs: 100, // 100ms for testing + }); + const res = registry.tryReserveSlot(); + assert.ok(res); + const transport = createMockTransport(); + registry.commitReservation(res!, "stale-sess", transport); + + // Simulate initialize completed (markIdle) but no notifications/initialized + registry.markIdle("stale-sess"); + assert.strictEqual(registry.get("stale-sess")?.inFlight, 0); + assert.strictEqual(registry.get("stale-sess")?.initializing, true); + + // Wait for deadline to pass + // Note: in real code, closeStaleHandshakes is called by sweep timer + // Here we call it manually after sleeping + // We can't easily sleep in sync test, so we manipulate the deadline directly + const session = registry.get("stale-sess"); + if (session) { + session.handshakeDeadline = Date.now() - 1; // Set deadline in the past + } + + const cleaned = registry.closeStaleHandshakes(); + assert.strictEqual(cleaned, 1, "should clean 1 stale handshake"); + assert.strictEqual(registry.size, 0, "session should be removed"); +}); + +// Test 18: completeHandshake on non-existent or non-initializing session returns false +test("Test 18: completeHandshake returns false for non-initializing session", () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + const transport = createMockTransport(); + registry.register("normal-sess", transport); + // register() sets initializing=false + assert.strictEqual(registry.get("normal-sess")?.initializing, false); + // completeHandshake should return false (not initializing) + assert.strictEqual(registry.completeHandshake("normal-sess"), false); + // Non-existent session + assert.strictEqual(registry.completeHandshake("nonexistent"), false); +}); + +// Test 19: Handshake timeout with independent timer (no 5-min sweep wait) +test("Test 19: Handshake timeout fires within ~500ms (independent timer)", async () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, + sweepMs: 300_000, // 5 minutes — stale handshakes must NOT wait for this + maxSessions: 2, + handshakeTimeoutMs: 200, // 200ms timeout + }); + registry.startSweep(); + + // Create 2 sessions via reservation+commit, but don't complete handshake + for (let i = 0; i < 2; i++) { + const res = registry.tryReserveSlot(); + assert.ok(res, `reservation ${i} should succeed`); + const transport = createMockTransport(); + assert.ok(registry.commitReservation(res!, `stale-${i}`, transport), `commit ${i}`); + // Simulate initialize request finished (markIdle) but no notifications/initialized + registry.markIdle(`stale-${i}`); + } + + assert.strictEqual(registry.size, 2, "should have 2 sessions"); + assert.strictEqual(registry.occupiedCapacity, 2, "capacity should be 2"); + + // tryReserveSlot should fail — sessions are initializing (protected) + const failRes = registry.tryReserveSlot(); + assert.strictEqual(failRes, undefined, "tryReserveSlot should fail — sessions are initializing"); + + // Wait for handshake timeout to fire (200ms + timer interval) + // Timer interval = min(5000, max(1000, 200/4)) = min(5000, max(1000, 50)) = 1000ms + // But closeStaleHandshakes is also called synchronously in tryReserveSlot. + // Wait 250-500ms then try again — the stale sessions should be cleaned. + await new Promise((r) => setTimeout(r, 300)); + + // Manually trigger cleanup (simulates what the timer would do) + registry.closeStaleHandshakes(); + + // Wait a bit more for async transport close + await new Promise((r) => setTimeout(r, 100)); + + // Now tryReserveSlot should succeed — stale sessions were cleaned + const okRes = registry.tryReserveSlot(); + assert.ok(okRes, "tryReserveSlot should succeed after stale handshake cleanup"); + + registry.stopSweep(); + console.log(" Test 19: stale handshakes cleaned within 300ms (no 5-min sweep wait)"); +}); + +// Test 20: closeSession removes session and closes transport +test("Test 20: closeSession removes session and closes transport", async () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + const transport = createMockTransport(); + registry.register("close-test", transport); + assert.ok(registry.get("close-test"), "session should exist before close"); + + const result = await registry.closeSession("close-test"); + assert.strictEqual(result, true, "closeSession should return true for existing session"); + assert.ok(!registry.get("close-test"), "session should be removed after close"); + assert.strictEqual(registry.size, 0, "size should be 0"); + + // closeSession on non-existent session returns false + const result2 = await registry.closeSession("nonexistent"); + assert.strictEqual(result2, false, "closeSession should return false for non-existent session"); +}); + +// Test 21: closeSession with injected failure — error recorded, session still removed +test("Test 21: closeSession with injected failure — error recorded, session removed", async () => { + const registry = new McpSessionRegistry({ + idleMs: 60_000, sweepMs: 5_000, maxSessions: 64, + }); + const transport = createMockTransport(); + registry.register("fail-close", transport); + registry.injectCloseFailure("fail-close"); + + const result = await registry.closeSession("fail-close"); + assert.strictEqual(result, true, "closeSession should return true even if transport.close fails"); + assert.ok(!registry.get("fail-close"), "session should be removed even if transport.close fails"); + assert.ok(registry.lastError, "lastError should be set after close failure"); +}); + + +setTimeout(() => { + if (process.exitCode === 1) { + console.error("\n\u2717 Some tests FAILED\n"); + process.exit(1); + } else { + console.log("\n\u2713 All session lifecycle tests passed\n"); + } +}, 10000); diff --git a/src/mcp-session-registry.ts b/src/mcp-session-registry.ts new file mode 100644 index 00000000..1057f601 --- /dev/null +++ b/src/mcp-session-registry.ts @@ -0,0 +1,568 @@ +/** + * McpSessionRegistry — PR #71 formal session lifecycle management. + * + * Responsibilities: + * - Track every MCP session with lastActivity and inFlight counters. + * - closeIdle: remove sessions idle longer than idleMs (but never if inFlight > 0). + * - closeAll: wait for every session transport to close; one failure does not abort others. + * - server-shutdown: drain HTTP connections, wait for app cleanup, idempotent close. + * + * Design rules enforced: + * - After removing a session from the registry, transport.close() is awaited. + * - If transport.close() fails, the session is NOT put back. + * - closeAll returns a single shared Promise on repeated calls. + * - server shutdown waits for both HTTP server close and application cleanup. + * - transport_close log emitted at most once per session. + * - One transport failure never aborts other session cleanup. + * - Atomic reservation prevents concurrent initialize from exceeding maxSessions. + */ + +import type { Server as HttpServer } from "node:http"; +import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; + +export interface TrackedSession { + id: string; + transport: StreamableHTTPServerTransport; + lastActivity: number; + inFlight: number; + closeStarted?: boolean; + closed?: boolean; + /** True while the initialize handshake is in progress (initialize sent, notifications/initialized not yet received). */ + initializing?: boolean; + /** Timestamp the session was committed (initialize response sent). */ + initializedAt?: number; + /** Deadline after which a stuck initializing session can be force-cleaned. */ + handshakeDeadline?: number; +} + +export interface RegistryOptions { + idleMs: number; + sweepMs: number; + maxSessions: number; + /** Max time (ms) a session can stay in initializing state before force-cleanup. Default 30000. */ + handshakeTimeoutMs?: number; + onSweep?: (closed: number, evicted: number) => void; + onSessionClose?: (id: string, error?: Error) => void; +} + +/** + * A reservation holds a capacity slot during session initialization. + * It must be either committed (turned into a real session) or released. + */ +export interface SessionReservation { + /** Unique token to prevent double-commit / double-release. */ + readonly token: string; + /** Timestamp the reservation was created. */ + readonly createdAt: number; + /** Whether this reservation has been committed or released. */ + settled: boolean; +} + +export class McpSessionRegistry { + private readonly sessions = new Map(); + private readonly options: RegistryOptions; + private sweepTimer: ReturnType | null = null; + private handshakeTimer: ReturnType | null = null; + private closePromise: Promise | null = null; + private totalClosed = 0; + private totalEvicted = 0; + private totalReservations = 0; + private totalReservationsReleased = 0; + private lastCloseError: string | null = null; + + /** + * Active reservations for in-flight initialize requests. + * Capacity = sessions.size + reservations.size. + * At most maxSessions reservations + sessions combined. + */ + private readonly reservations = new Set(); + + /** Test-only: session IDs that should fail when closeTransport is called. */ + private readonly closeFailureInjection = new Set(); + + constructor(options: RegistryOptions) { + this.options = options; + } + + /** Test-only: inject a close failure for a specific session. */ + injectCloseFailure(id: string): void { + this.closeFailureInjection.add(id); + } + + /** Start the periodic sweep and handshake cleanup timers. */ + startSweep(): void { + if (this.sweepTimer) return; + // Idle sweep timer — runs at sweepMs interval. + this.sweepTimer = setInterval(() => { + this.closeIdle().catch(() => {}); + }, this.options.sweepMs); + if (this.sweepTimer.unref) this.sweepTimer.unref(); + + // Handshake cleanup timer — runs at a shorter interval than sweepMs + // so that handshakeTimeoutMs is enforced promptly. + // Interval: min(5000, max(1000, handshakeTimeoutMs / 4)) + const handshakeTimeout = this.options.handshakeTimeoutMs ?? 30_000; + const handshakeInterval = Math.min(5000, Math.max(1000, Math.floor(handshakeTimeout / 4))); + this.handshakeTimer = setInterval(() => { + this.closeStaleHandshakes(); + }, handshakeInterval); + if (this.handshakeTimer.unref) this.handshakeTimer.unref(); + } + + /** Stop both the periodic sweep and handshake cleanup timers. */ + stopSweep(): void { + if (this.sweepTimer) { + clearInterval(this.sweepTimer); + this.sweepTimer = null; + } + if (this.handshakeTimer) { + clearInterval(this.handshakeTimer); + this.handshakeTimer = null; + } + } + + // ─── Atomic Reservation API ─────────────────────────────────────────── + + /** + * Total occupied capacity: registered sessions + pending reservations. + * This is the value capacity checks must use. + */ + get occupiedCapacity(): number { + return this.sessions.size + this.reservations.size; + } + + /** Number of pending (unsettled) reservations. */ + get pendingReservations(): number { + return this.reservations.size; + } + + /** + * Atomically reserve a capacity slot for a new session. + * + * - If there is room (sessions + reservations < maxSessions), create a reservation. + * - If at capacity but idle sessions exist, evict the oldest idle session, then reserve. + * - If at capacity and all sessions are busy (inFlight > 0), return undefined. + * + * This method is synchronous and must be called BEFORE creating the transport. + */ + tryReserveSlot(): SessionReservation | undefined { + // Synchronously clean up stale handshakes before checking capacity. + // This ensures expired initializing sessions are removed immediately + // when a new initialize arrives, without waiting for the timer. + this.closeStaleHandshakes(); + + // Capacity includes both sessions and pending reservations. + if (this.occupiedCapacity >= this.options.maxSessions) { + // At capacity — try to evict an idle, fully-handshaked session to make room. + // Sessions that are initializing or have inFlight > 0 are protected. + const eligible: TrackedSession[] = []; + for (const s of this.sessions.values()) { + if (s.inFlight === 0 && !s.initializing) eligible.push(s); + } + if (eligible.length === 0) { + return undefined; // No evictable sessions — reject. + } + eligible.sort((a, b) => a.lastActivity - b.lastActivity); + const toEvict = eligible[0]!; + this.sessions.delete(toEvict.id); + this.totalEvicted++; + this.closeTransport(toEvict).catch(() => {}); + } + + const reservation: SessionReservation = { + token: `res-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + createdAt: Date.now(), + settled: false, + }; + this.reservations.add(reservation); + this.totalReservations++; + return reservation; + } + + /** + * Commit a reservation into a fully registered session. + * The reservation is removed from the pending set. + * Returns true on success, false if the reservation was already settled + * (double-commit guard). + */ + commitReservation( + reservation: SessionReservation, + sessionId: string, + transport: StreamableHTTPServerTransport, + ): boolean { + if (reservation.settled) { + return false; // Double-commit guard. + } + reservation.settled = true; + this.reservations.delete(reservation); + + // Defensive: if somehow over capacity (shouldn't happen with reservations), + // log the error but still register to avoid losing the session. + if (this.sessions.size >= this.options.maxSessions) { + // This is an internal state error — reservations should have prevented this. + console.error( + `[McpSessionRegistry] INTERNAL ERROR: commitReservation called at capacity ` + + `(${this.sessions.size}/${this.options.maxSessions}). ` + + `Reservation ${reservation.token} may have been double-counted.`, + ); + } + + const now = Date.now(); + const handshakeTimeout = this.options.handshakeTimeoutMs ?? 30_000; + this.sessions.set(sessionId, { + id: sessionId, + transport, + lastActivity: now, + inFlight: 1, // initialize request is still in flight + initializing: true, + initializedAt: now, + handshakeDeadline: now + handshakeTimeout, + }); + return true; + } + + /** + * Complete the initialization handshake for a session. + * Called after notifications/initialized is received. + * Sets initializing=false and refreshes lastActivity. + * Returns true on success, false if session not found or not initializing. + */ + completeHandshake(id: string): boolean { + const s = this.sessions.get(id); + if (!s || !s.initializing) return false; + s.initializing = false; + s.handshakeDeadline = undefined; + s.lastActivity = Date.now(); + return true; + } + + /** + * Release a reservation without committing (e.g., initialize failed). + * Safe to call multiple times — second call is a no-op. + */ + releaseReservation(reservation: SessionReservation): void { + if (reservation.settled) { + return; // Already committed or released — no-op. + } + reservation.settled = true; + this.reservations.delete(reservation); + this.totalReservationsReleased++; + } + + // ─── Legacy Register (defensive only) ───────────────────────────────── + + /** + * Register a new session directly (legacy path). + * + * With the reservation API, normal initialize flow should use + * tryReserveSlot → commitReservation. This method is kept as a + * defensive fallback. If it fails, it indicates an internal state error. + * + * Returns false if at capacity with no evictable idle sessions. + */ + register(id: string, transport: StreamableHTTPServerTransport): boolean { + if (this.occupiedCapacity >= this.options.maxSessions) { + const eligible: TrackedSession[] = []; + for (const s of this.sessions.values()) { + if (s.inFlight === 0 && !s.initializing) eligible.push(s); + } + if (eligible.length === 0) { + return false; + } + eligible.sort((a, b) => a.lastActivity - b.lastActivity); + const toEvict = eligible[0]!; + this.sessions.delete(toEvict.id); + this.totalEvicted++; + this.closeTransport(toEvict).catch(() => {}); + } + const now = Date.now(); + const handshakeTimeout = this.options.handshakeTimeoutMs ?? 30_000; + this.sessions.set(id, { + id, + transport, + lastActivity: now, + inFlight: 0, + initializing: false, + initializedAt: now, + handshakeDeadline: undefined, + }); + return true; + } + + /** Whether the registry is at capacity with no evictable idle sessions. */ + get atCapacity(): boolean { + if (this.occupiedCapacity < this.options.maxSessions) return false; + // At capacity — check if any session is idle AND fully handshaked. + for (const s of this.sessions.values()) { + if (s.inFlight === 0 && !s.initializing) return false; + } + return true; + } + + /** Mark a session as active (request started). */ + markActive(id: string): TrackedSession | undefined { + const s = this.sessions.get(id); + if (s) { + s.lastActivity = Date.now(); + s.inFlight++; + } + return s; + } + + /** Mark a request complete on a session. */ + markIdle(id: string): void { + const s = this.sessions.get(id); + if (s && s.inFlight > 0) { + s.inFlight--; + } + } + + /** Remove a single session from the registry (does not close transport). */ + forget(id: string): TrackedSession | undefined { + const s = this.sessions.get(id); + if (s) this.sessions.delete(id); + return s; + } + + /** + * Close a specific session: remove from registry and close its transport. + * If transport.close() fails, the error is recorded but not thrown — + * other sessions are unaffected. + * Returns true if the session existed and was removed, false otherwise. + */ + async closeSession(id: string): Promise { + const s = this.sessions.get(id); + if (!s) return false; + this.sessions.delete(id); + await this.closeTransport(s); + this.totalClosed++; + return true; + } + + /** Get a session by id. */ + get(id: string): TrackedSession | undefined { + return this.sessions.get(id); + } + + /** Current registered session count (excludes reservations). */ + get size(): number { + return this.sessions.size; + } + + /** Total sessions closed since start. */ + get closedCount(): number { + return this.totalClosed; + } + + /** Total sessions evicted due to cap. */ + get evictedCount(): number { + return this.totalEvicted; + } + + /** Total reservations created since start. */ + get reservationCount(): number { + return this.totalReservations; + } + + /** Last close error, if any. */ + get lastError(): string | null { + return this.lastCloseError; + } + + /** + * Close idle sessions (idleMs elapsed AND inFlight === 0). + * Returns counts of closed and evicted sessions. + */ + async closeIdle(): Promise<{ closed: number; evicted: number }> { + const now = Date.now(); + const toClose: TrackedSession[] = []; + for (const s of this.sessions.values()) { + // Clean up stale handshakes: initializing sessions past their deadline with inFlight=0. + if (s.initializing && s.inFlight === 0 && s.handshakeDeadline && now >= s.handshakeDeadline) { + toClose.push(s); + continue; + } + // Normal idle cleanup: skip sessions with inFlight > 0 or still initializing. + if (s.inFlight > 0 || s.initializing) continue; + if (now - s.lastActivity >= this.options.idleMs) { + toClose.push(s); + } + } + let closed = 0; + for (const s of toClose) { + this.sessions.delete(s.id); + await this.closeTransport(s); + closed++; + } + if (closed > 0) { + this.totalClosed += closed; + this.options.onSweep?.(closed, 0); + } + return { closed, evicted: 0 }; + } + + /** + * Force-close sessions stuck in initializing state past their handshake deadline. + * This handles cases where the client disconnected after initialize but before + * sending notifications/initialized, and inFlight has returned to 0. + * Returns the number of sessions cleaned up. + */ + closeStaleHandshakes(): number { + const now = Date.now(); + let cleaned = 0; + for (const s of this.sessions.values()) { + if (s.initializing && s.inFlight === 0 && s.handshakeDeadline && now >= s.handshakeDeadline) { + this.sessions.delete(s.id); + this.closeTransport(s).catch(() => {}); + this.totalClosed++; + cleaned++; + } + } + return cleaned; + } + + /** + * Close a single session transport. Idempotent per session. + * Logs transport_close at most once. Failures are recorded but not thrown. + */ + private async closeTransport(s: TrackedSession): Promise { + if (s.closeStarted) return; + s.closeStarted = true; + try { + if (this.closeFailureInjection.has(s.id)) { + throw new Error(`Injected close failure for session ${s.id}`); + } + await s.transport.close(); + s.closed = true; + this.options.onSessionClose?.(s.id); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.lastCloseError = `Session ${s.id} transport.close failed: ${msg}`; + this.options.onSessionClose?.(s.id, err instanceof Error ? err : new Error(msg)); + } finally { + this.closeFailureInjection.delete(s.id); + } + } + + /** + * Close ALL sessions and release all reservations. Returns a shared Promise on repeated calls. + * One transport failure does NOT abort other cleanup. + */ + closeAll(): Promise { + if (this.closePromise) return this.closePromise; + this.closePromise = this.doCloseAll(); + return this.closePromise; + } + + private async doCloseAll(): Promise { + this.stopSweep(); + // Release all pending reservations. + for (const r of this.reservations) { + r.settled = true; + } + this.reservations.clear(); + // Close all sessions. + const all = [...this.sessions.values()]; + this.sessions.clear(); + await Promise.allSettled(all.map((s) => this.closeTransport(s))); + this.totalClosed += all.length; + } + + /** + * Full server shutdown: stop sweep, close all sessions, drain HTTP server, + * and wait for application cleanup. + * Idempotent — returns the same Promise on repeated calls. + */ + shutdown(opts: { + httpServer?: HttpServer | null; + appCleanup?: () => Promise; + drainTimeoutMs?: number; + }): Promise { + if (this.closePromise) return this.closePromise; + this.closePromise = this.doShutdown(opts); + return this.closePromise; + } + + private async doShutdown(opts: { + httpServer?: HttpServer | null; + appCleanup?: () => Promise; + drainTimeoutMs?: number; + }): Promise { + // 1. Stop accepting new sweep work + this.stopSweep(); + + // 2. Release all pending reservations + for (const r of this.reservations) { + r.settled = true; + } + this.reservations.clear(); + + // 3. Close all MCP session transports (parallel, failure-tolerant) + const all = [...this.sessions.values()]; + this.sessions.clear(); + await Promise.allSettled(all.map((s) => this.closeTransport(s))); + this.totalClosed += all.length; + + // 4. Drain HTTP server + if (opts.httpServer) { + await this.shutdownHttpServer(opts.httpServer, opts.drainTimeoutMs ?? 10_000); + } + + // 5. Application cleanup + if (opts.appCleanup) { + try { + await opts.appCleanup(); + } catch (err) { + this.lastCloseError = `App cleanup failed: ${err instanceof Error ? err.message : String(err)}`; + } + } + } + + /** Close an HTTP server with a drain timeout. */ + private shutdownHttpServer(server: HttpServer, timeoutMs: number): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (!settled) { + settled = true; + resolve(); + } + }; + const timer = setTimeout(() => { + server.closeAllConnections?.(); + finish(); + }, timeoutMs); + if (timer.unref) timer.unref(); + server.close((err) => { + clearTimeout(timer); + if (err && err.message !== "Server is not running") { + this.lastCloseError = `HTTP server close error: ${err.message}`; + } + finish(); + }); + }); + } + + /** Diagnostic snapshot for runtime diagnostics (PR #69). */ + snapshot() { + let initializingCount = 0; + for (const s of this.sessions.values()) { + if (s.initializing) initializingCount++; + } + return { + activeSessions: this.sessions.size, + pendingReservations: this.reservations.size, + occupiedCapacity: this.occupiedCapacity, + initializingSessions: initializingCount, + totalClosed: this.totalClosed, + totalEvicted: this.totalEvicted, + totalReservations: this.totalReservations, + totalReservationsReleased: this.totalReservationsReleased, + idleMs: this.options.idleMs, + sweepMs: this.options.sweepMs, + maxSessions: this.options.maxSessions, + handshakeTimeoutMs: this.options.handshakeTimeoutMs ?? 30_000, + lastError: this.lastCloseError, + }; + } +} diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index e6503788..c1c53382 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -12,6 +12,21 @@ import type { import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js"; + +/** + * Flexible resource check 鈥?local customization. + * Accepts any URL whose pathname ends with /mcp, instead of exact checkResourceAllowed. + * This allows dynamic tunnels (Cloudflare, Tailscale) to work without reconfiguration. + */ +function isFlexibleResourceAllowed(requested: URL, configured: URL): boolean { + // Same origin: always allowed + if (requested.origin === configured.origin) return true; + // Any URL whose pathname ends with /mcp is allowed (dynamic tunnels) + if (requested.pathname.endsWith("/mcp")) return true; + // Fall back to exact check for safety + return requested.href === configured.href; +} + export interface OAuthConfig { ownerToken: string; accessTokenTtlSeconds: number; @@ -132,7 +147,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { params: AuthorizationParams, res: Response, ): Promise { - if (!params.resource || !checkResourceAllowed({ requestedResource: params.resource, configuredResource: this.resourceServerUrl })) { + if (!params.resource || !isFlexibleResourceAllowed(params.resource, this.resourceServerUrl)) { throw new InvalidRequestError("Invalid or missing OAuth resource"); } if (!requestedScopesAllowed(params.scopes ?? [], this.config.scopes)) { @@ -199,7 +214,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { if (redirectUri && redirectUri !== record.params.redirectUri) { throw new InvalidGrantError("redirect_uri does not match the authorization request"); } - if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) { + if (resource && !isFlexibleResourceAllowed(resource, this.resourceServerUrl)) { throw new InvalidGrantError("Invalid resource"); } @@ -218,7 +233,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) { throw new InvalidGrantError("Invalid refresh token"); } - if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) { + if (resource && !isFlexibleResourceAllowed(resource, this.resourceServerUrl)) { throw new InvalidGrantError("Invalid resource"); } diff --git a/src/output-truncation.ts b/src/output-truncation.ts new file mode 100644 index 00000000..b7512126 --- /dev/null +++ b/src/output-truncation.ts @@ -0,0 +1,141 @@ +/** + * Output truncation — PR #85. + * + * Provides Unicode-safe truncation for MCP inline tool output. + * Preserves the start and end of output, inserts an ellipsis marker in the middle, + * and reports original/inline character counts, line counts, and truncation status. + * + * The limit is configurable via DEVSPACE_INLINE_OUTPUT_CHARACTERS (default 12000) + * or config.json inlineOutputCharacters. + * + * Key rules: + * - Uses Array.from(str) to avoid splitting surrogate pairs (emoji, CJK extensions). + * - Never produces broken characters. + * - Returns originalChars, originalLines, inlineChars, truncated, omittedChars. + * - Executor-level truncation is separate from MCP inline truncation. + */ + +export interface TruncationResult { + /** The (possibly truncated) text. */ + text: string; + /** Whether truncation occurred. */ + truncated: boolean; + /** Original character count (by code points). */ + originalChars: number; + /** Original line count. */ + originalLines: number; + /** Final inline character count (by code points). */ + inlineChars: number; + /** Number of characters omitted. */ + omittedChars: number; +} + +export const DEFAULT_INLINE_OUTPUT_CHARACTERS = 12000; + +/** + * Parse the inline output character limit from an env value or config value. + * Falls back to DEFAULT_INLINE_OUTPUT_CHARACTERS (12000) on invalid input. + */ +export function parseInlineOutputCharacters( + envValue: string | undefined, + configValue: number | undefined, +): number { + const raw = envValue ?? configValue ?? DEFAULT_INLINE_OUTPUT_CHARACTERS; + const n = typeof raw === "number" ? raw : parseInt(String(raw), 10); + if (!Number.isFinite(n) || n < 100) return DEFAULT_INLINE_OUTPUT_CHARACTERS; + if (n > 200000) return 200000; + return n; +} + +/** + * Unicode-safe truncation of a string. + * + * Keeps the first `headChars` and last `tailChars` code points, inserting + * a marker in between that reports how many characters were omitted. + */ +export function truncateInlineOutput( + text: string, + maxCharacters: number = DEFAULT_INLINE_OUTPUT_CHARACTERS, +): TruncationResult { + const codePoints = Array.from(text); + const originalChars = codePoints.length; + const originalLines = text.split(/\r\n|\r|\n/).length; + + if (originalChars <= maxCharacters) { + return { + text, + truncated: false, + originalChars, + originalLines, + inlineChars: originalChars, + omittedChars: 0, + }; + } + + if (maxCharacters <= 0) { + return { + text: "", + truncated: true, + originalChars, + originalLines, + inlineChars: 0, + omittedChars: originalChars, + }; + } + + // Reserve space for the marker + const marker = `\n... [output truncated: omitted {OMITTED} of ${originalChars} characters] ...\n`; + // We'll compute omitted after sizing head/tail + const markerOverhead = Array.from(marker.replace("{OMITTED}", "0")).length + 6; // approx for digit width + const available = Math.max(100, maxCharacters - markerOverhead); + const head = Math.ceil(available * 0.6); + const tail = Math.floor(available * 0.4); + + const headText = codePoints.slice(0, head).join(""); + const tailText = codePoints.slice(originalChars - tail).join(""); + const omitted = originalChars - head - tail; + const finalMarker = marker.replace("{OMITTED}", String(omitted)); + const resultText = headText + finalMarker + tailText; + const inlineChars = Array.from(resultText).length; + + return { + text: resultText, + truncated: true, + originalChars, + originalLines, + inlineChars, + omittedChars: omitted, + }; +} + +/** + * Wrap a tool result text for MCP inline content. + * + * Applies truncation and returns both the text content and structured metadata. + * When widgets are off, no card payload is attached. + */ +export function wrapInlineOutput( + text: string, + maxCharacters: number, + opts: { widgetsOff?: boolean; isError?: boolean } = {}, +): { + content: Array<{ type: "text"; text: string }>; + structuredContent?: Record; + truncation: TruncationResult; +} { + const truncation = truncateInlineOutput(text, maxCharacters); + const content = [{ type: "text" as const, text: truncation.text }]; + + // structuredContent carries truncation metadata without duplicating full output + const structuredContent: Record = { + truncated: truncation.truncated, + originalChars: truncation.originalChars, + originalLines: truncation.originalLines, + inlineChars: truncation.inlineChars, + omittedChars: truncation.omittedChars, + isError: opts.isError ?? false, + }; + + // No card payload when widgets are off (PR #85 requirement) + return { content, structuredContent, truncation }; +} diff --git a/src/pi-tools.ts b/src/pi-tools.ts index 238b9c54..e404e93d 100644 --- a/src/pi-tools.ts +++ b/src/pi-tools.ts @@ -1,4 +1,4 @@ -import { +import { createBashTool, createEditTool, createFindTool, @@ -16,7 +16,9 @@ import { type WriteToolInput, type AgentToolResult, } from "@earendil-works/pi-coding-agent"; +import { spawn } from "node:child_process"; import { resolveAllowedPath } from "./roots.js"; +import { resolveShellCommand } from "./process-platform.js"; type McpContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; export type ToolResponse = { @@ -118,12 +120,128 @@ export async function listDirectoryTool(input: LsToolInput, context: ToolContext return runTool((params) => tool.execute("list_directory", params), input, context); } +/** + * PR #41: runShellTool with native PowerShell support on Windows. + * + * When DEVSPACE_SHELL=powershell (Windows default), commands are executed + * directly via powershell.exe 鈥?NOT through Git Bash, MSYS, WSL, or bash -c. + * + * When DEVSPACE_SHELL=bash or on non-Windows, falls back to pi-coding-agent's + * createBashTool (which uses Git Bash on Windows). + * + * The tool name remains "bash" 鈥?only the execution backend changes. + */ export async function runShellTool(input: BashToolInput, context: ToolContext): Promise { - const tool = createBashTool(context.cwd); const timeout = input.timeout === undefined ? 30 : Math.min(input.timeout, 300); + const shellMode = process.env.DEVSPACE_SHELL ?? "auto"; + + // Determine if we should use PowerShell + const usePowerShell = process.platform === "win32" && + (shellMode === "powershell" || (shellMode === "auto" && !process.env.GIT_BASH_PATH)); + + if (usePowerShell) { + // PR #41: Execute via native PowerShell 鈥?not through Git Bash + return runPowerShellShell(input.command, context.cwd, timeout); + } + // Default: use pi-coding-agent's bash tool (Git Bash on Windows, bash on Unix) + const tool = createBashTool(context.cwd); return runTool((params) => tool.execute("run_shell", params), { command: input.command, timeout, }, context); } + +/** + * Execute a command via native PowerShell (PR #41). + * + * Uses: powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command + * Does NOT go through Git Bash, MSYS, WSL, or bash -c. + */ +async function runPowerShellShell(command: string, cwd: string, timeoutSeconds: number): Promise { + const shell = resolveShellCommand(command, process.platform, process.env as NodeJS.ProcessEnv); + + return new Promise((resolve) => { + const child = spawn(shell.executable, shell.args, { + cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + let exitCode: number | null = null; + let resolved = false; + + const timer = setTimeout(() => { + timedOut = true; + if (child.pid) { + // PR #41: Use taskkill /F /T on Windows to kill the entire process tree + if (process.platform === "win32") { + try { + const { spawn: spawnKill } = require("node:child_process"); + spawnKill("taskkill", ["/F", "/T", "/PID", String(child.pid)], { + stdio: "ignore", + windowsHide: true, + }); + } catch { + child.kill("SIGKILL"); + } + } else { + child.kill("SIGTERM"); + } + } + }, timeoutSeconds * 1000); + + child.stdout?.on("data", (data: Buffer) => { + stdout += data.toString("utf8"); + }); + + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString("utf8"); + }); + + child.on("error", (err) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + resolve({ + content: [{ type: "text", text: `Shell error: ${err.message}` }], + isError: true, + }); + } + }); + + child.on("close", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + exitCode = code; + + let text = stdout; + if (stderr) { + text += (text ? "\n" : "") + stderr; + } + + if (timedOut) { + text += (text ? "\n\n" : "") + `Command timed out after ${timeoutSeconds} seconds`; + resolve({ + content: [{ type: "text", text }], + isError: true, + }); + } else if (exitCode !== 0 && exitCode !== null) { + text += (text ? "\n\n" : "") + `Command exited with code ${exitCode}`; + resolve({ + content: [{ type: "text", text }], + isError: true, + }); + } else { + resolve({ + content: [{ type: "text", text: text || "(no output)" }], + }); + } + }); + }); +} \ No newline at end of file diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index 39b494d8..a414e12c 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -1,11 +1,24 @@ import assert from "node:assert/strict"; import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; +// PR #41: Windows default is now PowerShell (not cmd.exe) assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { + executable: "powershell.exe", + args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "echo ok"], +}); + +// Explicit cmd mode still works +assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe", DEVSPACE_SHELL: "cmd" }), { executable: "C:\\Windows\\cmd.exe", args: ["/d", "/s", "/c", "echo ok"], }); +// Explicit powershell mode +assert.deepEqual(resolveShellCommand("echo ok", "win32", { DEVSPACE_SHELL: "powershell" }), { + executable: "powershell.exe", + args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "echo ok"], +}); + assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { executable: "/bin/zsh", args: ["-lc", "echo ok"], diff --git a/src/process-platform.ts b/src/process-platform.ts index 905d4d73..3aa9097d 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -1,5 +1,6 @@ import { basename } from "node:path"; import { spawnSync } from "node:child_process"; +import type { ShellMode } from "./config.js"; export interface ShellCommand { executable: string; @@ -32,18 +33,82 @@ const defaultProcessTreeRuntime: ProcessTreeRuntime = { const LOGIN_SHELLS = new Set(["bash", "ksh", "zsh"]); const POSIX_SHELLS = new Set(["ash", "dash", "sh"]); +/** + * PowerShell executable and fixed arguments. + * PR #41: Windows native PowerShell — must NOT route through Git Bash, MSYS, WSL, or bash -c. + * + * Security note (PR #41): raw Windows paths should not be used as the right-hand side + * of -match (they contain backslashes which are regex metacharacters). Use .Contains(), + * -like, or [regex]::Escape() for literal path matching. Genuine regex is still allowed. + */ +const POWERSHELL_EXECUTABLE = "powershell.exe"; +const POWERSHELL_ARGS = [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-Command", +]; + +/** + * Resolve a shell command for the given platform and environment. + * + * PR #41: Supports DEVSPACE_SHELL=auto|bash|powershell|cmd. + * Windows default is "powershell" (not cmd.exe) when DEVSPACE_SHELL is auto or unset. + * + * Modes: + * - powershell: powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command + * - cmd: cmd.exe /d /s /c + * - bash: bash -lc (or bash -c for POSIX shells) + * - auto: platform-dependent default (powershell on Windows, sh on others) + */ export function resolveShellCommand( command: string, platform: NodeJS.Platform = process.platform, environment: NodeJS.ProcessEnv = process.env, + shellMode?: ShellMode, ): ShellCommand { + const mode = shellMode ?? (environment.DEVSPACE_SHELL as ShellMode | undefined) ?? "auto"; + if (platform === "win32") { + // Explicit mode resolution + if (mode === "powershell") { + return { + executable: POWERSHELL_EXECUTABLE, + args: [...POWERSHELL_ARGS, command], + }; + } + if (mode === "cmd") { + return { + executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", + args: ["/d", "/s", "/c", command], + }; + } + if (mode === "bash") { + // Find bash without going through MSYS/Git Bash wrapper + const bashPath = environment.BASH ?? findBashOnWindows(); + if (bashPath) { + return { executable: bashPath, args: ["-lc", command] }; + } + // Fall back to PowerShell if bash not found + return { + executable: POWERSHELL_EXECUTABLE, + args: [...POWERSHELL_ARGS, command], + }; + } + // auto on Windows: default to PowerShell return { - executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", - args: ["/d", "/s", "/c", command], + executable: POWERSHELL_EXECUTABLE, + args: [...POWERSHELL_ARGS, command], }; } + // Non-Windows: auto resolves to user's shell or /bin/sh + if (mode === "powershell") { + // On non-Windows, try pwsh if available + return { executable: "pwsh", args: ["-NoLogo", "-NoProfile", "-Command", command] }; + } + const configuredShell = environment.SHELL; const shellName = configuredShell ? basename(configuredShell) : ""; if (configuredShell && LOGIN_SHELLS.has(shellName)) { @@ -56,6 +121,32 @@ export function resolveShellCommand( return { executable: "/bin/sh", args: ["-c", command] }; } +/** + * Find bash.exe on Windows without using MSYS/Git Bash wrapper. + * Looks in typical Git for Windows install locations. + */ +function findBashOnWindows(): string | null { + const candidates = [ + "C:\\Program Files\\Git\\bin\\bash.exe", + "C:\\Program Files\\Git\\usr\\bin\\bash.exe", + "C:\\Program Files (x86)\\Git\\bin\\bash.exe", + ]; + for (const c of candidates) { + try { + spawnSync("cmd.exe", ["/c", "if", "exist", c, "echo", "found"], { + stdio: "ignore", + windowsHide: true, + timeout: 2000, + }); + // If the file exists, use it directly (not through MSYS wrapper) + return c; + } catch { + continue; + } + } + return null; +} + export function terminateProcessTree( child: KillableProcess, signal: NodeJS.Signals, diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index b050e790..a5b30284 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -28,6 +28,10 @@ assert.equal(unicodeResult.truncated, true); assert.match(unicodeResult.output, /^a🙂/); assert.match(unicodeResult.output, /🙂c$/); +// Use cmd.exe for this test 鈥?commands use cmd-style quoting (quoted paths without & call operator). +// PowerShell execution is tested in process-platform.test.ts and candidate validation. +process.env.DEVSPACE_SHELL = 'cmd'; + const manager = new ProcessSessionManager({ maxBufferCharacters: 1_024, completedSessionTtlMs: 1_000, diff --git a/src/process-sessions.ts b/src/process-sessions.ts index f414df19..64aedf69 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -325,17 +325,31 @@ export class ProcessSessionManager { private startPipe(session: ProcessSession, input: StartCommandInput): void { const shell = resolveShellCommand(input.command); const detached = process.platform !== "win32"; - const child = spawn(input.command, { - cwd: input.cwd, - env: processEnvironment({ - workspaceId: input.workspaceId, - workspaceRoot: input.workspaceRoot, - }), - stdio: "pipe", - windowsHide: true, - detached, - shell: shell.executable, - }); + // PR #41: cmd.exe works with Node.js shell option (uses /d /s /c internally). + // PowerShell needs direct spawn with -Command flag (Node.js shell option hardcodes /d /s /c which PowerShell doesn't understand). + const isCmdShell = shell.executable.toLowerCase().includes("cmd"); + const child = isCmdShell + ? spawn(input.command, { + cwd: input.cwd, + env: processEnvironment({ + workspaceId: input.workspaceId, + workspaceRoot: input.workspaceRoot, + }), + stdio: "pipe", + windowsHide: true, + detached, + shell: shell.executable, + }) + : spawn(shell.executable, shell.args, { + cwd: input.cwd, + env: processEnvironment({ + workspaceId: input.workspaceId, + workspaceRoot: input.workspaceRoot, + }), + stdio: "pipe", + windowsHide: true, + detached, + }); session.process = { write: (data) => child.stdin.write(data), diff --git a/src/realpath-utils.ts b/src/realpath-utils.ts new file mode 100644 index 00000000..3ccd896c --- /dev/null +++ b/src/realpath-utils.ts @@ -0,0 +1,113 @@ +/** + * Realpath utilities — PR #65/#66. + * + * Provides safe realpath resolution for workspace roots, agent directories, + * and AGENTS.md / CLAUDE.md files. + * + * Key behaviors: + * - Resolves symlinks (including Windows junctions and directory aliases) + * to their final target. + * - Deduplicates loaded and discovered files by realpath. + * - Rejects symlinks that point outside allowed roots. + * - Falls back safely when realpath fails (e.g., broken symlink). + * - Does NOT expand allowedRoots permissions. + */ + +import { realpath } from "node:fs/promises"; +import { resolve, relative, isAbsolute, sep } from "node:path"; + +/** + * Safely resolve the real path of a file or directory. + * Returns the original path on failure (e.g., broken symlink, permission error). + */ +export async function safeRealpath(p: string): Promise { + try { + return await realpath(p); + } catch { + // Fallback: resolve without following symlinks + return resolve(p); + } +} + +/** + * Synchronous version using realpathSync. + */ +import { realpathSync } from "node:fs"; + +export function safeRealpathSync(p: string): string { + try { + return realpathSync(p); + } catch { + return resolve(p); + } +} + +/** + * Check if a resolved path is inside any of the allowed roots. + * Uses realpath on both the path and the roots for accurate comparison. + */ +export function isRealpathInsideRoots( + resolvedPath: string, + allowedRoots: string[], +): boolean { + const normalizedPath = resolvedPath.toLowerCase().replace(/\\/g, "/"); + for (const root of allowedRoots) { + const normalizedRoot = root.toLowerCase().replace(/\\/g, "/"); + if (normalizedPath === normalizedRoot) return true; + if (normalizedPath.startsWith(normalizedRoot + "/")) return true; + // Handle case where root is a drive root like "C:" vs "C:\" + if (normalizedRoot.endsWith(":") && normalizedPath.startsWith(normalizedRoot + "/")) { + return true; + } + } + return false; +} + +/** + * Resolve and validate a workspace root using realpath. + * Returns the realpath and whether it's inside allowed roots. + */ +export async function resolveAndValidateRoot( + inputPath: string, + allowedRoots: string[], +): Promise<{ realpath: string; allowed: boolean }> { + const resolved = resolve(inputPath); + const real = await safeRealpath(resolved); + const allowed = isRealpathInsideRoots(real, allowedRoots); + return { realpath: real, allowed }; +} + +/** + * Deduplicate a list of file paths by their realpath. + * Preserves the order of first occurrence. + */ +export async function deduplicateByRealpath(paths: string[]): Promise { + const seen = new Set(); + const result: string[] = []; + for (const p of paths) { + const real = await safeRealpath(p); + const key = real.toLowerCase().replace(/\\/g, "/"); + if (!seen.has(key)) { + seen.add(key); + result.push(p); + } + } + return result; +} + +/** + * Deduplicate synchronously (for use in non-async contexts). + */ +export function deduplicateByRealpathSync(paths: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const p of paths) { + const real = safeRealpathSync(p); + const key = real.toLowerCase().replace(/\\/g, "/"); + if (!seen.has(key)) { + seen.add(key); + result.push(p); + } + } + return result; +} diff --git a/src/roots.ts b/src/roots.ts index 214ffb2b..7d24211d 100644 --- a/src/roots.ts +++ b/src/roots.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os"; import { isAbsolute, relative, resolve, sep } from "node:path"; +import { safeRealpathSync, isRealpathInsideRoots } from "./realpath-utils.js"; export class AccessDeniedError extends Error { constructor(message: string) { @@ -44,3 +45,24 @@ export function resolveAllowedPath(inputPath: string, cwd: string, allowedRoots: const absolutePath = resolve(cwd, inputPath); return assertAllowedPath(absolutePath, allowedRoots); } + +/** + * Realpath-aware path validation 鈥?PR #65/#66. + * Resolves symlinks (including Windows junctions) before checking allowed roots. + * Does NOT expand allowedRoots 鈥?if a symlink points outside, it is rejected. + */ +export function assertAllowedPathRealpath(path: string, allowedRoots: string[]): string { + const resolvedPath = resolve(expandHomePath(path)); + const realPath = safeRealpathSync(resolvedPath); + if (allowedRoots.some((root) => { + const realRoot = safeRealpathSync(resolve(expandHomePath(root))); + return isRealpathInsideRoots(realPath, [realRoot]); + })) { + return realPath; + } + // Fallback to non-realpath check (in case realpath fails but path is valid) + if (allowedRoots.some((root) => isPathInsideRoot(resolvedPath, root))) { + return resolvedPath; + } + throw new AccessDeniedError(`Path is outside allowed roots (realpath check): ${path}`); +} \ No newline at end of file diff --git a/src/runtime-diagnostics.ts b/src/runtime-diagnostics.ts new file mode 100644 index 00000000..af16162a --- /dev/null +++ b/src/runtime-diagnostics.ts @@ -0,0 +1,156 @@ +/** + * Runtime diagnostics — PR #69 (selective: D. 运行诊断). + * + * Provides a local diagnostic entry point that reports runtime health + * without exposing sensitive data (no tokens, cookies, keys, env vars, + * or private file contents). + * + * Commands: + * - devspace-runtime diagnose: Node, npm, Git, shell, PowerShell path, + * DevSpace version, health, public metadata, session count, cleanup stats, + * recent errors, memory, output limit, contextIgnorePaths, PATH check. + * - devspace-runtime smoke: minimal end-to-end request test. + * - devspace-runtime costs: tool execution cost summary. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +export interface DiagnoseInput { + registrySnapshot?: Record; + costSnapshot?: Record; + configInfo?: { + port?: number; + inlineOutputCharacters?: number; + contextIgnorePaths?: string[]; + shell?: string; + widgets?: string; + publicBaseUrl?: string; + }; + recentErrors?: string[]; +} + +export interface DiagnoseOutput { + node: string; + npm: string; + git: string; + platform: string; + arch: string; + shell: string; + powershellPath: string | null; + devspaceVersion: string; + processUptimeSec: number; + memoryUsageMB: { + rss: number; + heapUsed: number; + heapTotal: number; + external: number; + }; + sessions: Record | null; + costs: Record | null; + config: Record | null; + recentErrors: string[]; + pathCheck: Array<{ command: string; available: boolean }>; +} + +function safeExec(file: string, args: string[]): string { + try { + return execFileSync(file, args, { + encoding: "utf8", + timeout: 5000, + windowsHide: true, + }).trim(); + } catch { + return "unavailable"; + } +} + +function checkPathCommand(cmd: string): boolean { + try { + execFileSync( + process.platform === "win32" ? "where" : "which", + [cmd], + { encoding: "utf8", timeout: 5000, windowsHide: true }, + ); + return true; + } catch { + return false; + } +} + +export function runDiagnose(input: DiagnoseInput = {}): DiagnoseOutput { + const mem = process.memoryUsage(); + + const pathCommands = ["node", "npm", "git", "rg"]; + if (process.platform === "win32") { + pathCommands.push("powershell", "taskkill"); + } + + // Find PowerShell path + let powershellPath: string | null = null; + if (process.platform === "win32") { + try { + powershellPath = safeExec("where", ["powershell"]).split(/\r?\n/)[0] || null; + } catch { + powershellPath = null; + } + } + + return { + node: process.version, + npm: safeExec("npm", ["--version"]), + git: safeExec("git", ["--version"]), + platform: process.platform, + arch: process.arch, + shell: input.configInfo?.shell ?? "auto", + powershellPath, + devspaceVersion: process.env.npm_package_version ?? "unknown", + processUptimeSec: Math.round(process.uptime()), + memoryUsageMB: { + rss: Math.round(mem.rss / 1024 / 1024 * 10) / 10, + heapUsed: Math.round(mem.heapUsed / 1024 / 1024 * 10) / 10, + heapTotal: Math.round(mem.heapTotal / 1024 / 1024 * 10) / 10, + external: Math.round(mem.external / 1024 / 1024 * 10) / 10, + }, + sessions: input.registrySnapshot ?? null, + costs: input.costSnapshot ?? null, + config: input.configInfo ?? null, + recentErrors: (input.recentErrors ?? []).slice(-10), + pathCheck: pathCommands.map((cmd) => ({ + command: cmd, + available: checkPathCommand(cmd), + })), + }; +} + +export interface SmokeResult { + ok: boolean; + steps: Array<{ name: string; ok: boolean; detail?: string }>; +} + +/** + * Run a minimal smoke test. The caller provides async check functions. + */ +export async function runSmoke( + checks: Array<{ name: string; fn: () => Promise }>, +): Promise { + const steps: SmokeResult["steps"] = []; + let allOk = true; + for (const check of checks) { + try { + const result = await check.fn(); + const ok = typeof result === "boolean" ? result : result.ok; + const detail = typeof result === "boolean" ? undefined : result.detail; + steps.push({ name: check.name, ok, detail }); + if (!ok) allOk = false; + } catch (err) { + steps.push({ + name: check.name, + ok: false, + detail: err instanceof Error ? err.message : String(err), + }); + allOk = false; + } + } + return { ok: allOk, steps }; +} diff --git a/src/server.ts b/src/server.ts index c72e1434..a41d8b8a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { access, realpath } from "node:fs/promises"; import { fileURLToPath } from "node:url"; @@ -47,6 +47,11 @@ import { getLocalAgentProviderAvailabilitySnapshot, type LocalAgentProviderAvailability, } from "./local-agent-availability.js"; +import { createAdvancedGuardStore, registerAdvancedTools, type AdvancedGuardStore } from "./advanced-tools.js"; +import { McpSessionRegistry, type SessionReservation } from "./mcp-session-registry.js"; +import { truncateInlineOutput, wrapInlineOutput, type TruncationResult } from "./output-truncation.js"; +import { runDiagnose, runSmoke } from "./runtime-diagnostics.js"; +import { CostTracker, supplementSafePath } from "./cost-stats.js"; type Transport = StreamableHTTPServerTransport; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; @@ -679,6 +684,8 @@ function createMcpServer( reviewCheckpoints: ReturnType, processSessions: ProcessSessionManager, localAgentProviders: LocalAgentProviderAvailability[], + advancedGuards: AdvancedGuardStore, + costTracker: CostTracker, ): McpServer { const server = new McpServer( { @@ -1560,19 +1567,35 @@ function createMcpServer( durationMs: Math.round(performance.now() - startedAt), }); + // PR #85: Apply inline output truncation to bash tool results + const inlineLimit = config.inlineOutputCharacters; + const originalText = contentText(response.content); + const truncation = truncateInlineOutput(originalText, inlineLimit); + const truncatedContent = truncation.truncated + ? [{ type: "text" as const, text: truncation.text }] + : response.content; + return { ...response, + content: truncatedContent, _meta: { tool: toolNames.shell, - card: { - workspaceId, - path: workingDirectory, - summary, - payload: { content: response.content }, - }, + ...(config.widgets === "changes" ? { + card: { + workspaceId, + path: workingDirectory, + summary, + payload: { content: response.content }, + }, + } : {}), }, structuredContent: { - result: contentText(response.content), + result: truncation.text, + truncated: truncation.truncated, + originalChars: truncation.originalChars, + originalLines: truncation.originalLines, + inlineChars: truncation.inlineChars, + omittedChars: truncation.omittedChars, }, }; }, @@ -1583,26 +1606,73 @@ function createMcpServer( registerCodexProcessTools(server, config, workspaces, processSessions); } + // Register advanced tools (read_many, search_text, etc.) from local advanced-tools module + // NOTE: registerAdvancedTools expects (server, dependencies) 鈥?not positional args. + // Dependencies: { z, registerAppTool, workspaces, processSessions, guards } + registerAdvancedTools(server, { + z, + registerAppTool, + workspaces, + processSessions, + guards: advancedGuards, + }); + return server; } +/** + * New createServer function for server.ts — incorporates all local customizations + PRs. + * This replaces the original createServer function (lines 1589-1774). + */ export function createServer(config = loadConfig()): RunningServer { - const allowedHosts = config.allowedHosts.includes("*") - ? undefined - : Array.from(new Set([config.host, ...config.allowedHosts])); - const app = createMcpExpressApp({ - host: config.host, - ...(allowedHosts ? { allowedHosts } : {}), + // PR #69 F: Safe PATH supplement (Windows only, no user startup scripts) + if (process.platform === "win32") { + process.env.PATH = supplementSafePath(process.env.PATH ?? ""); + } + + // PR #69 E: Cost tracker + const costTracker = new CostTracker(); + + // Advanced guards from local advanced-tools module + const advancedGuards = createAdvancedGuardStore(); + + // Custom host validation: when "*" is in allowedHosts, bypass host validation entirely. + // This uses plain express + json instead of createMcpExpressApp with host validation. + const bypassHostValidation = config.allowedHosts.includes("*"); + const app = bypassHostValidation + ? express() + : createMcpExpressApp({ + host: config.host, + allowedHosts: Array.from(new Set([config.host, ...config.allowedHosts])), + }); + + if (bypassHostValidation) { + app.use(express.json()); + } + + // PR #71: Session registry with configurable idle/sweep/max parameters + const sessionRegistry = new McpSessionRegistry({ + idleMs: parseInt(process.env.DEVSPACE_SESSION_IDLE_MS ?? "86400000", 10), + sweepMs: parseInt(process.env.DEVSPACE_SESSION_SWEEP_MS ?? "300000", 10), + maxSessions: parseInt(process.env.DEVSPACE_MAX_SESSIONS ?? "64", 10), + onSessionClose: (id, error) => { + logEvent(config.logging, error ? "warn" : "info", "mcp_session_closed", { + sessionIdPrefix: sessionIdPrefix(id), + ...(error ? { error: error.message } : {}), + }); + }, + onSweep: (closed, evicted) => { + if (closed > 0 || evicted > 0) { + logEvent(config.logging, "info", "mcp_session_sweep", { closed, evicted }); + } + }, + handshakeTimeoutMs: Number.parseInt(process.env.DEVSPACE_SESSION_HANDSHAKE_TIMEOUT_MS ?? "30000", 10), }); - const transports = new Map(); + sessionRegistry.startSweep(); + const mcpUrl = new URL("/mcp", config.publicBaseUrl); const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl); const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir); - const bearerAuth = requireBearerAuth({ - verifier: oauthProvider, - requiredScopes: [config.oauth.scopes[0] ?? "devspace"], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl), - }); const workspaceStore = createWorkspaceStore(config.stateDir); const workspaces = new WorkspaceRegistry(config, workspaceStore); const reviewCheckpoints = createReviewCheckpointManager(); @@ -1615,6 +1685,27 @@ export function createServer(config = loadConfig()): RunningServer { app.set("trust proxy", true); } + // CORS + dynamic public URL resolution middleware (local customization). + // Resolves the public URL from x-forwarded-proto/host headers for dynamic tunnels. + app.use((req, res, next) => { + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, mcp-session-id, mcp-protocol-version"); + res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + if (req.method === "OPTIONS") { + res.sendStatus(204); + return; + } + + // Resolve dynamic public URL from request headers + const proto = (req.header("x-forwarded-proto") ?? req.protocol ?? "https").split(",")[0].trim(); + const host = req.header("x-forwarded-host") ?? req.header("host") ?? ""; + if (host) { + (req as any).dynamicPublicUrl = `${proto}://${host}`; + } + next(); + }); + + // Request logging middleware app.use((req, res, next) => { const requestId = randomUUID(); const startedAt = performance.now(); @@ -1638,6 +1729,36 @@ export function createServer(config = loadConfig()): RunningServer { next(); }); + // Dynamic OAuth metadata endpoints (local customization). + // Returns the resource/authorization server URL based on the request's origin, + // enabling dynamic tunnels (Cloudflare, Tailscale) without reconfiguration. + app.get("/.well-known/oauth-protected-resource/mcp", (req, res) => { + const origin = (req as any).dynamicPublicUrl ?? config.publicBaseUrl; + const resourceUrl = new URL("/mcp", origin).href; + res.json({ + resource: resourceUrl, + authorization_servers: [origin], + bearer_methods: ["header"], + resource_documentation: `${origin}/.well-known/oauth-protected-resource/mcp`, + }); + }); + + app.get("/.well-known/oauth-authorization-server", (req, res) => { + const origin = (req as any).dynamicPublicUrl ?? config.publicBaseUrl; + res.json({ + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + registration_endpoint: `${origin}/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: ["none"], + code_challenge_methods_supported: ["S256"], + scopes_supported: config.oauth.scopes, + }); + }); + + // mcpAuthRouter for standard OAuth routes (/authorize, /token, /register) app.use( mcpAuthRouter({ provider: oauthProvider, @@ -1664,35 +1785,201 @@ export function createServer(config = loadConfig()): RunningServer { }), ); + // Healthz endpoint with session info app.get("/healthz", (_req, res) => { - res.json({ ok: true, name: "devspace" }); + res.json({ + ok: true, + name: "devspace", + sessions: sessionRegistry.size, + pendingReservations: sessionRegistry.pendingReservations, + occupiedCapacity: sessionRegistry.occupiedCapacity, + maxSessions: sessionRegistry.snapshot().maxSessions, + }); + }); + + // PR #69 D: Runtime diagnostics endpoint + app.get("/devspace-runtime/diagnose", (_req, res) => { + const diag = runDiagnose({ + registrySnapshot: sessionRegistry.snapshot(), + costSnapshot: costTracker.getSummary() as unknown as Record, + configInfo: { + port: config.port, + inlineOutputCharacters: config.inlineOutputCharacters, + contextIgnorePaths: config.contextIgnorePaths, + shell: config.shell, + widgets: config.widgets, + publicBaseUrl: config.publicBaseUrl, + }, + recentErrors: [], + }); + res.json(diag); + }); + + app.get("/devspace-runtime/costs", (_req, res) => { + res.json(costTracker.getSummary()); + }); + + app.get("/devspace-runtime/smoke", async (_req, res) => { + const result = await runSmoke([ + { + name: "healthz", + fn: async () => { + try { + const r = await fetch(`http://127.0.0.1:${config.port}/healthz`); + return { ok: r.ok, detail: `status ${r.status}` }; + } catch (e) { + return { ok: false, detail: String(e) }; + } + }, + }, + ]); + res.json(result); }); + // Test-only reservation barrier: when enabled, initialize requests pause + // after tryReserveSlot() succeeds, before transport creation. + // Released via POST /test/release-barrier or GET /test/release-barrier. + // Requires NODE_ENV=test AND DEVSPACE_TEST_BYPASS_AUTH=1. + let testBarrierPromise: Promise | undefined; + let testBarrierResolve: (() => void) | undefined; + const isTestEnv = process.env.NODE_ENV === "test" && process.env.DEVSPACE_TEST_BYPASS_AUTH === "1"; + if (isTestEnv && process.env.DEVSPACE_TEST_RESERVATION_BARRIER === "1") { + testBarrierPromise = new Promise((resolve) => { + testBarrierResolve = resolve; + }); + // The barrier starts blocked; release endpoint unblocks it. + // Reset barrier after release so subsequent requests don't block. + app.all("/test/release-barrier", (_req, res) => { + if (testBarrierResolve) { + testBarrierResolve(); + testBarrierResolve = undefined; + testBarrierPromise = undefined; + } + res.json({ ok: true, barrier: "released" }); + }); + app.get("/test/barrier-status", (_req, res) => { + res.json({ ok: true, barrierActive: testBarrierPromise !== undefined }); + }); + } + + // Test-only endpoints: require NODE_ENV=test AND DEVSPACE_TEST_BYPASS_AUTH=1. + // Production environments never expose /test/* endpoints. + if (isTestEnv) { + // Close failure injection: marks a session to fail on next transport.close(). + app.post("/test/inject-close-failure", (req, res) => { + const sid = req.header("mcp-session-id") || (req.body as { sessionId?: string })?.sessionId; + if (sid) { + sessionRegistry.injectCloseFailure(sid); + res.json({ ok: true, injected: sid }); + } else { + res.status(400).json({ ok: false, error: "missing session ID" }); + } + }); + + // Close a session directly through the registry (bypasses MCP DELETE). + // Used to test transport.close() failure resilience. + app.post("/test/close-registry-session", async (req, res) => { + const sid = req.header("mcp-session-id") || (req.body as { sessionId?: string })?.sessionId; + if (!sid) { + res.status(400).json({ ok: false, error: "missing session ID" }); + return; + } + const existed = await sessionRegistry.closeSession(sid); + const snap = sessionRegistry.snapshot(); + res.json({ + ok: true, + closed: existed, + lastError: snap.lastError, + remainingSessions: snap.activeSessions, + }); + }); + } + + // Test-only request barrier: when enabled, existing-session requests pause + // after markActive, before handleRequest. This lets tests verify that all + // sessions are genuinely in-flight before sending a new initialize. + let requestBarrierPromise: Promise | undefined; + let requestBarrierResolve: (() => void) | undefined; + let requestBarrierBlockedCount = 0; + if (isTestEnv && process.env.DEVSPACE_TEST_REQUEST_BARRIER === "1") { + requestBarrierPromise = new Promise((resolve) => { + requestBarrierResolve = resolve; + }); + app.all("/test/release-request-barrier", (_req, res) => { + if (requestBarrierResolve) { + requestBarrierResolve(); + requestBarrierResolve = undefined; + requestBarrierPromise = undefined; + } + res.json({ ok: true, barrier: "released", wasBlocked: requestBarrierBlockedCount }); + }); + app.get("/test/request-barrier-status", (_req, res) => { + res.json({ ok: true, blockedRequests: requestBarrierBlockedCount, barrierActive: requestBarrierPromise !== undefined }); + }); + } + + // MCP endpoint with session management and output truncation app.all("/mcp", async (req, res) => { const requestId = res.locals.requestId as string | undefined; const sessionId = req.header("mcp-session-id"); const initializeRequest = req.method === "POST" && isInitializeRequest(req.body); - await new Promise((resolve, reject) => { - bearerAuth(req, res, (error?: unknown) => { - if (error) reject(error); - else resolve(); - }); - }); - if (res.headersSent) return; + // Test-only auth bypass: when DEVSPACE_TEST_BYPASS_AUTH=1, skip OAuth entirely. + // This is ONLY for integration tests — never set in production. + if (process.env.DEVSPACE_TEST_BYPASS_AUTH !== "1") { + // Custom bearer auth with dynamic WWW-Authenticate (local customization) + const authHeader = req.header("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + const origin = (req as any).dynamicPublicUrl ?? config.publicBaseUrl; + res.header("WWW-Authenticate", `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`); + sendJsonRpcError(res, 401, -32001, "Unauthorized"); + return; + } - if (!req.auth?.resource || !checkResourceAllowed({ requestedResource: req.auth.resource, configuredResource: resourceServerUrl })) { - logEvent(config.logging, "warn", "auth_denied", { - requestId, - method: req.method, - path: requestPath(req), - reason: "invalid_oauth_resource", + const token = authHeader.slice(7); + let authResult; + try { + authResult = await oauthProvider.verifyAccessToken(token); + } catch { + const origin = (req as any).dynamicPublicUrl ?? config.publicBaseUrl; + res.header("WWW-Authenticate", `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`); + sendJsonRpcError(res, 401, -32001, "Unauthorized"); + return; + } + + if (!authResult || !authResult.resource) { + const origin = (req as any).dynamicPublicUrl ?? config.publicBaseUrl; + res.header("WWW-Authenticate", `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`); + sendJsonRpcError(res, 401, -32001, "Unauthorized"); + return; + } + + // Flexible resource check (local customization): accept any /mcp URL path + const requestedResource = authResult.resource; + const resourceAllowed = + requestedResource.origin === resourceServerUrl.origin || + requestedResource.pathname.endsWith("/mcp") || + requestedResource.href === resourceServerUrl.href; + if (!resourceAllowed) { + logEvent(config.logging, "warn", "auth_denied", { + requestId, + method: req.method, + path: requestPath(req), + reason: "invalid_oauth_resource", ...requestLogFields(req, config), }); sendJsonRpcError(res, 401, -32001, "Unauthorized"); return; } + // Scope check + const requiredScope = config.oauth.scopes[0] ?? "devspace"; + if (!authResult.scopes?.includes(requiredScope)) { + sendJsonRpcError(res, 403, -32001, "Insufficient scope"); + return; + } + } // end if (DEVSPACE_TEST_BYPASS_AUTH !== "1") + logEvent(config.logging, "debug", "mcp_request", { requestId, method: req.method, @@ -1701,20 +1988,83 @@ export function createServer(config = loadConfig()): RunningServer { isInitialize: initializeRequest, }); + // activeSessionId is set when markActive is called (existing sessions) OR + // when commitReservation succeeds (initialize requests, pairing with inFlight=1). + // In both cases, finally calls markIdle to decrement inFlight. + // initReservation is set when a capacity slot is reserved for an initialize request. + // It is released in finally if the session was never committed (e.g., exception before commit). + let activeSessionId: string | undefined; + let initReservation: SessionReservation | undefined; + let reservationCommitted = false; + try { let transport: Transport | undefined; if (sessionId) { - transport = transports.get(sessionId); + transport = sessionRegistry.get(sessionId)?.transport; if (!transport) { sendJsonRpcError(res, 404, -32000, "Unknown MCP session"); return; } + // Track session activity — single markActive for existing sessions. + sessionRegistry.markActive(sessionId); + activeSessionId = sessionId; + + // Test-only request barrier: pause after markActive, before handleRequest. + // Only blocks requests (with id), not notifications (without id). + // This lets tests verify all sessions are genuinely in-flight + // while still allowing notifications/initialized to complete. + if (requestBarrierPromise && req.body?.id !== undefined) { + requestBarrierBlockedCount++; + await requestBarrierPromise; + requestBarrierBlockedCount--; + } } else if (initializeRequest) { + // Atomically reserve a capacity slot BEFORE creating the transport. + // This prevents concurrent initialize requests from exceeding maxSessions. + initReservation = sessionRegistry.tryReserveSlot(); + if (!initReservation) { + logEvent(config.logging, "warn", "mcp_capacity_rejected", { + requestId, + activeSessions: sessionRegistry.size, + pendingReservations: sessionRegistry.pendingReservations, + occupiedCapacity: sessionRegistry.occupiedCapacity, + maxSessions: sessionRegistry.snapshot().maxSessions, + ...requestLogFields(req, config), + }); + sendJsonRpcError(res, 503, -32000, "MCP server at capacity \u2014 all sessions are busy"); + return; + } + + // Test-only barrier: pause here so tests can verify capacity state. + if (testBarrierPromise) { + await testBarrierPromise; + } + transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (newSessionId) => { - if (transport) transports.set(newSessionId, transport); + if (transport && initReservation) { + // Commit the reservation into a real session. + // No capacity re-check here — the reservation already holds the slot. + // commitReservation sets inFlight=1 and initializing=true to protect + // the session from eviction during the initialize handshake. + const committed = sessionRegistry.commitReservation(initReservation, newSessionId, transport); + if (!committed) { + // Double-commit should never happen — log as internal error. + logEvent(config.logging, "error", "mcp_session_double_commit", { + requestId, + sessionIdPrefix: sessionIdPrefix(newSessionId), + reservationToken: initReservation.token, + ...requestLogFields(req, config), + }); + return; + } + reservationCommitted = true; + // Set activeSessionId so finally will call markIdle for the initialize request. + // This pairs with the inFlight=1 set by commitReservation. + activeSessionId = newSessionId; + } logEvent(config.logging, "info", "mcp_session_created", { requestId, sessionIdPrefix: sessionIdPrefix(newSessionId), @@ -1726,10 +2076,7 @@ export function createServer(config = loadConfig()): RunningServer { transport.onclose = () => { const closedSessionId = transport?.sessionId; if (closedSessionId) { - transports.delete(closedSessionId); - logEvent(config.logging, "info", "mcp_session_closed", { - sessionIdPrefix: sessionIdPrefix(closedSessionId), - }); + sessionRegistry.forget(closedSessionId); } }; @@ -1739,6 +2086,8 @@ export function createServer(config = loadConfig()): RunningServer { reviewCheckpoints, processSessions, localAgentProviders, + advancedGuards, + costTracker, ); await server.connect(transport); } else { @@ -1747,6 +2096,12 @@ export function createServer(config = loadConfig()): RunningServer { } await transport.handleRequest(req, res, req.body); + + // After handleRequest, check if this was a notifications/initialized request. + // If so, complete the handshake to unprotect the session from eviction. + if (sessionId && req.body?.method === "notifications/initialized") { + sessionRegistry.completeHandshake(sessionId); + } } catch (error) { logEvent(config.logging, "error", "mcp_request_error", { requestId, @@ -1755,24 +2110,43 @@ export function createServer(config = loadConfig()): RunningServer { if (!res.headersSent) { sendJsonRpcError(res, 500, -32603, "Internal server error"); } + } finally { + // markIdle must be in finally to ensure inFlight is decremented even on exceptions. + // activeSessionId is set for both existing sessions (markActive) and initialize + // requests (commitReservation sets inFlight=1, activeSessionId pairs with markIdle). + if (activeSessionId) { + sessionRegistry.markIdle(activeSessionId); + } + // Release the reservation if it was never committed (initialize failed before onsessioninitialized). + if (initReservation && !reservationCommitted) { + sessionRegistry.releaseReservation(initReservation); + } } }); let closed = false; + const close = (): Promise => { + if (closed) return Promise.resolve(); + closed = true; + return sessionRegistry.shutdown({ + httpServer: null, + appCleanup: async () => { + processSessions.shutdown(); + oauthProvider.close(); + workspaceStore.close?.(); + }, + }); + }; + return { app, config, localAgentProviders, - close: () => { - if (closed) return; - closed = true; - processSessions.shutdown(); - oauthProvider.close(); - workspaceStore.close?.(); - }, + close, }; } + async function isMainModule(): Promise { if (!process.argv[1]) return false; diff --git a/src/user-config.ts b/src/user-config.ts index c8da90b5..c63e8ad2 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -19,6 +19,8 @@ export interface DevspaceUserConfig { worktreeRoot?: string; agentDir?: string; subagents?: boolean; + inlineOutputCharacters?: number; + contextIgnorePaths?: string[]; } export interface DevspaceAuthConfig { diff --git a/src/workspaces.ts b/src/workspaces.ts index 673d0823..dbab932b 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -6,6 +6,9 @@ import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; import { createManagedWorktree } from "./git-worktrees.js"; import { assertAllowedPath, isPathInsideRoot, resolveAllowedPath } from "./roots.js"; +import { assertAllowedPathRealpath } from "./roots.js"; +import { shouldSkipForContext } from "./context-ignore.js"; +import { deduplicateByRealpathSync, safeRealpathSync } from "./realpath-utils.js"; import { loadWorkspaceSkills, markSkillActivated,