mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
Merge pull request #234 from morluto/rollup/batch-fixes
fix: rollup batch of fixes
This commit is contained in:
79 files changed
+3912
-581
No files matched your search
@@ -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"
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+9
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -81,5 +81,5 @@ export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
UnknownRegistryError
|
||||
>;
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
|
||||
onAvailabilityChanged?(listener: () => void): () => void;
|
||||
onAvailabilityChanged?(listener: () => void | Promise<void>): () => void;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -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";
|
||||
@@ -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 !== "" ||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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"]',
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+78
-7
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user