Merge pull request #234 from morluto/rollup/batch-fixes

fix: rollup batch of fixes
This commit is contained in:
morluto authored and GitHub committed 2026-07-15 07:03:15 +08:00
commit b348b2c987
79 files changed
+3912 -581

No files matched your search

+33
View File
@@ -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"
+11
View File
@@ -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;
+2
View File
@@ -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 <grant-id>` 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.
+9 -1
View File
@@ -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"
+2
View File
@@ -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",
+126
View File
@@ -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/<id>` 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();
}
+1 -1
View File
@@ -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");
+8 -14
View File
@@ -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" },
+30 -3
View File
@@ -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) {
+142 -10
View File
@@ -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;
}
}
+67 -1
View File
@@ -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 {
+20 -5
View File
@@ -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<void>>();
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>): () => 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<T>(operation: () => Promise<T>): Promise<T> {
+1 -1
View File
@@ -81,5 +81,5 @@ export interface BinarySessionPort extends AnalysisOperationPort {
UnknownRegistryError
>;
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
onAvailabilityChanged?(listener: () => void): () => void;
onAvailabilityChanged?(listener: () => void | Promise<void>): () => void;
}
+5 -4
View File
@@ -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";
+53
View File
@@ -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<ClientConfigPathStats>;
realpath?(path: string): Promise<string>;
}
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<string | undefined> => {
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";
+28 -81
View File
@@ -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<Result<null, AnalysisError>>;
readonly authorizeWorkspaceWrite?: () => Promise<Result<null, AnalysisError>>;
}
@@ -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<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
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,
@@ -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);
@@ -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<typeof parseArtifactInventoryEvidence>;
}
/** 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<string, Evidence>,
evidenceIds: readonly string[],
): Result<readonly Evidence[], EvidenceIntegrityError> => {
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<null, EvidenceIntegrityError> => {
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<ValidatedInventory, EvidenceIntegrityError> => {
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<typeof parseArtifactInventoryEvidence>["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<never, InvestigationWorkspaceError> =>
err(new InvestigationWorkspaceError("read", "revision-conflict"));
const replayIntegrity = (
message: string,
cause?: unknown,
): Result<never, EvidenceIntegrityError> =>
err(
new EvidenceIntegrityError(
message,
cause === undefined ? undefined : { cause },
),
);
+25 -13
View File
@@ -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(
+11 -2
View File
@@ -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<string, Evidence>,
unknowns: ReadonlyMap<string, ResidualUnknown>,
+40
View File
@@ -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<Result<null, PermissionPathError>> {
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[],
+6 -5
View File
@@ -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,
+7
View File
@@ -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<string | undefined> =>
(await host.environment(pid)).REA_PROCESS_RUN_ID;
/**
* Verify run-token ownership before signaling a POSIX process group.
*
+94 -13
View File
@@ -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<ProcessRow | undefined> =>
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<readonly ProcessRow[]> => {
@@ -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<number, string>();
const lastObservations = new Map<number, string>();
let pending: Promise<void> | 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({
+23 -12
View File
@@ -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),
+148 -35
View File
@@ -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<ClientConfigurationResult>;
clientNeedsConfigure(
client: SetupClient,
hopperPath: string | undefined,
command: readonly string[],
): Promise<boolean>;
skillNeedsInstall(): Promise<boolean>;
installSkill(): Promise<"installed" | "unchanged" | "failed">;
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
}
@@ -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<readonly SetupClient[]> => {
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<ClientConfigurationResult> => {
const transactionPath = await resolveClientConfigTransactionPath(
client.configPath,
);
if (transactionPath === undefined)
return { status: "failed", reason: "path" };
let document: Record<string, unknown> = {};
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<ClientConfigurationResult> => {
const transactionPath = await resolveClientConfigTransactionPath(
client.configPath,
);
if (transactionPath === undefined)
return { status: "failed", reason: "path" };
let document: Record<string, unknown> = {};
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<boolean> => {
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;
}
};
+8 -5
View File
@@ -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<string, ClientConfigurationResult>;
readonly appliedActions: string[];
}): Promise<string | undefined> => {
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;
};
+11 -6
View File
@@ -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,
},
]
: []),
];
+36 -9
View File
@@ -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<boolean> => {
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 });
+35 -7
View File
@@ -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<void>;
writeText(path: string, contents: string): Promise<void>;
stat(path: string): Promise<ManagedPathStats>;
realpath?(path: string): Promise<string>;
remove(path: string): Promise<void>;
}
@@ -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<string | UninstallItem> => {
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);
+18 -4
View File
@@ -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<UpgradeResult> => {
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,
+67 -110
View File
@@ -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<Result<WebPageInspection, AnalysisError>> {
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<Result<WebBundleAnalysis, AnalysisError>> {
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<Result<WebObservationSession, AnalysisError>> {
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<Result<WebMcpDiscovery, AnalysisError>> {
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<Result<WebScreenshot, AnalysisError>> {
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<CapturedPage> {
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<void> => {
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,
+1 -1
View File
@@ -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;
},
+4 -3
View File
@@ -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<void> => {
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<void>((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();
+49 -29
View File
@@ -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<number, PendingCommand>();
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<CdpConnection> {
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<unknown> {
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<string, unknown> =>
const waitForOpen = async (
socket: WebSocket,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<void> =>
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);
+6 -2
View File
@@ -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);
+31 -50
View File
@@ -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<Result<ElectronPageInspection, AnalysisError>> {
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<void> => {
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:";
+113 -8
View File
@@ -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<typeof endpointTargetSchema>;
/** 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 = <Output>(
schema: z.ZodType<Output>,
input: unknown,
@@ -104,11 +160,11 @@ const parseEndpointValue = <Output>(
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<typeof endpointTargetSchema>,
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 (
+32 -7
View File
@@ -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");
};
+16 -5
View File
@@ -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<void> => {
await connection.send("Runtime.enable", {}, sessionId, signal);
+1 -1
View File
@@ -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;
+81
View File
@@ -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<CdpTargetSession> => {
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<void> => {
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;
};
+2 -1
View File
@@ -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);
+16 -7
View File
@@ -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,
},
);
+58 -2
View File
@@ -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<boolean>;
}): Promise<PolicyRevocationApproval> => {
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<typeof Cli.create>,
@@ -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<boolean> => {
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,
+2 -1
View File
@@ -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";
+3 -2
View File
@@ -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 !== "" ||
+4
View File
@@ -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"),
+6 -1
View File
@@ -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,
);
+57 -11
View File
@@ -72,6 +72,7 @@ export class HopperClient {
#buffer = "";
#closing = false;
#launcherExitCode: number | null | undefined;
#startupController: AbortController | undefined;
#startPromise: Promise<Result<HopperServerInfo, HopperError>> | undefined;
#closePromise: Promise<void> | undefined;
@@ -86,14 +87,34 @@ export class HopperClient {
/** Launch the bridge once and complete its authenticated health handshake. */
start(signal?: AbortSignal): Promise<Result<HopperServerInfo, HopperError>> {
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<Result<HopperServerInfo, HopperError>> {
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<void> {
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<void> {
async #close(
starting: Promise<Result<HopperServerInfo, HopperError>> | undefined,
controller: AbortController | undefined,
): Promise<void> {
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<void> {
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,
+66 -68
View File
@@ -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<void> | undefined;
let unregisterShutdown = (): void => undefined;
const shutdown = (): Promise<void> => {
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;
};
+18 -16
View File
@@ -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<Result<null, AnalysisError>>)
| 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);
+107
View File
@@ -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<ReturnType<typeof ok>>();
+50
View File
@@ -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({
+209 -1
View File
@@ -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");
+9 -3
View File
@@ -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);
+50
View File
@@ -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({
+155
View File
@@ -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<boolean>>();
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<boolean>>();
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;
}
};
+15
View File
@@ -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"]',
+64
View File
@@ -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<unknown> => {
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"],
});
});
});
+19
View File
@@ -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,
+41
View File
@@ -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",
+78 -7
View File
@@ -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<void>((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<Record<string, unknown>> => {
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<Record<string, unknown>>,
): Readonly<Record<string, unknown>> => ({
@@ -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",
});
}
};
+63 -1
View File
@@ -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" },
});
});
});
+78
View File
@@ -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");
+126 -1
View File
@@ -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),
+43 -1
View File
@@ -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<void> => {
@@ -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 () => {
+253
View File
@@ -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<Parameters<typeof run>[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<void>();
const releaseValidRead = deferred<void>();
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<void>();
const releaseFirstRead = deferred<void>();
const secondReadStarted = deferred<void>();
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<RuntimeDependencies["readProjectPermissionStore"]>,
) => {
let reload: (() => void) | undefined;
let options: CreateServerOptions | undefined;
let notificationCount = 0;
let notification = deferred<void>();
const waiters = new Map<number, ReturnType<typeof deferred<void>>>();
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<void>();
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<void>();
waiters.set(count, waiter);
return waiter.promise;
},
};
};
const projectStore = (
projectRoot: string,
grants: readonly ReturnType<typeof projectGrant>[],
): 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<boolean> =>
(
await authority.explain(
{
capability: "evidence_read",
roots: [root],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `read:${root}`,
},
"read",
)
).ok;
const deferred = <T>() => {
let resolvePromise!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolve: resolvePromise };
};
+79
View File
@@ -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<boolean> =>
(
await authority.explain(
{
...evidenceScope(root),
operation_identity: `read:${root}`,
},
"read",
)
).ok;
+44 -1
View File
@@ -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);
+24
View File
@@ -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 }),
+25 -1
View File
@@ -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"],
+48
View File
@@ -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");
+51 -2
View File
@@ -23,6 +23,7 @@ class FakeSetupHost implements SetupHost {
clientResults = new Map<string, ClientConfigurationResult>();
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<boolean> =>
Promise.resolve(
this.clientResults.get(client.name)?.status !== "unchanged",
);
skillNeedsInstall = (): Promise<boolean> =>
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";
+70 -1
View File
@@ -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");
+87
View File
@@ -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 }),
};
+6 -1
View File
@@ -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");
});
});
+47 -3
View File
@@ -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();
+6 -1
View File
@@ -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,