diff --git a/.github/workflows/real-browser.yml b/.github/workflows/real-browser.yml new file mode 100644 index 0000000000..f57b1a4016 --- /dev/null +++ b/.github/workflows/real-browser.yml @@ -0,0 +1,33 @@ +name: Real browser verification + +on: + pull_request: + paths: + - ".github/workflows/real-browser.yml" + - "scripts/verify-real-browser.mjs" + - "scripts/fixtures/browser-verifier-site.mjs" + - "scripts/fixtures/page-cdp-proxy.mjs" + - "src/browser/**" + - "src/domain/browser*.ts" + - "package.json" + - "package-lock.json" + workflow_dispatch: + +permissions: + contents: read + +jobs: + real-browser: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run verify:browser + env: + REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome + REA_BROWSER_NO_SANDBOX: "true" diff --git a/docs/investigation-workspaces.md b/docs/investigation-workspaces.md index c698337a94..f4d8b79c78 100644 --- a/docs/investigation-workspaces.md +++ b/docs/investigation-workspaces.md @@ -31,6 +31,11 @@ Use `--expected-revision N` when an external coordinator needs an explicit compare-and-swap guard. Traversal, byte, page, and comparison limits are also available as CLI options. +After a completed run returns its stable run ID, `--replay-run-id RUN_ID` +selects that exact result without reading or rescanning either version input. +The workspace still requires read authority. REA rejects a selector that is +missing, incomplete, or inconsistent with the requested paths and controls. + ## MCP `find_changed_behavior` retains its existing aggregation mode. Its additive @@ -75,6 +80,12 @@ report checkpoints. A process interrupted after a checkpoint can resume from the last complete stage. A completed run is reused when both graph commitments and every bounded option are identical. +By default, REA rescans the version inputs so changed content creates a new +run. For an explicit cache-only replay, add the returned `run_id` as +`investigation_run.replay_run_id`. REA validates the workspace revision and +name, the complete run, its exact paths and controls, and every linked Evidence +record before it skips input-read authorization. + The run ID is a SHA-256 commitment to both root and graph digests plus the normalized options. Local paths and timestamps do not affect that identity. Changing either artifact or a budget creates a new run in the same workspace; diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 0199f18348..6f3e6d7688 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -91,6 +91,8 @@ never enabled by default. Send `SIGHUP` to the REA MCP process after a trusted local policy change; ceilings, administrator grants, and project grants reload without restarting. Revocation affects future operations; an already-running operation retains the decision made at its preflight boundary. +`rea policy revoke ` displays the exact grant and requires interactive +confirmation; automation must pass `--yes` (or `-y`) explicitly. Denials use the shared `permission_required` schema with requested scope, missing scope, administrator ceiling, elicitation support, and exact restart status. diff --git a/package-lock.json b/package-lock.json index 049e1ff806..626cb053a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "node-pty": "1.1.0", "pino": "9.14.0", "plist": "5.0.0", + "semver": "7.8.5", "smol-toml": "^1.7.0", "write-file-atomic": "7.0.0", "ws": "8.21.0", @@ -40,6 +41,7 @@ "@fast-check/vitest": "0.4.1", "@modelcontextprotocol/client": "2.0.0-beta.4", "@types/node": "24.10.1", + "@types/semver": "7.7.1", "@types/write-file-atomic": "4.0.3", "@types/ws": "8.18.1", "@vitest/coverage-v8": "4.1.10", @@ -1718,6 +1720,13 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4347,7 +4356,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/package.json b/package.json index 4e2f33542c..c5ed0a7196 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "node-pty": "1.1.0", "pino": "9.14.0", "plist": "5.0.0", + "semver": "7.8.5", "smol-toml": "^1.7.0", "write-file-atomic": "7.0.0", "ws": "8.21.0", @@ -117,6 +118,7 @@ "@fast-check/vitest": "0.4.1", "@modelcontextprotocol/client": "2.0.0-beta.4", "@types/node": "24.10.1", + "@types/semver": "7.7.1", "@types/write-file-atomic": "4.0.3", "@types/ws": "8.18.1", "@vitest/coverage-v8": "4.1.10", diff --git a/scripts/fixtures/page-cdp-proxy.mjs b/scripts/fixtures/page-cdp-proxy.mjs new file mode 100644 index 0000000000..6a002e9d8b --- /dev/null +++ b/scripts/fixtures/page-cdp-proxy.mjs @@ -0,0 +1,126 @@ +import { createServer } from "node:http"; + +import WebSocket, { WebSocketServer } from "ws"; + +/** + * Expose Chrome discovery through a page-scoped-only CDP endpoint. + * + * The proxy deliberately reports a `/devtools/page/` socket from + * `/json/version`, so the verifier cannot silently fall back to the browser + * socket. WebSocket bytes are forwarded without interpreting CDP messages. + */ +export async function startPageCdpProxy(upstreamEndpoint) { + const clients = new Set(); + const server = createServer(async (request, response) => { + try { + if (request.url !== "/json/version" && request.url !== "/json/list") { + response.writeHead(404).end(); + return; + } + const value = await upstreamJson(upstreamEndpoint, request.url); + const pageScoped = + request.url === "/json/version" + ? await pageScopedVersion(upstreamEndpoint, value) + : value; + const rewritten = rewriteWebSocketEndpoints(pageScoped, server); + const body = JSON.stringify(rewritten); + response.writeHead(200, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }); + response.end(body); + } catch (error) { + response.writeHead(502).end(String(error)); + } + }); + const webSockets = new WebSocketServer({ noServer: true }); + server.on("upgrade", (request, socket, head) => { + if (!request.url?.startsWith("/devtools/page/")) { + socket.destroy(); + return; + } + webSockets.handleUpgrade(request, socket, head, (client) => { + const upstream = new WebSocket( + new URL(request.url, upstreamEndpoint).href.replace(/^http/, "ws"), + ); + const pending = []; + clients.add(client); + const close = () => { + clients.delete(client); + if (client.readyState < WebSocket.CLOSING) client.close(); + if (upstream.readyState < WebSocket.CLOSING) upstream.close(); + }; + client.on("message", (data, binary) => { + if (upstream.readyState === WebSocket.OPEN) + upstream.send(data, { binary }); + else pending.push({ data, binary }); + }); + upstream.on("message", (data, binary) => { + if (client.readyState === WebSocket.OPEN) client.send(data, { binary }); + }); + upstream.on("open", () => { + for (const { data, binary } of pending) upstream.send(data, { binary }); + pending.length = 0; + webSockets.emit("connection", client, request); + }); + client.on("close", close); + client.on("error", close); + upstream.on("close", close); + upstream.on("error", close); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Page CDP proxy did not bind a TCP port"); + const endpoint = `http://127.0.0.1:${String(address.port)}`; + return { + endpoint, + disconnectClients() { + for (const client of clients) client.terminate(); + }, + async close() { + for (const client of clients) client.terminate(); + webSockets.close(); + await new Promise((resolve, reject) => + server.close((error) => + error === undefined ? resolve() : reject(error), + ), + ); + }, + }; +} + +async function pageScopedVersion(upstreamEndpoint, version) { + const targets = await upstreamJson(upstreamEndpoint, "/json/list"); + const page = targets.find( + (target) => + target.type === "page" && typeof target.webSocketDebuggerUrl === "string", + ); + if (page === undefined) throw new Error("Chrome exposed no page CDP socket"); + return { ...version, webSocketDebuggerUrl: page.webSocketDebuggerUrl }; +} + +function rewriteWebSocketEndpoints(value, server) { + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Page CDP proxy has no TCP address"); + const rewrite = (entry) => { + if (typeof entry.webSocketDebuggerUrl !== "string") return entry; + const url = new URL(entry.webSocketDebuggerUrl); + url.hostname = "127.0.0.1"; + url.port = String(address.port); + return { ...entry, webSocketDebuggerUrl: url.href }; + }; + return Array.isArray(value) ? value.map(rewrite) : rewrite(value); +} + +async function upstreamJson(endpoint, path) { + const response = await fetch(new URL(path, endpoint)); + if (!response.ok) + throw new Error(`Upstream CDP ${path} returned ${response.status}`); + return await response.json(); +} diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 6d74339105..2ad29c8e04 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -8,7 +8,7 @@ const { default: packageJson } = await import("../package.json", { }); process.env.REA_PACKAGE_VERSION = packageJson.version; const isMcpMode = - args[0] === "--mcp" || (args.length === 1 && args[0] === "mcp"); + args.length === 1 && (args[0] === "--mcp" || args[0] === "mcp"); if (isMcpMode) { const { runEntrypoint } = await import("../dist/main.js"); diff --git a/scripts/scan-todos.mjs b/scripts/scan-todos.mjs index 6723856f39..e783d4e85c 100644 --- a/scripts/scan-todos.mjs +++ b/scripts/scan-todos.mjs @@ -6,21 +6,15 @@ import { relative, resolve } from "node:path"; const root = resolve(import.meta.dirname, ".."); -// Collect all TS, MJS, and Python source files, excluding generated and third-party dirs. -const sourceFiles = [ - ...globSync("src/**/*.ts", { +// Native globSync is available throughout the Node versions in package.engines. +// Its exclusion option is `exclude`; `ignore` and `nodir` belong to npm glob. +const sourceFiles = globSync( + ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs", "bridge/**/*.py"], + { cwd: root, - nodir: true, - ignore: ["dist/**", "node_modules/**"], - }), - ...globSync("tests/**/*.ts", { - cwd: root, - nodir: true, - ignore: ["node_modules/**"], - }), - ...globSync("scripts/**/*.mjs", { cwd: root, nodir: true }), - ...globSync("bridge/**/*.py", { cwd: root, nodir: true }), -]; + exclude: (path) => path.split(/[\\/]/u).includes("node_modules"), + }, +); const patterns = [ { regex: /TODO(?::\s*(.*))?$/gm, label: "TODO" }, diff --git a/scripts/verify-package-investigation.mjs b/scripts/verify-package-investigation.mjs index 1ab7942729..442ae10fc4 100644 --- a/scripts/verify-package-investigation.mjs +++ b/scripts/verify-package-investigation.mjs @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -27,16 +27,43 @@ export async function verifyPackagedInvestigation(input) { ]; const investigated = await runJson(input.cli, args, input.environment); const reused = await runJson(input.cli, args, input.environment); - const persisted = JSON.parse(await readFile(workspacePath, "utf8")); + let persisted = JSON.parse(await readFile(workspacePath, "utf8")); + const runId = investigated.normalized_result?.investigation_run?.run_id; + if (runId === undefined) + throw new Error("packaged investigation did not return a run ID"); + await Promise.all([ + rm(input.artifactArchive, { force: true }), + rm(artifactArchiveV2, { force: true }), + ]); + const replayed = await runJson( + input.cli, + [...args.slice(0, -1), "--replay-run-id", runId, "--json"], + input.environment, + ); + persisted = JSON.parse(await readFile(workspacePath, "utf8")); if ( investigated.operation !== "find_changed_behavior" || investigated.normalized_result?.behavior_status !== "unknown" || - investigated.normalized_result?.investigation_run?.run_id === undefined || + replayed.evidence_id !== investigated.evidence_id || reused.evidence_id !== investigated.evidence_id || persisted.revision !== 3 || persisted.runs?.[0]?.status !== "complete" ) throw new Error("packaged persistent investigation CLI failed"); + return { + arguments: { + approved: true, + workspace_path: workspacePath, + workspace_name: "package-versions", + expected_workspace_revision: persisted.revision, + replay_run_id: runId, + left_path: input.artifactArchive, + right_path: artifactArchiveV2, + }, + evidenceId: investigated.evidence_id, + workspacePath, + revision: persisted.revision, + }; } async function runJson(command, args, environment) { diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 2ce2d9785c..c28fdee8a1 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -1,10 +1,12 @@ import { execFile } from "node:child_process"; import { chmod, + lstat, mkdtemp, mkdir, readFile, rm, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -82,15 +84,23 @@ try { chmod(npx, 0o755), ]); const claudeDir = join(home, "Library/Application Support/Claude"); + const codexDir = join(home, ".codex"); const cursorDir = join(home, ".cursor"); await Promise.all([ mkdir(claudeDir, { recursive: true }), + mkdir(codexDir, { recursive: true }), mkdir(cursorDir, { recursive: true }), ]); const claudeConfig = join(claudeDir, "claude_desktop_config.json"); + const codexConfig = join(codexDir, "config.toml"); + const codexTarget = join(home, "managed-codex.toml"); const cursorConfig = join(cursorDir, "mcp.json"); + const cursorTarget = join(home, "managed-cursor.json"); await writeFile(claudeConfig, '{"existing":true}\n'); - await writeFile(cursorConfig, '{"existing":true}\n'); + await writeFile(codexTarget, 'model = "gpt-5"\n'); + await writeFile(cursorTarget, '{"existing":true}\n'); + await symlink(codexTarget, codexConfig); + await symlink(cursorTarget, cursorConfig); const environment = { ...process.env, HOME: home, @@ -169,7 +179,7 @@ try { ) throw new Error("packaged artifact inventory CLI failed"); // prettier-ignore - await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment }); + const investigationReplay = await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment }); if (process.platform === "linux") { const unsupportedExecution = await runWithStatus( cli, @@ -364,6 +374,7 @@ try { const planned = json(plannedExecution.stdout); if (supportedSetupHost) { const plannedClaudeConfig = await readFile(claudeConfig, "utf8"); + const plannedCodexConfig = await readFile(codexTarget, "utf8"); const plannedCursorConfig = await readFile(cursorConfig, "utf8"); const plannedSkill = await readFile(skillPath, "utf8"); if ( @@ -373,11 +384,12 @@ try { !planned.plannedActions.some(({ kind }) => kind === "configure_client") || !planned.plannedActions.some(({ kind }) => kind === "install_skill") || plannedClaudeConfig !== '{"existing":true}\n' || + plannedCodexConfig !== 'model = "gpt-5"\n' || plannedCursorConfig !== '{"existing":true}\n' || plannedSkill !== "stale managed skill\n" ) throw new Error( - `packaged setup plan was not read-only: ${JSON.stringify({ status: planned.status, exitCode: plannedExecution.status, plannedKinds: planned.plannedActions.map(({ kind }) => kind), appliedKinds: planned.appliedActions.map(({ kind }) => kind), claudeConfig: plannedClaudeConfig, cursorConfig: plannedCursorConfig, skill: plannedSkill })}`, + `packaged setup plan was not read-only: ${JSON.stringify({ status: planned.status, exitCode: plannedExecution.status, plannedKinds: planned.plannedActions.map(({ kind }) => kind), appliedKinds: planned.appliedActions.map(({ kind }) => kind), claudeConfig: plannedClaudeConfig, codexConfig: plannedCodexConfig, cursorConfig: plannedCursorConfig, skill: plannedSkill })}`, ); } const firstExecution = await runWithStatus( @@ -386,6 +398,12 @@ try { environment, ); const first = json(firstExecution.stdout); + const alignedExecution = await runWithStatus( + cli, + ["setup", "--json"], + environment, + ); + const aligned = json(alignedExecution.stdout); const secondExecution = await runWithStatus( cli, ["setup", "--yes", "--install-hopper", "--json"], @@ -399,9 +417,15 @@ try { second.status !== status || firstExecution.status !== (status === "ready" ? 0 : 1) || secondExecution.status !== (status === "ready" ? 0 : 1) || + aligned.status !== status || + alignedExecution.status !== (status === "ready" ? 0 : 1) || + aligned.plannedActions.length !== 0 || + aligned.appliedActions.length !== 0 || second.appliedActions.length !== 0 ) - throw new Error("packaged setup was not ready and idempotent"); + throw new Error( + `packaged setup was not ready and idempotent without confirmation: ${JSON.stringify({ first, aligned, second })}`, + ); for (const configPath of [claudeConfig, cursorConfig]) { const config = json(await readFile(configPath, "utf8")); if ( @@ -417,6 +441,18 @@ try { ) throw new Error("packaged client backup failed"); } + const codex = await readFile(codexTarget, "utf8"); + if ( + !(await lstat(codexConfig)).isSymbolicLink() || + !(await lstat(cursorConfig)).isSymbolicLink() || + !codex.includes("[mcp_servers.rea]") || + !codex.includes(`command = "${cli}"`) || + (await readFile(`${codexConfig}.rea.backup`, "utf8")) !== + 'model = "gpt-5"\n' || + (await readFile(`${cursorConfig}.rea.backup`, "utf8")) !== + '{"existing":true}\n' + ) + throw new Error("packaged setup did not preserve config symlinks"); const skill = await readFile(skillPath, "utf8"); const canonicalSkill = await readFile( join(root, "skills/rea-analysis/SKILL.md"), @@ -431,7 +467,8 @@ try { ) throw new Error("packaged stale-skill upgrade was not isolated"); - await writeFile(cursorConfig, "malformed"); + await writeFile(claudeConfig, "malformed"); + await writeFile(cursorConfig, '{"existing":true}\n'); const failedExecution = await runWithStatus( cli, ["setup", "--yes", "--json"], @@ -441,10 +478,17 @@ try { if ( failed.status !== "needs_human" || failedExecution.status !== 1 || - (await readFile(cursorConfig, "utf8")) !== "malformed" + failed.clients?.claude_desktop?.status !== "failed" || + failed.clients?.cursor?.status !== "configured" || + !failed.appliedActions.includes("configured_cursor") || + (await readFile(claudeConfig, "utf8")) !== "malformed" || + json(await readFile(cursorConfig, "utf8")).mcpServers?.rea?.command !== + cli ) - throw new Error("packaged setup failure recovery did not preserve input"); - await writeFile(cursorConfig, "{}\n"); + throw new Error( + `packaged setup did not continue after an earlier client failure: ${JSON.stringify(failed)}`, + ); + await writeFile(claudeConfig, "{}\n"); const recoveredExecution = await runWithStatus( cli, ["setup", "--yes", "--json"], @@ -456,8 +500,64 @@ try { recoveredExecution.status !== (status === "ready" ? 0 : 1) ) throw new Error("packaged setup did not recover"); + + const geminiDir = join(home, ".gemini"); + const geminiConfig = join(geminiDir, "settings.json"); + await mkdir(geminiDir, { recursive: true }); + await symlink(join(home, "missing-gemini.json"), geminiConfig); + const danglingExecution = await runWithStatus( + cli, + ["setup", "--yes", "--json"], + environment, + ); + const dangling = json(danglingExecution.stdout); + if ( + dangling.status !== "needs_human" || + danglingExecution.status !== 1 || + dangling.clients?.gemini_cli?.status !== "failed" || + dangling.clients?.gemini_cli?.reason !== "path" || + !(await lstat(geminiConfig)).isSymbolicLink() || + (await pathExists(`${geminiConfig}.rea.backup`)) + ) + throw new Error( + `packaged setup did not reject a dangling config symlink before mutation: ${JSON.stringify(dangling)}`, + ); + await rm(geminiDir, { recursive: true }); + + const uninstallExecution = await runWithStatus( + cli, + ["uninstall", "--json"], + environment, + ); + const uninstall = json(uninstallExecution.stdout); + const cursorAfterUninstall = json(await readFile(cursorTarget, "utf8")); + const codexAfterUninstall = await readFile(codexTarget, "utf8"); + if ( + uninstall.status !== "complete" || + uninstallExecution.status !== 0 || + uninstall.items?.find(({ name }) => name === "cursor")?.status !== + "removed" || + uninstall.items?.find(({ name }) => name === "codex")?.status !== + "removed" || + !(await lstat(cursorConfig)).isSymbolicLink() || + !(await lstat(codexConfig)).isSymbolicLink() || + cursorAfterUninstall.existing !== true || + cursorAfterUninstall.mcpServers?.rea !== undefined || + !codexAfterUninstall.includes('model = "gpt-5"') || + codexAfterUninstall.includes("mcp_servers.rea") || + !(await readFile(`${cursorConfig}.rea.backup`, "utf8")).includes( + '"rea"', + ) || + !(await readFile(`${codexConfig}.rea.backup`, "utf8")).includes( + "[mcp_servers.rea]", + ) + ) + throw new Error( + `packaged uninstall did not preserve config symlinks: ${JSON.stringify(uninstall)}`, + ); } else if ( first.status !== "needs_human" || + aligned.status !== "needs_human" || second.status !== "needs_human" ) { throw new Error("packaged setup did not reject an unsupported host"); @@ -466,7 +566,10 @@ try { const transport = new StdioClientTransport({ command: cli, args: ["mcp"], - env: environment, + env: { + ...environment, + REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([]), + }, stderr: "pipe", }); let mcpStderr = ""; @@ -484,6 +587,25 @@ try { throw new Error("packaged MCP tool inventory diverged from contracts"); await prompts.verifyPromptCatalog(client, mcpOptions, prompts.names); await prompts.verifyPromptCompletion(client, mcpOptions, false); + const replay = await client.callTool( + { + name: "find_changed_behavior", + arguments: { investigation_run: investigationReplay.arguments }, + }, + mcpOptions, + ); + const replayEvidence = json(prompts.mcpText(replay)); + const replayWorkspace = json( + await readFile(investigationReplay.workspacePath, "utf8"), + ); + if ( + replay.isError === true || + replayEvidence.evidence_id !== investigationReplay.evidenceId || + replayWorkspace.revision !== investigationReplay.revision + ) + throw new Error( + "packaged MCP did not replay with workspace-only authority", + ); const result = await client.callTool( { name: "current_document", arguments: {} }, mcpOptions, @@ -559,7 +681,7 @@ try { } process.stdout.write( - `${JSON.stringify({ cli: true, analysisCli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 2 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, skill: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true })}\n`, + `${JSON.stringify({ cli: true, analysisCli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 3 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true })}\n`, ); } finally { if (tarball) await rm(join(root, tarball), { force: true }); @@ -586,3 +708,13 @@ async function runWithStatus(command, args, env) { function json(text) { return JSON.parse(text); } +async function pathExists(path) { + try { + await lstat(path); + return true; + } catch (cause) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return false; + throw cause; + } +} diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index ad08e70b6c..7bf8fe197a 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -18,6 +18,7 @@ import { compareWebScreenshotsInputSchema, } from "../dist/domain/webScreenshot.js"; import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs"; +import { startPageCdpProxy } from "./fixtures/page-cdp-proxy.mjs"; const SECRET_VALUES = [ "network-secret-value", @@ -31,11 +32,15 @@ const executable = await browserExecutable(); const profile = await mkdtemp(join(tmpdir(), "rea-real-browser-")); const site = await startBrowserVerifierSite(); let browser; +let pageProxy; try { browser = spawn( executable, [ "--headless=new", + ...(process.env.REA_BROWSER_NO_SANDBOX === "true" + ? ["--no-sandbox"] + : []), "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=0", `--user-data-dir=${profile}`, @@ -43,6 +48,7 @@ try { "--no-default-browser-check", "--disable-background-networking", "--disable-component-update", + "--disable-dev-shm-usage", "--disable-sync", "--metrics-recording-only", `${site.origin}/app?startup=browser-secret-value`, @@ -191,6 +197,9 @@ try { ) throw new Error("Real Chrome same-origin SPA timeline was missing"); + pageProxy = await startPageCdpProxy(endpoint); + await verifyPageScopedTransport(provider, pageProxy, site.origin); + process.stdout.write( `${JSON.stringify({ browser: observed.value.browser.product, @@ -205,11 +214,13 @@ try { bundleScripts: bundle.value.capture.scripts_analyzed, sourceMaps: bundle.value.observations.source_maps.processed, sessionEvents: session.value.timeline.length, + pageScopedTransport: true, screenshotBytes: screenshot.value.artifact.bytes, verified: true, })}\n`, ); } finally { + if (pageProxy !== undefined) await pageProxy.close(); if (browser !== undefined) await stopProcess(browser); await site.close(); await rm(profile, { @@ -220,6 +231,61 @@ try { }); } +async function verifyPageScopedTransport(provider, proxy, origin) { + const target = await pageTarget(provider, proxy.endpoint, origin); + const input = inspectWebPageInputSchema.parse({ + cdp_endpoint: proxy.endpoint, + allowed_origins: [origin], + approved: true, + target_id: target, + observation_ms: 100, + }); + const observed = await provider.inspectPage(input); + if (!observed.ok) throw observed.error; + if (observed.value.dom.nodes.length < 1) + throw new Error("Page-scoped CDP transport returned no DOM nodes"); + + const controller = new AbortController(); + const cancelledPromise = provider.inspectPage( + { ...input, observation_ms: 5_000 }, + { signal: controller.signal }, + ); + await delay(100); + controller.abort(); + const cancelled = await cancelledPromise; + if ( + cancelled.ok || + cancelled.error._tag !== "AnalysisCancelledError" || + cancelled.error.operation !== "inspect_web_page" + ) + throw new Error( + "Page-scoped CDP cancellation lost its operation semantics", + ); + + const disconnectedPromise = provider.observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: proxy.endpoint, + allowed_origins: [origin], + approved: true, + target_id: target, + observation_ms: 5_000, + }), + ); + await delay(100); + proxy.disconnectClients(); + const disconnected = await disconnectedPromise; + if ( + !disconnected.ok || + disconnected.value.window.end_reason !== "target_terminated" || + !disconnected.value.timeline.some( + ({ type }) => type === "target_terminated", + ) + ) + throw new Error( + "Page-scoped CDP disconnect was not reported as target_terminated", + ); +} + async function browserExecutable() { const candidates = [ process.env.REA_BROWSER_EXECUTABLE, @@ -247,7 +313,7 @@ async function browserExecutable() { async function devtoolsPort(profile, child, stderr) { const activePort = join(profile, "DevToolsActivePort"); - for (let attempt = 0; attempt < 200; attempt += 1) { + for (let attempt = 0; attempt < 600; attempt += 1) { if (child.exitCode !== null) throw new Error(`Chrome exited before CDP startup: ${stderr()}`); try { diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts index 9844a9ef4e..cc9c077e83 100644 --- a/src/application/BinarySession.ts +++ b/src/application/BinarySession.ts @@ -35,7 +35,10 @@ import type { AnalysisOperation } from "./AnalysisProvider.js"; import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js"; import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js"; import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js"; -import { snapshotMatchesTarget } from "../domain/analysisSnapshot.js"; +import { + snapshotMatchesTarget, + snapshotTarget, +} from "../domain/analysisSnapshot.js"; import { AnalysisSnapshotCache, isSnapshotCacheable, @@ -82,7 +85,7 @@ export class BinarySession implements BinarySessionPort { string, { readonly available: boolean; readonly reason: string | null } >(); - readonly #availabilityListeners = new Set<() => void>(); + readonly #availabilityListeners = new Set<() => void | Promise>(); readonly #snapshotsEnabled: boolean; #snapshotInvalidated = false; @@ -132,7 +135,7 @@ export class BinarySession implements BinarySessionPort { } /** Observe runtime provider-health changes that affect discovery metadata. */ - onAvailabilityChanged(listener: () => void): () => void { + onAvailabilityChanged(listener: () => void | Promise): () => void { this.#availabilityListeners.add(listener); return () => this.#availabilityListeners.delete(listener); } @@ -348,7 +351,10 @@ export class BinarySession implements BinarySessionPort { ); if (isAborted(options.signal)) return err(new AnalysisCancelledError("open_binary")); - if (this.#active?.target.path === parsed.value.path) { + if ( + this.#active?.target.path === parsed.value.path && + snapshotMatchesTarget(snapshotTarget(this.#active.target), parsed.value) + ) { if (options.snapshot !== undefined) { const imported = this.importAnalysisSnapshot(options.snapshot); if (!imported.ok) return imported; @@ -583,7 +589,16 @@ export class BinarySession implements BinarySessionPort { } #emitAvailabilityChanged(): void { - for (const listener of this.#availabilityListeners) listener(); + for (const listener of this.#availabilityListeners) { + try { + const notification = listener(); + if (notification !== undefined) + void notification.catch(() => undefined); + } catch { + // External observers are best-effort notifications. Contain only the + // callback failure so state transitions and other listeners continue. + } + } } #serialize(operation: () => Promise): Promise { diff --git a/src/application/BinarySessionPort.ts b/src/application/BinarySessionPort.ts index 1cd6f31115..60ad2ed737 100644 --- a/src/application/BinarySessionPort.ts +++ b/src/application/BinarySessionPort.ts @@ -81,5 +81,5 @@ export interface BinarySessionPort extends AnalysisOperationPort { UnknownRegistryError >; providerIdentity(operation?: AnalysisOperation): ProviderIdentity; - onAvailabilityChanged?(listener: () => void): () => void; + onAvailabilityChanged?(listener: () => void | Promise): () => void; } diff --git a/src/application/BrowserObservationService.ts b/src/application/BrowserObservationService.ts index 6ad7c1f58e..79b923849e 100644 --- a/src/application/BrowserObservationService.ts +++ b/src/application/BrowserObservationService.ts @@ -1,7 +1,8 @@ import type { Evidence } from "../domain/evidence.js"; -import type { - InspectWebPageInput, - ListBrowserTargetsInput, +import { + isLiteralLoopbackHostname, + type InspectWebPageInput, + type ListBrowserTargetsInput, } from "../domain/browserObservation.js"; import type { AnalyzeWebBundleInput } from "../domain/webBundleAnalysis.js"; import type { ObserveWebSessionInput } from "../domain/browserSession.js"; @@ -285,7 +286,7 @@ const browserNetworkScope = ( ): "loopback" | "external" => allowedOrigins.every((origin) => { const hostname = new URL(origin).hostname; - return hostname === "127.0.0.1" || hostname === "[::1]"; + return isLiteralLoopbackHostname(hostname); }) ? "loopback" : "external"; diff --git a/src/application/ClientConfigPath.ts b/src/application/ClientConfigPath.ts new file mode 100644 index 0000000000..34b2cf3496 --- /dev/null +++ b/src/application/ClientConfigPath.ts @@ -0,0 +1,53 @@ +import { lstat, realpath } from "node:fs/promises"; + +interface ClientConfigPathStats { + readonly uid?: number; + isFile?(): boolean; + isSymbolicLink(): boolean; +} + +interface ClientConfigPathFileSystem { + lstat(path: string): Promise; + realpath?(path: string): Promise; +} + +const systemFileSystem: ClientConfigPathFileSystem = { lstat, realpath }; + +/** + * Resolve an existing, user-owned config symlink to one stable transaction + * path. Missing regular paths remain eligible for first-run creation. + */ +export const resolveClientConfigTransactionPath = async ( + requestedPath: string, + fileSystem: ClientConfigPathFileSystem = systemFileSystem, +): Promise => { + let requestedStats: ClientConfigPathStats; + try { + requestedStats = await fileSystem.lstat(requestedPath); + } catch (cause: unknown) { + return isMissing(cause) ? requestedPath : undefined; + } + if (!requestedStats.isSymbolicLink()) return requestedPath; + + const currentUid = + typeof process.getuid === "function" ? process.getuid() : undefined; + if ( + currentUid === undefined || + requestedStats.uid !== currentUid || + fileSystem.realpath === undefined + ) + return undefined; + + try { + const canonicalPath = await fileSystem.realpath(requestedPath); + const targetStats = await fileSystem.lstat(canonicalPath); + return targetStats.isFile?.() === true && targetStats.uid === currentUid + ? canonicalPath + : undefined; + } catch { + return undefined; + } +}; + +const isMissing = (cause: unknown): boolean => + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; diff --git a/src/application/CrossVersionInvestigation.ts b/src/application/CrossVersionInvestigation.ts index 7c5b2ee7f4..19b43056f4 100644 --- a/src/application/CrossVersionInvestigation.ts +++ b/src/application/CrossVersionInvestigation.ts @@ -1,6 +1,4 @@ -import { compareArtifacts } from "../domain/artifactComparison.js"; -import { findChangedBehavior } from "../domain/changedBehavior.js"; -import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { type Evidence } from "../domain/evidence.js"; import { createEvidenceBundle, type EvidenceFilePolicy, @@ -18,18 +16,15 @@ import { createInvestigationWorkspace, crossVersionInvestigationInputSchema, investigationRunSchema, - investigationRunSummarySchema, reviseInvestigationWorkspace, type CrossVersionInvestigationInput, type InvestigationRun, type InvestigationWorkspace, } from "../domain/investigationWorkspace.js"; -import { jsonValueSchema } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import type { BinarySessionPort } from "./BinarySession.js"; import type { ProgressReporter } from "./ProgressReporter.js"; import { - AUTOMATIC_RUN_LIMITATION, createInventoryEvidencePages, runLimitations, scanVersions, @@ -42,9 +37,10 @@ import { writeInvestigationWorkspace, } from "./InvestigationWorkspaceStore.js"; import { - ARTIFACT_COMPARISON_PROVIDER, - CHANGED_BEHAVIOR_PROVIDER, -} from "./InvestigationProviders.js"; + createArtifactComparisonEvidence, + createChangedBehaviorEvidence, +} from "./CrossVersionInvestigationEvidence.js"; +import { selectCompletedInvestigationReplay } from "./CrossVersionInvestigationReplay.js"; export interface CrossVersionInvestigationOutcome { readonly evidence: Evidence; @@ -59,6 +55,7 @@ export interface CrossVersionInvestigationExecution { readonly signal?: AbortSignal; readonly progress?: ProgressReporter; readonly integrityContinueEnabled?: boolean; + readonly authorizeInputRead?: () => Promise>; readonly authorizeWorkspaceWrite?: () => Promise>; } @@ -84,6 +81,23 @@ export const runCrossVersionInvestigation = async ( if (!preflight.ok) return preflight; if (isCancelled(execution.signal)) return err(new AnalysisCancelledError("find_changed_behavior")); + if (parsed.data.replay_run_id !== undefined) { + const replay = selectCompletedInvestigationReplay( + initial.value, + parsed.data, + ); + if (!replay.ok) return replay; + return completeOutcome( + replay.value.workspace, + replay.value.run, + true, + execution.session, + ); + } + if (execution.authorizeInputRead !== undefined) { + const authorized = await execution.authorizeInputRead(); + if (!authorized.ok) return authorized; + } await execution.progress?.report({ phase: "scan_versions", completed: 0, @@ -304,38 +318,6 @@ const ensureComparisonCheckpoint = async ( return saved.ok ? ok({ workspace: saved.value, run }) : saved; }; -const createArtifactComparisonEvidence = ( - inventory: { - readonly left: readonly Evidence[]; - readonly right: readonly Evidence[]; - }, - limit: number, -): Evidence => { - const comparison = compareArtifacts( - inventory.left, - inventory.right, - 0, - limit, - ); - const leftIds = evidenceIds(inventory.left); - const rightIds = evidenceIds(inventory.right); - return createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, { - predicateType: "rea.artifact-comparison/v1", - operation: "compare_artifacts", - parameters: { - left_evidence_ids: leftIds, - right_evidence_ids: rightIds, - offset: 0, - limit, - }, - result: jsonValueSchema.parse(comparison), - confidence: "derived", - authority: "analyst-inference", - limitations: comparison.limitations, - evidenceLinks: [...leftIds, ...rightIds], - }); -}; - const finalizeInvestigation = async ( input: CrossVersionInvestigationInput, state: RunState, @@ -344,7 +326,11 @@ const finalizeInvestigation = async ( ): Promise> => { const comparison = comparisonEvidenceFor(state.workspace, state.run); if (!comparison.ok) return comparison; - const evidence = createChangedBehaviorEvidence(state, comparison.value); + const evidence = createChangedBehaviorEvidence( + state.workspace, + state.run, + comparison.value, + ); const run = investigationRunSchema.parse({ ...state.run, status: "complete", @@ -367,45 +353,6 @@ const finalizeInvestigation = async ( return saved.ok ? completeOutcome(saved.value, run, false, session) : saved; }; -const createChangedBehaviorEvidence = ( - state: RunState, - comparison: Evidence, -): Evidence => { - const limit = Math.min(state.run.options.change_limit, 100); - const changed = findChangedBehavior([comparison], 0, limit); - const summary = investigationRunSummarySchema.parse({ - schema_version: 1, - workspace_id: state.workspace.workspace_id, - run_id: state.run.run_id, - left_manifest_id: state.run.left.manifest_id, - right_manifest_id: state.run.right.manifest_id, - inventory_evidence_count: - state.run.left_inventory_evidence_ids.length + - state.run.right_inventory_evidence_ids.length, - comparison_evidence_id: comparison.evidence_id, - limitations: state.run.limitations, - }); - return createEvidence(undefined, CHANGED_BEHAVIOR_PROVIDER, { - predicateType: "rea.changed-behavior/v1", - operation: "find_changed_behavior", - parameters: { - workspace_id: state.workspace.workspace_id, - run_id: state.run.run_id, - comparison_evidence_ids: [comparison.evidence_id], - offset: 0, - limit, - }, - result: jsonValueSchema.parse({ - ...changed, - investigation_run: summary, - }), - confidence: "derived", - authority: "analyst-inference", - limitations: [...changed.limitations, AUTOMATIC_RUN_LIMITATION], - evidenceLinks: changed.evidence_links, - }); -}; - const comparisonEvidenceFor = ( workspace: InvestigationWorkspace, run: InvestigationRun, diff --git a/src/application/CrossVersionInvestigationEvidence.ts b/src/application/CrossVersionInvestigationEvidence.ts new file mode 100644 index 0000000000..313c5db62e --- /dev/null +++ b/src/application/CrossVersionInvestigationEvidence.ts @@ -0,0 +1,91 @@ +import { compareArtifacts } from "../domain/artifactComparison.js"; +import { findChangedBehavior } from "../domain/changedBehavior.js"; +import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { + investigationRunSummarySchema, + type InvestigationRun, + type InvestigationWorkspace, +} from "../domain/investigationWorkspace.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import { AUTOMATIC_RUN_LIMITATION } from "./CrossVersionInventory.js"; +import { + ARTIFACT_COMPARISON_PROVIDER, + CHANGED_BEHAVIOR_PROVIDER, +} from "./InvestigationProviders.js"; + +/** Derive deterministic artifact-comparison Evidence from inventory pages. */ +export const createArtifactComparisonEvidence = ( + inventory: { + readonly left: readonly Evidence[]; + readonly right: readonly Evidence[]; + }, + limit: number, +): Evidence => { + const comparison = compareArtifacts( + inventory.left, + inventory.right, + 0, + limit, + ); + const leftIds = evidenceIds(inventory.left); + const rightIds = evidenceIds(inventory.right); + return createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, { + predicateType: "rea.artifact-comparison/v1", + operation: "compare_artifacts", + parameters: { + left_evidence_ids: leftIds, + right_evidence_ids: rightIds, + offset: 0, + limit, + }, + result: jsonValueSchema.parse(comparison), + confidence: "derived", + authority: "analyst-inference", + limitations: comparison.limitations, + evidenceLinks: [...leftIds, ...rightIds], + }); +}; + +/** Derive the deterministic final report for one comparison checkpoint. */ +export const createChangedBehaviorEvidence = ( + workspace: InvestigationWorkspace, + run: InvestigationRun, + comparison: Evidence, +): Evidence => { + const limit = Math.min(run.options.change_limit, 100); + const changed = findChangedBehavior([comparison], 0, limit); + const summary = investigationRunSummarySchema.parse({ + schema_version: 1, + workspace_id: workspace.workspace_id, + run_id: run.run_id, + left_manifest_id: run.left.manifest_id, + right_manifest_id: run.right.manifest_id, + inventory_evidence_count: + run.left_inventory_evidence_ids.length + + run.right_inventory_evidence_ids.length, + comparison_evidence_id: comparison.evidence_id, + limitations: run.limitations, + }); + return createEvidence(undefined, CHANGED_BEHAVIOR_PROVIDER, { + predicateType: "rea.changed-behavior/v1", + operation: "find_changed_behavior", + parameters: { + workspace_id: workspace.workspace_id, + run_id: run.run_id, + comparison_evidence_ids: [comparison.evidence_id], + offset: 0, + limit, + }, + result: jsonValueSchema.parse({ + ...changed, + investigation_run: summary, + }), + confidence: "derived", + authority: "analyst-inference", + limitations: [...changed.limitations, AUTOMATIC_RUN_LIMITATION], + evidenceLinks: changed.evidence_links, + }); +}; + +const evidenceIds = (records: readonly Evidence[]): string[] => + records.map(({ evidence_id: id }) => id); diff --git a/src/application/CrossVersionInvestigationReplay.ts b/src/application/CrossVersionInvestigationReplay.ts new file mode 100644 index 0000000000..f0504be055 --- /dev/null +++ b/src/application/CrossVersionInvestigationReplay.ts @@ -0,0 +1,341 @@ +import { isDeepStrictEqual } from "node:util"; + +import { + artifactInventoryResultSchema, + type ArtifactInventoryResult, +} from "../domain/artifactGraph.js"; +import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js"; +import type { Evidence } from "../domain/evidence.js"; +import { + EvidenceIntegrityError, + InvestigationWorkspaceError, +} from "../domain/errors.js"; +import type { + CrossVersionInvestigationInput, + InvestigationRun, + InvestigationRunTarget, + InvestigationWorkspace, +} from "../domain/investigationWorkspace.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + createArtifactComparisonEvidence, + createChangedBehaviorEvidence, +} from "./CrossVersionInvestigationEvidence.js"; +import { AUTOMATIC_RUN_LIMITATION } from "./CrossVersionInventory.js"; +import { ARTIFACT_GRAPH_PROVIDER } from "./InvestigationProviders.js"; + +/** Validated workspace state returned by an explicit cache-only selector. */ +export interface CompletedInvestigationReplay { + readonly workspace: InvestigationWorkspace; + readonly run: InvestigationRun; +} + +interface ValidatedInventory { + readonly records: readonly Evidence[]; + readonly result: ReturnType; +} + +/** Select and verify one caller-identified complete run without input access. */ +export const selectCompletedInvestigationReplay = ( + workspace: InvestigationWorkspace | null, + input: CrossVersionInvestigationInput, +): Result< + CompletedInvestigationReplay, + InvestigationWorkspaceError | EvidenceIntegrityError +> => { + const run = workspace?.runs.find( + ({ run_id: runId }) => runId === input.replay_run_id, + ); + if ( + workspace === null || + run === undefined || + run.status !== "complete" || + !controlsMatch(run, input) + ) + return replayConflict(); + const records = new Map( + workspace.bundle.records.map((record) => [record.evidence_id, record]), + ); + const left = inventoryRecords(records, run.left_inventory_evidence_ids); + const right = inventoryRecords(records, run.right_inventory_evidence_ids); + if (!left.ok) return left; + if (!right.ok) return right; + if ( + !pathsMatch(left.value, input.left_path) || + !pathsMatch(right.value, input.right_path) + ) + return replayConflict(); + const validated = validateLinkedEvidence( + workspace, + run, + left.value, + right.value, + ); + return validated.ok ? ok({ workspace, run }) : validated; +}; + +const controlsMatch = ( + run: InvestigationRun, + input: CrossVersionInvestigationInput, +): boolean => + isDeepStrictEqual(run.options, input.options) && + run.integrity_policy === input.integrity_policy && + run.integrity_continue_approved === input.integrity_continue_approved && + run.max_integrity_mismatches === input.max_integrity_mismatches; + +const inventoryRecords = ( + records: ReadonlyMap, + evidenceIds: readonly string[], +): Result => { + if (new Set(evidenceIds).size !== evidenceIds.length) + return replayIntegrity("Investigation replay repeats inventory Evidence"); + const selected = evidenceIds.flatMap((evidenceId) => { + const evidence = records.get(evidenceId); + return evidence === undefined ? [] : [evidence]; + }); + return selected.length === evidenceIds.length + ? ok(selected) + : replayIntegrity("Investigation replay is missing inventory Evidence"); +}; + +const pathsMatch = (records: readonly Evidence[], path: string): boolean => + records.every(({ subject }) => subject?.local_path === path); + +const validateLinkedEvidence = ( + workspace: InvestigationWorkspace, + run: InvestigationRun, + leftRecords: readonly Evidence[], + rightRecords: readonly Evidence[], +): Result => { + try { + const left = validateInventory(leftRecords, run.left, run); + const right = validateInventory(rightRecords, run.right, run); + if (!left.ok) return left; + if (!right.ok) return right; + const expectedLimitations = runLimitations(left.value, right.value); + if (!isDeepStrictEqual(run.limitations, expectedLimitations)) + return replayIntegrity( + "Investigation replay limitations disagree with inventory Evidence", + ); + const comparison = createArtifactComparisonEvidence( + { left: left.value.records, right: right.value.records }, + run.options.change_limit, + ); + const storedComparison = evidenceFor(workspace, run.comparison_evidence_id); + if ( + storedComparison === undefined || + !isDeepStrictEqual(storedComparison, comparison) + ) + return replayIntegrity( + "Investigation replay comparison Evidence is inconsistent", + ); + const result = createChangedBehaviorEvidence(workspace, run, comparison); + const storedResult = evidenceFor(workspace, run.result_evidence_id); + return storedResult !== undefined && isDeepStrictEqual(storedResult, result) + ? ok(null) + : replayIntegrity("Investigation replay result Evidence is inconsistent"); + } catch (cause: unknown) { + return replayIntegrity( + "Investigation replay linked Evidence is invalid", + cause, + ); + } +}; + +const validateInventory = ( + records: readonly Evidence[], + target: InvestigationRunTarget, + run: InvestigationRun, +): Result => { + const pages = records.map((record) => + artifactInventoryResultSchema.parse(record.normalized_result), + ); + const expectedPageCount = Math.max( + 1, + Math.ceil((pages[0]?.manifest.node_count ?? 0) / run.options.page_size), + Math.ceil( + (pages[0]?.manifest.occurrence_count ?? 0) / run.options.page_size, + ), + Math.ceil((pages[0]?.manifest.edge_count ?? 0) / run.options.page_size), + ); + if (records.length !== expectedPageCount) + return replayIntegrity( + "Investigation replay inventory Evidence pagination is inconsistent", + ); + if (!sameSnapshotMetadata(pages)) + return replayIntegrity( + "Investigation replay inventory pages disagree on snapshot metadata", + ); + for (const [index, record] of records.entries()) { + const page = pages[index]; + if (page === undefined || !inventoryRecordMatches(record, page, run, index)) + return replayIntegrity( + "Investigation replay inventory Evidence is inconsistent", + ); + } + const result = parseArtifactInventoryEvidence(records); + if (!result.inventory.complete || !targetMatches(result.inventory, target)) + return replayIntegrity( + "Investigation replay inventory commitment is inconsistent", + ); + return ok({ records, result }); +}; + +const inventoryRecordMatches = ( + record: Evidence, + page: ArtifactInventoryResult, + run: InvestigationRun, + index: number, +): boolean => { + const offset = index * run.options.page_size; + const parameters = { + node_offset: offset, + node_limit: run.options.page_size, + occurrence_offset: offset, + occurrence_limit: run.options.page_size, + edge_offset: offset, + edge_limit: run.options.page_size, + max_entries: run.options.max_entries, + max_total_bytes: run.options.max_total_bytes, + max_entry_bytes: run.options.max_entry_bytes, + max_compression_ratio: run.options.max_compression_ratio, + max_depth: run.options.max_depth, + max_path_bytes: run.options.max_path_bytes, + integrity_policy: run.integrity_policy, + integrity_continue_approved: run.integrity_continue_approved, + max_integrity_mismatches: run.max_integrity_mismatches, + }; + const locations = page.occurrences.items.map(({ logical_path: path }) => ({ + kind: "artifact-path" as const, + path, + })); + return ( + record.subject !== null && + record.subject.name === record.subject.local_path.split("/").at(-1) && + record.subject.digest.sha256 === page.manifest.root_sha256 && + record.subject.format === page.manifest.root_format && + record.subject.architecture === null && + isDeepStrictEqual(record.provider, ARTIFACT_GRAPH_PROVIDER) && + record.predicate_type === "rea.analysis/v2" && + record.operation === "inventory_artifact" && + isDeepStrictEqual(record.parameters, parameters) && + record.raw_result === null && + record.confidence === "observed" && + record.authority === "shipped-artifact" && + record.environment === null && + isDeepStrictEqual(record.limitations, page.limitations) && + isDeepStrictEqual(record.locations, locations) && + record.evidence_links.length === 0 && + isDeepStrictEqual(page.limits, traversalLimits(run)) && + pageMatches( + page.nodes, + offset, + run.options.page_size, + page.manifest.node_count, + ) && + pageMatches( + page.occurrences, + offset, + run.options.page_size, + page.manifest.occurrence_count, + ) && + pageMatches( + page.edges, + offset, + run.options.page_size, + page.manifest.edge_count, + ) + ); +}; + +const sameSnapshotMetadata = ( + pages: readonly ArtifactInventoryResult[], +): boolean => { + const first = pages[0]; + return ( + first !== undefined && + pages.every( + (page) => + isDeepStrictEqual(page.manifest, first.manifest) && + isDeepStrictEqual(page.limits, first.limits) && + isDeepStrictEqual(page.provenance, first.provenance) && + isDeepStrictEqual( + page.integrity_contradictions, + first.integrity_contradictions, + ) && + isDeepStrictEqual(page.limitations, first.limitations), + ) + ); +}; + +const pageMatches = ( + page: { + readonly items: readonly unknown[]; + readonly offset: number; + readonly limit: number; + readonly total: number; + readonly next_offset: number | null; + }, + offset: number, + limit: number, + total: number, +): boolean => + page.offset === offset && + page.limit === limit && + page.total === total && + page.items.length === Math.max(0, Math.min(limit, page.total - offset)) && + page.next_offset === (offset + limit < page.total ? offset + limit : null); + +const traversalLimits = (run: InvestigationRun) => ({ + max_entries: run.options.max_entries, + max_total_bytes: run.options.max_total_bytes, + max_entry_bytes: run.options.max_entry_bytes, + max_compression_ratio: run.options.max_compression_ratio, + max_depth: run.options.max_depth, + max_path_bytes: run.options.max_path_bytes, +}); + +const targetMatches = ( + inventory: ReturnType["inventory"], + target: InvestigationRunTarget, +): boolean => + inventory.manifest.root_sha256 === target.root_sha256 && + inventory.manifest.graph_sha256 === target.graph_sha256 && + inventory.manifest.manifest_id === target.manifest_id && + inventory.manifest.root_format === target.format; + +const runLimitations = ( + left: ValidatedInventory, + right: ValidatedInventory, +): string[] => + [ + ...new Set([ + ...left.result.inventory.limitations.map((item) => `Left: ${item}`), + ...right.result.inventory.limitations.map((item) => `Right: ${item}`), + AUTOMATIC_RUN_LIMITATION, + ]), + ].sort((first, second) => first.localeCompare(second)); + +const evidenceFor = ( + workspace: InvestigationWorkspace, + evidenceId: string | null, +): Evidence | undefined => + evidenceId === null + ? undefined + : workspace.bundle.records.find( + ({ evidence_id: current }) => current === evidenceId, + ); + +const replayConflict = (): Result => + err(new InvestigationWorkspaceError("read", "revision-conflict")); + +const replayIntegrity = ( + message: string, + cause?: unknown, +): Result => + err( + new EvidenceIntegrityError( + message, + cause === undefined ? undefined : { cause }, + ), + ); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 0226aaa986..2cfa7e63d0 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -121,14 +121,16 @@ export const runDoctor = async ( }, ), ); - const candidates = [ - host.configuredHopperPath, - DEFAULT_HOPPER, - SYSTEM_LINUX_HOPPER, - linuxHopperLauncherPath(homedir()), - ...(await host.manualHopperPaths()), - await host.brewHopperPath(), - ].filter((value): value is string => value !== undefined); + const candidates = + host.configuredHopperPath === undefined + ? [ + DEFAULT_HOPPER, + SYSTEM_LINUX_HOPPER, + linuxHopperLauncherPath(homedir()), + ...(await host.manualHopperPaths()), + await host.brewHopperPath(), + ].filter((value): value is string => value !== undefined) + : [host.configuredHopperPath]; let hopperPath: string | undefined; for (const candidate of new Set(candidates)) if (await host.executable(candidate)) { @@ -136,11 +138,21 @@ export const runDoctor = async ( break; } checks.push( - check("hopper", hopperPath !== undefined, hopperPath, { - remediation: - "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.", - classification: "missing_analysis_engine", - }), + check( + "hopper", + hopperPath !== undefined, + hopperPath ?? host.configuredHopperPath, + { + remediation: + host.configuredHopperPath === undefined + ? "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH." + : "Unset or update HOPPER_LAUNCHER_PATH to an executable Hopper launcher.", + classification: + host.configuredHopperPath === undefined + ? "missing_analysis_engine" + : "config_drift", + }, + ), ); if (host.platform === "linux" && hopperPath !== undefined) checks.push( diff --git a/src/application/EvidenceLedger.ts b/src/application/EvidenceLedger.ts index 367b64b779..8248920a5f 100644 --- a/src/application/EvidenceLedger.ts +++ b/src/application/EvidenceLedger.ts @@ -62,7 +62,12 @@ export class EvidenceLedger { ? ok("duplicate") : err(new EvidenceIntegrityError("Conflicting evidence record")); } - if (this.#records.size >= this.limits.maxRecords) + if ( + this.#exceedsRecordLimit( + this.#records.size + 1, + this.#unknownRevisions.size, + ) + ) return err(new EvidenceLimitError("records", this.limits.maxRecords)); const bytes = serializedBytes(evidence); if (this.#bytes + bytes > this.limits.maxBytes) @@ -344,7 +349,7 @@ export class EvidenceLedger { number, EvidenceIntegrityError | EvidenceLimitError | UnknownRegistryError > { - if (records.size + unknowns.size > this.limits.maxRecords) + if (this.#exceedsRecordLimit(records.size, unknowns.size)) return err(new EvidenceLimitError("records", this.limits.maxRecords)); const bytes = [...records.values()].reduce( @@ -367,6 +372,10 @@ export class EvidenceLedger { return ok(bytes); } + #exceedsRecordLimit(recordCount: number, unknownCount: number): boolean { + return recordCount + unknownCount > this.limits.maxRecords; + } + #commit( records: ReadonlyMap, unknowns: ReadonlyMap, diff --git a/src/application/PermissionAuthority.ts b/src/application/PermissionAuthority.ts index 81a4bd8a83..ab0e06579f 100644 --- a/src/application/PermissionAuthority.ts +++ b/src/application/PermissionAuthority.ts @@ -232,6 +232,46 @@ export class PermissionAuthority { return ok(null); } + /** Atomically replace reloadable ceilings and persisted grants. */ + async replaceConfiguredPolicy(configuration: { + readonly ceilings: readonly PermissionCeiling[]; + readonly administratorGrants: readonly PermissionGrant[]; + readonly projectGrants: readonly PermissionGrant[]; + }): Promise> { + const ceilings = await canonicalizePermissionCeilings( + configuration.ceilings, + ); + if (!ceilings.ok) return ceilings; + const grants: PermissionGrant[] = []; + for (const grant of [ + ...configuration.administratorGrants, + ...configuration.projectGrants, + ]) { + const canonical = await canonicalizeGrant(grant); + if (!canonical.ok) return canonical; + grants.push(canonical.value); + } + let candidate: PermissionPolicy = { + ...reloadPermissionCeilings(this.policy, ceilings.value), + grants: this.policy.grants.filter( + ({ lifetime }) => + lifetime !== "administrator" && lifetime !== "project", + ), + }; + for (const grant of grants) { + const added = grantPermission(candidate, grant); + if (!added.ok) + return err( + new PermissionPathError(grant.grant_id, "io", { + cause: added.error, + }), + ); + candidate = added.value; + } + this.policy = candidate; + return ok(null); + } + /** Replace ceilings without restarting the MCP process. */ async reload( ceilings: readonly PermissionCeiling[], diff --git a/src/application/ProcessHarness.ts b/src/application/ProcessHarness.ts index d16502f8b8..04b64db6d8 100644 --- a/src/application/ProcessHarness.ts +++ b/src/application/ProcessHarness.ts @@ -245,12 +245,13 @@ const startCaptureRuntime = async (options: { renderer, started, }); - const stopSampler = startProcessSampler( - terminal.pid, + const stopSampler = startProcessSampler({ + rootPid: terminal.pid, + runId: options.runId, started, - scenario.limits.processes, - options.samples, - ); + limit: scenario.limits.processes, + samples: options.samples, + }); return { replay, renderer, diff --git a/src/application/ProcessOwnership.ts b/src/application/ProcessOwnership.ts index cceacae433..10b220d363 100644 --- a/src/application/ProcessOwnership.ts +++ b/src/application/ProcessOwnership.ts @@ -121,6 +121,13 @@ const systemHost: ProcessOwnershipHost = { }, }; +/** Read the capture run token currently exposed by one live process. */ +export const readProcessRunId = async ( + pid: number, + host: ProcessOwnershipHost = systemHost, +): Promise => + (await host.environment(pid)).REA_PROCESS_RUN_ID; + /** * Verify run-token ownership before signaling a POSIX process group. * diff --git a/src/application/ProcessSampling.ts b/src/application/ProcessSampling.ts index 29bca043a2..9c2aecf4be 100644 --- a/src/application/ProcessSampling.ts +++ b/src/application/ProcessSampling.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import { readFile, readdir } from "node:fs/promises"; import { promisify } from "node:util"; import type { ProcessSample } from "../domain/processCapture.js"; +import { readProcessRunId } from "./ProcessOwnership.js"; const execFileAsync = promisify(execFile); const PROC_READ_CONCURRENCY = 64; @@ -27,6 +28,29 @@ interface SampleProcessContext { readonly signal: AbortSignal; } +type ProcessIdentitySnapshot = Pick< + ProcessRow, + "pid" | "parent_pid" | "process_group_id" | "session_id" | "startTime" +>; + +/** Prove that forkpty completed session setup and installed the run token. */ +export const isInitializedPtyRoot = (options: { + readonly rootPid: number; + readonly expectedRunId: string; + readonly before: ProcessIdentitySnapshot; + readonly observedRunId: string | undefined; + readonly after: ProcessIdentitySnapshot; +}): boolean => + options.before.pid === options.rootPid && + options.before.process_group_id === options.rootPid && + options.before.session_id === options.rootPid && + options.after.pid === options.rootPid && + options.after.parent_pid === options.before.parent_pid && + options.after.process_group_id === options.rootPid && + options.after.session_id === options.rootPid && + options.after.startTime === options.before.startTime && + options.observedRunId === options.expectedRunId; + const isExpectedProcReadFailure = (cause: unknown): boolean => cause instanceof Error && "code" in cause && @@ -305,6 +329,47 @@ const readPsRows = async ( .sort((left, right) => left.pid - right.pid); }; +const readRootIdentity = async ( + rootPid: number, + signal: AbortSignal, +): Promise => + process.platform === "linux" + ? readProcessStat(rootPid, signal) + : (await readPsRows(signal)).find(({ pid }) => pid === rootPid); + +const inspectInitializedPtyRoot = async ( + rootPid: number, + runId: string, + signal: AbortSignal, +): Promise<{ readonly startTime: string | undefined } | undefined> => { + const before = await readRootIdentity(rootPid, signal); + if ( + before === undefined || + before.process_group_id !== rootPid || + before.session_id !== rootPid + ) + return undefined; + let observedRunId: string | undefined; + try { + observedRunId = await readProcessRunId(rootPid); + } catch { + return undefined; + } + const after = await readRootIdentity(rootPid, signal); + if ( + after === undefined || + !isInitializedPtyRoot({ + rootPid, + expectedRunId: runId, + before, + observedRunId, + after, + }) + ) + return undefined; + return { startTime: after.startTime }; +}; + const samplePs = async ( context: SampleProcessContext, ): Promise => { @@ -375,31 +440,47 @@ const sampleProcesses = async ( }; /** Start bounded process-tree sampling and expose an awaited stop. */ -export const startProcessSampler = ( - rootPid: number, - started: number, - limit: number, - samples: ProcessSample[], -): (() => Promise<{ readonly partial: boolean }>) => { +export const startProcessSampler = (options: { + readonly rootPid: number; + readonly runId: string; + readonly started: number; + readonly limit: number; + readonly samples: ProcessSample[]; +}): (() => Promise<{ readonly partial: boolean }>) => { + const { rootPid, runId, started, limit, samples } = options; const identities = new Map(); const lastObservations = new Map(); let pending: Promise | undefined; let stopped = false; let abortCurrent: (() => void) | undefined; let partial = false; + let rootInitialized = false; const sample = (): void => { if (stopped || pending !== undefined) return; const controller = new AbortController(); abortCurrent = () => controller.abort(); - pending = sampleProcesses({ - rootPid, - elapsedMs: Date.now() - started, - limit: limit + 1, - identities, - signal: controller.signal, - }) + pending = (async () => { + if (!rootInitialized) { + const identity = await inspectInitializedPtyRoot( + rootPid, + runId, + controller.signal, + ); + if (identity === undefined) return []; + if (identity.startTime !== undefined) + identities.set(rootPid, identity.startTime); + rootInitialized = true; + } + return sampleProcesses({ + rootPid, + elapsedMs: Date.now() - started, + limit: limit + 1, + identities, + signal: controller.signal, + }); + })() .then((values) => { for (const value of values) { const observation = JSON.stringify({ diff --git a/src/application/ReferenceSourceImport.ts b/src/application/ReferenceSourceImport.ts index d68a651a1f..c0048ae60b 100644 --- a/src/application/ReferenceSourceImport.ts +++ b/src/application/ReferenceSourceImport.ts @@ -1,6 +1,7 @@ import { isSecretLikePath } from "../domain/referenceSourceClassification.js"; import { createHistoricalSourceGraph, + historicalSourceParseFailureKey, type HistoricalSourceGraph, type HistoricalSourceGraphInput, } from "../domain/referenceSourceGraph.js"; @@ -66,6 +67,27 @@ const deduplicateRelationships = ( return result; }; +/** Sort parse failures deterministically while retaining distinct reasons. */ +export const normalizeHistoricalSourceParseFailures = ( + failures: HistoricalSourceGraphInput["parse_failures"], +): HistoricalSourceGraphInput["parse_failures"] => + [...failures] + .sort((left, right) => + byCodePoint( + historicalSourceParseFailureKey(left), + historicalSourceParseFailureKey(right), + ), + ) + .filter((value, index, array) => { + if (index === 0) return true; + const previous = array[index - 1]; + return ( + previous === undefined || + historicalSourceParseFailureKey(value) !== + historicalSourceParseFailureKey(previous) + ); + }); + const buildProvenance = ( options: ReferenceSourceImportOptions, ): HistoricalSourceGraphInput["provenance"] => ({ @@ -204,18 +226,7 @@ export const importReferenceSource = async ( if (isAborted(options.signal)) return err(cancelled()); const uniqueRelationships = deduplicateRelationships(relationships); - const uniqueFailures = [...parseFailures] - .sort((left, right) => { - const byPath = byCodePoint(left.path, right.path); - if (byPath !== 0) return byPath; - return byCodePoint(left.parser, right.parser); - }) - .filter((value, index, array) => { - if (index === 0) return true; - const previous = array[index - 1]; - if (previous === undefined) return true; - return value.path !== previous.path || value.parser !== previous.parser; - }); + const uniqueFailures = normalizeHistoricalSourceParseFailures(parseFailures); const sortedEntries = [...entries].sort((left, right) => byCodePoint(left.path, right.path), diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 639f71d726..80dc20b9d4 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -3,7 +3,6 @@ import { copyFile, mkdir, readFile, - rename, rm, writeFile, } from "node:fs/promises"; @@ -15,6 +14,7 @@ import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; import { PRODUCT_IDENTITY } from "../identity.js"; import { supportsNodeVersion } from "../domain/runtimeVersion.js"; +import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import { runDoctor, systemDoctorHost } from "./Doctor.js"; import { installLinuxHopper, @@ -25,7 +25,10 @@ import { installMacHopper } from "./MacHopper.js"; import { configureDetectedClients } from "./SetupClients.js"; import { supportedClients, type SetupClient } from "./SupportedClients.js"; export type { SetupClient } from "./SupportedClients.js"; -import { installCanonicalSkill } from "./SetupSkill.js"; +import { + canonicalSkillNeedsInstall, + installCanonicalSkill, +} from "./SetupSkill.js"; import { setupPlan } from "./SetupPlan.js"; import { setupInstallFailure, @@ -34,7 +37,10 @@ import { } from "./SetupInstallFailure.js"; export type { SetupHopperInstallResult } from "./SetupInstallFailure.js"; -export { installCanonicalSkill } from "./SetupSkill.js"; +export { + canonicalSkillNeedsInstall, + installCanonicalSkill, +} from "./SetupSkill.js"; const registrationCommand = (): readonly string[] => process.env.npm_command === "exec" @@ -49,7 +55,7 @@ export type ClientConfigurationResult = } | { readonly status: "failed"; - readonly reason: "backup" | "write" | "readback"; + readonly reason: "path" | "backup" | "write" | "readback"; }; /** * Effects required by the idempotent setup workflow. @@ -69,6 +75,12 @@ export interface SetupHost { hopperPath: string | undefined, command: readonly string[], ): Promise; + clientNeedsConfigure( + client: SetupClient, + hopperPath: string | undefined, + command: readonly string[], + ): Promise; + skillNeedsInstall(): Promise; installSkill(): Promise<"installed" | "unchanged" | "failed">; doctor(): Promise>>; } @@ -131,17 +143,10 @@ export const runSetup = async ( const unsupported = await hostRemediation(host); if (unsupported !== undefined) return fail(unsupported); let hopperPath = await host.hopperPath(); - const initialDoctor = await host.doctor(); - const linuxHopperRepairNeeded = initialDoctor.checks.some( - ({ name, ok, detail }) => - !ok && - (name === "hopper-demo-runtime" || - (name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")), - ); - const installHopper = hopperPath === undefined || linuxHopperRepairNeeded; - const detectedClients = await host.detectedClients(); - plannedActions = setupPlan(host.platform, installHopper, detectedClients); - let approved = options.approved; + const discovery = await discoverSetupActions(host, hopperPath); + const { detectedClients, installHopper, installSkill } = discovery; + plannedActions = discovery.plannedActions; + let approved = options.approved || plannedActions.length === 0; let interactiveApproval = false; if (!approved && confirm !== undefined && !options.structured) { interactiveApproval = await confirm(plannedActions); @@ -182,12 +187,14 @@ export const runSetup = async ( appliedActions, }); if (clientFailure !== undefined) return fail(clientFailure); - const skill = await host.installSkill(); - if (skill === "failed") - return fail( - "REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.", - ); - if (skill === "installed") appliedActions.push("installed_skill"); + if (installSkill) { + const skill = await host.installSkill(); + if (skill === "failed") + return fail( + "REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.", + ); + if (skill === "installed") appliedActions.push("installed_skill"); + } const doctor = await host.doctor(); const remediation = finalSetupRemediation( host.platform, @@ -205,6 +212,59 @@ export const runSetup = async ( }; }; +const discoverSetupActions = async ( + host: SetupHost, + hopperPath: string | undefined, +) => { + const initialDoctor = await host.doctor(); + const linuxHopperRepairNeeded = initialDoctor.checks.some( + ({ name, ok, detail }) => + !ok && + (name === "hopper-demo-runtime" || + (name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")), + ); + const installHopper = hopperPath === undefined || linuxHopperRepairNeeded; + const [detectedClients, installSkill] = await Promise.all([ + host.detectedClients(), + host.skillNeedsInstall(), + ]); + const command = registrationCommand(); + const clients = installHopper + ? detectedClients + : await filterClientsNeedingConfigure( + host, + detectedClients, + hopperPath, + command, + ); + const plannedActions = setupPlan( + host.platform, + installHopper, + installSkill, + clients, + ); + return { + installHopper, + installSkill, + detectedClients: clients, + plannedActions, + }; +}; + +const filterClientsNeedingConfigure = async ( + host: SetupHost, + detectedClients: readonly SetupClient[], + hopperPath: string | undefined, + command: readonly string[], +): Promise => { + const needs = await Promise.all( + detectedClients.map((client) => + host.clientNeedsConfigure(client, hopperPath, command), + ), + ); + return detectedClients.filter((_, index) => needs[index]); +}; + const finalSetupRemediation = ( platform: NodeJS.Platform, installedHopper: boolean, @@ -261,6 +321,11 @@ const systemSetupHost = (): SetupHost => { : client.format === "toml" ? configureTomlClient(client, hopperPath, command) : configureJsonClient(client, hopperPath, command), + clientNeedsConfigure: (client, hopperPath, command) => + clientConfigurationAligned(client, hopperPath, command).then( + (aligned) => !aligned, + ), + skillNeedsInstall: () => canonicalSkillNeedsInstall(homedir()), installSkill: () => installCanonicalSkill(homedir()), doctor: () => runDoctor(undefined, doctorHost), }; @@ -288,10 +353,15 @@ export const configureJsonClient = async ( "mcp", ], ): Promise => { + const transactionPath = await resolveClientConfigTransactionPath( + client.configPath, + ); + if (transactionPath === undefined) + return { status: "failed", reason: "path" }; let document: Record = {}; let original: string | undefined; try { - original = await readFile(client.configPath, "utf8"); + original = await readFile(transactionPath, "utf8"); document = parseObject(original); } catch (cause: unknown) { if (!isMissing(cause)) return { status: "failed", reason: "readback" }; @@ -318,7 +388,7 @@ export const configureJsonClient = async ( if (original !== undefined) { backupPath = `${client.configPath}.rea.backup`; try { - await copyFile(client.configPath, backupPath); + await copyFile(transactionPath, backupPath); } catch { return { status: "failed", reason: "backup" }; } @@ -328,26 +398,26 @@ export const configureJsonClient = async ( [PRODUCT_IDENTITY.mcpServerKey]: desired, }; const encoded = `${JSON.stringify(document, null, 2)}\n`; - const temporary = `${client.configPath}.rea.tmp`; try { await mkdir(dirname(client.configPath), { recursive: true }); - await writeFile(temporary, encoded, { encoding: "utf8", mode: 0o600 }); - await rename(temporary, client.configPath); + await writeFileAtomic(transactionPath, encoded, { + encoding: "utf8", + mode: 0o600, + }); } catch { - await rm(temporary, { force: true }).catch(() => undefined); return { status: "failed", reason: "write" }; } try { - const readback = parseObject(await readFile(client.configPath, "utf8")); + const readback = parseObject(await readFile(transactionPath, "utf8")); const value = parseOptionalObject(readback.mcpServers)[ PRODUCT_IDENTITY.mcpServerKey ]; if (JSON.stringify(value) !== JSON.stringify(desired)) { - await restoreConfig(client.configPath, original); + await restoreConfig(transactionPath, original); return { status: "failed", reason: "readback" }; } } catch { - await restoreConfig(client.configPath, original); + await restoreConfig(transactionPath, original); return { status: "failed", reason: "readback" }; } return { @@ -367,10 +437,15 @@ export const configureTomlClient = async ( "mcp", ], ): Promise => { + const transactionPath = await resolveClientConfigTransactionPath( + client.configPath, + ); + if (transactionPath === undefined) + return { status: "failed", reason: "path" }; let document: Record = {}; let original: string | undefined; try { - original = await readFile(client.configPath, "utf8"); + original = await readFile(transactionPath, "utf8"); document = objectSchema.parse(parseToml(original)); } catch (cause: unknown) { if (!isMissing(cause)) return { status: "failed", reason: "readback" }; @@ -396,7 +471,7 @@ export const configureTomlClient = async ( const backupPath = original === undefined ? undefined : `${client.configPath}.rea.backup`; try { - if (backupPath !== undefined) await copyFile(client.configPath, backupPath); + if (backupPath !== undefined) await copyFile(transactionPath, backupPath); } catch { return { status: "failed", reason: "backup" }; } @@ -406,12 +481,12 @@ export const configureTomlClient = async ( }; try { await mkdir(dirname(client.configPath), { recursive: true }); - await writeFileAtomic(client.configPath, stringifyToml(document), { + await writeFileAtomic(transactionPath, stringifyToml(document), { encoding: "utf8", mode: 0o600, }); const readback = objectSchema.parse( - parseToml(await readFile(client.configPath, "utf8")), + parseToml(await readFile(transactionPath, "utf8")), ); if ( JSON.stringify( @@ -422,7 +497,7 @@ export const configureTomlClient = async ( ) throw new Error("TOML readback mismatch"); } catch { - await restoreConfig(client.configPath, original); + await restoreConfig(transactionPath, original); return { status: "failed", reason: "readback" }; } return { @@ -459,3 +534,41 @@ const restoreConfig = async ( // The backup remains available for the remediation reported by setup. } }; + +const clientConfigurationDesired = ( + hopperPath: string | undefined, + command: readonly string[], +) => ({ + command: command[0] ?? PRODUCT_IDENTITY.cliBinary, + args: command.slice(1), + ...(hopperPath === undefined + ? {} + : { env: { HOPPER_LAUNCHER_PATH: hopperPath } }), +}); + +const clientConfigurationAligned = async ( + client: SetupClient, + hopperPath: string | undefined, + command: readonly string[], +): Promise => { + const desired = clientConfigurationDesired(hopperPath, command); + try { + const original = await readFile(client.configPath, "utf8"); + if (client.format === "toml") { + const document = objectSchema.parse(parseToml(original)); + const servers = parseOptionalObject(document.mcp_servers); + return ( + JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) === + JSON.stringify(desired) + ); + } + const document = parseObject(original); + const servers = parseOptionalObject(document.mcpServers); + return ( + JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) === + JSON.stringify(desired) + ); + } catch { + return false; + } +}; diff --git a/src/application/SetupClients.ts b/src/application/SetupClients.ts index bcaca48e01..8554fe8c60 100644 --- a/src/application/SetupClients.ts +++ b/src/application/SetupClients.ts @@ -5,8 +5,10 @@ import type { } from "./Setup.js"; const failedConfigurationMessage = ( - reason: "backup" | "write" | "readback", + reason: "path" | "backup" | "write" | "readback", ): string => { + if (reason === "path") + return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup."; if (reason === "backup") return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup."; if (reason === "write") @@ -14,7 +16,7 @@ const failedConfigurationMessage = ( return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup."; }; -/** Configure each detected agent, stopping after the first failed transaction. */ +/** Configure every detected agent and report the first failed transaction. */ export const configureDetectedClients = async (options: { readonly host: SetupHost; readonly detectedClients: readonly SetupClient[]; @@ -23,6 +25,7 @@ export const configureDetectedClients = async (options: { readonly clients: Record; readonly appliedActions: string[]; }): Promise => { + let failure: string | undefined; for (const client of options.detectedClients) { const result = await options.host.configureClient( client, @@ -31,9 +34,9 @@ export const configureDetectedClients = async (options: { ); options.clients[client.name] = result; if (result.status === "failed") - return failedConfigurationMessage(result.reason); - if (result.status === "configured") + failure ??= failedConfigurationMessage(result.reason); + else if (result.status === "configured") options.appliedActions.push(`configured_${client.name}`); } - return undefined; + return failure; }; diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 38aba05e48..7c67898869 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -4,6 +4,7 @@ import type { SetupAction, SetupClient } from "./Setup.js"; export const setupPlan = ( platform: NodeJS.Platform, installHopper: boolean, + installSkill: boolean, clients: readonly SetupClient[], ): readonly SetupAction[] => [ ...(installHopper @@ -32,10 +33,14 @@ export const setupPlan = ( external: false, }), ), - { - kind: "install_skill", - target: "~/.agents/skills/rea-analysis/SKILL.md", - detail: "Install or update the bundled REA analysis skill.", - external: false, - }, + ...(installSkill + ? [ + { + kind: "install_skill" as const, + target: "~/.agents/skills/rea-analysis/SKILL.md", + detail: "Install or update the bundled REA analysis skill.", + external: false, + }, + ] + : []), ]; diff --git a/src/application/SetupSkill.ts b/src/application/SetupSkill.ts index 5df9377807..c8907cd583 100644 --- a/src/application/SetupSkill.ts +++ b/src/application/SetupSkill.ts @@ -4,26 +4,53 @@ import writeFileAtomic from "write-file-atomic"; import { PRODUCT_IDENTITY } from "../identity.js"; +const canonicalSkillFiles = async ( + home: string, +): Promise<{ readonly content: string; readonly destination: string }> => ({ + destination: join( + home, + ".agents/skills", + PRODUCT_IDENTITY.skillName, + "SKILL.md", + ), + content: await readFile( + new URL( + `../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`, + import.meta.url, + ), + "utf8", + ), +}); + +/** Report whether setup would change the managed REA skill. */ +export const canonicalSkillNeedsInstall = async ( + home: string, +): Promise => { + try { + const { content, destination } = await canonicalSkillFiles(home); + return (await readFile(destination, "utf8")) !== content; + } catch { + return true; + } +}; + /** Transactionally install or upgrade the versioned canonical REA skill. */ export const installCanonicalSkill = async ( home: string, ): Promise<"installed" | "unchanged" | "failed"> => { - const destination = join( + let destination = join( home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md", ); - const backup = `${destination}.rea.backup`; + let backup = `${destination}.rea.backup`; let original: string | undefined; try { - const content = await readFile( - new URL( - `../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`, - import.meta.url, - ), - "utf8", - ); + const canonical = await canonicalSkillFiles(home); + destination = canonical.destination; + backup = `${destination}.rea.backup`; + const { content } = canonical; original = await readFile(destination, "utf8").catch(() => undefined); if (original === content) return "unchanged"; await mkdir(dirname(destination), { recursive: true }); diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index d9a15f7b9c..efe3c93293 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -1,4 +1,4 @@ -import { copyFile, lstat, readFile, rm } from "node:fs/promises"; +import { copyFile, lstat, readFile, realpath, rm } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, isAbsolute, join } from "node:path"; @@ -7,9 +7,12 @@ import writeFileAtomic from "write-file-atomic"; import { z } from "zod"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import { supportedClients, type SetupClient } from "./SupportedClients.js"; interface ManagedPathStats { + readonly uid?: number; + isFile?(): boolean; isSymbolicLink(): boolean; } @@ -19,6 +22,7 @@ export interface UninstallFileSystem { copy(source: string, destination: string): Promise; writeText(path: string, contents: string): Promise; stat(path: string): Promise; + realpath?(path: string): Promise; remove(path: string): Promise; } @@ -28,6 +32,7 @@ const systemFileSystem: UninstallFileSystem = { writeText: (path, contents) => writeFileAtomic(path, contents, { encoding: "utf8" }), stat: (path) => lstat(path), + realpath: (path) => realpath(path), remove: (path) => rm(path, { recursive: true }), }; @@ -104,9 +109,12 @@ const removeClient = async ( "skipped", "This client has no documented local MCP configuration boundary.", ); + const resolved = await resolveUninstallConfigPath(client, fileSystem); + if (typeof resolved !== "string") return resolved; + const transactionPath = resolved; let original: string; try { - original = await fileSystem.readText(client.configPath); + original = await fileSystem.readText(transactionPath); } catch (cause: unknown) { return isMissing(cause) ? item(client.name, "skipped", "Configuration does not exist.") @@ -146,7 +154,7 @@ const removeClient = async ( document[key] = remaining; const backupPath = `${client.configPath}.rea.backup`; try { - await fileSystem.copy(client.configPath, backupPath); + await fileSystem.copy(transactionPath, backupPath); } catch { return item( client.name, @@ -159,11 +167,11 @@ const removeClient = async ( client.format === "toml" ? stringifyToml(document) : `${JSON.stringify(document, null, 2)}\n`; - await fileSystem.writeText(client.configPath, encoded); + await fileSystem.writeText(transactionPath, encoded); const readback = objectSchema.parse( client.format === "toml" - ? parseToml(await fileSystem.readText(client.configPath)) - : JSON.parse(await fileSystem.readText(client.configPath)), + ? parseToml(await fileSystem.readText(transactionPath)) + : JSON.parse(await fileSystem.readText(transactionPath)), ); if (PRODUCT_IDENTITY.mcpServerKey in optionalObject(readback[key])) throw new Error("registration readback mismatch"); @@ -174,7 +182,7 @@ const removeClient = async ( ); } catch { try { - await fileSystem.writeText(client.configPath, original); + await fileSystem.writeText(transactionPath, original); } catch { return item( client.name, @@ -190,6 +198,26 @@ const removeClient = async ( } }; +const resolveUninstallConfigPath = async ( + client: SetupClient, + fileSystem: UninstallFileSystem, +): Promise => { + const path = await resolveClientConfigTransactionPath( + client.configPath, + fileSystem.realpath === undefined + ? { lstat: fileSystem.stat } + : { lstat: fileSystem.stat, realpath: fileSystem.realpath }, + ); + return ( + path ?? + item( + client.name, + "failed", + "Configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun uninstall.", + ) + ); +}; + /** Ownership rule for persistent MCP registrations created by REA setup. */ const isOwnedRegistration = (value: unknown): boolean => { const parsed = registrationSchema.safeParse(value); diff --git a/src/application/Upgrade.ts b/src/application/Upgrade.ts index f8a8b97048..756c5d5e41 100644 --- a/src/application/Upgrade.ts +++ b/src/application/Upgrade.ts @@ -4,6 +4,7 @@ import { basename, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { gt, valid } from "semver"; import { z } from "zod"; import { PRODUCT_IDENTITY } from "../identity.js"; @@ -59,7 +60,7 @@ export type UpgradeResult = readonly status: "failed"; readonly currentVersion: string; readonly latestVersion: string | null; - readonly reason: "unknown-install-method" | "install"; + readonly reason: "unknown-install-method" | "version-check" | "install"; readonly remediation: string; }; @@ -70,7 +71,20 @@ export const runUpgrade = async ( output: UpgradeOutput = "human", ): Promise => { const latestVersion = await host.latestVersion(); - if (latestVersion === currentVersion) + const currentIsValid = valid(currentVersion) !== null; + const latestIsValid = + latestVersion !== undefined && valid(latestVersion) !== null; + if (!currentIsValid || !latestIsValid) + return { + status: "failed", + currentVersion, + latestVersion: latestVersion ?? null, + reason: "version-check", + remediation: + "REA could not verify a newer registry release. Check npm registry access and version metadata, then rerun rea upgrade.", + }; + + if (!gt(latestVersion, currentVersion)) return { status: "current", currentVersion, @@ -100,8 +114,8 @@ export const runUpgrade = async ( return { status: "upgraded", previousVersion: currentVersion, - latestVersion: latestVersion ?? null, - versionCheck: latestVersion === undefined ? "unavailable" : "available", + latestVersion, + versionCheck: "available", installMethod: "npm", command: UPGRADE_COMMAND, clientRestartRequired: true, diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index b49202f0c3..602e9f7a58 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -47,12 +47,17 @@ import { type BrowserObservationOperation, } from "../domain/errors.js"; import { err, ok, type Result } from "../domain/result.js"; -import { CdpConnection } from "./CdpConnection.js"; import { discoverCdpEndpoint, + hasCdpTargetWebSocket, type CdpEndpointDiscovery, type CdpEndpointTarget, } from "./CdpEndpoint.js"; +import { + closeCdpTargetSession, + openCdpTargetSession, + type CdpTargetSession, +} from "./CdpTargetSession.js"; import { capturePage, type CapturedPage } from "./CdpPageCapture.js"; import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js"; import { observeCdpSession } from "./CdpObservationSession.js"; @@ -65,6 +70,7 @@ const IDENTITY: ProviderIdentity = { name: "REA Chrome DevTools Protocol observation provider", version: "2", }; +const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const; /** Passive, origin-scoped browser observation through a user-owned CDP endpoint. */ export class CdpBrowserProvider implements BrowserObservationPort { @@ -105,6 +111,11 @@ export class CdpBrowserProvider implements BrowserObservationPort { excluded: selected.excluded, limitations: [ "Only page targets whose current URL matches an approved exact origin are listed.", + ...(selected.unconnectable === 0 + ? [] + : [ + `${String(selected.unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`, + ]), ], }), ); @@ -118,7 +129,7 @@ export class CdpBrowserProvider implements BrowserObservationPort { options: ExecutionOptions = {}, ): Promise> { try { - const captured = await this.#capture(input, options); + const captured = await this.#capture(input, options, "inspect_web_page"); return ok(webPageInspectionSchema.parse(captured.inspection)); } catch (cause: unknown) { return err(providerError(cause, "inspect_web_page")); @@ -130,7 +141,11 @@ export class CdpBrowserProvider implements BrowserObservationPort { options: ExecutionOptions = {}, ): Promise> { try { - const captured = await this.#capture(input, options); + const captured = await this.#capture( + input, + options, + "analyze_web_bundle", + ); const sourceMaps = input.fetch_source_maps ? await fetchWebSourceMaps( captured.sourceMapRequests, @@ -152,31 +167,25 @@ export class CdpBrowserProvider implements BrowserObservationPort { input: ObserveWebSessionInput, options: ExecutionOptions = {}, ): Promise> { - let connection: CdpConnection | undefined; - let sessionId: string | undefined; + let targetSession: CdpTargetSession | undefined; try { const discovery = await discoverCdpEndpoint( input.cdp_endpoint, - "inspect_web_page", + "observe_web_session", options.signal, ); const target = authorizeTarget(discovery, input); - connection = await CdpConnection.connect( - discovery.browserWebSocketUrl, + targetSession = await openCdpTargetSession( + discovery, + target, + "observe_web_session", options.signal, ); - const attached = await connection.send( - "Target.attachToTarget", - { targetId: target.id, flatten: true }, - undefined, - options.signal, - ); - sessionId = attachedSessionId(attached); return ok( webObservationSessionSchema.parse( await observeCdpSession({ - connection, - sessionId, + connection: targetSession.connection, + sessionId: targetSession.sessionId, discovery, target, input, @@ -190,11 +199,8 @@ export class CdpBrowserProvider implements BrowserObservationPort { } catch (cause: unknown) { return err(providerError(cause, "observe_web_session")); } finally { - if (connection !== undefined) { - if (sessionId !== undefined) - await bestEffortCleanup(connection, sessionId); - await connection.close(); - } + if (targetSession !== undefined) + await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS); } } @@ -202,31 +208,25 @@ export class CdpBrowserProvider implements BrowserObservationPort { input: DiscoverWebMcpToolsInput, options: ExecutionOptions = {}, ): Promise> { - let connection: CdpConnection | undefined; - let sessionId: string | undefined; + let targetSession: CdpTargetSession | undefined; try { const discovery = await discoverCdpEndpoint( input.cdp_endpoint, - "inspect_web_page", + "discover_webmcp_tools", options.signal, ); const target = authorizeTarget(discovery, input); - connection = await CdpConnection.connect( - discovery.browserWebSocketUrl, + targetSession = await openCdpTargetSession( + discovery, + target, + "discover_webmcp_tools", options.signal, ); - const attached = await connection.send( - "Target.attachToTarget", - { targetId: target.id, flatten: true }, - undefined, - options.signal, - ); - sessionId = attachedSessionId(attached); return ok( webMcpDiscoverySchema.parse( await discoverWebMcp({ - connection, - sessionId, + connection: targetSession.connection, + sessionId: targetSession.sessionId, discovery, target, input, @@ -240,11 +240,8 @@ export class CdpBrowserProvider implements BrowserObservationPort { } catch (cause: unknown) { return err(providerError(cause, "discover_webmcp_tools")); } finally { - if (connection !== undefined) { - if (sessionId !== undefined) - await bestEffortCleanup(connection, sessionId); - await connection.close(); - } + if (targetSession !== undefined) + await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS); } } @@ -262,31 +259,25 @@ export class CdpBrowserProvider implements BrowserObservationPort { input: CaptureWebScreenshotInput, options: ExecutionOptions = {}, ): Promise> { - let connection: CdpConnection | undefined; - let sessionId: string | undefined; + let targetSession: CdpTargetSession | undefined; try { const discovery = await discoverCdpEndpoint( input.cdp_endpoint, - "inspect_web_page", + "capture_web_screenshot", options.signal, ); const target = authorizeTarget(discovery, input); - connection = await CdpConnection.connect( - discovery.browserWebSocketUrl, + targetSession = await openCdpTargetSession( + discovery, + target, + "capture_web_screenshot", options.signal, ); - const attached = await connection.send( - "Target.attachToTarget", - { targetId: target.id, flatten: true }, - undefined, - options.signal, - ); - sessionId = attachedSessionId(attached); return ok( webScreenshotSchema.parse( await captureCdpScreenshot({ - connection, - sessionId, + connection: targetSession.connection, + sessionId: targetSession.sessionId, discovery, target, input, @@ -300,11 +291,8 @@ export class CdpBrowserProvider implements BrowserObservationPort { } catch (cause: unknown) { return err(providerError(cause, "capture_web_screenshot")); } finally { - if (connection !== undefined) { - if (sessionId !== undefined) - await bestEffortCleanup(connection, sessionId); - await connection.close(); - } + if (targetSession !== undefined) + await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS); } } @@ -321,30 +309,26 @@ export class CdpBrowserProvider implements BrowserObservationPort { async #capture( input: InspectWebPageInput, options: ExecutionOptions, + operation: "inspect_web_page" | "analyze_web_bundle", ): Promise { - let connection: CdpConnection | undefined; - let sessionId: string | undefined; + let targetSession: CdpTargetSession | undefined; try { const discovery = await discoverCdpEndpoint( input.cdp_endpoint, - "inspect_web_page", + operation, options.signal, ); const target = authorizeTarget(discovery, input); - connection = await CdpConnection.connect( - discovery.browserWebSocketUrl, + targetSession = await openCdpTargetSession( + discovery, + target, + operation, options.signal, ); - const attached = await connection.send( - "Target.attachToTarget", - { targetId: target.id, flatten: true }, - undefined, - options.signal, - ); - sessionId = attachedSessionId(attached); return await capturePage({ - connection, - sessionId, + connection: targetSession.connection, + sessionId: targetSession.sessionId, + operation, discovery, target, input, @@ -354,11 +338,8 @@ export class CdpBrowserProvider implements BrowserObservationPort { : { progress: options.progress }), }); } finally { - if (connection !== undefined) { - if (sessionId !== undefined) - await bestEffortCleanup(connection, sessionId); - await connection.close(); - } + if (targetSession !== undefined) + await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS); } } } @@ -369,11 +350,13 @@ const selectTargets = ( ): { readonly allowed: readonly BrowserTargetList["targets"]["items"][number][]; readonly excluded: BrowserTargetList["excluded"]; + readonly unconnectable: number; } => { const allowed = []; let disallowedOrigin = 0; let unsupportedUrl = 0; let nonPage = 0; + let unconnectable = 0; for (const target of discovery.targets) { if (target.type !== "page") { nonPage += 1; @@ -388,6 +371,10 @@ const selectTargets = ( disallowedOrigin += 1; continue; } + if (!hasCdpTargetWebSocket(discovery, target)) { + unconnectable += 1; + continue; + } allowed.push({ target_id: target.id, type: target.type, @@ -405,6 +392,7 @@ const selectTargets = ( unsupported_url: unsupportedUrl, non_page: nonPage, }, + unconnectable, }; }; @@ -427,37 +415,6 @@ const authorizeTarget = ( return target; }; -const attachedSessionId = (value: unknown): string => { - if (typeof value !== "object" || value === null || !("sessionId" in value)) - throw new BrowserObservationError("inspect_web_page", "protocol_error"); - if (typeof value.sessionId !== "string" || value.sessionId.length > 256) - throw new BrowserObservationError("inspect_web_page", "protocol_error"); - return value.sessionId; -}; - -const bestEffortCleanup = async ( - connection: CdpConnection, - sessionId: string, -): Promise => { - for (const method of [ - "Network.disable", - "Debugger.disable", - "Runtime.disable", - "Page.disable", - ]) { - try { - await connection.send(method, {}, sessionId); - } catch { - // Cleanup continues so detach is still attempted. - } - } - try { - await connection.send("Target.detachFromTarget", { sessionId }); - } catch { - // Closing REA's socket is the final non-destructive cleanup boundary. - } -}; - const providerError = ( cause: unknown, operation: BrowserObservationOperation, diff --git a/src/browser/CdpCaptureStorage.ts b/src/browser/CdpCaptureStorage.ts index 21f6b32f59..00463d07dc 100644 --- a/src/browser/CdpCaptureStorage.ts +++ b/src/browser/CdpCaptureStorage.ts @@ -15,7 +15,7 @@ import { export const captureStorage = async ( context: { readonly connection: CdpConnection; - readonly sessionId: string; + readonly sessionId: string | undefined; readonly input: InspectWebPageInput; readonly signal?: AbortSignal; }, diff --git a/src/browser/CdpCaptureValues.ts b/src/browser/CdpCaptureValues.ts index ffa601fc81..3352de9d0b 100644 --- a/src/browser/CdpCaptureValues.ts +++ b/src/browser/CdpCaptureValues.ts @@ -1,6 +1,7 @@ import { AnalysisCancelledError, BrowserObservationError, + type BrowserObservationOperation, } from "../domain/errors.js"; import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; import { createWebTextArtifact } from "../domain/webContentArtifact.js"; @@ -74,11 +75,11 @@ export const sourceExcluded = ( export const delayWithCancellation = async ( durationMs: number, + operation: BrowserObservationOperation, signal?: AbortSignal, ): Promise => { if (durationMs === 0) return; - if (signal?.aborted === true) - throw new AnalysisCancelledError("inspect_web_page"); + if (signal?.aborted === true) throw new AnalysisCancelledError(operation); await new Promise((resolve, reject) => { const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); @@ -87,7 +88,7 @@ export const delayWithCancellation = async ( const onAbort = (): void => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); - reject(new AnalysisCancelledError("inspect_web_page")); + reject(new AnalysisCancelledError(operation)); }; signal?.addEventListener("abort", onAbort, { once: true }); if (signal?.aborted === true) onAbort(); diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index fc4d069b82..667bb6344f 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -5,6 +5,7 @@ import { AnalysisError, AnalysisTimeoutError, BrowserObservationError, + type BrowserObservationOperation, } from "../domain/errors.js"; export interface CdpEvent { @@ -25,34 +26,38 @@ const COMMAND_TIMEOUT_MS = 5_000; const MAX_PAYLOAD_BYTES = 16 * 1_024 * 1_024; const MAX_PENDING_COMMANDS = 128; -/** Correlated, bounded JSON-RPC transport for a browser-level CDP socket. */ +/** Correlated, bounded JSON-RPC transport for one CDP target operation. */ export class CdpConnection { readonly #pending = new Map(); readonly #listeners = new Set<(event: CdpEvent) => void>(); + readonly #disconnectListeners = new Set<() => void>(); #nextId = 1; #closed = false; #protocolFailed = false; - private constructor(private readonly socket: WebSocket) { + private constructor( + private readonly socket: WebSocket, + private readonly operation: BrowserObservationOperation, + ) { socket.on("message", (data) => this.#receive(data)); - socket.on("close", () => this.#failPending("disconnected")); - socket.on("error", () => this.#failPending("disconnected")); + socket.on("close", () => this.#disconnect()); + socket.on("error", () => this.#disconnect()); } - /** Connect to one already-validated loopback browser WebSocket. */ + /** Connect to one already-validated loopback CDP WebSocket. */ static async connect( url: string, + operation: BrowserObservationOperation, signal?: AbortSignal, ): Promise { - if (signal?.aborted === true) - throw new AnalysisCancelledError("inspect_web_page"); + if (signal?.aborted === true) throw new AnalysisCancelledError(operation); const socket = new WebSocket(url, { handshakeTimeout: CONNECT_TIMEOUT_MS, maxPayload: MAX_PAYLOAD_BYTES, perMessageDeflate: false, }); - await waitForOpen(socket, signal); - return new CdpConnection(socket); + await waitForOpen(socket, operation, signal); + return new CdpConnection(socket, operation); } /** Subscribe to validated CDP event envelopes. */ @@ -61,6 +66,16 @@ export class CdpConnection { return () => this.#listeners.delete(listener); } + /** Subscribe to an unexpected transport loss while an operation is active. */ + onDisconnect(listener: () => void): () => void { + if (this.#closed) { + listener(); + return () => undefined; + } + this.#disconnectListeners.add(listener); + return () => this.#disconnectListeners.delete(listener); + } + /** Execute one bounded command, optionally within a flat target session. */ async send( method: string, @@ -69,13 +84,13 @@ export class CdpConnection { signal?: AbortSignal, ): Promise { if (this.#protocolFailed) - throw new BrowserObservationError("inspect_web_page", "protocol_error"); + throw new BrowserObservationError(this.operation, "protocol_error"); if (this.#closed || this.socket.readyState !== WebSocket.OPEN) - throw new BrowserObservationError("inspect_web_page", "disconnected"); + throw new BrowserObservationError(this.operation, "disconnected"); if (this.#pending.size >= MAX_PENDING_COMMANDS) - throw new BrowserObservationError("inspect_web_page", "payload_limit"); + throw new BrowserObservationError(this.operation, "payload_limit"); if (signal?.aborted === true) - throw new AnalysisCancelledError("inspect_web_page"); + throw new AnalysisCancelledError(this.operation); const id = this.#nextId; this.#nextId += 1; return await new Promise((resolve, reject) => { @@ -85,15 +100,13 @@ export class CdpConnection { clearTimeout(pending.timer); pending.removeAbort(); this.#pending.delete(id); - reject(new AnalysisCancelledError("inspect_web_page")); + reject(new AnalysisCancelledError(this.operation)); }; signal?.addEventListener("abort", onAbort, { once: true }); const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); this.#pending.delete(id); - reject( - new AnalysisTimeoutError("inspect_web_page", COMMAND_TIMEOUT_MS), - ); + reject(new AnalysisTimeoutError(this.operation, COMMAND_TIMEOUT_MS)); }, COMMAND_TIMEOUT_MS); this.#pending.set(id, { resolve, @@ -106,7 +119,7 @@ export class CdpConnection { id, method, params, - ...(sessionId ? { sessionId } : {}), + ...(sessionId === undefined ? {} : { sessionId }), }), (error) => { if (error === undefined || error === null) return; @@ -114,7 +127,7 @@ export class CdpConnection { if (pending === undefined) return; this.#complete(id, pending); reject( - new BrowserObservationError("inspect_web_page", "disconnected", { + new BrowserObservationError(this.operation, "disconnected", { cause: error, }), ); @@ -191,7 +204,7 @@ export class CdpConnection { this.#complete(id, pending); if ("error" in message) { pending.reject( - new BrowserObservationError("inspect_web_page", "protocol_error"), + new BrowserObservationError(this.operation, "protocol_error"), ); return; } @@ -209,9 +222,17 @@ export class CdpConnection { else this.#protocolFailed = true; for (const [id, pending] of this.#pending) { this.#complete(id, pending); - pending.reject(new BrowserObservationError("inspect_web_page", reason)); + pending.reject(new BrowserObservationError(this.operation, reason)); } } + + #disconnect(): void { + const wasClosed = this.#closed; + this.#failPending("disconnected"); + if (wasClosed) return; + for (const listener of this.#disconnectListeners) listener(); + this.#disconnectListeners.clear(); + } } const rawText = (data: RawData): string => @@ -226,13 +247,14 @@ const isRecord = (value: unknown): value is Record => const waitForOpen = async ( socket: WebSocket, + operation: BrowserObservationOperation, signal?: AbortSignal, ): Promise => await new Promise((resolve, reject) => { const timer = setTimeout(() => { cleanup(); terminateSocket(socket); - reject(new AnalysisTimeoutError("inspect_web_page", CONNECT_TIMEOUT_MS)); + reject(new AnalysisTimeoutError(operation, CONNECT_TIMEOUT_MS)); }, CONNECT_TIMEOUT_MS); const onOpen = (): void => { cleanup(); @@ -242,17 +264,15 @@ const waitForOpen = async ( cleanup(); reject( signal?.aborted === true - ? new AnalysisCancelledError("inspect_web_page") - : new BrowserObservationError( - "inspect_web_page", - "endpoint_unreachable", - { cause }, - ), + ? new AnalysisCancelledError(operation) + : new BrowserObservationError(operation, "endpoint_unreachable", { + cause, + }), ); }; const onAbort = (): void => { terminateSocket(socket); - onFailure(new AnalysisCancelledError("inspect_web_page")); + onFailure(new AnalysisCancelledError(operation)); }; const cleanup = (): void => { clearTimeout(timer); diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index 9aa0e71729..adf84e50f4 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -29,7 +29,7 @@ import { interface ElectronContext { readonly connection: CdpConnection; - readonly sessionId: string; + readonly sessionId: string | undefined; readonly discovery: CdpEndpointDiscovery; readonly target: CdpEndpointTarget; readonly input: InspectElectronPageInput; @@ -95,7 +95,11 @@ export const inspectCdpElectronPage = async ( context.sessionId, context.signal, ); - await delayWithCancellation(context.input.observation_ms, context.signal); + await delayWithCancellation( + context.input.observation_ms, + "inspect_electron_page", + context.signal, + ); await report(context.progress, 2, "Capturing Electron structure"); const resources = await captureResources(context, roots, completeness); const dom = await captureDom(context, roots, completeness); diff --git a/src/browser/CdpElectronProvider.ts b/src/browser/CdpElectronProvider.ts index d779ca5b53..da400e6300 100644 --- a/src/browser/CdpElectronProvider.ts +++ b/src/browser/CdpElectronProvider.ts @@ -18,8 +18,16 @@ import { type BrowserObservationOperation, } from "../domain/errors.js"; import { err, ok, type Result } from "../domain/result.js"; -import { CdpConnection } from "./CdpConnection.js"; -import { discoverCdpEndpoint, type CdpEndpointTarget } from "./CdpEndpoint.js"; +import { + discoverCdpEndpoint, + hasCdpTargetWebSocket, + type CdpEndpointTarget, +} from "./CdpEndpoint.js"; +import { + closeCdpTargetSession, + openCdpTargetSession, + type CdpTargetSession, +} from "./CdpTargetSession.js"; import { inspectCdpElectronPage } from "./CdpElectronInspection.js"; import { authorizedElectronFile, @@ -46,13 +54,14 @@ export class CdpElectronProvider implements ElectronObservationPort { const roots = await canonicalElectronRoots(input.allowed_file_roots); const discovery = await discoverCdpEndpoint( input.cdp_endpoint, - "list_browser_targets", + "list_electron_targets", options.signal, ); const allowed = []; let outsideRoot = 0; let unsupportedUrl = 0; let nonPage = 0; + let unconnectable = 0; for (const target of discovery.targets) { if (target.type !== "page") { nonPage += 1; @@ -64,6 +73,10 @@ export class CdpElectronProvider implements ElectronObservationPort { else unsupportedUrl += 1; continue; } + if (!hasCdpTargetWebSocket(discovery, target)) { + unconnectable += 1; + continue; + } allowed.push({ target_id: target.id, type: target.type, @@ -96,6 +109,11 @@ export class CdpElectronProvider implements ElectronObservationPort { }, limitations: [ "Only page targets whose canonical file path is contained by an approved root are listed.", + ...(unconnectable === 0 + ? [] + : [ + `${String(unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`, + ]), ], }), ); @@ -108,13 +126,12 @@ export class CdpElectronProvider implements ElectronObservationPort { input: InspectElectronPageInput, options: ExecutionOptions = {}, ): Promise> { - let connection: CdpConnection | undefined; - let sessionId: string | undefined; + let targetSession: CdpTargetSession | undefined; try { const roots = await canonicalElectronRoots(input.allowed_file_roots); const discovery = await discoverCdpEndpoint( input.cdp_endpoint, - "inspect_web_page", + "inspect_electron_page", options.signal, ); const target = await authorizeTarget( @@ -122,22 +139,17 @@ export class CdpElectronProvider implements ElectronObservationPort { input.target_id, roots, ); - connection = await CdpConnection.connect( - discovery.browserWebSocketUrl, + targetSession = await openCdpTargetSession( + discovery, + target, + "inspect_electron_page", options.signal, ); - const attached = await connection.send( - "Target.attachToTarget", - { targetId: target.id, flatten: true }, - undefined, - options.signal, - ); - sessionId = attachedSessionId(attached); return ok( electronPageInspectionSchema.parse( await inspectCdpElectronPage({ - connection, - sessionId, + connection: targetSession.connection, + sessionId: targetSession.sessionId, discovery, target, input, @@ -151,10 +163,8 @@ export class CdpElectronProvider implements ElectronObservationPort { } catch (cause: unknown) { return err(providerError(cause, "inspect_electron_page")); } finally { - if (connection !== undefined) { - if (sessionId !== undefined) await cleanup(connection, sessionId); - await connection.close(); - } + if (targetSession !== undefined) + await closeCdpTargetSession(targetSession, ["Debugger", "Page"]); } } } @@ -175,35 +185,6 @@ const authorizeTarget = async ( return target; }; -const attachedSessionId = (value: unknown): string => { - if ( - typeof value !== "object" || - value === null || - !("sessionId" in value) || - typeof value.sessionId !== "string" || - value.sessionId.length > 256 - ) - throw new BrowserObservationError("inspect_web_page", "protocol_error"); - return value.sessionId; -}; - -const cleanup = async ( - connection: CdpConnection, - sessionId: string, -): Promise => { - for (const method of ["Debugger.disable", "Page.disable"]) - try { - await connection.send(method, {}, sessionId); - } catch { - // Detach remains the final non-destructive cleanup boundary. - } - try { - await connection.send("Target.detachFromTarget", { sessionId }); - } catch { - // Closing REA's socket never closes the Electron target. - } -}; - const isFileUrl = (value: string): boolean => { try { return new URL(value).protocol === "file:"; diff --git a/src/browser/CdpEndpoint.ts b/src/browser/CdpEndpoint.ts index b09c9d1ca9..4aa55b8df6 100644 --- a/src/browser/CdpEndpoint.ts +++ b/src/browser/CdpEndpoint.ts @@ -29,13 +29,34 @@ const endpointTargetSchema = z.object({ title: z.string().max(16_384).default(""), url: z.string().max(65_536), attached: z.boolean().default(false), + webSocketDebuggerUrl: z.string().min(1).max(2_048).optional(), }); const endpointTargetsSchema = z.array(endpointTargetSchema).max(1_000); -export type CdpEndpointTarget = z.infer; +/** Validated direct CDP WebSocket bound to one discovered page target. */ +interface CdpPageWebSocketEndpoint { + readonly scope: "page"; + readonly targetId: string; + readonly url: string; +} + +/** Validated CDP discovery socket and its command-routing scope. */ +export type CdpWebSocketEndpoint = + | { readonly scope: "browser"; readonly url: string } + | CdpPageWebSocketEndpoint; + +/** Bounded discovery target with an optional validated direct transport. */ +export interface CdpEndpointTarget { + readonly id: string; + readonly type: string; + readonly title: string; + readonly url: string; + readonly attached: boolean; + readonly webSocket?: CdpPageWebSocketEndpoint; +} export interface CdpEndpointDiscovery { - readonly browserWebSocketUrl: string; + readonly webSocket: CdpWebSocketEndpoint; readonly version: { readonly product: string; readonly protocol_version: string; @@ -75,7 +96,7 @@ export const discoverCdpEndpoint = async ( operation, ); return { - browserWebSocketUrl: safeBrowserWebSocketUrl( + webSocket: safeCdpWebSocketEndpoint( endpoint, version.webSocketDebuggerUrl, operation, @@ -87,10 +108,45 @@ export const discoverCdpEndpoint = async ( user_agent: version["User-Agent"], js_version: version["V8-Version"], }, - targets, + targets: targets.map((target) => ({ + id: target.id, + type: target.type, + title: target.title, + url: target.url, + attached: target.attached, + ...targetWebSocket(endpoint, target, operation), + })), }; }; +/** Select a browser attachment socket or a direct socket for one page target. */ +export const cdpTargetWebSocket = ( + discovery: CdpEndpointDiscovery, + target: CdpEndpointTarget, + operation: BrowserObservationOperation, +): CdpWebSocketEndpoint => { + const webSocket = availableCdpTargetWebSocket(discovery, target); + if (webSocket !== undefined) return webSocket; + throw new BrowserObservationError(operation, "invalid_endpoint_response"); +}; + +/** Report whether discovery contains a validated transport for one target. */ +export const hasCdpTargetWebSocket = ( + discovery: CdpEndpointDiscovery, + target: CdpEndpointTarget, +): boolean => availableCdpTargetWebSocket(discovery, target) !== undefined; + +const availableCdpTargetWebSocket = ( + discovery: CdpEndpointDiscovery, + target: CdpEndpointTarget, +): CdpWebSocketEndpoint | undefined => { + if (discovery.webSocket.scope === "browser") return discovery.webSocket; + if (target.webSocket !== undefined) return target.webSocket; + return discovery.webSocket.targetId === target.id + ? discovery.webSocket + : undefined; +}; + const parseEndpointValue = ( schema: z.ZodType, input: unknown, @@ -104,11 +160,11 @@ const parseEndpointValue = ( return parsed.data; }; -const safeBrowserWebSocketUrl = ( +const safeCdpWebSocketEndpoint = ( endpoint: string, reported: string, operation: BrowserObservationOperation, -): string => { +): CdpWebSocketEndpoint => { let parsed: URL; try { parsed = new URL(reported); @@ -118,6 +174,7 @@ const safeBrowserWebSocketUrl = ( }); } const trustedEndpoint = new URL(endpoint); + const path = cdpWebSocketPath(parsed.pathname); if ( parsed.protocol !== "ws:" || parsed.port !== trustedEndpoint.port || @@ -125,11 +182,59 @@ const safeBrowserWebSocketUrl = ( parsed.password !== "" || parsed.search !== "" || parsed.hash !== "" || - !parsed.pathname.startsWith("/devtools/browser/") + path === undefined ) throw new BrowserObservationError(operation, "invalid_endpoint_response"); parsed.hostname = trustedEndpoint.hostname; - return parsed.href; + return path.scope === "browser" + ? { scope: "browser", url: parsed.href } + : { scope: "page", targetId: path.targetId, url: parsed.href }; +}; + +const cdpWebSocketPath = ( + pathname: string, +): + | { readonly scope: "browser" } + | { readonly scope: "page"; targetId: string } + | undefined => { + const browserId = pathIdentifier(pathname, "/devtools/browser/", 1_024); + if (browserId !== undefined) return { scope: "browser" }; + const targetId = pathIdentifier(pathname, "/devtools/page/", 256); + return targetId === undefined ? undefined : { scope: "page", targetId }; +}; + +const pathIdentifier = ( + pathname: string, + prefix: string, + maximumLength: number, +): string | undefined => { + if (!pathname.startsWith(prefix)) return undefined; + const identifier = pathname.slice(prefix.length); + return identifier.length > 0 && + identifier.length <= maximumLength && + !identifier.includes("/") + ? identifier + : undefined; +}; + +const targetWebSocket = ( + endpoint: string, + target: z.infer, + operation: BrowserObservationOperation, +): { readonly webSocket?: CdpPageWebSocketEndpoint } => { + if (target.webSocketDebuggerUrl === undefined) return {}; + try { + const webSocket = safeCdpWebSocketEndpoint( + endpoint, + target.webSocketDebuggerUrl, + operation, + ); + return webSocket.scope === "page" && webSocket.targetId === target.id + ? { webSocket } + : {}; + } catch { + return {}; + } }; const readJson = async ( diff --git a/src/browser/CdpObservationSession.ts b/src/browser/CdpObservationSession.ts index 2016f651de..ff55925359 100644 --- a/src/browser/CdpObservationSession.ts +++ b/src/browser/CdpObservationSession.ts @@ -3,7 +3,10 @@ import type { ObserveWebSessionInput, WebObservationSession, } from "../domain/browserSession.js"; -import { BrowserObservationError } from "../domain/errors.js"; +import { + AnalysisCancelledError, + BrowserObservationError, +} from "../domain/errors.js"; import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js"; import { CdpConnection, type CdpEvent } from "./CdpConnection.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; @@ -20,7 +23,7 @@ import { mainFrameUrl } from "./CdpCaptureDocuments.js"; interface ObservationContext { readonly connection: CdpConnection; - readonly sessionId: string; + readonly sessionId: string | undefined; readonly discovery: CdpEndpointDiscovery; readonly target: CdpEndpointTarget; readonly input: ObserveWebSessionInput; @@ -48,8 +51,12 @@ export const observeCdpSession = async ( initialUrl, ); const removeListener = context.connection.onEvent((event) => { - if (event.sessionId === context.sessionId) capture.ingest(event); + if (observationEventMatches(event, context.sessionId)) + capture.ingest(event); }); + const removeDisconnectListener = context.connection.onDisconnect(() => + capture.targetTerminated(), + ); try { await context.connection.send( "Page.enable", @@ -115,6 +122,7 @@ export const observeCdpSession = async ( }; } finally { removeListener(); + removeDisconnectListener(); } }; @@ -162,8 +170,7 @@ class TimelineCapture { break; case "Inspector.targetCrashed": case "Target.detachedFromTarget": - this.#add("target_terminated", params, null, null, "target_terminated"); - this.end("target_terminated"); + this.targetTerminated(); break; } } @@ -181,6 +188,12 @@ class TimelineCapture { this.#listeners.clear(); } + targetTerminated(): void { + if (this.#endReason !== undefined) return; + this.#add("target_terminated", {}, null, null, "target_terminated"); + this.end("target_terminated"); + } + setFinalUrl(rawUrl: string): void { const destination = scopedUrl(rawUrl, this.allowedOrigins); this.finalUrl = destination.scope === "approved" ? destination.url : null; @@ -300,6 +313,18 @@ class TimelineCapture { } } +const observationEventMatches = ( + event: CdpEvent, + sessionId: string | undefined, +): boolean => { + if (event.method !== "Target.detachedFromTarget") + return event.sessionId === sessionId; + return ( + sessionId !== undefined && + stringValue(recordValue(event.params)?.sessionId) === sessionId + ); +}; + const waitForWindow = async ( durationMs: number, capture: TimelineCapture, @@ -321,7 +346,7 @@ const waitForWindow = async ( settled = true; clearTimeout(timer); removeEnd(); - reject(signal?.reason ?? new Error("browser observation cancelled")); + reject(new AnalysisCancelledError("observe_web_session")); }; const timer = setTimeout(() => finish("window_elapsed"), durationMs); removeEnd = capture.onEnd(finish); @@ -347,7 +372,7 @@ const authorizedFrame = async ( "inspect_web_page", "target_not_allowed", ); - await delayWithCancellation(25, context.signal); + await delayWithCancellation(25, "observe_web_session", context.signal); } throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); }; diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index 45418e7048..9972d86c4e 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -3,7 +3,10 @@ import type { WebPageInspection, } from "../domain/browserObservation.js"; import type { ProgressReporter } from "../application/ProgressReporter.js"; -import { BrowserObservationError } from "../domain/errors.js"; +import { + BrowserObservationError, + type BrowserObservationOperation, +} from "../domain/errors.js"; import { reconcileCapturedWebScript, stableWebResources, @@ -37,7 +40,11 @@ import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js"; interface CaptureContext { readonly connection: CdpConnection; - readonly sessionId: string; + readonly sessionId: string | undefined; + readonly operation: Extract< + BrowserObservationOperation, + "inspect_web_page" | "analyze_web_bundle" + >; readonly discovery: CdpEndpointDiscovery; readonly target: CdpEndpointTarget; readonly input: InspectWebPageInput; @@ -225,7 +232,11 @@ const authorizeObservationWindow = async ( events.beginAuthorizedFrame(mainFrame.frame_id); await enableObservationDomains(connection, sessionId, signal); await report(context.progress, 2, "Observing page events"); - await delayWithCancellation(context.input.observation_ms, signal); + await delayWithCancellation( + context.input.observation_ms, + context.operation, + signal, + ); if (events.originViolation) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); }; @@ -323,14 +334,14 @@ const authorizedFrameTree = async ( "inspect_web_page", "target_not_allowed", ); - await delayWithCancellation(25, context.signal); + await delayWithCancellation(25, context.operation, context.signal); } throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); }; const enableObservationDomains = async ( connection: CdpConnection, - sessionId: string, + sessionId: string | undefined, signal?: AbortSignal, ): Promise => { await connection.send("Runtime.enable", {}, sessionId, signal); diff --git a/src/browser/CdpScreenshot.ts b/src/browser/CdpScreenshot.ts index 6c27518e9f..f2cdb9c9f5 100644 --- a/src/browser/CdpScreenshot.ts +++ b/src/browser/CdpScreenshot.ts @@ -19,7 +19,7 @@ import { decodeCanonicalBase64 } from "../domain/webScreenshot.js"; interface ScreenshotContext { readonly connection: CdpConnection; - readonly sessionId: string; + readonly sessionId: string | undefined; readonly discovery: CdpEndpointDiscovery; readonly target: CdpEndpointTarget; readonly input: CaptureWebScreenshotInput; diff --git a/src/browser/CdpTargetSession.ts b/src/browser/CdpTargetSession.ts new file mode 100644 index 0000000000..5567c78906 --- /dev/null +++ b/src/browser/CdpTargetSession.ts @@ -0,0 +1,81 @@ +import { + BrowserObservationError, + type BrowserObservationOperation, +} from "../domain/errors.js"; +import { CdpConnection } from "./CdpConnection.js"; +import { + cdpTargetWebSocket, + type CdpEndpointDiscovery, + type CdpEndpointTarget, +} from "./CdpEndpoint.js"; + +/** One CDP connection scoped either by a flat browser session or page socket. */ +export interface CdpTargetSession { + readonly connection: CdpConnection; + readonly sessionId: string | undefined; +} + +/** Open one authorized target through its supported browser or page transport. */ +export const openCdpTargetSession = async ( + discovery: CdpEndpointDiscovery, + target: CdpEndpointTarget, + operation: BrowserObservationOperation, + signal?: AbortSignal, +): Promise => { + const webSocket = cdpTargetWebSocket(discovery, target, operation); + const connection = await CdpConnection.connect( + webSocket.url, + operation, + signal, + ); + if (webSocket.scope === "page") return { connection, sessionId: undefined }; + try { + const attached = await connection.send( + "Target.attachToTarget", + { targetId: target.id, flatten: true }, + undefined, + signal, + ); + return { connection, sessionId: attachedSessionId(attached, operation) }; + } catch (cause: unknown) { + await connection.close(); + throw cause; + } +}; + +/** Disable enabled domains, detach browser sessions, and close REA's socket. */ +export const closeCdpTargetSession = async ( + targetSession: CdpTargetSession, + enabledDomains: readonly string[], +): Promise => { + const { connection, sessionId } = targetSession; + for (const domain of enabledDomains) + try { + await connection.send(`${domain}.disable`, {}, sessionId); + } catch { + // Cleanup continues to the detach or direct-socket close boundary. + } + if (sessionId !== undefined) + try { + await connection.send("Target.detachFromTarget", { sessionId }); + } catch { + // Closing REA's socket is the final non-destructive cleanup boundary. + } + await connection.close(); +}; + +const attachedSessionId = ( + value: unknown, + operation: BrowserObservationOperation, +): string => { + if ( + typeof value !== "object" || + value === null || + !("sessionId" in value) || + typeof value.sessionId !== "string" || + value.sessionId.length === 0 || + value.sessionId.length > 256 + ) + throw new BrowserObservationError(operation, "protocol_error"); + return value.sessionId; +}; diff --git a/src/browser/CdpWebMcpDiscovery.ts b/src/browser/CdpWebMcpDiscovery.ts index 8297a8a855..d0bbbdf61a 100644 --- a/src/browser/CdpWebMcpDiscovery.ts +++ b/src/browser/CdpWebMcpDiscovery.ts @@ -25,7 +25,7 @@ import { boundedSensitiveText } from "./SensitiveTextCapture.js"; interface DiscoveryContext { readonly connection: CdpConnection; - readonly sessionId: string; + readonly sessionId: string | undefined; readonly discovery: CdpEndpointDiscovery; readonly target: CdpEndpointTarget; readonly input: DiscoverWebMcpToolsInput; @@ -96,6 +96,7 @@ export const discoverWebMcp = async ( // CDP sends the enable response before the required toolsAdded replay. await delayWithCancellation( Math.max(context.input.observation_ms, 25), + "discover_webmcp_tools", context.signal, ); await assertStableAuthorizedFrame(context, initialUrl, origins); diff --git a/src/cliInvestigationCommands.ts b/src/cliInvestigationCommands.ts index 89adc3d2da..6cc1df3b70 100644 --- a/src/cliInvestigationCommands.ts +++ b/src/cliInvestigationCommands.ts @@ -18,6 +18,11 @@ const investigationOptionsSchema = z.object({ .describe("Approve persistent workspace writes"), workspaceName: z.string().trim().min(1).max(200).default("default"), expectedRevision: z.number().int().min(1).optional(), + replayRunId: z + .string() + .regex(/^run_[a-f0-9]{64}$/u) + .optional() + .describe("Replay one verified complete workspace run without rescanning"), maxEntries: z.number().int().min(1).max(50_000).default(10_000), maxTotalBytes: z .number() @@ -55,6 +60,7 @@ export const registerInvestigationCommands = ( alias: { workspaceName: "workspace-name", expectedRevision: "expected-revision", + replayRunId: "replay-run-id", maxEntries: "max-entries", maxTotalBytes: "max-total-bytes", maxEntryBytes: "max-entry-bytes", @@ -99,13 +105,6 @@ const runInvestigation = async ( }, ); if (!workspaceAuthorization.ok) return cliError(workspaceAuthorization.error); - const inputAuthorization = await authorizeRootPermission(authority.value, { - capability: "investigation_input", - roots: [args.leftPath, args.rightPath], - access: "read", - operation: "investigate_versions", - }); - if (!inputAuthorization.ok) return cliError(inputAuthorization.error); const investigated = await runCrossVersionInvestigation( { approved: true, @@ -114,6 +113,9 @@ const runInvestigation = async ( ...(options.expectedRevision === undefined ? {} : { expected_workspace_revision: options.expectedRevision }), + ...(options.replayRunId === undefined + ? {} + : { replay_run_id: options.replayRunId }), left_path: args.leftPath, right_path: args.rightPath, integrity_policy: options.integrityPolicy, @@ -134,6 +136,13 @@ const runInvestigation = async ( { inputRoots: config.value.investigationInputRoots, integrityContinueEnabled: config.value.artifactIntegrityContinueEnabled, + authorizeInputRead: () => + authorizeRootPermission(authority.value, { + capability: "investigation_input", + roots: [args.leftPath, args.rightPath], + access: "read", + operation: "investigate_versions", + }), authorizeWorkspaceWrite: workspaceAuthorization.value.authorizeWrite, }, ); diff --git a/src/cliPolicyCommands.ts b/src/cliPolicyCommands.ts index d28e2abe07..eebcb34a59 100644 --- a/src/cliPolicyCommands.ts +++ b/src/cliPolicyCommands.ts @@ -1,4 +1,5 @@ import { Cli, z } from "incur"; +import { createInterface } from "node:readline/promises"; import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { @@ -30,6 +31,24 @@ const capabilitySchema = z.enum([ "reference_read", ]); +type PolicyRevocationApproval = + | { readonly approved: true } + | { readonly approved: false; readonly reason: "cancelled" | "required" }; + +/** Require an explicit decision before a project permission grant is removed. */ +export const approvePolicyRevocation = async (options: { + readonly approved: boolean; + readonly interactive: boolean; + readonly grantId: string; + readonly confirm: (grantId: string) => Promise; +}): Promise => { + if (options.approved) return { approved: true }; + if (!options.interactive) return { approved: false, reason: "required" }; + return (await options.confirm(options.grantId)) + ? { approved: true } + : { approved: false, reason: "cancelled" }; +}; + /** Register inspectable, dry-run, and revocation policy UX for the CLI. */ export const registerPolicyCommands = ( cli: ReturnType, @@ -49,9 +68,13 @@ export const registerPolicyCommands = ( network: z.enum(["none", "loopback", "external"]).default("none"), mount: z.boolean().default(false), write: z.boolean().default(false), + yes: z + .boolean() + .default(false) + .describe("Confirm project permission revocation without prompting"), }), - alias: { environmentNames: "environment-names" }, - run: ({ args, options }) => + alias: { environmentNames: "environment-names", yes: "y" }, + run: ({ agent, args, formatExplicit, options }) => logCliCommand(logger, "policy", async () => { const config = parseConfig(process.env); if (!config.ok) return failure(config.error); @@ -76,6 +99,24 @@ export const registerPolicyCommands = ( permissionProjectStore === undefined ) return { error: "Project policy is not configured" }; + const approval = await approvePolicyRevocation({ + approved: options.yes, + interactive: + !agent && !formatExplicit && process.stdin.isTTY === true, + grantId: args.value, + confirm: confirmPolicyRevocation, + }); + if (!approval.approved) + return approval.reason === "required" + ? { + error: + "Policy revoke requires confirmation. Rerun interactively or with --yes.", + grant_id: args.value, + } + : { + error: "Policy revocation was cancelled", + grant_id: args.value, + }; const revoked = await revokeProjectPermissionGrant( permissionProjectStore, permissionProjectRoot, @@ -100,6 +141,21 @@ export const registerPolicyCommands = ( }); }; +const confirmPolicyRevocation = async (grantId: string): Promise => { + const prompt = createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = await prompt.question( + `Revoke project permission grant ${JSON.stringify(grantId)}? [y/N] `, + ); + return ["y", "yes"].includes(answer.trim().toLowerCase()); + } finally { + prompt.close(); + } +}; + const explain = async ( config: AppConfig, capability: PermissionCapability, diff --git a/src/config.ts b/src/config.ts index ad81f50edb..413fb9c212 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,7 @@ import type { import { browserEndpointSchema, browserOriginSchema, + isLiteralLoopbackHostname, } from "./domain/browserObservation.js"; import { electronFileRootsSchema } from "./domain/electronObservation.js"; @@ -406,7 +407,7 @@ const browserNetworkScope = ( ): "loopback" | "external" => origins.every((origin) => { const hostname = new URL(origin).hostname; - return hostname === "127.0.0.1" || hostname === "[::1]"; + return isLiteralLoopbackHostname(hostname); }) ? "loopback" : "external"; diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index c94f43f921..dadd9773d8 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -46,7 +46,8 @@ export const browserOriginSchema = z return origin; }); -const isLiteralLoopback = (hostname: string): boolean => +/** Recognize the URL API's bracketed IPv6 form and normalized bare literals. */ +export const isLiteralLoopbackHostname = (hostname: string): boolean => hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"; /** Loopback-only HTTP endpoint accepted for a user-owned CDP browser. */ @@ -64,7 +65,7 @@ export const browserEndpointSchema = z } if ( url.protocol !== "http:" || - !isLiteralLoopback(url.hostname) || + !isLiteralLoopbackHostname(url.hostname) || url.port === "" || url.username !== "" || url.password !== "" || diff --git a/src/domain/investigationWorkspace.ts b/src/domain/investigationWorkspace.ts index 4b24601224..a004d8081c 100644 --- a/src/domain/investigationWorkspace.ts +++ b/src/domain/investigationWorkspace.ts @@ -59,6 +59,10 @@ export const crossVersionInvestigationInputSchema = z workspace_path: z.string().min(1).max(4_096), workspace_name: z.string().trim().min(1).max(200).default("default"), expected_workspace_revision: z.number().int().min(1).optional(), + replay_run_id: z + .string() + .regex(/^run_[a-f0-9]{64}$/u) + .optional(), left_path: z.string().min(1).max(4_096), right_path: z.string().min(1).max(4_096), integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"), diff --git a/src/domain/referenceSourceGraph.ts b/src/domain/referenceSourceGraph.ts index b9689c2172..3c54131994 100644 --- a/src/domain/referenceSourceGraph.ts +++ b/src/domain/referenceSourceGraph.ts @@ -225,6 +225,11 @@ export type HistoricalSourceManifest = z.infer< typeof historicalSourceManifestBaseSchema >; +/** Stable identity used to sort and deduplicate source parse failures. */ +export const historicalSourceParseFailureKey = ( + failure: HistoricalSourceGraphInput["parse_failures"][number], +): string => `${failure.path}\u0000${failure.parser}\u0000${failure.reason}`; + const canonicalJson = (value: unknown): string => { const serialized = canonicalize(value); if (serialized === undefined) @@ -400,7 +405,7 @@ function checkGraphInvariants( context, ); checkSortedUnique( - graph.parse_failures.map(({ path, parser }) => `${path}\u0000${parser}`), + graph.parse_failures.map(historicalSourceParseFailureKey), "parse_failures", context, ); diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index 8de3ad704b..91c3fe0eff 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -72,6 +72,7 @@ export class HopperClient { #buffer = ""; #closing = false; #launcherExitCode: number | null | undefined; + #startupController: AbortController | undefined; #startPromise: Promise> | undefined; #closePromise: Promise | undefined; @@ -86,14 +87,34 @@ export class HopperClient { /** Launch the bridge once and complete its authenticated health handshake. */ start(signal?: AbortSignal): Promise> { - this.#startPromise ??= this.#start(signal); - return this.#startPromise; + if (this.#closePromise !== undefined) { + return this.#closePromise.then(() => this.start(signal)); + } + if (this.#startPromise !== undefined) return this.#startPromise; + + const controller = new AbortController(); + const startupSignal = + signal === undefined + ? controller.signal + : AbortSignal.any([signal, controller.signal]); + const started = this.#start(startupSignal); + this.#startupController = controller; + this.#startPromise = started; + void started.then((result) => { + if (!result.ok && this.#startPromise === started) { + this.#startPromise = undefined; + if (this.#startupController === controller) { + this.#startupController = undefined; + } + } + }); + return started; } async #start( signal?: AbortSignal, ): Promise> { - if (signal?.aborted === true) return err(new HopperCancelledError()); + if (isAborted(signal)) return err(new HopperCancelledError()); if (this.#socket !== undefined || this.#directory !== undefined) { return err(new HopperProtocolError("Hopper client is already started")); } @@ -102,6 +123,10 @@ export class HopperClient { } catch (cause: unknown) { return err(new HopperStartError({ cause })); } + if (isAborted(signal)) { + await this.#cleanup(); + return err(new HopperCancelledError()); + } const socketPath = join(this.#directory, "bridge.sock"); this.#token = randomBytes(32).toString("hex"); this.#launcherExitCode = undefined; @@ -116,14 +141,14 @@ export class HopperClient { signal === undefined ? {} : { signal }, ); if (!launched.ok) { - await this.close(); + await this.#cleanup(); return launched; } this.#launch = launched.value; this.#attachLauncher(launched.value); const connected = await this.#connect(socketPath, signal); if (!connected.ok) { - await this.close(); + await this.#cleanup(); return connected; } const health = await this.#request( @@ -135,11 +160,11 @@ export class HopperClient { }, ); if (!health.ok) { - await this.close(); + await this.#cleanup(); return health; } const parsed = parseServerInfo(health.value, runId); - if (!parsed.ok) await this.close(); + if (!parsed.ok) await this.#cleanup(); return parsed; } @@ -159,11 +184,32 @@ export class HopperClient { /** Stop the bridge, settle outstanding requests, and remove session artifacts. */ close(): Promise { - this.#closePromise ??= Promise.resolve().then(() => this.#close()); + const starting = this.#startPromise; + const controller = this.#startupController; + controller?.abort(); + this.#closePromise ??= Promise.resolve().then(() => + this.#close(starting, controller), + ); return this.#closePromise; } - async #close(): Promise { + async #close( + starting: Promise> | undefined, + controller: AbortController | undefined, + ): Promise { + try { + await starting?.catch(() => undefined); + await this.#cleanup(); + } finally { + if (this.#startPromise === starting) this.#startPromise = undefined; + if (this.#startupController === controller) { + this.#startupController = undefined; + } + this.#closePromise = undefined; + } + } + + async #cleanup(): Promise { this.#closing = true; try { const socket = this.#socket; @@ -216,10 +262,8 @@ export class HopperClient { this.#directory = undefined; } this.#token = undefined; - this.#startPromise = undefined; } finally { this.#closing = false; - this.#closePromise = undefined; } } @@ -411,6 +455,8 @@ export class HopperClient { } } +const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true; + const waitForDelay = ( milliseconds: number, signal?: AbortSignal, diff --git a/src/main.ts b/src/main.ts index 7fc4a2c7e3..5908d565f8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,7 @@ import { readProjectPermissionStore } from "./application/ProjectPermissionStore import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js"; import { CdpElectronProvider } from "./browser/CdpElectronProvider.js"; +import type { PermissionGrant } from "./domain/permissionPolicy.js"; const MCP_CONNECTION_LOST = "REA lost its MCP client connection. Restart REA from your MCP client."; @@ -33,8 +34,10 @@ interface RuntimeDependencies { readonly serve: typeof serveStdio; readonly writeStderr: (text: string) => void; readonly setExitCode: (code: number) => void; - readonly registerShutdown: (handler: () => void) => void; + readonly registerShutdown: (handler: () => void) => () => void; readonly registerReload?: (handler: () => void) => () => void; + readonly createServer?: typeof createServer; + readonly readProjectPermissionStore?: typeof readProjectPermissionStore; } const runtimeDependencies = (): RuntimeDependencies => ({ @@ -49,6 +52,12 @@ const runtimeDependencies = (): RuntimeDependencies => ({ process.once("SIGTERM", handler); process.stdin.once("end", handler); process.stdin.once("close", handler); + return () => { + process.off("SIGINT", handler); + process.off("SIGTERM", handler); + process.stdin.off("end", handler); + process.stdin.off("close", handler); + }; }, registerReload: (handler) => { process.on("SIGHUP", handler); @@ -123,30 +132,35 @@ export const run = async ( try { handle = dependencies.serve( () => { - const server = createServer(session, session, { - logger, - processPolicy: runtimeProcessPolicy, - evidenceFilePolicy: runtimeEvidencePolicy, - investigationInputRoots: runtimeInvestigationRoots, - analysisSnapshotFilePolicy: runtimeSnapshotPolicy, - permissionAuthority: permissionAuthority.value, - browserObservation, - electronObservation, - artifactIntegrityContinueEnabled: () => - currentConfig.artifactIntegrityContinueEnabled, - availabilityPolicy: () => ({ - processCaptureEnabled: currentConfig.processExecutionPolicy.enabled, - evidenceFileRoots: currentConfig.evidenceFilePolicy.roots.length, - browserObservationEnabled: - currentConfig.browserObservationEnabled && - currentConfig.browserCdpEndpoints.length > 0 && - currentConfig.browserAllowedOrigins.length > 0, - electronObservationEnabled: - currentConfig.electronObservationEnabled && - currentConfig.electronCdpEndpoints.length > 0 && - currentConfig.electronFileRoots.length > 0, - }), - }); + const server = (dependencies.createServer ?? createServer)( + session, + session, + { + logger, + processPolicy: runtimeProcessPolicy, + evidenceFilePolicy: runtimeEvidencePolicy, + investigationInputRoots: runtimeInvestigationRoots, + analysisSnapshotFilePolicy: runtimeSnapshotPolicy, + permissionAuthority: permissionAuthority.value, + browserObservation, + electronObservation, + artifactIntegrityContinueEnabled: () => + currentConfig.artifactIntegrityContinueEnabled, + availabilityPolicy: () => ({ + processCaptureEnabled: + currentConfig.processExecutionPolicy.enabled, + evidenceFileRoots: currentConfig.evidenceFilePolicy.roots.length, + browserObservationEnabled: + currentConfig.browserObservationEnabled && + currentConfig.browserCdpEndpoints.length > 0 && + currentConfig.browserAllowedOrigins.length > 0, + electronObservationEnabled: + currentConfig.electronObservationEnabled && + currentConfig.electronCdpEndpoints.length > 0 && + currentConfig.electronFileRoots.length > 0, + }), + }, + ); liveServers.add(server); return server; }, @@ -164,38 +178,24 @@ export const run = async ( return 1; } + let reloadQueue = Promise.resolve(); const unregisterReload = dependencies.registerReload?.(() => { - void (async () => { - const refreshed = parseConfig(dependencies.env); - if (!refreshed.ok) { - serverLogger.error("Reloaded permission policy is invalid"); - return; - } - const reloaded = await permissionAuthority.value.reload( - refreshed.value.permissionCeilings, - ); - if (!reloaded.ok) { - serverLogger.error( - "Reloaded permission ceiling could not be applied", - ); - return; - } - const administrators = - await permissionAuthority.value.replaceAdministratorGrants( - refreshed.value.administratorPermissionGrants, - ); - if (!administrators.ok) { - serverLogger.error( - "Reloaded administrator grants could not be applied", - ); - return; - } + const refreshed = parseConfig(dependencies.env); + if (!refreshed.ok) { + serverLogger.error("Reloaded permission policy is invalid"); + return; + } + reloadQueue = reloadQueue.then(async () => { + let projectGrants: readonly PermissionGrant[] = []; if ( refreshed.value.permissionProjectRoot !== undefined && refreshed.value.permissionProjectStore !== undefined ) { - const project = await readProjectPermissionStore( + const project = await ( + dependencies.readProjectPermissionStore ?? + readProjectPermissionStore + )( refreshed.value.permissionProjectStore, refreshed.value.permissionProjectRoot, ); @@ -203,21 +203,17 @@ export const run = async ( serverLogger.error("Reloaded project grants could not be read"); return; } - const projects = await permissionAuthority.value.replaceProjectGrants( - project.value?.grants ?? [], - ); - if (!projects.ok) { - serverLogger.error("Reloaded project grants could not be applied"); - return; - } - } else { - const projects = await permissionAuthority.value.replaceProjectGrants( - [], - ); - if (!projects.ok) { - serverLogger.error("Reloaded project grants could not be cleared"); - return; - } + projectGrants = project.value?.grants ?? []; + } + const reloaded = + await permissionAuthority.value.replaceConfiguredPolicy({ + ceilings: refreshed.value.permissionCeilings, + administratorGrants: refreshed.value.administratorPermissionGrants, + projectGrants, + }); + if (!reloaded.ok) { + serverLogger.error("Reloaded permission policy could not be applied"); + return; } currentConfig = refreshed.value; Object.assign( @@ -238,13 +234,15 @@ export const run = async ( ...refreshed.value.investigationInputRoots, ); for (const server of liveServers) server.sendToolListChanged(); - })(); + }); }) ?? (() => undefined); let shutdownPromise: Promise | undefined; + let unregisterShutdown = (): void => undefined; const shutdown = (): Promise => { shutdownPromise ??= (async () => { unregisterReload(); + unregisterShutdown(); await handle.close(); await session.close(); permissionAuthority.value.clearSessionGrants(); @@ -259,7 +257,7 @@ export const run = async ( }); }; - dependencies.registerShutdown(requestShutdown); + unregisterShutdown = dependencies.registerShutdown(requestShutdown); return 0; }; diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index c60bc2c741..92fca322b7 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -19,7 +19,10 @@ import { } from "../domain/changedBehavior.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; -import { EvidenceIntegrityError } from "../domain/errors.js"; +import { + EvidenceIntegrityError, + type AnalysisError, +} from "../domain/errors.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -93,14 +96,19 @@ const registerChangedBehavior = ( contractOptions(contract), async (input, context) => { const parsed = changedBehaviorInputSchema.parse(input); - if (parsed.investigation_run !== undefined) { + const investigationRun = parsed.investigation_run; + if (investigationRun !== undefined) { const progress = mcpProgressReporter(context); let workspaceAuthorization: DeferredFileWriteAuthorization | undefined; + let authorizeInputRead: + | (() => Promise>) + | undefined; if (policies.permissionAuthority !== undefined) { + const authority = policies.permissionAuthority; const authorizedWorkspace = await authorizeFileReadWithDeferredWrite( - policies.permissionAuthority, + authority, { - path: parsed.investigation_run.workspace_path, + path: investigationRun.workspace_path, readCapability: "investigation_workspace_read", writeCapability: "investigation_workspace_write", operation: contract.name, @@ -109,23 +117,16 @@ const registerChangedBehavior = ( if (!authorizedWorkspace.ok) return toCallToolResult(authorizedWorkspace, contract); workspaceAuthorization = authorizedWorkspace.value; - const authorizedInput = await authorizeRootPermission( - policies.permissionAuthority, - { + authorizeInputRead = () => + authorizeRootPermission(authority, { capability: "investigation_input", - roots: [ - parsed.investigation_run.left_path, - parsed.investigation_run.right_path, - ], + roots: [investigationRun.left_path, investigationRun.right_path], access: "read", operation: contract.name, - }, - ); - if (!authorizedInput.ok) - return toCallToolResult(authorizedInput, contract); + }); } const investigated = await runCrossVersionInvestigation( - parsed.investigation_run, + investigationRun, policies.evidenceFiles, { ...investigationExecution( @@ -141,6 +142,7 @@ const registerChangedBehavior = ( authorizeWorkspaceWrite: workspaceAuthorization.authorizeWrite, }), + ...(authorizeInputRead === undefined ? {} : { authorizeInputRead }), }, ); if (!investigated.ok) return toCallToolResult(investigated, contract); diff --git a/tests/binarySession.test.ts b/tests/binarySession.test.ts index ab90a86668..8895e063ea 100644 --- a/tests/binarySession.test.ts +++ b/tests/binarySession.test.ts @@ -254,6 +254,55 @@ describe("binary session", () => { expect(changes).toBe(2); }); + it("isolates availability observers from execution results and session state", async () => { + const [first] = await targets(); + const provider = cacheProvider([]); + let providerCalls = 0; + provider.createClient = () => ({ + execute: (operation) => { + if (operation === "health") return Promise.resolve(ok(null)); + providerCalls += 1; + return Promise.resolve( + providerCalls === 1 + ? err(new ProviderAdapterError("fixture", operation)) + : ok(operation), + ); + }, + close: () => Promise.resolve(), + }); + const session = new BinarySession(provider); + expect((await session.open(first)).ok).toBe(true); + const input = { address: "0x1000", document: "first" }; + expect((await session.execute("address_name", input)).ok).toBe(false); + + session.onAvailabilityChanged(() => { + throw new Error("external observer failed"); + }); + session.onAvailabilityChanged(() => + Promise.reject(new Error("async external observer failed")), + ); + let delivered = 0; + session.onAvailabilityChanged(() => { + delivered += 1; + }); + + await expect(session.execute("address_name", input)).resolves.toMatchObject( + { ok: true }, + ); + expect(delivered).toBe(1); + expect(session.status()).toMatchObject({ + capabilities: expect.arrayContaining([ + expect.objectContaining({ + operation: "address_name", + available: true, + reason: null, + }), + ]), + }); + expect((await session.execute("address_name", input)).ok).toBe(true); + expect(providerCalls).toBe(2); + }); + it("does not replay operations with filesystem side effects", async () => { const [first] = await targets(); const calls: string[] = []; @@ -365,6 +414,64 @@ describe("binary session", () => { expect(clients[0]?.closed).toBe(1); }); + it("replaces the active client when a canonical path changes contents", async () => { + directory = await mkdtemp(join(tmpdir(), "bb-session-")); + const path = join(directory, "mutable.hop"); + await writeFile(path, "one"); + const clients: Array<{ + readonly targetSha256: string; + readonly calls: string[]; + closed: number; + }> = []; + const session = new BinarySession((target) => { + const state = { + targetSha256: target.sha256, + calls: [] as string[], + closed: 0, + }; + clients.push(state); + return { + execute: (operation) => { + state.calls.push(operation); + return Promise.resolve( + ok(operation === "health" ? null : target.sha256), + ); + }, + close: () => { + state.closed += 1; + return Promise.resolve(); + }, + }; + }); + + const first = await session.open(path); + expect(first.ok).toBe(true); + await writeFile(path, "two"); + const second = await session.open(path); + expect(second.ok).toBe(true); + if (!first.ok || !second.ok) return; + + expect(second.value.sha256).not.toBe(first.value.sha256); + expect(session.activeTarget()?.sha256).toBe(second.value.sha256); + expect(await session.execute("binary_overview", {})).toMatchObject({ + ok: true, + value: { result: second.value.sha256 }, + }); + expect(clients).toMatchObject([ + { + targetSha256: first.value.sha256, + calls: ["health"], + closed: 1, + }, + { + targetSha256: second.value.sha256, + calls: ["health", "binary_overview"], + closed: 0, + }, + ]); + await session.close(); + }); + it("waits for an active call before closing its client during a switch", async () => { const [first, second] = await targets(); const active = deferred>(); diff --git a/tests/browserObservation.test.ts b/tests/browserObservation.test.ts index 29cb36c20a..5294e0ba1e 100644 --- a/tests/browserObservation.test.ts +++ b/tests/browserObservation.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; +import { listBrowserTargets } from "../src/application/BrowserObservationService.js"; +import { createPermissionAuthority } from "../src/application/PermissionAuthority.js"; import { browserEndpointSchema, browserOriginSchema, inspectWebPageInputSchema, + isLiteralLoopbackHostname, listBrowserTargetsInputSchema, sanitizeBrowserUrl, } from "../src/domain/browserObservation.js"; @@ -44,6 +47,53 @@ describe("browser observation contracts", () => { expect(browserEndpointSchema.safeParse(value).success, value).toBe(false); }); + it("recognizes bracketed and normalized IPv6 loopback hostnames", () => { + for (const hostname of ["127.0.0.1", "[::1]", "::1"]) + expect(isLiteralLoopbackHostname(hostname), hostname).toBe(true); + for (const hostname of ["localhost", "127.0.0.2", "::2"]) + expect(isLiteralLoopbackHostname(hostname), hostname).toBe(false); + }); + + it("requests loopback authority for an IPv6-only browser origin", async () => { + const endpoint = "http://127.0.0.1:9222"; + const origin = "http://[::1]:3000"; + const ceiling = { + capability: "browser_observe" as const, + roots: [], + executables: [], + environment_names: [], + origins: [endpoint, origin], + network: "loopback" as const, + mount: false, + }; + const authority = await createPermissionAuthority( + [ceiling], + [ + { + ...ceiling, + grant_id: "administrator:browser_observe", + lifetime: "administrator", + operation_identity: null, + expires_at: null, + }, + ], + ); + if (!authority.ok) throw authority.error; + const result = await listBrowserTargets( + undefined, + authority.value, + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: endpoint, + allowed_origins: [origin], + approved: true, + }), + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + }); + it("applies conservative bounded defaults to public tool input", () => { expect( listBrowserTargetsInputSchema.parse({ diff --git a/tests/cdpBrowserProvider.test.ts b/tests/cdpBrowserProvider.test.ts index 1a3d06c885..3a8ce3a5ed 100644 --- a/tests/cdpBrowserProvider.test.ts +++ b/tests/cdpBrowserProvider.test.ts @@ -53,6 +53,159 @@ describe("CdpBrowserProvider", () => { expect(JSON.stringify(result.value)).not.toContain("page-secret"); }); + it("uses page-scoped discovery sockets as direct target transports", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + additionalPageWithWebSocket: true, + additionalPageWithoutWebSocket: true, + }); + browsers.push(browser); + const provider = new CdpBrowserProvider(); + const listed = await provider.listTargets( + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + approved: true, + }), + ); + if (!listed.ok) throw listed.error; + expect( + listed.value.targets.items.map(({ target_id }) => target_id), + ).toEqual(["allowed-page", "allowed-page-with-socket"]); + expect(listed.value.limitations).toContain( + "1 otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.", + ); + + const inspected = await provider.inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + if (!inspected.ok) throw inspected.error; + expect(inspected.value.target.target_id).toBe("allowed-page"); + expect(inspected.value.network.requests).toHaveLength(1); + + const second = await provider.inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page-with-socket", + approved: true, + observation_ms: 0, + }), + ); + if (!second.ok) throw second.error; + expect(second.value.target.target_id).toBe("allowed-page-with-socket"); + const methods = browser.commands.map(({ method }) => method); + expect(methods).not.toContain("Target.attachToTarget"); + expect(methods).not.toContain("Target.detachFromTarget"); + expect( + browser.commands.every(({ sessionId }) => sessionId === undefined), + ).toBe(true); + }); + + it("projects direct-session cancellation onto the requested operation", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + }); + browsers.push(browser); + const controller = new AbortController(); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + { + signal: controller.signal, + progress: { + report(update) { + if (update.completed === 1) controller.abort(); + return Promise.resolve(); + }, + }, + }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "observe_web_session", + }, + }); + const methods = browser.commands.map(({ method }) => method); + expect(methods).not.toContain("Target.attachToTarget"); + expect(methods).not.toContain("Target.detachFromTarget"); + expect(methods).not.toContain("Target.closeTarget"); + expect(methods).not.toContain("Browser.close"); + }); + + it("projects capture-window cancellation onto bundle analysis", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + }); + browsers.push(browser); + const controller = new AbortController(); + const result = await new CdpBrowserProvider().analyzeBundle( + analyzeWebBundleInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + source_capture_approved: true, + observation_ms: 1_000, + }), + { + signal: controller.signal, + progress: { + report(update) { + if (update.completed === 2) controller.abort(); + return Promise.resolve(); + }, + }, + }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "analyze_web_bundle", + }, + }); + }); + + it("rejects empty browser attachment session identifiers", async () => { + const browser = await startFakeCdpBrowser({ invalidAttachedSession: true }); + browsers.push(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "protocol_error" }, + }); + expect(browser.commands.map(({ method }) => method)).toEqual([ + "Target.attachToTarget", + ]); + }); + it("captures bounded passive evidence without retaining sensitive values", async () => { const browser = await startFakeCdpBrowser({ binaryWebSocketEvent: true, @@ -567,6 +720,58 @@ describe("CdpBrowserProvider", () => { ); }); + it("ends a browser session when its flat target session detaches", async () => { + const browser = await startFakeCdpBrowser({ + sessionTimeline: "target_detached", + }); + browsers.push(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_terminated"); + expect(result.value.timeline.at(-1)).toMatchObject({ + type: "target_terminated", + detail: "target_terminated", + }); + }); + + it("ends a direct page session when its transport disconnects", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + closeAfterMethod: "Page.setLifecycleEventsEnabled", + }); + browsers.push(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_terminated"); + expect(result.value.timeline.at(-1)).toMatchObject({ + type: "target_terminated", + detail: "target_terminated", + }); + const methods = browser.commands.map(({ method }) => method); + expect(methods).not.toContain("Target.attachToTarget"); + expect(methods).not.toContain("Target.detachFromTarget"); + expect(methods).not.toContain("Target.closeTarget"); + }); + it("discovers untrusted WebMCP declarations without registering or invoking them", async () => { const browser = await startFakeCdpBrowser({ webMcpTools: true }); browsers.push(browser); @@ -1121,7 +1326,10 @@ describe("CdpBrowserProvider", () => { const result = await pending; expect(result).toMatchObject({ ok: false, - error: { _tag: "AnalysisCancelledError" }, + error: { + _tag: "AnalysisCancelledError", + operation: "inspect_web_page", + }, }); const methods = browser.commands.map(({ method }) => method); expect(methods).toContain("Target.detachFromTarget"); diff --git a/tests/cdpConnection.test.ts b/tests/cdpConnection.test.ts index a957f6bad9..dc1c3e0514 100644 --- a/tests/cdpConnection.test.ts +++ b/tests/cdpConnection.test.ts @@ -19,7 +19,10 @@ describe("CDP connection", () => { it("correlates concurrent command responses over a real WebSocket", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser); - const connection = await CdpConnection.connect(browser.browserWebSocketUrl); + const connection = await CdpConnection.connect( + browser.browserWebSocketUrl, + "inspect_web_page", + ); try { const [attached, frames] = await Promise.all([ connection.send("Target.attachToTarget"), @@ -37,13 +40,16 @@ describe("CDP connection", () => { it("returns a typed timeout for an unresponsive CDP command", async () => { const browser = await startFakeCdpBrowser({ hangOnMethod: "Page.enable" }); browsers.push(browser); - const connection = await CdpConnection.connect(browser.browserWebSocketUrl); + const connection = await CdpConnection.connect( + browser.browserWebSocketUrl, + "observe_web_session", + ); vi.useFakeTimers(); try { const pending = connection.send("Page.enable"); const assertion = expect(pending).rejects.toMatchObject({ _tag: "AnalysisTimeoutError", - operation: "inspect_web_page", + operation: "observe_web_session", timeoutMs: 5_000, }); await vi.advanceTimersByTimeAsync(5_000); diff --git a/tests/cdpElectronProvider.test.ts b/tests/cdpElectronProvider.test.ts index 98cac969bd..ddeac1c1b9 100644 --- a/tests/cdpElectronProvider.test.ts +++ b/tests/cdpElectronProvider.test.ts @@ -114,6 +114,56 @@ describe("CdpElectronProvider", () => { }); }); + it("inspects a page-scoped Electron endpoint without browser attachment", async () => { + const root = await electronFixture(); + const index = join(root, "index.html"); + const browser = await startFakeCdpBrowser({ + electronFileUrl: pathToFileURL(index).href, + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + }); + browsers.push(browser); + const result = await new CdpElectronProvider().inspectPage( + inspectElectronPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_file_roots: [root], + target_id: "electron-page", + approved: true, + observation_ms: 0, + }), + ); + if (!result.ok) throw result.error; + expect(result.value.target.file_path).toBe(index); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "Target.attachToTarget", + ); + expect( + browser.commands.every(({ sessionId }) => sessionId === undefined), + ).toBe(true); + }); + + it("maps cancelled Electron discovery to the Electron operation", async () => { + const root = await electronFixture(); + const controller = new AbortController(); + controller.abort(); + const result = await new CdpElectronProvider().listTargets( + listElectronTargetsInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_file_roots: [root], + approved: true, + }), + { signal: controller.signal }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "list_electron_targets", + }, + }); + }); + it("captures Electron script source only after separate approval", async () => { const root = await electronFixture(); const browser = await startFakeCdpBrowser({ diff --git a/tests/cliPolicyCommands.test.ts b/tests/cliPolicyCommands.test.ts new file mode 100644 index 0000000000..9dadd6fc6b --- /dev/null +++ b/tests/cliPolicyCommands.test.ts @@ -0,0 +1,155 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + readProjectPermissionStore, + writeProjectPermissionStore, +} from "../src/application/ProjectPermissionStore.js"; +import { approvePolicyRevocation } from "../src/cliPolicyCommands.js"; + +const execute = promisify(execFile); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe("policy revocation approval", () => { + it("accepts --yes without invoking the interactive prompt", async () => { + const confirm = vi.fn<() => Promise>(); + + await expect( + approvePolicyRevocation({ + approved: true, + interactive: false, + grantId: "grant-1", + confirm, + }), + ).resolves.toEqual({ approved: true }); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("fails closed for non-interactive revocation without --yes", async () => { + const confirm = vi.fn<() => Promise>(); + + await expect( + approvePolicyRevocation({ + approved: false, + interactive: false, + grantId: "grant-1", + confirm, + }), + ).resolves.toEqual({ approved: false, reason: "required" }); + expect(confirm).not.toHaveBeenCalled(); + }); + + it.each([ + [true, { approved: true }], + [false, { approved: false, reason: "cancelled" }], + ] as const)( + "uses the interactive decision %s", + async (decision, expected) => { + const confirm = vi.fn(() => Promise.resolve(decision)); + + await expect( + approvePolicyRevocation({ + approved: false, + interactive: true, + grantId: "grant-1", + confirm, + }), + ).resolves.toEqual(expected); + expect(confirm).toHaveBeenCalledWith("grant-1"); + }, + ); + + it("keeps the packaged project store unchanged until --yes is supplied", async () => { + const project = await mkdtemp(join(tmpdir(), "rea-policy-cli-")); + roots.push(project); + const store = join(project, ".rea", "permissions.json"); + const grant = { + grant_id: "project:revoke-me", + capability: "evidence_read" as const, + roots: [project], + executables: [], + environment_names: [], + network: "none" as const, + mount: false, + lifetime: "project" as const, + operation_identity: null, + expires_at: null, + }; + expect( + await writeProjectPermissionStore(store, project, [grant]), + ).toMatchObject({ + ok: true, + value: { grants: [grant] }, + }); + const before = await readFile(store); + const environment = { + ...process.env, + REA_PERMISSION_PROJECT_ROOT: project, + REA_PERMISSION_PROJECT_STORE: store, + }; + + const denied = await runCli( + ["policy", "revoke", grant.grant_id, "--json"], + environment, + ); + expect(denied.status).toBe(1); + expect(denied.output).toEqual({ + error: + "Policy revoke requires confirmation. Rerun interactively or with --yes.", + grant_id: grant.grant_id, + }); + expect(await readFile(store)).toEqual(before); + + const approved = await runCli( + ["policy", "revoke", grant.grant_id, "--yes", "--json"], + environment, + ); + expect(approved).toEqual({ + status: 0, + output: { + revoked: grant.grant_id, + reload: "Send SIGHUP to the REA MCP process", + }, + }); + expect(await readProjectPermissionStore(store, project)).toMatchObject({ + ok: true, + value: { grants: [] }, + }); + }, 40_000); +}); + +const runCli = async ( + arguments_: readonly string[], + environment: NodeJS.ProcessEnv, +): Promise<{ readonly status: number; readonly output: unknown }> => { + try { + const { stdout } = await execute( + process.execPath, + ["scripts/rea.mjs", ...arguments_], + { cwd: process.cwd(), env: environment }, + ); + return { status: 0, output: JSON.parse(stdout) }; + } catch (cause: unknown) { + if ( + typeof cause === "object" && + cause !== null && + "code" in cause && + typeof cause.code === "number" && + "stdout" in cause && + typeof cause.stdout === "string" + ) + return { status: cause.code, output: JSON.parse(cause.stdout) }; + throw cause; + } +}; diff --git a/tests/config.test.ts b/tests/config.test.ts index 542113700c..6e89671d9c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -232,6 +232,21 @@ describe("runtime configuration", () => { ); }); + it("classifies IPv6 browser origins as loopback network scope", () => { + const result = parseConfig({ + REA_BROWSER_OBSERVE_ENABLED: "true", + REA_BROWSER_CDP_ENDPOINTS_JSON: '["http://127.0.0.1:9222"]', + REA_BROWSER_ALLOWED_ORIGINS_JSON: '["http://[::1]:3000"]', + }); + if (!result.ok) throw result.error; + expect(result.value.permissionCeilings).toContainEqual( + expect.objectContaining({ + capability: "browser_observe", + network: "loopback", + }), + ); + }); + it.each([ '["http://localhost:9222"]', '["http://192.168.1.5:9222"]', diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts new file mode 100644 index 0000000000..2d082f9897 --- /dev/null +++ b/tests/dispatcher.test.ts @@ -0,0 +1,64 @@ +import { execFile } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { afterEach, describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +let fixtureRoot: string | undefined; + +afterEach(async () => { + if (fixtureRoot !== undefined) { + await rm(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = undefined; + } +}); + +describe("executable dispatcher", () => { + it("reserves production MCP routing for bare mode arguments", async () => { + fixtureRoot = await mkdtemp(join(tmpdir(), "rea-dispatcher-")); + const scripts = join(fixtureRoot, "scripts"); + const dist = join(fixtureRoot, "dist"); + await Promise.all([mkdir(scripts), mkdir(dist)]); + await Promise.all([ + copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")), + writeFile( + join(fixtureRoot, "package.json"), + JSON.stringify({ type: "module", version: "1.0.0" }), + ), + writeFile( + join(dist, "main.js"), + 'export const runEntrypoint = () => process.stdout.write(JSON.stringify({ adapter: "mcp" }));\n', + ), + writeFile( + join(dist, "cli.js"), + 'export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n', + ), + writeFile( + join(dist, "cliOutput.js"), + "export const sanitizeCliOutput = (output) => output;\n", + ), + ]); + + const invoke = async (args: readonly string[]): Promise => { + const { stdout } = await execFileAsync(process.execPath, [ + join(scripts, "rea.mjs"), + ...args, + ]); + return JSON.parse(stdout) as unknown; + }; + + await expect(invoke(["mcp"])).resolves.toEqual({ adapter: "mcp" }); + await expect(invoke(["--mcp"])).resolves.toEqual({ adapter: "mcp" }); + await expect(invoke(["mcp", "add"])).resolves.toEqual({ + adapter: "cli", + args: ["mcp", "add"], + }); + await expect(invoke(["--mcp", "extra"])).resolves.toEqual({ + adapter: "cli", + args: ["--mcp", "extra"], + }); + }); +}); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index f9d0ab02f4..e7ed68d2ca 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -60,6 +60,25 @@ describe("doctor", () => { expect(result.healthy).toBe(true); }); + it("does not fall back when the configured Hopper launcher is invalid", async () => { + const configuredPath = "/invalid/custom/hopper"; + const result = await runDoctor( + undefined, + host({ configuredHopperPath: configuredPath }), + ); + + expect(result.hopperPath).toBeUndefined(); + expect(result.healthy).toBe(false); + expect(result.checks.find(({ name }) => name === "hopper")).toEqual({ + name: "hopper", + ok: false, + classification: "config_drift", + detail: configuredPath, + remediation: + "Unset or update HOPPER_LAUNCHER_PATH to an executable Hopper launcher.", + }); + }); + it("distinguishes stale skills, path shadowing, and unobservable live state", async () => { const result = await runDoctor( undefined, diff --git a/tests/evidence.test.ts b/tests/evidence.test.ts index fdfd8f7a2a..da517b4dce 100644 --- a/tests/evidence.test.ts +++ b/tests/evidence.test.ts @@ -204,6 +204,47 @@ describe("analysis evidence", () => { expect(byteBound.export().records).toEqual([]); }); + it("counts unknown revisions when enforcing direct record limits", () => { + const ledger = new EvidenceLedger({ + maxRecords: 2, + maxBytes: 1_000_000, + }); + const mutation = createEvidence(undefined, PROVIDER, { + predicateType: "rea.residual-unknown-mutation/v1", + operation: "record_unknown", + parameters: {}, + result: { action: "record" }, + }); + const unknown = recordUnknownInputSchema.parse({ + approved: true, + question: "What remains unresolved?", + severity: "high", + domain: "record-limit-test", + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + required_authority: "shipped-artifact", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + }); + expect(ledger.recordUnknown(unknown, mutation).ok).toBe(true); + + const direct = createEvidence(TARGET, PROVIDER, { + operation: "health", + parameters: {}, + result: true, + }); + expect(ledger.record(direct)).toMatchObject({ + ok: false, + error: { _tag: "EvidenceLimitError", limit: "records", maximum: 2 }, + }); + expect(ledger.export()).toMatchObject({ + records: [mutation], + unknowns: [expect.objectContaining({ domain: "record-limit-test" })], + }); + }); + it("derives byte-stable manifests independent of record insertion order", () => { const artifactEvidence = createEvidence(TARGET, PROVIDER, { operation: "health", diff --git a/tests/fixtures/fakeCdpBrowser.ts b/tests/fixtures/fakeCdpBrowser.ts index 38fc98d4ab..25f6c56129 100644 --- a/tests/fixtures/fakeCdpBrowser.ts +++ b/tests/fixtures/fakeCdpBrowser.ts @@ -27,6 +27,11 @@ interface FakeOptions { readonly malformedDiscovery?: boolean; readonly oversizedDiscovery?: boolean; readonly invalidBrowserWebSocket?: boolean; + readonly pageScopedVersionWebSocket?: boolean; + readonly omitTargetWebSocket?: boolean; + readonly additionalPageWithWebSocket?: boolean; + readonly additionalPageWithoutWebSocket?: boolean; + readonly invalidAttachedSession?: boolean; readonly malformedMessageOnMethod?: string; readonly malformedEventOnMethod?: string; readonly malformedEventShapeOnMethod?: string; @@ -46,7 +51,11 @@ interface FakeOptions { readonly binaryWebSocketEvent?: boolean; readonly invalidBinaryWebSocketEvent?: boolean; readonly sourceMapBody?: string; - readonly sessionTimeline?: "same_origin" | "outside_policy"; + readonly sessionTimeline?: + | "same_origin" + | "outside_policy" + | "target_detached"; + readonly closeAfterMethod?: string; readonly sensitiveShapes?: boolean; readonly invalidResponseBodyBase64?: boolean; readonly webMcpTools?: boolean; @@ -98,8 +107,10 @@ export const startFakeCdpBrowser = async ( "WebKit-Version": "fake-revision", webSocketDebuggerUrl: options.invalidBrowserWebSocket === true - ? `ws://localhost:${String(port)}/devtools/page/not-browser` - : `ws://localhost:${String(port)}/devtools/browser/fake`, + ? `ws://localhost:${String(port)}/devtools/invalid/fake` + : options.pageScopedVersionWebSocket === true + ? `ws://localhost:${String(port)}/devtools/page/${versionTargetId(options)}` + : `ws://localhost:${String(port)}/devtools/browser/fake`, }), ); return; @@ -113,7 +124,12 @@ export const startFakeCdpBrowser = async ( }); const webSockets = new WebSocketServer({ noServer: true }); http.on("upgrade", (request, socket, head) => { - if (request.url !== "/devtools/browser/fake") { + if ( + request.url !== "/devtools/browser/fake" && + request.url !== "/devtools/page/allowed-page" && + request.url !== "/devtools/page/allowed-page-with-socket" && + request.url !== "/devtools/page/electron-page" + ) { socket.destroy(); return; } @@ -122,7 +138,7 @@ export const startFakeCdpBrowser = async ( ); }); webSockets.on("connection", (socket: WebSocket, request: IncomingMessage) => { - void request; + const directPage = request.url?.startsWith("/devtools/page/") === true; sockets.add(socket); socket.on("close", () => sockets.delete(socket)); socket.on("message", (raw) => { @@ -146,6 +162,19 @@ export const startFakeCdpBrowser = async ( ); return; } + if ( + directPage && + (command.method === "Target.attachToTarget" || + command.sessionId !== undefined) + ) { + socket.send( + JSON.stringify({ + id: command.id, + error: { code: -32_600, message: "Direct page socket expected" }, + }), + ); + return; + } if (command.method === "Page.getFrameTree") frameTreeReads += 1; socket.send( JSON.stringify({ @@ -158,6 +187,8 @@ export const startFakeCdpBrowser = async ( if (options.malformedEventShapeOnMethod === command.method) socket.send(JSON.stringify({ method: 42 })); emitEvents(socket, command, port, options); + if (options.closeAfterMethod === command.method) + setImmediate(() => socket.close()); }); }); await new Promise((resolve, reject) => { @@ -197,7 +228,11 @@ const targets = ( title: "Inspectable application", url: `http://127.0.0.1:${String(port)}/app?token=page-secret#fragment`, attached: false, - webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page`, + ...(options.omitTargetWebSocket === true + ? {} + : { + webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page`, + }), }, { id: "disallowed-page", @@ -206,6 +241,29 @@ const targets = ( url: "https://private.example.test/app?token=forbidden", attached: false, }, + ...(options.additionalPageWithWebSocket === true + ? [ + { + id: "allowed-page-with-socket", + type: "page", + title: "Second inspectable application page", + url: `http://127.0.0.1:${String(port)}/second`, + attached: false, + webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page-with-socket`, + }, + ] + : []), + ...(options.additionalPageWithoutWebSocket === true + ? [ + { + id: "allowed-page-without-socket", + type: "page", + title: "Unavailable application page", + url: `http://127.0.0.1:${String(port)}/unavailable`, + attached: false, + }, + ] + : []), { id: "unsupported-page", type: "page", @@ -254,6 +312,11 @@ const targets = ( title: "Electron application", url: options.electronFileUrl, attached: false, + ...(options.omitTargetWebSocket === true + ? {} + : { + webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/electron-page`, + }), }, ]), ]; @@ -289,7 +352,7 @@ const resultFor = ( ): Readonly> => { switch (command.method) { case "Target.attachToTarget": - return { sessionId: "session-1" }; + return { sessionId: options.invalidAttachedSession ? "" : "session-1" }; case "Page.getFrameTree": return frameTree( port, @@ -366,6 +429,9 @@ const resultFor = ( } }; +const versionTargetId = (options: FakeOptions): string => + options.electronFileUrl === undefined ? "allowed-page" : "electron-page"; + const endpointTargetToInfo = ( target: Readonly>, ): Readonly> => ({ @@ -903,6 +969,11 @@ const emitEvents = ( name: "networkIdle", timestamp: 15, }); + if (options.sessionTimeline === "target_detached") + event(socket, "Target.detachedFromTarget", undefined, { + sessionId: command.sessionId, + targetId: "allowed-page", + }); } }; diff --git a/tests/hopperClient.test.ts b/tests/hopperClient.test.ts index 67169868fc..e03d97b195 100644 --- a/tests/hopperClient.test.ts +++ b/tests/hopperClient.test.ts @@ -59,6 +59,17 @@ class SilentLauncher implements BridgeLauncher { } } +class CancelThenFixtureLauncher implements BridgeLauncher { + #launches = 0; + + launch(session: BridgeSession) { + this.#launches += 1; + return this.#launches === 1 + ? new SilentLauncher().launch() + : new FixtureLauncher().launch(session); + } +} + class ExitingLauncher implements BridgeLauncher { constructor(readonly code: number) {} @@ -182,7 +193,7 @@ describe("HopperClient", () => { it("cancels bridge startup without waiting for the startup timeout", async () => { const client = new HopperClient({ - launcher: new SilentLauncher(), + launcher: new CancelThenFixtureLauncher(), startupTimeoutMs: 10_000, }); clients.push(client); @@ -196,6 +207,16 @@ describe("HopperClient", () => { expect(result.ok).toBe(false); if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); expect(Date.now() - startedAt).toBeLessThan(500); + await expect(client.start()).resolves.toEqual({ + ok: true, + value: { name: "REA Hopper bridge", version: "1.0.0" }, + }); + await expect( + client.callTool("echo", { value: "retried" }), + ).resolves.toEqual({ + ok: true, + value: { value: "retried" }, + }); }); it.each([70, 71, 72, 73, 74, 75, 76, 77, 78, 79])( @@ -254,4 +275,45 @@ describe("HopperClient", () => { expect(firstSettled).toBe(true); await first; }); + + it("does not finish startup after an immediate close", async () => { + const client = new HopperClient({ + launcher: new FixtureLauncher(), + startupTimeoutMs: 1_000, + }); + clients.push(client); + + const starting = client.start(); + await client.close(); + + const result = await starting; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); + }); + + it("starts a fresh session after close completes", async () => { + const client = await startClient(); + await client.close(); + + await expect(client.start()).resolves.toEqual({ + ok: true, + value: { name: "REA Hopper bridge", version: "1.0.0" }, + }); + }); + + it("serializes a restart requested while close is in progress", async () => { + const client = await startClient(); + const closing = client.close(); + const restarting = client.start(); + + await closing; + await expect(restarting).resolves.toEqual({ + ok: true, + value: { name: "REA Hopper bridge", version: "1.0.0" }, + }); + await expect(client.callTool("echo", { value: "fresh" })).resolves.toEqual({ + ok: true, + value: { value: "fresh" }, + }); + }); }); diff --git a/tests/investigationMcp.test.ts b/tests/investigationMcp.test.ts index 4004afb833..89cc31dafa 100644 --- a/tests/investigationMcp.test.ts +++ b/tests/investigationMcp.test.ts @@ -14,6 +14,7 @@ import { PROCESS_CAPTURE_REFERENCE, PROCESS_COMPARISON_EVIDENCE, } from "../src/contracts/investigationExamples.js"; +import { changedBehaviorResultSchema } from "../src/domain/changedBehavior.js"; import { createEvidence, parseEvidence } from "../src/domain/evidence.js"; import { jsonObjectSchema, jsonValueSchema } from "../src/domain/jsonValue.js"; import { createServer } from "../src/server/createServer.js"; @@ -214,6 +215,83 @@ describe("investigation MCP workflows", () => { } }); + it("defers input permission for an explicit replay with deleted inputs", async () => { + const directory = await mkdtemp( + join(tmpdir(), "rea-investigation-replay-"), + ); + const left = join(directory, "left"); + const right = join(directory, "right"); + const workspace = join(directory, "workspace.json"); + await Promise.all([mkdir(left), mkdir(right)]); + await Promise.all([ + writeFile(join(left, "feature.txt"), "old\n"), + writeFile(join(right, "feature.txt"), "new\n"), + ]); + const filePolicy = evidencePolicy(directory); + const investigationRun = { + approved: true, + workspace_path: workspace, + workspace_name: "selected-replay", + left_path: left, + right_path: right, + options: { page_size: 500, change_limit: 100 }, + }; + const writer = await investigationAuthority(directory, true); + const initial = await connected(filePolicy, [directory], writer); + let runId: string | undefined; + try { + const created = await initial.client.callTool({ + name: "find_changed_behavior", + arguments: { investigation_run: investigationRun }, + }); + expect(created.isError, JSON.stringify(created)).not.toBe(true); + const evidence = parseEvidence( + z.object({ result: z.unknown() }).parse(created.structuredContent) + .result, + ); + const result = changedBehaviorResultSchema.parse( + evidence.normalized_result, + ); + runId = result.investigation_run?.run_id; + } finally { + await close(initial.session, initial.server, initial.client); + } + if (runId === undefined) throw new Error("missing completed run ID"); + + await Promise.all([ + rm(left, { recursive: true, force: true }), + rm(right, { recursive: true, force: true }), + ]); + const workspaceOnly = await permissionAuthorityForRoot( + directory, + [ + "investigation_workspace_read", + "investigation_workspace_write", + "investigation_input", + ], + ["investigation_workspace_read"], + ); + const replay = await connected(filePolicy, [], workspaceOnly); + try { + const cached = await replay.client.callTool({ + name: "find_changed_behavior", + arguments: { + investigation_run: { + ...investigationRun, + replay_run_id: runId, + }, + }, + }); + expect(cached.isError, JSON.stringify(cached)).not.toBe(true); + expect( + await readInvestigationWorkspace(workspace, filePolicy), + ).toMatchObject({ ok: true, value: { revision: 3 } }); + } finally { + await close(replay.session, replay.server, replay.client); + await rm(directory, { recursive: true, force: true }); + } + }); + it("refuses automatic artifact reads outside operator-approved roots", async () => { const directory = await mkdtemp(join(tmpdir(), "rea-investigation-mcp-")); const approvedInputs = join(directory, "approved-inputs"); diff --git a/tests/investigationWorkspace.test.ts b/tests/investigationWorkspace.test.ts index e064cd5858..76a43f4063 100644 --- a/tests/investigationWorkspace.test.ts +++ b/tests/investigationWorkspace.test.ts @@ -12,7 +12,7 @@ import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createPackageWithOptions } from "@electron/asar"; import canonicalize from "canonicalize"; @@ -31,6 +31,7 @@ import { parseInvestigationWorkspace, serializeInvestigationWorkspace, } from "../src/domain/investigationWorkspace.js"; +import { ok } from "../src/domain/result.js"; const digestCanonical = (value: unknown): string => { const encoded = canonicalize(value); @@ -239,6 +240,110 @@ describe("persistent cross-version investigation workspace", () => { }); }); + it("replays only an explicitly selected and fully verified complete run", async () => { + const { left, right, input } = await fixture(); + if (directory === undefined) throw new Error("missing fixture root"); + const completed = await runCrossVersionInvestigation( + input, + policy(directory), + { inputRoots: [directory] }, + ); + if (!completed.ok) throw completed.error; + const run = completed.value.workspace.runs[0]; + if (run === undefined || run.comparison_evidence_id === null) + throw new Error("missing completed run"); + + const defaultAuthorization = vi.fn(() => Promise.resolve(ok(null))); + await expect( + runCrossVersionInvestigation(input, policy(directory), { + inputRoots: [directory], + authorizeInputRead: defaultAuthorization, + }), + ).resolves.toMatchObject({ ok: true, value: { reused: true } }); + expect(defaultAuthorization).toHaveBeenCalledOnce(); + + const inconsistentRun = investigationRunSchema.parse({ + ...run, + result_evidence_id: run.comparison_evidence_id, + }); + const inconsistent = createInvestigationWorkspace( + input.workspace_name, + completed.value.workspace.bundle, + [inconsistentRun], + ); + const inconsistentPath = join(directory, "inconsistent.json"); + expect( + await writeInvestigationWorkspace( + inconsistent, + inconsistentPath, + null, + policy(directory), + ), + ).toMatchObject({ ok: true }); + await expect( + runCrossVersionInvestigation( + { + ...input, + workspace_path: inconsistentPath, + replay_run_id: run.run_id, + }, + policy(directory), + { inputRoots: [] }, + ), + ).resolves.toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + + await Promise.all([ + rm(left, { recursive: true, force: true }), + rm(right, { recursive: true, force: true }), + ]); + const replayAuthorization = vi.fn(() => Promise.resolve(ok(null))); + await expect( + runCrossVersionInvestigation( + { + ...input, + expected_workspace_revision: completed.value.workspace.revision, + replay_run_id: run.run_id, + }, + policy(directory), + { inputRoots: [], authorizeInputRead: replayAuthorization }, + ), + ).resolves.toMatchObject({ + ok: true, + value: { + reused: true, + evidence: { evidence_id: completed.value.evidence.evidence_id }, + workspace: { revision: completed.value.workspace.revision }, + }, + }); + expect(replayAuthorization).not.toHaveBeenCalled(); + + for (const replayInput of [ + { ...input, replay_run_id: `run_${"f".repeat(64)}` }, + { ...input, right_path: `${right}-other`, replay_run_id: run.run_id }, + { + ...input, + options: { ...input.options, change_limit: 99 }, + replay_run_id: run.run_id, + }, + ]) + await expect( + runCrossVersionInvestigation(replayInput, policy(directory), { + inputRoots: [], + authorizeInputRead: replayAuthorization, + }), + ).resolves.toMatchObject({ + ok: false, + error: { + _tag: "InvestigationWorkspaceError", + reason: "revision-conflict", + }, + }); + expect(replayAuthorization).not.toHaveBeenCalled(); + }); + it("enforces shared JSON depth limits while reading workspaces", async () => { const { path, input } = await fixture(); if (directory === undefined) throw new Error("missing fixture root"); @@ -300,6 +405,26 @@ describe("persistent cross-version investigation workspace", () => { ), ).toMatchObject({ ok: true }); + const replayAuthorization = vi.fn(() => Promise.resolve(ok(null))); + await expect( + runCrossVersionInvestigation( + { + ...input, + workspace_path: resumePath, + replay_run_id: partialRun.run_id, + }, + policy(directory), + { inputRoots: [], authorizeInputRead: replayAuthorization }, + ), + ).resolves.toMatchObject({ + ok: false, + error: { + _tag: "InvestigationWorkspaceError", + reason: "revision-conflict", + }, + }); + expect(replayAuthorization).not.toHaveBeenCalled(); + const resumed = await runCrossVersionInvestigation( { ...input, workspace_path: resumePath }, policy(directory), diff --git a/tests/mainErrors.test.ts b/tests/mainErrors.test.ts index 4b24f61f46..b0ee88447e 100644 --- a/tests/mainErrors.test.ts +++ b/tests/mainErrors.test.ts @@ -13,18 +13,23 @@ const dependencies = ( output: string[], shutdown: Array<() => void>, exitCodes: number[], + shutdownUnregistrations: string[] = [], ): RuntimeDependencies => ({ env: {}, serve, writeStderr: (text) => output.push(text), setExitCode: (code) => exitCodes.push(code), - registerShutdown: (handler) => shutdown.push(handler), + registerShutdown: (handler) => { + shutdown.push(handler); + return () => shutdownUnregistrations.push("shutdown"); + }, }); describe("MCP runtime errors", () => { it("reports connection loss without the transport cause", async () => { const output: string[] = []; const shutdown: Array<() => void> = []; + const shutdownUnregistrations: string[] = []; let options: ServeStdioOptions | undefined; let closeCalls = 0; const close = (): Promise => { @@ -42,6 +47,7 @@ describe("MCP runtime errors", () => { output, shutdown, exitCodes, + shutdownUnregistrations, ), ), ).toBe(0); @@ -52,6 +58,42 @@ describe("MCP runtime errors", () => { shutdown[0]?.(); await nextTurn(); expect(closeCalls).toBe(1); + expect(shutdownUnregistrations).toEqual(["shutdown"]); + }); + + it("unregisters every process-lifetime handler during idempotent shutdown", async () => { + const shutdown: Array<() => void> = []; + const reload: Array<() => void> = []; + const unregistrations: string[] = []; + let closeCalls = 0; + const runtime: RuntimeDependencies = { + ...dependencies( + () => ({ + close: async () => { + closeCalls += 1; + }, + }), + [], + shutdown, + [], + unregistrations, + ), + registerReload: (handler) => { + reload.push(handler); + return () => unregistrations.push("reload"); + }, + }; + + expect(await run(runtime)).toBe(0); + expect(shutdown).toHaveLength(1); + expect(reload).toHaveLength(1); + + shutdown[0]?.(); + shutdown[0]?.(); + await nextTurn(); + + expect(closeCalls).toBe(1); + expect(unregistrations).toEqual(["reload", "shutdown"]); }); it("reports transport startup failure without its cause", async () => { diff --git a/tests/mainReload.test.ts b/tests/mainReload.test.ts new file mode 100644 index 0000000000..084d04edbf --- /dev/null +++ b/tests/mainReload.test.ts @@ -0,0 +1,253 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { PermissionAuthority } from "../src/application/PermissionAuthority.js"; +import type { ProjectPermissionStore } from "../src/application/ProjectPermissionStore.js"; +import type { PermissionGrant } from "../src/domain/permissionPolicy.js"; +import { ok } from "../src/domain/result.js"; +import { run } from "../src/main.js"; +import { + createServer, + type CreateServerOptions, +} from "../src/server/createServer.js"; + +type RuntimeDependencies = NonNullable[0]>; + +const directories: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("runtime permission reload", () => { + it("keeps live state unchanged after a late grant failure, then commits together", async () => { + const fixture = await reloadFixture(); + const invalid = projectStore(fixture.projectRoot, [ + projectGrant("invalid", fixture.outsideRoot), + ]); + const valid = projectStore(fixture.projectRoot, [ + projectGrant("valid", fixture.newRoot), + ]); + const validReadStarted = deferred(); + const releaseValidRead = deferred(); + let reads = 0; + const runtime = await startRuntime(fixture.env, async () => { + reads += 1; + if (reads === 1) return ok(invalid); + validReadStarted.resolve(); + await releaseValidRead.promise; + return ok(valid); + }); + + configure(fixture.env, fixture.newRoot, true); + runtime.reload(); + runtime.reload(); + await validReadStarted.promise; + + expect(runtime.notifications()).toBe(0); + expect(runtime.options.processPolicy?.enabled).toBe(false); + expect(runtime.options.evidenceFilePolicy?.roots).toEqual([ + fixture.oldRoot, + ]); + expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(false); + expect(await canRead(runtime.authority, fixture.oldRoot)).toBe(true); + expect(await canRead(runtime.authority, fixture.newRoot)).toBe(false); + + releaseValidRead.resolve(); + await runtime.notificationCount(1); + + expect(runtime.options.processPolicy?.enabled).toBe(true); + expect(runtime.options.evidenceFilePolicy?.roots).toEqual([ + fixture.newRoot, + ]); + expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(true); + expect(await canRead(runtime.authority, fixture.oldRoot)).toBe(false); + expect(await canRead(runtime.authority, fixture.newRoot)).toBe(true); + }); + + it("serializes overlapping reloads so an older read cannot commit last", async () => { + const fixture = await reloadFixture(); + const firstReadStarted = deferred(); + const releaseFirstRead = deferred(); + const secondReadStarted = deferred(); + let secondStarted = false; + let reads = 0; + const runtime = await startRuntime(fixture.env, async () => { + reads += 1; + if (reads === 1) { + firstReadStarted.resolve(); + await releaseFirstRead.promise; + return ok(projectStore(fixture.projectRoot, [])); + } + secondStarted = true; + secondReadStarted.resolve(); + return ok(projectStore(fixture.projectRoot, [])); + }); + + configure(fixture.env, fixture.newRoot, true); + runtime.reload(); + await firstReadStarted.promise; + configure(fixture.env, fixture.latestRoot, false); + runtime.reload(); + + expect(secondStarted).toBe(false); + releaseFirstRead.resolve(); + await secondReadStarted.promise; + await runtime.notificationCount(2); + + expect(runtime.options.processPolicy?.enabled).toBe(false); + expect(runtime.options.evidenceFilePolicy?.roots).toEqual([ + fixture.latestRoot, + ]); + expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(false); + expect(await canRead(runtime.authority, fixture.newRoot)).toBe(false); + expect(await canRead(runtime.authority, fixture.latestRoot)).toBe(true); + }); +}); + +const reloadFixture = async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-main-reload-")); + directories.push(directory); + const oldRoot = join(directory, "old"); + const newRoot = join(directory, "new"); + const latestRoot = join(directory, "latest"); + const outsideRoot = join(directory, "outside"); + const projectRoot = join(directory, "project"); + await Promise.all( + [oldRoot, newRoot, latestRoot, outsideRoot, projectRoot].map((path) => + mkdir(path), + ), + ); + const env: NodeJS.ProcessEnv = { + REA_EVIDENCE_ROOTS_JSON: JSON.stringify([oldRoot]), + REA_PERMISSION_PROJECT_ROOT: projectRoot, + REA_PERMISSION_PROJECT_STORE: join(directory, "permissions.json"), + }; + return { env, oldRoot, newRoot, latestRoot, outsideRoot, projectRoot }; +}; + +const configure = ( + env: NodeJS.ProcessEnv, + root: string, + enabled: boolean, +): void => { + env.REA_EVIDENCE_ROOTS_JSON = JSON.stringify([root]); + env.REA_PROCESS_CAPTURE_ENABLED = String(enabled); + env.REA_PROCESS_EXECUTABLE_ROOTS_JSON = JSON.stringify([root]); + env.REA_PROCESS_WORKING_ROOTS_JSON = JSON.stringify([root]); + env.REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED = String(enabled); +}; + +const startRuntime = async ( + env: NodeJS.ProcessEnv, + readStore: NonNullable, +) => { + let reload: (() => void) | undefined; + let options: CreateServerOptions | undefined; + let notificationCount = 0; + let notification = deferred(); + const waiters = new Map>>(); + const result = await run({ + env, + serve: (factory) => { + void factory({ era: "legacy" }); + return { close: () => Promise.resolve() }; + }, + writeStderr: () => undefined, + setExitCode: () => undefined, + registerShutdown: () => () => undefined, + registerReload: (handler) => { + reload = handler; + return () => undefined; + }, + readProjectPermissionStore: readStore, + createServer: (analysis, session, received) => { + options = received; + const server = createServer(analysis, session, received); + vi.spyOn(server, "sendToolListChanged").mockImplementation(() => { + notificationCount += 1; + notification.resolve(undefined); + notification = deferred(); + waiters.get(notificationCount)?.resolve(undefined); + }); + return server; + }, + }); + expect(result).toBe(0); + const authority = options?.permissionAuthority; + if (reload === undefined || options === undefined || authority === undefined) + throw new Error("Runtime reload seam was not initialized"); + const capturedOptions = options; + return { + reload, + options: capturedOptions, + authority, + notifications: () => notificationCount, + notificationCount: (count: number) => { + if (notificationCount >= count) return Promise.resolve(); + const waiter = deferred(); + waiters.set(count, waiter); + return waiter.promise; + }, + }; +}; + +const projectStore = ( + projectRoot: string, + grants: readonly ReturnType[], +): ProjectPermissionStore => ({ + schema_version: 1, + project_id: `project_${createHash("sha256").update(projectRoot).digest("hex")}`, + project_root: projectRoot, + grants: [...grants], +}); + +const projectGrant = (id: string, root: string) => + ({ + grant_id: `project:${id}`, + capability: "evidence_read", + roots: [root], + executables: [], + environment_names: [], + network: "none", + mount: false, + lifetime: "project", + operation_identity: null, + expires_at: null, + }) satisfies PermissionGrant; + +const canRead = async ( + authority: PermissionAuthority, + root: string, +): Promise => + ( + await authority.explain( + { + capability: "evidence_read", + roots: [root], + executables: [], + environment_names: [], + network: "none", + mount: false, + operation_identity: `read:${root}`, + }, + "read", + ) + ).ok; + +const deferred = () => { + let resolvePromise!: (value: T | PromiseLike) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +}; diff --git a/tests/permissionAuthority.test.ts b/tests/permissionAuthority.test.ts index 74f5e3fb03..775d4f9272 100644 --- a/tests/permissionAuthority.test.ts +++ b/tests/permissionAuthority.test.ts @@ -7,10 +7,14 @@ import { describe, expect, it } from "vitest"; import { canonicalizePermissionCeilings, canonicalizePermissionRequest, + createPermissionAuthority, + type PermissionAuthority, } from "../src/application/PermissionAuthority.js"; import { createPermissionPolicy, evaluatePermission, + type PermissionGrant, + type PermissionScope, } from "../src/domain/permissionPolicy.js"; describe("permission authority", () => { @@ -99,4 +103,79 @@ describe("permission authority", () => { if (result.ok) expect(result.value.roots).toEqual([join(sandbox, "new.json")]); }); + + it("commits a configured policy only after every grant validates", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "rea-permission-reload-")); + const oldRoot = join(sandbox, "old"); + const newRoot = join(sandbox, "new"); + const outsideRoot = join(sandbox, "outside"); + await Promise.all([mkdir(oldRoot), mkdir(newRoot), mkdir(outsideRoot)]); + const oldScope = evidenceScope(oldRoot); + const newScope = evidenceScope(newRoot); + const authority = await createPermissionAuthority( + [oldScope], + [administratorGrant(oldScope)], + ); + expect(authority.ok).toBe(true); + if (!authority.ok) return; + + const reloaded = await authority.value.replaceConfiguredPolicy({ + ceilings: [newScope], + administratorGrants: [administratorGrant(newScope)], + projectGrants: [ + { + ...evidenceScope(outsideRoot), + grant_id: "project:outside-ceiling", + lifetime: "project", + operation_identity: null, + expires_at: null, + }, + ], + }); + + expect(reloaded.ok).toBe(false); + expect(await canRead(authority.value, oldRoot)).toBe(true); + expect(await canRead(authority.value, newRoot)).toBe(false); + + expect( + await authority.value.replaceConfiguredPolicy({ + ceilings: [newScope], + administratorGrants: [administratorGrant(newScope)], + projectGrants: [], + }), + ).toEqual({ ok: true, value: null }); + expect(await canRead(authority.value, oldRoot)).toBe(false); + expect(await canRead(authority.value, newRoot)).toBe(true); + }); }); + +const evidenceScope = (root: string): PermissionScope => ({ + capability: "evidence_read", + roots: [root], + executables: [], + environment_names: [], + network: "none", + mount: false, +}); + +const administratorGrant = (scope: PermissionScope): PermissionGrant => ({ + ...scope, + grant_id: "administrator:evidence_read", + lifetime: "administrator", + operation_identity: null, + expires_at: null, +}); + +const canRead = async ( + authority: PermissionAuthority, + root: string, +): Promise => + ( + await authority.explain( + { + ...evidenceScope(root), + operation_identity: `read:${root}`, + }, + "read", + ) + ).ok; diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts index d1b5b30473..6309d9c24a 100644 --- a/tests/processCapture.test.ts +++ b/tests/processCapture.test.ts @@ -19,7 +19,10 @@ import { } from "../src/application/ProcessCaptureLifecycle.js"; import { ProcessCheckpoints } from "../src/application/ProcessCheckpoints.js"; import { normalizeProcessSamples } from "../src/application/ProcessNormalization.js"; -import { readLinuxChildren } from "../src/application/ProcessSampling.js"; +import { + isInitializedPtyRoot, + readLinuxChildren, +} from "../src/application/ProcessSampling.js"; import { TerminalRenderer } from "../src/application/TerminalRenderer.js"; import { authorizeProcessScenario, @@ -211,6 +214,43 @@ describe("process capture domain", () => { ).toEqual([201, 202, 203]); }); + it("admits PTY samples only after stable session and token setup", () => { + const initialized = { + pid: 100, + parent_pid: 10, + process_group_id: 100, + session_id: 100, + startTime: "200", + }; + expect( + isInitializedPtyRoot({ + rootPid: 100, + expectedRunId: "run-token", + before: { ...initialized, process_group_id: 10, session_id: 10 }, + observedRunId: undefined, + after: initialized, + }), + ).toBe(false); + expect( + isInitializedPtyRoot({ + rootPid: 100, + expectedRunId: "run-token", + before: initialized, + observedRunId: "run-token", + after: { ...initialized, startTime: "201" }, + }), + ).toBe(false); + expect( + isInitializedPtyRoot({ + rootPid: 100, + expectedRunId: "run-token", + before: initialized, + observedRunId: "run-token", + after: initialized, + }), + ).toBe(true); + }); + it("keeps interaction and shim residual uncertainty in separate scopes", () => { const baseCapture = emptyCapture(); const interaction = parseProcessCapture({ @@ -1134,6 +1174,9 @@ describe("process capture adapter", () => { expect(commands.some((command) => command.includes("tree-child"))).toBe( true, ); + expect(commands.some((command) => command.includes("forks.js"))).toBe( + false, + ); expect( commands.some((command) => command.includes("tree-grandchild")), ).toBe(true); diff --git a/tests/referenceSourceGraph.test.ts b/tests/referenceSourceGraph.test.ts index e00e8c66c7..bf3a35953b 100644 --- a/tests/referenceSourceGraph.test.ts +++ b/tests/referenceSourceGraph.test.ts @@ -5,6 +5,7 @@ import { createHistoricalSourceGraph, createHistoricalSourceManifest, historicalSourceGraphSchema, + historicalSourceParseFailureKey, parseHistoricalSourceGraph, parseHistoricalSourceManifest, type HistoricalSourceGraphInput, @@ -192,6 +193,29 @@ describe("historical source graph", () => { ).toThrow(/Complete inventory/u); }); + it("keeps distinct parse failure reasons for the same path and parser", () => { + const input = graphInput(); + const malformed = { + path: "src/main.ts", + parser: "typescript", + reason: "Malformed input", + }; + const unexpected = { + path: "src/main.ts", + parser: "typescript", + reason: "Unexpected token", + }; + input.inventory_state = "partial"; + input.parse_failures = [malformed, unexpected]; + + expect(createHistoricalSourceGraph(input).parse_failures).toEqual( + input.parse_failures, + ); + expect(historicalSourceParseFailureKey(malformed)).not.toBe( + historicalSourceParseFailureKey(unexpected), + ); + }); + it("rejects strict unknown fields, invalid VCS, dangling internal edges, and false manifests", () => { expect(() => createHistoricalSourceGraph({ ...graphInput(), surprise: true }), diff --git a/tests/referenceSourceImport.test.ts b/tests/referenceSourceImport.test.ts index 957387450a..ccba32cc47 100644 --- a/tests/referenceSourceImport.test.ts +++ b/tests/referenceSourceImport.test.ts @@ -6,7 +6,10 @@ import { join } from "node:path"; import { add, commit, init } from "isomorphic-git"; import { describe, expect, it } from "vitest"; -import { importReferenceSource } from "../src/application/ReferenceSourceImport.js"; +import { + importReferenceSource, + normalizeHistoricalSourceParseFailures, +} from "../src/application/ReferenceSourceImport.js"; import { projectReferenceSourceEntryFailure } from "../src/application/ReferenceSourceImportEntries.js"; import { projectReferenceSourceImportError, @@ -52,6 +55,27 @@ const importTree = ( }); describe("reference source import", () => { + it("deduplicates exact parse failures without collapsing distinct reasons", () => { + const malformed = { + path: "src/main.ts", + parser: "babel", + reason: "Malformed input", + }; + const unexpected = { + path: "src/main.ts", + parser: "babel", + reason: "Unexpected token", + }; + + expect( + normalizeHistoricalSourceParseFailures([ + unexpected, + malformed, + unexpected, + ]), + ).toEqual([malformed, unexpected]); + }); + it("projects entry failures without low-level reader diagnostics", () => { for (const [kind, code] of [ ["directory", "io"], diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts index 6b67ee338e..04f91551a2 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/sessionMcp.test.ts @@ -32,6 +32,54 @@ afterEach(async () => { }); describe("target-free MCP lifecycle", () => { + it("reopens replaced content at one canonical path through MCP", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-mcp-replaced-target-")); + const targetPath = join(directory, "mutable.hop"); + await writeFile(targetPath, "first"); + const closed: string[] = []; + const session = new BinarySession((target) => client(target.path, closed)); + const server = createServer(session, session, { logger: silentLogger }); + const mcp = new Client({ name: "replaced-target", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + + const first = structured( + await mcp.callTool({ + name: "open_binary", + arguments: { path: targetPath }, + }), + ).result; + await writeFile(targetPath, "second"); + const second = structured( + await mcp.callTool({ + name: "open_binary", + arguments: { path: targetPath }, + }), + ).result; + + expect(first).toMatchObject({ path: targetPath }); + expect(second).toMatchObject({ path: targetPath }); + expect(z.object({ sha256: z.string() }).parse(second).sha256).not.toBe( + z.object({ sha256: z.string() }).parse(first).sha256, + ); + expect(closed).toEqual([targetPath]); + expect( + z + .object({ result: z.object({ path: z.string(), sha256: z.string() }) }) + .parse( + structured( + await mcp.callTool({ name: "binary_session", arguments: {} }), + ), + ).result, + ).toEqual({ + path: targetPath, + sha256: z.object({ sha256: z.string() }).parse(second).sha256, + }); + }); + it("reports no-target, opens, analyzes, switches, reports status, and closes", async () => { directory = await mkdtemp(join(tmpdir(), "rea-mcp-session-")); const first = join(directory, "first.hop"); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 11c79dfb1b..c3e1830aa5 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -23,6 +23,7 @@ class FakeSetupHost implements SetupHost { clientResults = new Map(); hopperInstalls = 0; configurations = 0; + skillInstalls = 0; doctorHealthy: boolean | undefined; linuxDemoRuntimeMissing = false; unsupportedHopperVersion = false; @@ -62,8 +63,16 @@ class FakeSetupHost implements SetupHost { this.clientResults.get(client.name) ?? { status: "configured" }, ); }; - installSkill = (): Promise<"installed" | "unchanged" | "failed"> => - Promise.resolve(this.skill); + clientNeedsConfigure = (client: SetupClient): Promise => + Promise.resolve( + this.clientResults.get(client.name)?.status !== "unchanged", + ); + skillNeedsInstall = (): Promise => + Promise.resolve(this.skill !== "unchanged"); + installSkill = (): Promise<"installed" | "unchanged" | "failed"> => { + this.skillInstalls += 1; + return Promise.resolve(this.skill); + }; doctor = (): Promise<{ healthy: boolean; hopperPath?: string; @@ -218,6 +227,17 @@ describe("setup workflow", () => { expect(host.hopperInstalls).toBe(0); }); + it("omits an aligned managed skill from an otherwise empty plan", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.skill = "unchanged"; + const result = await runSetup(options(false), host); + expect(result.status).toBe("ready"); + expect(result.plannedActions).toEqual([]); + expect(result.appliedActions).toEqual([]); + expect(host.skillInstalls).toBe(0); + }); + it("installs missing Linux demo dependencies for existing Hopper", async () => { const host = new FakeSetupHost("linux"); host.distribution = { @@ -274,6 +294,10 @@ describe("setup workflow", () => { }); it.each([ + [ + "path", + "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.", + ], [ "backup", "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.", @@ -297,6 +321,31 @@ describe("setup workflow", () => { expect(result.remediation).not.toContain("internal_client_key"); }); + it("records every detected client outcome after an earlier failure", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.clients = [ + { name: "first", configPath: "/first.json" }, + { name: "second", configPath: "/second.json" }, + { name: "third", configPath: "/third.json" }, + ]; + host.clientResults.set("first", { status: "failed", reason: "write" }); + host.clientResults.set("second", { status: "configured" }); + host.clientResults.set("third", { status: "failed", reason: "readback" }); + + const result = await runSetup(options(true), host); + + expect(host.configurations).toBe(3); + expect(result.clients).toEqual({ + first: { status: "failed", reason: "write" }, + second: { status: "configured" }, + third: { status: "failed", reason: "readback" }, + }); + expect(result.appliedActions).toEqual(["configured_second"]); + expect(result.status).toBe("needs_human"); + expect(result.remediation).toContain("could not be updated"); + }); + it("explains skill installation recovery", async () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; diff --git a/tests/setupConfig.test.ts b/tests/setupConfig.test.ts index d5467df7a7..9dd9516ed9 100644 --- a/tests/setupConfig.test.ts +++ b/tests/setupConfig.test.ts @@ -1,8 +1,16 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { + lstat, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { resolveClientConfigTransactionPath } from "../src/application/ClientConfigPath.js"; import { configureJsonClient } from "../src/application/Setup.js"; let directory: string | undefined; @@ -13,6 +21,25 @@ afterEach(async () => { }); describe("JSON client configuration transaction", () => { + it("rejects a symlink target not owned by the current user", async () => { + if (typeof process.getuid !== "function") return; + const currentUid = process.getuid(); + let statCalls = 0; + expect( + await resolveClientConfigTransactionPath("/config", { + lstat: () => { + statCalls += 1; + return Promise.resolve({ + uid: statCalls === 1 ? currentUid : currentUid + 1, + isFile: () => statCalls > 1, + isSymbolicLink: () => statCalls === 1, + }); + }, + realpath: () => Promise.resolve("/target"), + }), + ).toBeUndefined(); + }); + it("preserves existing keys, creates a backup, and reads back the MCP entry", async () => { directory = await mkdtemp(join(tmpdir(), "rea-setup-")); const configPath = join(directory, "mcp.json"); @@ -34,6 +61,48 @@ describe("JSON client configuration transaction", () => { }); }); + it("updates a symlink target without replacing the JSON config symlink", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-setup-")); + const targetPath = join(directory, "managed.json"); + const configPath = join(directory, "mcp.json"); + const original = '{"theme":"dark"}\n'; + await writeFile(targetPath, original); + await symlink(targetPath, configPath); + + expect( + await configureJsonClient({ name: "cursor", configPath }), + ).toMatchObject({ status: "configured" }); + expect((await lstat(configPath)).isSymbolicLink()).toBe(true); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); + expect(JSON.parse(await readFile(targetPath, "utf8"))).toMatchObject({ + theme: "dark", + mcpServers: { + rea: { + command: "npx", + args: ["-y", "rea-agents", "mcp"], + }, + }, + }); + expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({ + status: "unchanged", + }); + }); + + it("fails before mutation when a JSON config symlink is dangling", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-setup-")); + const configPath = join(directory, "mcp.json"); + await symlink(join(directory, "missing.json"), configPath); + + expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({ + status: "failed", + reason: "path", + }); + expect((await lstat(configPath)).isSymbolicLink()).toBe(true); + await expect( + readFile(`${configPath}.rea.backup`, "utf8"), + ).rejects.toThrow(); + }); + it("performs no write or second backup when configuration already matches", async () => { directory = await mkdtemp(join(tmpdir(), "rea-setup-")); const configPath = join(directory, "mcp.json"); diff --git a/tests/setupLifecycle.test.ts b/tests/setupLifecycle.test.ts index 65a8c625d0..06ba37dc21 100644 --- a/tests/setupLifecycle.test.ts +++ b/tests/setupLifecycle.test.ts @@ -4,7 +4,9 @@ import { mkdtemp, mkdir, readFile, + realpath, rm, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -92,6 +94,44 @@ describe("agent lifecycle", () => { ).toEqual({ status: "unchanged" }); }); + it("updates a symlink target without replacing the TOML config symlink", async () => { + const home = await mkdtemp(join(tmpdir(), "rea-toml-symlink-")); + roots.push(home); + const configPath = join(home, ".codex/config.toml"); + const targetPath = join(home, "managed-config.toml"); + const original = 'model = "gpt-5"\n'; + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(targetPath, original); + await symlink(targetPath, configPath); + + expect( + await configureTomlClient( + { name: "codex", configPath, format: "toml" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + expect((await lstat(configPath)).isSymbolicLink()).toBe(true); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); + expect(await readFile(targetPath, "utf8")).toContain("[mcp_servers.rea]"); + }); + + it("fails before mutation when a TOML config symlink is dangling", async () => { + const home = await mkdtemp(join(tmpdir(), "rea-toml-symlink-")); + roots.push(home); + const configPath = join(home, ".codex/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + await symlink(join(home, "missing-config.toml"), configPath); + + expect( + await configureTomlClient({ name: "codex", configPath, format: "toml" }), + ).toEqual({ status: "failed", reason: "path" }); + expect((await lstat(configPath)).isSymbolicLink()).toBe(true); + await expect( + readFile(`${configPath}.rea.backup`, "utf8"), + ).rejects.toThrow(); + }); + it("uninstalls only owned entries and refuses purge symlinks", async () => { const home = await mkdtemp(join(tmpdir(), "rea-uninstall-")); roots.push(home); @@ -131,6 +171,52 @@ describe("agent lifecycle", () => { ); }); + it("removes an owned entry through a symlink without replacing it", async () => { + const home = await mkdtemp(join(tmpdir(), "rea-uninstall-symlink-")); + roots.push(home); + const configPath = join(home, ".cursor/mcp.json"); + const targetPath = join(home, "managed-mcp.json"); + const original = JSON.stringify({ + mcpServers: { + rea: { command: "rea", args: ["mcp"] }, + other: { command: "other" }, + }, + }); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(targetPath, original); + await symlink(targetPath, configPath); + + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + expect((await lstat(configPath)).isSymbolicLink()).toBe(true); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); + expect(JSON.parse(await readFile(targetPath, "utf8"))).toEqual({ + mcpServers: { other: { command: "other" } }, + }); + }); + + it("fails before mutation when an uninstall config symlink is dangling", async () => { + const home = await mkdtemp(join(tmpdir(), "rea-uninstall-symlink-")); + roots.push(home); + const configPath = join(home, ".cursor/mcp.json"); + await mkdir(dirname(configPath), { recursive: true }); + await symlink(join(home, "missing-mcp.json"), configPath); + + const result = await runUninstall(false, systemUninstallHost(home)); + expect(result.status).toBe("failed"); + expect(result.items).toContainEqual({ + name: "cursor", + status: "failed", + detail: + "Configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun uninstall.", + }); + expect((await lstat(configPath)).isSymbolicLink()).toBe(true); + await expect( + readFile(`${configPath}.rea.backup`, "utf8"), + ).rejects.toThrow(); + }); + it("fails closed on malformed client configuration", async () => { const home = await mkdtemp(join(tmpdir(), "rea-uninstall-bad-")); roots.push(home); @@ -228,6 +314,7 @@ const testFileSystem: UninstallFileSystem = { copy: (source, destination) => copyFile(source, destination), writeText: (path, contents) => writeFile(path, contents), stat: (path) => lstat(path), + realpath: (path) => realpath(path), remove: (path) => rm(path, { recursive: true }), }; diff --git a/tests/setupSkill.test.ts b/tests/setupSkill.test.ts index 9e27a8d8a2..559084c1e0 100644 --- a/tests/setupSkill.test.ts +++ b/tests/setupSkill.test.ts @@ -2,7 +2,10 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { installCanonicalSkill } from "../src/application/Setup.js"; +import { + canonicalSkillNeedsInstall, + installCanonicalSkill, +} from "../src/application/Setup.js"; import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; const roots: string[] = []; @@ -23,6 +26,7 @@ describe("canonical skill transaction", () => { await writeFile(destination, "stale managed skill\n"); await writeFile(sibling, "unrelated skill\n"); + expect(await canonicalSkillNeedsInstall(home)).toBe(true); expect(await installCanonicalSkill(home)).toBe("installed"); expect(await readFile(`${destination}.rea.backup`, "utf8")).toBe( "stale managed skill\n", @@ -32,6 +36,7 @@ describe("canonical skill transaction", () => { `tool_count: ${String(TOOL_CONTRACTS.length)}`, ); expect(await readFile(sibling, "utf8")).toBe("unrelated skill\n"); + expect(await canonicalSkillNeedsInstall(home)).toBe(false); expect(await installCanonicalSkill(home)).toBe("unchanged"); }); }); diff --git a/tests/upgrade.test.ts b/tests/upgrade.test.ts index 9344d186a7..f3399c8556 100644 --- a/tests/upgrade.test.ts +++ b/tests/upgrade.test.ts @@ -61,18 +61,62 @@ describe("CLI upgrade", () => { expect(host.installs).toBe(1); }); - it("continues with npm latest when the registry check is unavailable", async () => { + it("does not mutate when the registry check is unavailable", async () => { const host = new FakeUpgradeHost(); host.latest = undefined; expect(await runUpgrade("0.5.0", host)).toMatchObject({ - status: "upgraded", + status: "failed", latestVersion: null, - versionCheck: "unavailable", + reason: "version-check", + }); + expect(host.installs).toBe(0); + }); + + it.each([ + ["1.10.0", "1.9.0"], + ["1.0.0", "1.0.0-rc.1"], + ])( + "does not downgrade installed version %s to registry version %s", + async (currentVersion, latestVersion) => { + const host = new FakeUpgradeHost(); + host.latest = latestVersion; + + expect(await runUpgrade(currentVersion, host)).toEqual({ + status: "current", + currentVersion, + latestVersion, + installMethod: "npm", + }); + expect(host.installs).toBe(0); + }, + ); + + it("upgrades a prerelease when the final release is available", async () => { + const host = new FakeUpgradeHost(); + host.latest = "1.0.0"; + + expect(await runUpgrade("1.0.0-rc.1", host)).toMatchObject({ + status: "upgraded", + previousVersion: "1.0.0-rc.1", + latestVersion: "1.0.0", }); expect(host.installs).toBe(1); }); + it("rejects invalid registry version metadata without mutation", async () => { + const host = new FakeUpgradeHost(); + host.latest = "latest"; + + expect(await runUpgrade("1.0.0", host)).toMatchObject({ + status: "failed", + currentVersion: "1.0.0", + latestVersion: "latest", + reason: "version-check", + }); + expect(host.installs).toBe(0); + }); + it("preserves stdout for an explicitly structured CLI response", async () => { const host = new FakeUpgradeHost(); diff --git a/vitest.config.ts b/vitest.config.ts index 7b15580eb9..10acb3978b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,5 @@ -import { availableParallelism } from "node:os"; +import { availableParallelism, tmpdir } from "node:os"; +import { join } from "node:path"; import { defineConfig } from "vitest/config"; @@ -42,6 +43,10 @@ export default defineConfig({ coverage: { enabled: true, provider: "v8", + reportsDirectory: join( + tmpdir(), + `rea-vitest-coverage-${String(process.pid)}`, + ), include: ["src/**"], thresholds: { statements: 65,