Compare commits

..
72 changed files with 1261 additions and 3261 deletions

No files matched your search

-33
View File
@@ -1,33 +0,0 @@
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"
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
npm install --global --prefix "${prefix}" "./${tarball}"
echo "REA_VERIFY_SERVER_COMMAND=${prefix}/bin/rea" >> "${GITHUB_ENV}"
echo 'REA_VERIFY_SERVER_ARGS_JSON=["mcp"]' >> "${GITHUB_ENV}"
- name: Verify two binaries through an alternate launcher symlink
- name: Verify two binaries through the in-process bridge
env:
HOPPER_TARGET_PATH: ${{ inputs.first_target }}
HOPPER_SECOND_TARGET_PATH: ${{ inputs.second_target }}
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.6.0"
".": "1.5.0"
}
-94
View File
@@ -1,99 +1,5 @@
# Changelog
## [1.6.0](https://github.com/morluto/rea/compare/rea-agents-1.5.0...rea-agents-1.6.0) (2026-07-14)
### Features
* add web and Electron reverse-engineering workflows ([be76f80](https://github.com/morluto/rea/commit/be76f8090b5195680cdf73a8c21f65e6516efc92))
### Bug Fixes
* **application:** add explicit investigation replay ([7112e1e](https://github.com/morluto/rea/commit/7112e1e6bf33bccd605bf3ee5541b83a522831e5))
* **application:** add explicit investigation replay ([a6b5ca4](https://github.com/morluto/rea/commit/a6b5ca4135ac2cf168470b47e97a05d8b67f3147))
* **browser:** keep page endpoint type internal ([3743962](https://github.com/morluto/rea/commit/37439620710d4c47bf48f1df0b099eab0c115446))
* **browser:** observe direct target disconnects ([9be24f8](https://github.com/morluto/rea/commit/9be24f8de14edc04cdd3649e0ea66d53edb72328))
* **browser:** preserve operation-aware cancellation errors ([0aac8aa](https://github.com/morluto/rea/commit/0aac8aaba5a5097d99277d7501dae6fdd017f2d0))
* **browser:** support page-scoped CDP transports ([c4930f4](https://github.com/morluto/rea/commit/c4930f4820dbf114da6fa850500f1415d0d6b7a9))
* **browser:** support page-scoped CDP transports ([bf96192](https://github.com/morluto/rea/commit/bf9619267b45f2383ebdd648dea05a7d2e3945a6))
* **browser:** support relative source map URLs ([5231e95](https://github.com/morluto/rea/commit/5231e95c19d309113a4335fe44a5793e3924219a))
* **cli:** confirm project grant revocation ([6397e54](https://github.com/morluto/rea/commit/6397e54169a55b4fcab7ad92f42bbd8f8c616810))
* **cli:** confirm project grant revocation ([7a4cfb4](https://github.com/morluto/rea/commit/7a4cfb4fbd96df34f5502adbf70f5bfb12bbc56c))
* **cli:** restrict production MCP dispatch ([8bd0e5f](https://github.com/morluto/rea/commit/8bd0e5f1ea7f36b23f83113ef285c31a36ff315c))
* **cli:** restrict production MCP dispatch ([190e5d3](https://github.com/morluto/rea/commit/190e5d3282f0f1bc980ad488a7774a500ae3e87d))
* **doctor:** honor explicit Hopper launcher ([c69fe2c](https://github.com/morluto/rea/commit/c69fe2c897b8f567e1607424690e9d59aaefadf1))
* **doctor:** honor explicit Hopper launcher ([9208592](https://github.com/morluto/rea/commit/9208592f41ed920c32206c78fcf43c8d7db77f22))
* **evidence:** enforce the combined record limit ([c791802](https://github.com/morluto/rea/commit/c7918024bc89995f4054bd499fef88a34a3e24d7))
* **evidence:** enforce the combined record limit ([834c70c](https://github.com/morluto/rea/commit/834c70cf4b9c8dca000c7a20491215d1acc0db89))
* **hopper:** cancel startup before closing ([f471a63](https://github.com/morluto/rea/commit/f471a63c87679eff9564553741d9967724c57f0a))
* **hopper:** cancel startup before closing ([6cc2fdf](https://github.com/morluto/rea/commit/6cc2fdf80ce2d0220fe996ddbed2f0378f5397cd))
* **hopper:** select verified Linux demo mode explicitly ([46e4052](https://github.com/morluto/rea/commit/46e40520285c2338bd1ffedd16599977c333befb))
* **hopper:** select verified Linux demo mode explicitly ([04c5fd6](https://github.com/morluto/rea/commit/04c5fd63b72e6e497363ecbde5e24088eef639cd))
* **native:** harden command capture edge cases ([c2e64a4](https://github.com/morluto/rea/commit/c2e64a42c84bd245f3df4eb8b61039c8594704f8))
* **native:** harden command capture edge cases ([d39f98d](https://github.com/morluto/rea/commit/d39f98d06dd53cdfd427e75e2f45f2c8b8792f50))
* preserve setup diagnostics and clean Knip config ([2d1e021](https://github.com/morluto/rea/commit/2d1e021782dc903faaa401207dad47278bfb57b2))
* preserve setup diagnostics and clean Knip config ([5a7ccef](https://github.com/morluto/rea/commit/5a7ccefc68e3d7359bfd9a6262701641da380d37))
* **process:** gate sampling on initialized PTY root ([57938a8](https://github.com/morluto/rea/commit/57938a8465add2d6e89dae13e66059bad7f72a45))
* **process:** gate sampling on initialized PTY roots ([e831a41](https://github.com/morluto/rea/commit/e831a41375148336d72beadd5d13405047c82e67))
* **process:** keep validation detail internal ([614a119](https://github.com/morluto/rea/commit/614a1197f5b699b61c46d82a24e0564bf0d87829))
* **process:** restrict cleanup to captured group leaders ([40c6f60](https://github.com/morluto/rea/commit/40c6f60ac9a4b86f4cbe28a72e7692e1acf761b1))
* **process:** settle exited zombie groups ([072ce5a](https://github.com/morluto/rea/commit/072ce5aede03a2dab59ce8776a04063218448925))
* **process:** settle exited zombie groups ([4828326](https://github.com/morluto/rea/commit/482832607910a6e872bccc27c956eeaf55dd6056))
* **process:** stabilize and speed up the test suite ([a3b7de9](https://github.com/morluto/rea/commit/a3b7de9fa5e89808a3c01a5623779709edb7c691))
* **reference:** preserve distinct parse failures ([e02cb2f](https://github.com/morluto/rea/commit/e02cb2f877e8fd9874245ee6e15f68666afaeb46))
* **reference:** preserve distinct parse failures ([543510f](https://github.com/morluto/rea/commit/543510f62fd257b42e6a7dfc876112dda0e0ea01))
* rollup batch of fixes ([b348b2c](https://github.com/morluto/rea/commit/b348b2c98772b3d38b33e6aa4d14ae7dce78a4b9))
* **runtime:** apply permission reloads atomically ([49e843e](https://github.com/morluto/rea/commit/49e843e8ff2eb87bf1c6174823191cc97ad1ced8))
* **runtime:** apply permission reloads atomically ([3ae7109](https://github.com/morluto/rea/commit/3ae7109faa29175e84ef695b296ad4db14097a37))
* **runtime:** serialize permission reloads ([7e53dd6](https://github.com/morluto/rea/commit/7e53dd6ad321f9b75dbe458f9e116da7637d740c))
* **runtime:** unregister shutdown handlers ([71072d8](https://github.com/morluto/rea/commit/71072d86b161530d523a10e920e5d67ca220a7e9))
* **runtime:** unregister shutdown handlers ([f9e3c23](https://github.com/morluto/rea/commit/f9e3c23987bdf37be07d6c4602666c6ac90ae0ed))
* **session:** isolate availability observers ([43766fd](https://github.com/morluto/rea/commit/43766fda699f5498909af21e386ae4817159e4cf))
* **session:** isolate availability observers ([e5a80d4](https://github.com/morluto/rea/commit/e5a80d4f06cd0d4bf64e6b94b50f7430829c336a))
* **session:** reopen replaced targets ([e98413c](https://github.com/morluto/rea/commit/e98413c3d5b0adae72ac9f3814f401ad9eec2bb6))
* **session:** reopen replaced targets ([5c55999](https://github.com/morluto/rea/commit/5c559998e14bcb9be1e589a1266e31296a8f2af8))
* **setup:** configure every detected client ([69744a4](https://github.com/morluto/rea/commit/69744a421a28bad525fe95697938b99d7ffcf5b6))
* **setup:** configure every detected client ([24f9a1f](https://github.com/morluto/rea/commit/24f9a1ff11e4d1a6361071739fa937d88159fb33))
* **setup:** omit aligned client configurations from plan ([a86da4f](https://github.com/morluto/rea/commit/a86da4ff3f9279e4ca3fc792e6f18542fee9e0ce))
* **setup:** omit aligned skill from plan ([d81687f](https://github.com/morluto/rea/commit/d81687f38393128b0a91ca83deb9b250190fb509))
* **setup:** omit aligned skill from plan ([2984fa7](https://github.com/morluto/rea/commit/2984fa74acfb6f7cbb504677a348e20bff85b806))
* **setup:** preserve client config symlinks ([85285f1](https://github.com/morluto/rea/commit/85285f1563d8d24bb934e1fe227cb91fe36c6dd4))
* **setup:** preserve client config symlinks ([df87544](https://github.com/morluto/rea/commit/df87544a5dcee13f15aa6e435c2b61fb3b220397))
* **test:** adapt mainReload shutdown seam to registerShutdown signature ([0a430c1](https://github.com/morluto/rea/commit/0a430c12c158cf7f1ef04cdf537d5277c08c6106))
* **tooling:** isolate concurrent repository checks ([fb17156](https://github.com/morluto/rea/commit/fb17156499c592b5e18ffae996c55c372e3727d0))
* **tooling:** isolate concurrent repository checks ([ab8c5c1](https://github.com/morluto/rea/commit/ab8c5c1e13041690e07896f2f2f0dfa2134ad97e))
* **upgrade:** prevent version downgrades ([a204840](https://github.com/morluto/rea/commit/a2048406302b808e4f897eabd6c00ce64c529c16))
* **upgrade:** prevent version downgrades ([1d1d234](https://github.com/morluto/rea/commit/1d1d234eedd3b73108cd2fb41878680ca107bb7c))
* **verify:** reconcile PR-212/215/228 package E2E expectations ([cfa110c](https://github.com/morluto/rea/commit/cfa110ccb4021d383cc8be1caa17fef923edc183))
### Performance Improvements
* **application:** scan version artifacts in parallel ([528c05e](https://github.com/morluto/rea/commit/528c05e2faa557e17b6b83ccd7e1b0e779ba892a))
* **application:** scan version artifacts in parallel ([c00739b](https://github.com/morluto/rea/commit/c00739bb1f1ae3cb6f00464e8fd9f5b5f7dc6b6e))
### Tests
* **application:** verify packaged MCP replay ([6601e0c](https://github.com/morluto/rea/commit/6601e0cb7e57fe94cf0ae65bf0d04de25a3a7b49))
* **browser:** harden Chrome startup on CI ([74c5121](https://github.com/morluto/rea/commit/74c512130c5bc25d6c119eda72a86e73fcc9f1ff))
* **browser:** route discovery sockets through proxy ([e363f0e](https://github.com/morluto/rea/commit/e363f0eab8325a9f83568295d4669d70e7d47a2d))
* **browser:** verify page-scoped Chrome transport ([c252536](https://github.com/morluto/rea/commit/c252536494c15f36e69978eaf068eae176e71688))
* **cli:** verify packaged policy revocation ([3d71737](https://github.com/morluto/rea/commit/3d71737dc9ba8b449c0d174da90c3f1df54789d2))
* **hopper:** cover verified Linux rejection ([db202c2](https://github.com/morluto/rea/commit/db202c2b6ac9ce771c401cc558c56ecf6d2e51ee))
* **hopper:** retry after cancelled startup ([6de726f](https://github.com/morluto/rea/commit/6de726ffddc54ea354e6970f022eaab2f0863586))
* **hopper:** verify alternate Linux launcher path ([d93257b](https://github.com/morluto/rea/commit/d93257b50fae81272cbd2da28ba24d03647c2c99))
* **package:** cover aligned setup plan ([a597639](https://github.com/morluto/rea/commit/a5976396eb424933232844e161cdab84b452b361))
* **package:** cover config symlink lifecycle ([d1ef7a5](https://github.com/morluto/rea/commit/d1ef7a54394ff2ed4316ba01cfe0aa4aac313aed))
* **package:** preserve unrelated symlink config ([c50e51e](https://github.com/morluto/rea/commit/c50e51eb6fe5686ad9d680abe0d96b3e4b58dc78))
* reduce test suite runtime ([2223da1](https://github.com/morluto/rea/commit/2223da19166a3ec485d98a0d005113a9d0358f37))
* **reference:** cover failure normalization ([3d5041f](https://github.com/morluto/rea/commit/3d5041fb2f8964c30f0273615b4f039620eef0bf))
* **runtime:** cover idempotent handler cleanup ([1bdb59e](https://github.com/morluto/rea/commit/1bdb59e201a26b53f5ed8b2eb870941ca4b788f7))
* serialize subprocess-heavy integrations ([f46fc7f](https://github.com/morluto/rea/commit/f46fc7f1f3a265123c69d007c64c928a8974d98f))
* **session:** reopen replaced target through MCP ([1c32880](https://github.com/morluto/rea/commit/1c32880e56450b610d766c5a6c1bee6873ee6323))
* **setup:** cover later clients after failure ([8f73112](https://github.com/morluto/rea/commit/8f7311285b2ba7a1ed427d61e437dd2665e20d12))
## [1.5.0](https://github.com/morluto/rea/compare/rea-agents-1.4.0...rea-agents-1.5.0) (2026-07-14)
+3 -3
View File
@@ -82,9 +82,9 @@ 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.
`investigation_run.replay_run_id`. REA first validates the workspace revision,
the complete run, its inventory paths and controls, and its linked Evidence;
only then may it return without requesting input-read authority.
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.
-2
View File
@@ -91,8 +91,6 @@ 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.
+3 -11
View File
@@ -1,12 +1,12 @@
{
"name": "rea-agents",
"version": "1.6.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rea-agents",
"version": "1.6.0",
"version": "1.5.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -28,7 +28,6 @@
"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",
@@ -41,7 +40,6 @@
"@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",
@@ -1720,13 +1718,6 @@
"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",
@@ -4356,6 +4347,7 @@
"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"
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "rea-agents",
"version": "1.6.0",
"version": "1.5.0",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"license": "MIT",
"repository": {
@@ -51,7 +51,6 @@
"scripts": {
"prebuild": "node scripts/generate-package-metadata.mjs",
"build": "tsc -p tsconfig.build.json",
"postbuild": "node scripts/record-build-cache.mjs",
"check": "npm run typecheck && npm run lint && npm run format:check && npm test",
"format": "prettier --write .",
"format:check": "prettier --check .",
@@ -108,7 +107,6 @@
"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",
@@ -118,7 +116,6 @@
"@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",
-103
View File
@@ -1,103 +0,0 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
const CACHE_DIRECTORY = ["node_modules", ".cache"];
const BUILD_INFO_NAME = "rea-tsconfig-build.tsbuildinfo";
const MANIFEST_NAME = "rea-tsconfig-build-outputs.json";
const isMissing = (cause) =>
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT";
const outputFiles = async (root) => {
const sourceRoot = join(root, "src");
const entries = await readdir(sourceRoot, { recursive: true });
return entries
.filter((entry) => entry.endsWith(".ts") && !entry.endsWith(".d.ts"))
.sort()
.map((entry) => {
const output = `${entry.slice(0, -3)}.js`;
return { output, path: join(root, "dist", output) };
});
};
const digest = async (path) =>
createHash("sha256")
.update(await readFile(path))
.digest("hex");
const cachePath = (root, name) => join(root, ...CACHE_DIRECTORY, name);
const invalidate = async (root) => {
await Promise.all([
rm(cachePath(root, BUILD_INFO_NAME), { force: true }),
rm(cachePath(root, MANIFEST_NAME), { force: true }),
]);
};
const readManifest = async (root) => {
try {
const parsed = JSON.parse(
await readFile(cachePath(root, MANIFEST_NAME), "utf8"),
);
if (
typeof parsed !== "object" ||
parsed === null ||
parsed.version !== 1 ||
typeof parsed.outputs !== "object" ||
parsed.outputs === null ||
Array.isArray(parsed.outputs)
)
return undefined;
return parsed.outputs;
} catch (cause) {
if (isMissing(cause) || cause instanceof SyntaxError) return undefined;
throw cause;
}
};
/** Invalidates incremental state when generated JavaScript was removed or changed. */
export const validateBuildCache = async (root) => {
const [files, manifest] = await Promise.all([
outputFiles(root),
readManifest(root),
]);
if (manifest === undefined || Object.keys(manifest).length !== files.length) {
await invalidate(root);
return;
}
const matches = await Promise.all(
files.map(async ({ output, path }) => {
if (typeof manifest[output] !== "string") return false;
try {
return (await digest(path)) === manifest[output];
} catch (cause) {
if (isMissing(cause)) return false;
throw cause;
}
}),
);
if (!matches.every(Boolean)) await invalidate(root);
};
/** Records generated JavaScript so a later incremental build can verify its cache. */
export const recordBuildCache = async (root) => {
const files = await outputFiles(root);
const entries = await Promise.all(
files.map(async ({ output, path }) => [output, await digest(path)]),
);
const source = `${JSON.stringify({ version: 1, outputs: Object.fromEntries(entries) })}\n`;
const directory = join(root, ...CACHE_DIRECTORY);
await mkdir(directory, { recursive: true });
const path = cachePath(root, MANIFEST_NAME);
let existing;
try {
existing = await readFile(path, "utf8");
} catch (cause) {
if (!isMissing(cause)) throw cause;
}
if (existing !== source) await writeFile(path, source, "utf8");
};
-126
View File
@@ -1,126 +0,0 @@
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 -17
View File
@@ -2,8 +2,6 @@ import { readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { validateBuildCache } from "./build-cache.mjs";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const packageJson = JSON.parse(
await readFile(join(root, "package.json"), "utf8"),
@@ -34,18 +32,4 @@ export const PACKAGE_METADATA = {
skillVersion: ${JSON.stringify(skillVersion)},
} as const;
`;
const outputPath = join(root, "src/generatedPackageMetadata.ts");
let existingSource;
try {
existingSource = await readFile(outputPath, "utf8");
} catch (cause) {
if (
typeof cause !== "object" ||
cause === null ||
!("code" in cause) ||
cause.code !== "ENOENT"
)
throw cause;
}
if (existingSource !== source) await writeFile(outputPath, source, "utf8");
await validateBuildCache(root);
await writeFile(join(root, "src/generatedPackageMetadata.ts"), source, "utf8");
+1 -1
View File
@@ -8,7 +8,7 @@ const { default: packageJson } = await import("../package.json", {
});
process.env.REA_PACKAGE_VERSION = packageJson.version;
const isMcpMode =
args.length === 1 && (args[0] === "--mcp" || args[0] === "mcp");
args[0] === "--mcp" || (args.length === 1 && args[0] === "mcp");
if (isMcpMode) {
const { runEntrypoint } = await import("../dist/main.js");
-7
View File
@@ -1,7 +0,0 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { recordBuildCache } from "./build-cache.mjs";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
await recordBuildCache(root);
+3 -30
View File
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { readFile, rm, writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -27,43 +27,16 @@ export async function verifyPackagedInvestigation(input) {
];
const investigated = await runJson(input.cli, args, input.environment);
const reused = await runJson(input.cli, args, input.environment);
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"));
const persisted = JSON.parse(await readFile(workspacePath, "utf8"));
if (
investigated.operation !== "find_changed_behavior" ||
investigated.normalized_result?.behavior_status !== "unknown" ||
replayed.evidence_id !== investigated.evidence_id ||
investigated.normalized_result?.investigation_run?.run_id === undefined ||
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) {
+10 -142
View File
@@ -1,12 +1,10 @@
import { execFile } from "node:child_process";
import {
chmod,
lstat,
mkdtemp,
mkdir,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -84,23 +82,15 @@ 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(codexTarget, 'model = "gpt-5"\n');
await writeFile(cursorTarget, '{"existing":true}\n');
await symlink(codexTarget, codexConfig);
await symlink(cursorTarget, cursorConfig);
await writeFile(cursorConfig, '{"existing":true}\n');
const environment = {
...process.env,
HOME: home,
@@ -179,7 +169,7 @@ try {
)
throw new Error("packaged artifact inventory CLI failed");
// prettier-ignore
const investigationReplay = await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment });
await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment });
if (process.platform === "linux") {
const unsupportedExecution = await runWithStatus(
cli,
@@ -374,7 +364,6 @@ 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 (
@@ -384,12 +373,11 @@ 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, codexConfig: plannedCodexConfig, 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, cursorConfig: plannedCursorConfig, skill: plannedSkill })}`,
);
}
const firstExecution = await runWithStatus(
@@ -398,12 +386,6 @@ 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"],
@@ -417,15 +399,9 @@ 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 without confirmation: ${JSON.stringify({ first, aligned, second })}`,
);
throw new Error("packaged setup was not ready and idempotent");
for (const configPath of [claudeConfig, cursorConfig]) {
const config = json(await readFile(configPath, "utf8"));
if (
@@ -441,18 +417,6 @@ 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"),
@@ -467,8 +431,7 @@ try {
)
throw new Error("packaged stale-skill upgrade was not isolated");
await writeFile(claudeConfig, "malformed");
await writeFile(cursorConfig, '{"existing":true}\n');
await writeFile(cursorConfig, "malformed");
const failedExecution = await runWithStatus(
cli,
["setup", "--yes", "--json"],
@@ -478,17 +441,10 @@ try {
if (
failed.status !== "needs_human" ||
failedExecution.status !== 1 ||
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
(await readFile(cursorConfig, "utf8")) !== "malformed"
)
throw new Error(
`packaged setup did not continue after an earlier client failure: ${JSON.stringify(failed)}`,
);
await writeFile(claudeConfig, "{}\n");
throw new Error("packaged setup failure recovery did not preserve input");
await writeFile(cursorConfig, "{}\n");
const recoveredExecution = await runWithStatus(
cli,
["setup", "--yes", "--json"],
@@ -500,64 +456,8 @@ 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");
@@ -566,10 +466,7 @@ try {
const transport = new StdioClientTransport({
command: cli,
args: ["mcp"],
env: {
...environment,
REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([]),
},
env: environment,
stderr: "pipe",
});
let mcpStderr = "";
@@ -587,25 +484,6 @@ 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,
@@ -681,7 +559,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 ? 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`,
`${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`,
);
} finally {
if (tarball) await rm(join(root, tarball), { force: true });
@@ -708,13 +586,3 @@ 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;
}
}
+1 -67
View File
@@ -18,7 +18,6 @@ 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",
@@ -32,15 +31,11 @@ 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}`,
@@ -48,7 +43,6 @@ 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`,
@@ -197,9 +191,6 @@ 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,
@@ -214,13 +205,11 @@ 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, {
@@ -231,61 +220,6 @@ 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,
@@ -313,7 +247,7 @@ async function browserExecutable() {
async function devtoolsPort(profile, child, stderr) {
const activePort = join(profile, "DevToolsActivePort");
for (let attempt = 0; attempt < 600; attempt += 1) {
for (let attempt = 0; attempt < 200; attempt += 1) {
if (child.exitCode !== null)
throw new Error(`Chrome exited before CDP startup: ${stderr()}`);
try {
+3 -14
View File
@@ -1,7 +1,5 @@
import { access, mkdtemp, rm, symlink } from "node:fs/promises";
import { access } from "node:fs/promises";
import { execFile } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
@@ -16,14 +14,5 @@ await access("/usr/bin/python3");
const linked = await execFileAsync("ldd", [launcher]);
if (`${linked.stdout}\n${linked.stderr}`.includes("not found"))
throw new Error("Hopper has unresolved shared-library dependencies");
const alternateDirectory = await mkdtemp(
join(tmpdir(), "rea-real-hopper-launcher-"),
);
const alternateLauncher = join(alternateDirectory, "Hopper-alternate");
try {
await symlink(launcher, alternateLauncher);
process.env.HOPPER_LAUNCHER_PATH = alternateLauncher;
await import("./verify-real-hopper.mjs");
} finally {
await rm(alternateDirectory, { recursive: true, force: true });
}
process.env.HOPPER_LAUNCHER_PATH = launcher;
await import("./verify-real-hopper.mjs");
+5 -20
View File
@@ -35,10 +35,7 @@ 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,
snapshotTarget,
} from "../domain/analysisSnapshot.js";
import { snapshotMatchesTarget } from "../domain/analysisSnapshot.js";
import {
AnalysisSnapshotCache,
isSnapshotCacheable,
@@ -85,7 +82,7 @@ export class BinarySession implements BinarySessionPort {
string,
{ readonly available: boolean; readonly reason: string | null }
>();
readonly #availabilityListeners = new Set<() => void | Promise<void>>();
readonly #availabilityListeners = new Set<() => void>();
readonly #snapshotsEnabled: boolean;
#snapshotInvalidated = false;
@@ -135,7 +132,7 @@ export class BinarySession implements BinarySessionPort {
}
/** Observe runtime provider-health changes that affect discovery metadata. */
onAvailabilityChanged(listener: () => void | Promise<void>): () => void {
onAvailabilityChanged(listener: () => void): () => void {
this.#availabilityListeners.add(listener);
return () => this.#availabilityListeners.delete(listener);
}
@@ -351,10 +348,7 @@ export class BinarySession implements BinarySessionPort {
);
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
if (
this.#active?.target.path === parsed.value.path &&
snapshotMatchesTarget(snapshotTarget(this.#active.target), parsed.value)
) {
if (this.#active?.target.path === parsed.value.path) {
if (options.snapshot !== undefined) {
const imported = this.importAnalysisSnapshot(options.snapshot);
if (!imported.ok) return imported;
@@ -589,16 +583,7 @@ export class BinarySession implements BinarySessionPort {
}
#emitAvailabilityChanged(): void {
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.
}
}
for (const listener of this.#availabilityListeners) listener();
}
#serialize<T>(operation: () => Promise<T>): Promise<T> {
+1 -1
View File
@@ -81,5 +81,5 @@ export interface BinarySessionPort extends AnalysisOperationPort {
UnknownRegistryError
>;
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
onAvailabilityChanged?(listener: () => void | Promise<void>): () => void;
onAvailabilityChanged?(listener: () => void): () => void;
}
-53
View File
@@ -1,53 +0,0 @@
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";
+81 -9
View File
@@ -1,4 +1,6 @@
import { type Evidence } from "../domain/evidence.js";
import { compareArtifacts } from "../domain/artifactComparison.js";
import { findChangedBehavior } from "../domain/changedBehavior.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import {
createEvidenceBundle,
type EvidenceFilePolicy,
@@ -16,15 +18,18 @@ 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,
@@ -37,9 +42,9 @@ import {
writeInvestigationWorkspace,
} from "./InvestigationWorkspaceStore.js";
import {
createArtifactComparisonEvidence,
createChangedBehaviorEvidence,
} from "./CrossVersionInvestigationEvidence.js";
ARTIFACT_COMPARISON_PROVIDER,
CHANGED_BEHAVIOR_PROVIDER,
} from "./InvestigationProviders.js";
import { selectCompletedInvestigationReplay } from "./CrossVersionInvestigationReplay.js";
export interface CrossVersionInvestigationOutcome {
@@ -318,6 +323,38 @@ 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,
@@ -326,11 +363,7 @@ const finalizeInvestigation = async (
): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
const comparison = comparisonEvidenceFor(state.workspace, state.run);
if (!comparison.ok) return comparison;
const evidence = createChangedBehaviorEvidence(
state.workspace,
state.run,
comparison.value,
);
const evidence = createChangedBehaviorEvidence(state, comparison.value);
const run = investigationRunSchema.parse({
...state.run,
status: "complete",
@@ -353,6 +386,45 @@ 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,
@@ -1,91 +0,0 @@
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);
+305 -274
View File
@@ -1,9 +1,8 @@
import { isDeepStrictEqual } from "node:util";
import {
artifactInventoryResultSchema,
type ArtifactInventoryResult,
} from "../domain/artifactGraph.js";
changedBehaviorResultSchema,
type ChangedBehaviorResult,
} from "../domain/changedBehavior.js";
import { artifactInventoryResultSchema } from "../domain/artifactGraph.js";
import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js";
import type { Evidence } from "../domain/evidence.js";
import {
@@ -13,28 +12,21 @@ import {
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";
ARTIFACT_COMPARISON_PROVIDER,
ARTIFACT_GRAPH_PROVIDER,
CHANGED_BEHAVIOR_PROVIDER,
} from "./InvestigationProviders.js";
/** Validated workspace state returned by an explicit cache-only selector. */
/** Validated workspace state returned by an explicit replay 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,
@@ -46,229 +38,139 @@ export const selectCompletedInvestigationReplay = (
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;
if (workspace === null || run === undefined || run.status !== "complete")
return err(new InvestigationWorkspaceError("read", "revision-conflict"));
if (!replayRequestMatches(workspace, run, input))
return err(new InvestigationWorkspaceError("read", "revision-conflict"));
const evidence = validateCompletedReplayEvidence(workspace, run);
return evidence.ok ? ok({ workspace, run }) : evidence;
};
const controlsMatch = (
const replayRequestMatches = (
workspace: InvestigationWorkspace,
run: InvestigationRun,
input: CrossVersionInvestigationInput,
): boolean =>
isDeepStrictEqual(run.options, input.options) &&
JSON.stringify(run.options) === JSON.stringify(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];
run.max_integrity_mismatches === input.max_integrity_mismatches &&
inventoryEvidenceMatches(workspace, run.left_inventory_evidence_ids, {
path: input.left_path,
target: run.left,
input,
}) &&
inventoryEvidenceMatches(workspace, run.right_inventory_evidence_ids, {
path: input.right_path,
target: run.right,
input,
});
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);
interface InventoryReplayExpectation {
readonly path: string;
readonly target: InvestigationRun["left"];
readonly input: CrossVersionInvestigationInput;
}
const validateLinkedEvidence = (
const inventoryEvidenceMatches = (
workspace: InvestigationWorkspace,
run: InvestigationRun,
leftRecords: readonly Evidence[],
rightRecords: readonly Evidence[],
): Result<null, EvidenceIntegrityError> => {
evidenceIds: readonly string[],
expectation: InventoryReplayExpectation,
): boolean => {
const byId = new Map(
workspace.bundle.records.map((record) => [record.evidence_id, record]),
);
const evidence = evidenceIds.flatMap((evidenceId) => {
const record = byId.get(evidenceId);
return record === undefined ? [] : [record];
});
if (
evidence.length !== evidenceIds.length ||
!evidence.every((record, index) =>
inventoryPageMatches(record, index, expectation),
)
)
return false;
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,
const inventory = parseArtifactInventoryEvidence(evidence).inventory;
return (
inventory.complete &&
evidence.length ===
expectedInventoryPageCount(
inventory.manifest.node_count,
inventory.manifest.occurrence_count,
inventory.manifest.edge_count,
expectation.input.options.page_size,
) &&
isDeepStrictEqual(page.limitations, first.limitations),
)
inventory.manifest.root_sha256 === expectation.target.root_sha256 &&
inventory.manifest.graph_sha256 === expectation.target.graph_sha256 &&
inventory.manifest.manifest_id === expectation.target.manifest_id &&
inventory.manifest.root_format === expectation.target.format
);
} catch {
return false;
}
};
const inventoryPageMatches = (
evidence: Evidence,
index: number,
expectation: InventoryReplayExpectation,
): boolean => {
const page = artifactInventoryResultSchema.safeParse(
evidence.normalized_result,
);
if (!page.success) return false;
const offset = index * expectation.input.options.page_size;
return (
inventoryEvidenceMetadataMatches(evidence, expectation) &&
inventoryParametersMatch(evidence.parameters, offset, expectation.input) &&
inventoryResultPageMatches(page.data, offset, expectation.input)
);
};
const pageMatches = (
const inventoryEvidenceMetadataMatches = (
evidence: Evidence,
expectation: InventoryReplayExpectation,
): boolean =>
evidence.operation === "inventory_artifact" &&
evidence.predicate_type === "rea.analysis/v2" &&
providerMatches(evidence.provider, ARTIFACT_GRAPH_PROVIDER) &&
evidence.subject?.local_path === expectation.path &&
evidence.subject.digest.sha256 === expectation.target.root_sha256 &&
evidence.subject.format === expectation.target.format &&
evidence.confidence === "observed" &&
evidence.authority === "shipped-artifact" &&
evidence.raw_result === null &&
evidence.environment === null &&
evidence.evidence_links.length === 0;
const inventoryResultPageMatches = (
page: ReturnType<typeof artifactInventoryResultSchema.parse>,
offset: number,
input: CrossVersionInvestigationInput,
): boolean =>
collectionPageMatches(
page.nodes,
offset,
input.options.page_size,
page.manifest.node_count,
) &&
collectionPageMatches(
page.occurrences,
offset,
input.options.page_size,
page.manifest.occurrence_count,
) &&
collectionPageMatches(
page.edges,
offset,
input.options.page_size,
page.manifest.edge_count,
) &&
valuesMatch(page.limits, traversalLimits(input));
const collectionPageMatches = (
page: {
readonly items: readonly unknown[];
readonly offset: number;
@@ -279,63 +181,192 @@ const pageMatches = (
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);
): boolean => {
const expectedItems = Math.max(0, Math.min(limit, total - offset));
const nextOffset = offset + limit < total ? offset + limit : null;
return (
page.offset === offset &&
page.limit === limit &&
page.total === total &&
page.items.length === expectedItems &&
page.next_offset === nextOffset
);
};
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 expectedInventoryPageCount = (
nodeCount: number,
occurrenceCount: number,
edgeCount: number,
pageSize: number,
): number =>
Math.max(
1,
Math.ceil(nodeCount / pageSize),
Math.ceil(occurrenceCount / pageSize),
Math.ceil(edgeCount / pageSize),
);
const inventoryParametersMatch = (
parameters: Readonly<Record<string, unknown>>,
offset: number,
input: CrossVersionInvestigationInput,
): boolean =>
Object.keys(parameters).length === 15 &&
parameters.node_offset === offset &&
parameters.node_limit === input.options.page_size &&
parameters.occurrence_offset === offset &&
parameters.occurrence_limit === input.options.page_size &&
parameters.edge_offset === offset &&
parameters.edge_limit === input.options.page_size &&
parameters.max_entries === input.options.max_entries &&
parameters.max_total_bytes === input.options.max_total_bytes &&
parameters.max_entry_bytes === input.options.max_entry_bytes &&
parameters.max_compression_ratio === input.options.max_compression_ratio &&
parameters.max_depth === input.options.max_depth &&
parameters.max_path_bytes === input.options.max_path_bytes &&
parameters.integrity_policy === input.integrity_policy &&
parameters.integrity_continue_approved ===
input.integrity_continue_approved &&
parameters.max_integrity_mismatches === input.max_integrity_mismatches;
const traversalLimits = (input: CrossVersionInvestigationInput) => ({
max_entries: input.options.max_entries,
max_total_bytes: input.options.max_total_bytes,
max_entry_bytes: input.options.max_entry_bytes,
max_compression_ratio: input.options.max_compression_ratio,
max_depth: input.options.max_depth,
max_path_bytes: input.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 = (
const validateCompletedReplayEvidence = (
workspace: InvestigationWorkspace,
evidenceId: string | null,
): Evidence | undefined =>
evidenceId === null
? undefined
: workspace.bundle.records.find(
({ evidence_id: current }) => current === evidenceId,
);
run: InvestigationRun,
): Result<null, EvidenceIntegrityError> => {
const inventoryIds = [
...run.left_inventory_evidence_ids,
...run.right_inventory_evidence_ids,
];
const comparison = workspace.bundle.records.find(
({ evidence_id: evidenceId }) => evidenceId === run.comparison_evidence_id,
);
if (!comparisonEvidenceMatches(comparison, inventoryIds, run))
return inconsistentReplayEvidence();
const resultLinks = [
...new Set([comparison.evidence_id, ...inventoryIds]),
].sort((left, right) => left.localeCompare(right, "en"));
const result = workspace.bundle.records.find(
({ evidence_id: evidenceId }) => evidenceId === run.result_evidence_id,
);
if (!resultEvidenceMatches(result, resultLinks, workspace, run))
return inconsistentReplayEvidence();
const parsedResult = changedBehaviorResultSchema.safeParse(
result.normalized_result,
);
if (
!parsedResult.success ||
!resultPayloadMatches(parsedResult.data, resultLinks, workspace, run)
)
return inconsistentReplayEvidence();
return ok(null);
};
const replayConflict = (): Result<never, InvestigationWorkspaceError> =>
err(new InvestigationWorkspaceError("read", "revision-conflict"));
const comparisonEvidenceMatches = (
comparison: Evidence | undefined,
inventoryIds: readonly string[],
run: InvestigationRun,
): comparison is Evidence =>
comparison?.operation === "compare_artifacts" &&
comparison.predicate_type === "rea.artifact-comparison/v1" &&
providerMatches(comparison.provider, ARTIFACT_COMPARISON_PROVIDER) &&
comparison.subject === null &&
comparison.confidence === "derived" &&
comparison.authority === "analyst-inference" &&
valuesMatch(comparison.evidence_links, inventoryIds) &&
comparisonParametersMatch(comparison.parameters, run);
const replayIntegrity = (
message: string,
cause?: unknown,
): Result<never, EvidenceIntegrityError> =>
const resultEvidenceMatches = (
result: Evidence | undefined,
resultLinks: readonly string[],
workspace: InvestigationWorkspace,
run: InvestigationRun,
): result is Evidence =>
result?.operation === "find_changed_behavior" &&
result.predicate_type === "rea.changed-behavior/v1" &&
providerMatches(result.provider, CHANGED_BEHAVIOR_PROVIDER) &&
result.subject === null &&
result.confidence === "derived" &&
result.authority === "analyst-inference" &&
valuesMatch(result.evidence_links, resultLinks) &&
resultParametersMatch(result.parameters, workspace, run);
const resultPayloadMatches = (
result: ChangedBehaviorResult,
resultLinks: readonly string[],
workspace: InvestigationWorkspace,
run: InvestigationRun,
): boolean => {
const summary = result.investigation_run;
return (
summary?.workspace_id === workspace.workspace_id &&
summary.run_id === run.run_id &&
summary.left_manifest_id === run.left.manifest_id &&
summary.right_manifest_id === run.right.manifest_id &&
summary.inventory_evidence_count ===
run.left_inventory_evidence_ids.length +
run.right_inventory_evidence_ids.length &&
summary.comparison_evidence_id === run.comparison_evidence_id &&
valuesMatch(summary.limitations, run.limitations) &&
valuesMatch(result.evidence_links, resultLinks)
);
};
const inconsistentReplayEvidence = (): Result<never, EvidenceIntegrityError> =>
err(
new EvidenceIntegrityError(
message,
cause === undefined ? undefined : { cause },
"Completed investigation replay Evidence is inconsistent",
),
);
const comparisonParametersMatch = (
parameters: Readonly<Record<string, unknown>>,
run: InvestigationRun,
): boolean =>
Object.keys(parameters).length === 4 &&
JSON.stringify(parameters.left_evidence_ids) ===
JSON.stringify(run.left_inventory_evidence_ids) &&
JSON.stringify(parameters.right_evidence_ids) ===
JSON.stringify(run.right_inventory_evidence_ids) &&
parameters.offset === 0 &&
parameters.limit === run.options.change_limit;
const resultParametersMatch = (
parameters: Readonly<Record<string, unknown>>,
workspace: InvestigationWorkspace,
run: InvestigationRun,
): boolean =>
Object.keys(parameters).length === 5 &&
parameters.workspace_id === workspace.workspace_id &&
parameters.run_id === run.run_id &&
JSON.stringify(parameters.comparison_evidence_ids) ===
JSON.stringify([run.comparison_evidence_id]) &&
parameters.offset === 0 &&
parameters.limit === Math.min(run.options.change_limit, 100);
const providerMatches = (
actual: {
readonly id: string;
readonly name: string;
readonly version: string | null;
},
expected: {
readonly id: string;
readonly name: string;
readonly version: string | null;
},
): boolean =>
actual.id === expected.id &&
actual.name === expected.name &&
actual.version === expected.version;
const valuesMatch = (left: unknown, right: unknown): boolean =>
JSON.stringify(left) === JSON.stringify(right);
+13 -25
View File
@@ -121,16 +121,14 @@ export const runDoctor = async (
},
),
);
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];
const candidates = [
host.configuredHopperPath,
DEFAULT_HOPPER,
SYSTEM_LINUX_HOPPER,
linuxHopperLauncherPath(homedir()),
...(await host.manualHopperPaths()),
await host.brewHopperPath(),
].filter((value): value is string => value !== undefined);
let hopperPath: string | undefined;
for (const candidate of new Set(candidates))
if (await host.executable(candidate)) {
@@ -138,21 +136,11 @@ export const runDoctor = async (
break;
}
checks.push(
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",
},
),
check("hopper", hopperPath !== undefined, hopperPath, {
remediation:
"Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
classification: "missing_analysis_engine",
}),
);
if (host.platform === "linux" && hopperPath !== undefined)
checks.push(
+2 -11
View File
@@ -62,12 +62,7 @@ export class EvidenceLedger {
? ok("duplicate")
: err(new EvidenceIntegrityError("Conflicting evidence record"));
}
if (
this.#exceedsRecordLimit(
this.#records.size + 1,
this.#unknownRevisions.size,
)
)
if (this.#records.size >= this.limits.maxRecords)
return err(new EvidenceLimitError("records", this.limits.maxRecords));
const bytes = serializedBytes(evidence);
if (this.#bytes + bytes > this.limits.maxBytes)
@@ -349,7 +344,7 @@ export class EvidenceLedger {
number,
EvidenceIntegrityError | EvidenceLimitError | UnknownRegistryError
> {
if (this.#exceedsRecordLimit(records.size, unknowns.size))
if (records.size + unknowns.size > this.limits.maxRecords)
return err(new EvidenceLimitError("records", this.limits.maxRecords));
const bytes =
[...records.values()].reduce(
@@ -372,10 +367,6 @@ export class EvidenceLedger {
return ok(bytes);
}
#exceedsRecordLimit(recordCount: number, unknownCount: number): boolean {
return recordCount + unknownCount > this.limits.maxRecords;
}
#commit(
records: ReadonlyMap<string, Evidence>,
unknowns: ReadonlyMap<string, ResidualUnknown>,
-40
View File
@@ -232,46 +232,6 @@ export class PermissionAuthority {
return ok(null);
}
/** Atomically replace reloadable ceilings and persisted grants. */
async replaceConfiguredPolicy(configuration: {
readonly ceilings: readonly PermissionCeiling[];
readonly administratorGrants: readonly PermissionGrant[];
readonly projectGrants: readonly PermissionGrant[];
}): Promise<Result<null, PermissionPathError>> {
const ceilings = await canonicalizePermissionCeilings(
configuration.ceilings,
);
if (!ceilings.ok) return ceilings;
const grants: PermissionGrant[] = [];
for (const grant of [
...configuration.administratorGrants,
...configuration.projectGrants,
]) {
const canonical = await canonicalizeGrant(grant);
if (!canonical.ok) return canonical;
grants.push(canonical.value);
}
let candidate: PermissionPolicy = {
...reloadPermissionCeilings(this.policy, ceilings.value),
grants: this.policy.grants.filter(
({ lifetime }) =>
lifetime !== "administrator" && lifetime !== "project",
),
};
for (const grant of grants) {
const added = grantPermission(candidate, grant);
if (!added.ok)
return err(
new PermissionPathError(grant.grant_id, "io", {
cause: added.error,
}),
);
candidate = added.value;
}
this.policy = candidate;
return ok(null);
}
/** Replace ceilings without restarting the MCP process. */
async reload(
ceilings: readonly PermissionCeiling[],
+6 -2
View File
@@ -285,7 +285,7 @@ export const releaseProcessResources = async (options: {
readonly checkpoints: ProcessCheckpoints | undefined;
readonly runId: string;
readonly temporaryRoot: string;
readonly capturedProcessGroupIds: readonly number[];
readonly sampledProcessGroupIds: readonly number[];
}): Promise<string | undefined> => {
for (const timer of options.timers) clearTimeout(timer);
let failure: string | undefined;
@@ -310,7 +310,11 @@ export const releaseProcessResources = async (options: {
failure ??= "filesystem checkpoint cleanup failed";
}
if (options.terminal !== undefined && process.platform !== "win32") {
for (const processGroupId of new Set(options.capturedProcessGroupIds)) {
const processGroupIds = new Set([
options.terminal.pid,
...options.sampledProcessGroupIds,
]);
for (const processGroupId of processGroupIds) {
const cleaned = await cleanupOwnedProcessGroup({
runId: options.runId,
leaderPid: processGroupId,
+10 -11
View File
@@ -25,7 +25,6 @@ import {
type CommandShimReplay,
} from "./CommandShimReplay.js";
import { normalizeProcessText } from "./ProcessNormalization.js";
import { selectCapturedProcessGroupIds } from "./ProcessOwnership.js";
import {
awaitTerminalExit,
buildCaptureResult,
@@ -158,8 +157,7 @@ const cleanupFailedStartup = async (options: {
options.terminal?.kill("SIGKILL");
const cleanupFailure = await releaseProcessResources({
...options,
capturedProcessGroupIds:
options.terminal === undefined ? [] : [options.terminal.pid],
sampledProcessGroupIds: [],
});
if (cleanupFailure !== undefined)
throw new ProcessCaptureError(cleanupFailure, {
@@ -322,13 +320,9 @@ const finishProcessRun = async (options: {
checkpoints: options.runtime?.checkpoints,
runId: options.runId,
temporaryRoot: options.temporaryRoot,
capturedProcessGroupIds:
options.runtime === undefined
? []
: selectCapturedProcessGroupIds(
options.runtime.terminal.pid,
options.samples,
),
sampledProcessGroupIds: options.samples.flatMap(({ process_group_id }) =>
process_group_id === null ? [] : [process_group_id],
),
});
let { capture, executionFailure } = options;
if (capture !== undefined) {
@@ -372,7 +366,12 @@ const completeCapture = async (options: {
if (reason === "cancelled") throw processCaptureCancelled();
const settlement = await observeSettlement(
options.runId,
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
[
runtime.terminal.pid,
...options.samples.flatMap(({ process_group_id }) =>
process_group_id === null ? [] : [process_group_id],
),
],
scenario.settle_ms,
);
runtime.checkpoints.trigger("settled");
+1 -18
View File
@@ -1,7 +1,6 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { promisify } from "node:util";
import type { ProcessSample } from "../domain/processCapture.js";
const execFileAsync = promisify(execFile);
@@ -32,7 +31,7 @@ export interface ProcessOwnershipHost {
}
/** Cleanup succeeds only when the group is absent or every member is owned. */
interface ProcessOwnershipValidationFailure {
export interface ProcessOwnershipValidationFailure {
readonly pid: number;
readonly reason: "environment-unreadable" | "run-token-mismatch";
}
@@ -52,22 +51,6 @@ export type ProcessGroupObservation =
| { readonly state: "alive" }
| { readonly state: "unverifiable"; readonly reason: string };
/** Select the PTY root group and groups led by an observed captured process. */
export const selectCapturedProcessGroupIds = (
rootPid: number,
samples: readonly Pick<ProcessSample, "pid" | "process_group_id">[],
): readonly number[] => {
const processGroupIds = new Set<number>([rootPid]);
for (const sample of samples) {
// A sampled member may transiently inherit an unrelated group. POSIX group
// leaders have pid === pgid, so only that observation establishes that the
// group leader itself belonged to the captured tree. Token checks below
// remain the final authority immediately before observation or signaling.
if (sample.pid === sample.process_group_id) processGroupIds.add(sample.pid);
}
return [...processGroupIds];
};
/** Parse the NUL-delimited Linux process environment without nameless keys. */
export const parseProcessEnvironment = (
value: string,
+14 -23
View File
@@ -1,7 +1,7 @@
import { isSecretLikePath } from "../domain/referenceSourceClassification.js";
import {
createHistoricalSourceGraph,
historicalSourceParseFailureKey,
referenceSourceParseFailureKey,
type HistoricalSourceGraph,
type HistoricalSourceGraphInput,
} from "../domain/referenceSourceGraph.js";
@@ -67,27 +67,6 @@ 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"] => ({
@@ -226,7 +205,19 @@ export const importReferenceSource = async (
if (isAborted(options.signal)) return err(cancelled());
const uniqueRelationships = deduplicateRelationships(relationships);
const uniqueFailures = normalizeHistoricalSourceParseFailures(parseFailures);
const uniqueFailures = [
...new Map(
parseFailures.map((parseFailure) => [
referenceSourceParseFailureKey(parseFailure),
parseFailure,
]),
).values(),
].sort((left, right) =>
byCodePoint(
referenceSourceParseFailureKey(left),
referenceSourceParseFailureKey(right),
),
);
const sortedEntries = [...entries].sort((left, right) =>
byCodePoint(left.path, right.path),
+37 -137
View File
@@ -3,6 +3,7 @@ import {
copyFile,
mkdir,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
@@ -14,7 +15,6 @@ 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,
@@ -26,7 +26,7 @@ import { configureDetectedClients } from "./SetupClients.js";
import { supportedClients, type SetupClient } from "./SupportedClients.js";
export type { SetupClient } from "./SupportedClients.js";
import {
canonicalSkillNeedsInstall,
canonicalSkillInstallationNeeded,
installCanonicalSkill,
} from "./SetupSkill.js";
import { setupPlan } from "./SetupPlan.js";
@@ -37,10 +37,7 @@ import {
} from "./SetupInstallFailure.js";
export type { SetupHopperInstallResult } from "./SetupInstallFailure.js";
export {
canonicalSkillNeedsInstall,
installCanonicalSkill,
} from "./SetupSkill.js";
export { installCanonicalSkill } from "./SetupSkill.js";
const registrationCommand = (): readonly string[] =>
process.env.npm_command === "exec"
@@ -55,7 +52,7 @@ export type ClientConfigurationResult =
}
| {
readonly status: "failed";
readonly reason: "path" | "backup" | "write" | "readback";
readonly reason: "backup" | "write" | "readback";
};
/**
* Effects required by the idempotent setup workflow.
@@ -75,12 +72,7 @@ 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>;
skillInstallationNeeded(): Promise<boolean>;
installSkill(): Promise<"installed" | "unchanged" | "failed">;
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
}
@@ -143,10 +135,23 @@ export const runSetup = async (
const unsupported = await hostRemediation(host);
if (unsupported !== undefined) return fail(unsupported);
let hopperPath = await host.hopperPath();
const discovery = await discoverSetupActions(host, hopperPath);
const { detectedClients, installHopper, installSkill } = discovery;
plannedActions = discovery.plannedActions;
let approved = options.approved || plannedActions.length === 0;
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();
const installSkill = await host.skillInstallationNeeded();
plannedActions = setupPlan(
host.platform,
installHopper,
detectedClients,
installSkill,
);
let approved = options.approved;
let interactiveApproval = false;
if (!approved && confirm !== undefined && !options.structured) {
interactiveApproval = await confirm(plannedActions);
@@ -212,59 +217,6 @@ 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,
@@ -321,11 +273,7 @@ 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()),
skillInstallationNeeded: () => canonicalSkillInstallationNeeded(homedir()),
installSkill: () => installCanonicalSkill(homedir()),
doctor: () => runDoctor(undefined, doctorHost),
};
@@ -353,15 +301,10 @@ 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(transactionPath, "utf8");
original = await readFile(client.configPath, "utf8");
document = parseObject(original);
} catch (cause: unknown) {
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
@@ -388,7 +331,7 @@ export const configureJsonClient = async (
if (original !== undefined) {
backupPath = `${client.configPath}.rea.backup`;
try {
await copyFile(transactionPath, backupPath);
await copyFile(client.configPath, backupPath);
} catch {
return { status: "failed", reason: "backup" };
}
@@ -398,26 +341,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 writeFileAtomic(transactionPath, encoded, {
encoding: "utf8",
mode: 0o600,
});
await writeFile(temporary, encoded, { encoding: "utf8", mode: 0o600 });
await rename(temporary, client.configPath);
} catch {
await rm(temporary, { force: true }).catch(() => undefined);
return { status: "failed", reason: "write" };
}
try {
const readback = parseObject(await readFile(transactionPath, "utf8"));
const readback = parseObject(await readFile(client.configPath, "utf8"));
const value = parseOptionalObject(readback.mcpServers)[
PRODUCT_IDENTITY.mcpServerKey
];
if (JSON.stringify(value) !== JSON.stringify(desired)) {
await restoreConfig(transactionPath, original);
await restoreConfig(client.configPath, original);
return { status: "failed", reason: "readback" };
}
} catch {
await restoreConfig(transactionPath, original);
await restoreConfig(client.configPath, original);
return { status: "failed", reason: "readback" };
}
return {
@@ -437,15 +380,10 @@ 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(transactionPath, "utf8");
original = await readFile(client.configPath, "utf8");
document = objectSchema.parse(parseToml(original));
} catch (cause: unknown) {
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
@@ -471,7 +409,7 @@ export const configureTomlClient = async (
const backupPath =
original === undefined ? undefined : `${client.configPath}.rea.backup`;
try {
if (backupPath !== undefined) await copyFile(transactionPath, backupPath);
if (backupPath !== undefined) await copyFile(client.configPath, backupPath);
} catch {
return { status: "failed", reason: "backup" };
}
@@ -481,12 +419,12 @@ export const configureTomlClient = async (
};
try {
await mkdir(dirname(client.configPath), { recursive: true });
await writeFileAtomic(transactionPath, stringifyToml(document), {
await writeFileAtomic(client.configPath, stringifyToml(document), {
encoding: "utf8",
mode: 0o600,
});
const readback = objectSchema.parse(
parseToml(await readFile(transactionPath, "utf8")),
parseToml(await readFile(client.configPath, "utf8")),
);
if (
JSON.stringify(
@@ -497,7 +435,7 @@ export const configureTomlClient = async (
)
throw new Error("TOML readback mismatch");
} catch {
await restoreConfig(transactionPath, original);
await restoreConfig(client.configPath, original);
return { status: "failed", reason: "readback" };
}
return {
@@ -534,41 +472,3 @@ const restoreConfig = async (
// The backup remains available for the remediation reported by setup.
}
};
const clientConfigurationDesired = (
hopperPath: string | undefined,
command: readonly string[],
) => ({
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
args: command.slice(1),
...(hopperPath === undefined
? {}
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
});
const clientConfigurationAligned = async (
client: SetupClient,
hopperPath: string | undefined,
command: readonly string[],
): Promise<boolean> => {
const desired = clientConfigurationDesired(hopperPath, command);
try {
const original = await readFile(client.configPath, "utf8");
if (client.format === "toml") {
const document = objectSchema.parse(parseToml(original));
const servers = parseOptionalObject(document.mcp_servers);
return (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
JSON.stringify(desired)
);
}
const document = parseObject(original);
const servers = parseOptionalObject(document.mcpServers);
return (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
JSON.stringify(desired)
);
} catch {
return false;
}
};
+8 -9
View File
@@ -5,10 +5,8 @@ import type {
} from "./Setup.js";
const failedConfigurationMessage = (
reason: "path" | "backup" | "write" | "readback",
reason: "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")
@@ -16,7 +14,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 every detected agent and report the first failed transaction. */
/** Configure every detected agent and report all failed transaction classes. */
export const configureDetectedClients = async (options: {
readonly host: SetupHost;
readonly detectedClients: readonly SetupClient[];
@@ -25,7 +23,7 @@ export const configureDetectedClients = async (options: {
readonly clients: Record<string, ClientConfigurationResult>;
readonly appliedActions: string[];
}): Promise<string | undefined> => {
let failure: string | undefined;
const failures = new Set<"backup" | "write" | "readback">();
for (const client of options.detectedClients) {
const result = await options.host.configureClient(
client,
@@ -33,10 +31,11 @@ export const configureDetectedClients = async (options: {
options.command,
);
options.clients[client.name] = result;
if (result.status === "failed")
failure ??= failedConfigurationMessage(result.reason);
else if (result.status === "configured")
if (result.status === "failed") failures.add(result.reason);
if (result.status === "configured")
options.appliedActions.push(`configured_${client.name}`);
}
return failure;
return failures.size === 0
? undefined
: [...failures].map(failedConfigurationMessage).join(" ");
};
+1 -1
View File
@@ -4,8 +4,8 @@ import type { SetupAction, SetupClient } from "./Setup.js";
export const setupPlan = (
platform: NodeJS.Platform,
installHopper: boolean,
installSkill: boolean,
clients: readonly SetupClient[],
installSkill: boolean,
): readonly SetupAction[] => [
...(installHopper
? [
+17 -27
View File
@@ -4,32 +4,30 @@ 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(
const canonicalSkillDestination = (home: string): string =>
join(home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md");
const readCanonicalSkill = (): Promise<string> =>
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 (
/** Determine whether setup would need to install or update the canonical skill. */
export const canonicalSkillInstallationNeeded = async (
home: string,
): Promise<boolean> => {
try {
const { content, destination } = await canonicalSkillFiles(home);
return (await readFile(destination, "utf8")) !== content;
const [canonical, installed] = await Promise.all([
readCanonicalSkill(),
readFile(canonicalSkillDestination(home), "utf8").catch(() => undefined),
]);
return installed !== canonical;
} catch {
// Disclose the action when setup cannot prove the installed copy is aligned.
return true;
}
};
@@ -38,19 +36,11 @@ export const canonicalSkillNeedsInstall = async (
export const installCanonicalSkill = async (
home: string,
): Promise<"installed" | "unchanged" | "failed"> => {
let destination = join(
home,
".agents/skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
);
let backup = `${destination}.rea.backup`;
const destination = canonicalSkillDestination(home);
const backup = `${destination}.rea.backup`;
let original: string | undefined;
try {
const canonical = await canonicalSkillFiles(home);
destination = canonical.destination;
backup = `${destination}.rea.backup`;
const { content } = canonical;
const content = await readCanonicalSkill();
original = await readFile(destination, "utf8").catch(() => undefined);
if (original === content) return "unchanged";
await mkdir(dirname(destination), { recursive: true });
+7 -35
View File
@@ -1,4 +1,4 @@
import { copyFile, lstat, readFile, realpath, rm } from "node:fs/promises";
import { copyFile, lstat, readFile, rm } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, isAbsolute, join } from "node:path";
@@ -7,12 +7,9 @@ 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;
}
@@ -22,7 +19,6 @@ 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>;
}
@@ -32,7 +28,6 @@ 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 }),
};
@@ -109,12 +104,9 @@ 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(transactionPath);
original = await fileSystem.readText(client.configPath);
} catch (cause: unknown) {
return isMissing(cause)
? item(client.name, "skipped", "Configuration does not exist.")
@@ -154,7 +146,7 @@ const removeClient = async (
document[key] = remaining;
const backupPath = `${client.configPath}.rea.backup`;
try {
await fileSystem.copy(transactionPath, backupPath);
await fileSystem.copy(client.configPath, backupPath);
} catch {
return item(
client.name,
@@ -167,11 +159,11 @@ const removeClient = async (
client.format === "toml"
? stringifyToml(document)
: `${JSON.stringify(document, null, 2)}\n`;
await fileSystem.writeText(transactionPath, encoded);
await fileSystem.writeText(client.configPath, encoded);
const readback = objectSchema.parse(
client.format === "toml"
? parseToml(await fileSystem.readText(transactionPath))
: JSON.parse(await fileSystem.readText(transactionPath)),
? parseToml(await fileSystem.readText(client.configPath))
: JSON.parse(await fileSystem.readText(client.configPath)),
);
if (PRODUCT_IDENTITY.mcpServerKey in optionalObject(readback[key]))
throw new Error("registration readback mismatch");
@@ -182,7 +174,7 @@ const removeClient = async (
);
} catch {
try {
await fileSystem.writeText(transactionPath, original);
await fileSystem.writeText(client.configPath, original);
} catch {
return item(
client.name,
@@ -198,26 +190,6 @@ 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);
+6 -18
View File
@@ -4,7 +4,6 @@ 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";
@@ -60,7 +59,7 @@ export type UpgradeResult =
readonly status: "failed";
readonly currentVersion: string;
readonly latestVersion: string | null;
readonly reason: "unknown-install-method" | "version-check" | "install";
readonly reason: "unknown-install-method" | "install";
readonly remediation: string;
};
@@ -71,20 +70,7 @@ export const runUpgrade = async (
output: UpgradeOutput = "human",
): Promise<UpgradeResult> => {
const latestVersion = await host.latestVersion();
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))
if (latestVersion === currentVersion)
return {
status: "current",
currentVersion,
@@ -114,8 +100,8 @@ export const runUpgrade = async (
return {
status: "upgraded",
previousVersion: currentVersion,
latestVersion,
versionCheck: "available",
latestVersion: latestVersion ?? null,
versionCheck: latestVersion === undefined ? "unavailable" : "available",
installMethod: "npm",
command: UPGRADE_COMMAND,
clientRestartRequired: true,
@@ -208,6 +194,8 @@ const runCommand = (
): Promise<boolean> =>
new Promise((resolveResult) => {
const child = spawn(command, [...arguments_], {
// Reserve parent stdout for Incur's structured result. Array positions
// are child fds 0/1/2, so npm stdout and stderr both reach parent stderr.
stdio:
output === "human" ? "inherit" : ["inherit", process.stderr, "inherit"],
});
+1 -1
View File
@@ -34,7 +34,7 @@ const endpointTargetSchema = z.object({
const endpointTargetsSchema = z.array(endpointTargetSchema).max(1_000);
/** Validated direct CDP WebSocket bound to one discovered page target. */
interface CdpPageWebSocketEndpoint {
export interface CdpPageWebSocketEndpoint {
readonly scope: "page";
readonly targetId: string;
readonly url: string;
+3 -1
View File
@@ -160,7 +160,9 @@ const registerSetupCommands = (
installHopper: z
.boolean()
.default(false)
.describe("Also approve Hopper installation with --yes"),
.describe(
"Approve Hopper installation when missing or repair is needed (requires --yes)",
),
}),
alias: { yes: "y", installHopper: "install-hopper" },
run: ({ options, formatExplicit }) =>
+25 -43
View File
@@ -31,24 +31,6 @@ 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>,
@@ -71,10 +53,10 @@ export const registerPolicyCommands = (
yes: z
.boolean()
.default(false)
.describe("Confirm project permission revocation without prompting"),
.describe("Confirm a destructive policy revocation without prompting"),
}),
alias: { environmentNames: "environment-names", yes: "y" },
run: ({ agent, args, formatExplicit, options }) =>
run: ({ args, options, formatExplicit }) =>
logCliCommand(logger, "policy", async () => {
const config = parseConfig(process.env);
if (!config.ok) return failure(config.error);
@@ -99,24 +81,22 @@ 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,
};
if (!options.yes) {
if (formatExplicit || process.stdin.isTTY !== true)
return {
error: "ConfirmationRequired",
message: "Policy grant revocation requires confirmation",
grant_id: args.value,
remediation:
"Verify the grant ID, then rerun the same command with --yes.",
};
if (!(await confirmRevocation(args.value)))
return {
error: "Cancelled",
message: "Policy grant revocation was cancelled",
grant_id: args.value,
};
}
const revoked = await revokeProjectPermissionGrant(
permissionProjectStore,
permissionProjectRoot,
@@ -141,16 +121,18 @@ export const registerPolicyCommands = (
});
};
const confirmPolicyRevocation = async (grantId: string): Promise<boolean> => {
const confirmRevocation = 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());
const answer = (
await prompt.question(`Revoke policy grant "${grantId}"? [y/N] `)
)
.trim()
.toLowerCase();
return answer === "y" || answer === "yes";
} finally {
prompt.close();
}
+6 -6
View File
@@ -128,6 +128,11 @@ const parseFailureSchema = z.strictObject({
reason: boundedTextSchema,
});
/** Stable identity for one distinct reference-source parse failure. */
export const referenceSourceParseFailureKey = (
failure: z.infer<typeof parseFailureSchema>,
): string => JSON.stringify([failure.path, failure.parser, failure.reason]);
const exclusionSchema = z.strictObject({
path: relativePathSchema,
reason: z.enum([
@@ -225,11 +230,6 @@ 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)
@@ -405,7 +405,7 @@ function checkGraphInvariants(
context,
);
checkSortedUnique(
graph.parse_failures.map(historicalSourceParseFailureKey),
graph.parse_failures.map(referenceSourceParseFailureKey),
"parse_failures",
context,
);
+11 -57
View File
@@ -72,7 +72,6 @@ export class HopperClient {
#buffer = "";
#closing = false;
#launcherExitCode: number | null | undefined;
#startupController: AbortController | undefined;
#startPromise: Promise<Result<HopperServerInfo, HopperError>> | undefined;
#closePromise: Promise<void> | undefined;
@@ -87,34 +86,14 @@ export class HopperClient {
/** Launch the bridge once and complete its authenticated health handshake. */
start(signal?: AbortSignal): Promise<Result<HopperServerInfo, HopperError>> {
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;
this.#startPromise ??= this.#start(signal);
return this.#startPromise;
}
async #start(
signal?: AbortSignal,
): Promise<Result<HopperServerInfo, HopperError>> {
if (isAborted(signal)) return err(new HopperCancelledError());
if (signal?.aborted === true) return err(new HopperCancelledError());
if (this.#socket !== undefined || this.#directory !== undefined) {
return err(new HopperProtocolError("Hopper client is already started"));
}
@@ -123,10 +102,6 @@ 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;
@@ -141,14 +116,14 @@ export class HopperClient {
signal === undefined ? {} : { signal },
);
if (!launched.ok) {
await this.#cleanup();
await this.close();
return launched;
}
this.#launch = launched.value;
this.#attachLauncher(launched.value);
const connected = await this.#connect(socketPath, signal);
if (!connected.ok) {
await this.#cleanup();
await this.close();
return connected;
}
const health = await this.#request(
@@ -160,11 +135,11 @@ export class HopperClient {
},
);
if (!health.ok) {
await this.#cleanup();
await this.close();
return health;
}
const parsed = parseServerInfo(health.value, runId);
if (!parsed.ok) await this.#cleanup();
if (!parsed.ok) await this.close();
return parsed;
}
@@ -184,32 +159,11 @@ export class HopperClient {
/** Stop the bridge, settle outstanding requests, and remove session artifacts. */
close(): Promise<void> {
const starting = this.#startPromise;
const controller = this.#startupController;
controller?.abort();
this.#closePromise ??= Promise.resolve().then(() =>
this.#close(starting, controller),
);
this.#closePromise ??= Promise.resolve().then(() => this.#close());
return this.#closePromise;
}
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> {
async #close(): Promise<void> {
this.#closing = true;
try {
const socket = this.#socket;
@@ -262,8 +216,10 @@ export class HopperClient {
this.#directory = undefined;
}
this.#token = undefined;
this.#startPromise = undefined;
} finally {
this.#closing = false;
this.#closePromise = undefined;
}
}
@@ -455,8 +411,6 @@ export class HopperClient {
}
}
const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true;
const waitForDelay = (
milliseconds: number,
signal?: AbortSignal,
+72 -58
View File
@@ -17,7 +17,6 @@ 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.";
@@ -36,8 +35,6 @@ interface RuntimeDependencies {
readonly setExitCode: (code: number) => void;
readonly registerShutdown: (handler: () => void) => () => void;
readonly registerReload?: (handler: () => void) => () => void;
readonly createServer?: typeof createServer;
readonly readProjectPermissionStore?: typeof readProjectPermissionStore;
}
const runtimeDependencies = (): RuntimeDependencies => ({
@@ -52,7 +49,10 @@ const runtimeDependencies = (): RuntimeDependencies => ({
process.once("SIGTERM", handler);
process.stdin.once("end", handler);
process.stdin.once("close", handler);
let registered = true;
return () => {
if (!registered) return;
registered = false;
process.off("SIGINT", handler);
process.off("SIGTERM", handler);
process.stdin.off("end", handler);
@@ -132,35 +132,30 @@ export const run = async (
try {
handle = dependencies.serve(
() => {
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,
}),
},
);
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,
}),
});
liveServers.add(server);
return server;
},
@@ -178,24 +173,38 @@ export const run = async (
return 1;
}
let reloadQueue = Promise.resolve();
const unregisterReload =
dependencies.registerReload?.(() => {
const refreshed = parseConfig(dependencies.env);
if (!refreshed.ok) {
serverLogger.error("Reloaded permission policy is invalid");
return;
}
reloadQueue = reloadQueue.then(async () => {
let projectGrants: readonly PermissionGrant[] = [];
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;
}
if (
refreshed.value.permissionProjectRoot !== undefined &&
refreshed.value.permissionProjectStore !== undefined
) {
const project = await (
dependencies.readProjectPermissionStore ??
readProjectPermissionStore
)(
const project = await readProjectPermissionStore(
refreshed.value.permissionProjectStore,
refreshed.value.permissionProjectRoot,
);
@@ -203,17 +212,21 @@ export const run = async (
serverLogger.error("Reloaded project grants could not be read");
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;
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;
}
}
currentConfig = refreshed.value;
Object.assign(
@@ -234,15 +247,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;
let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => {
shutdownPromise ??= (async () => {
unregisterReload();
unregisterShutdown();
unregisterReload();
await handle.close();
await session.close();
permissionAuthority.value.clearSessionGrants();
@@ -258,6 +271,7 @@ export const run = async (
};
unregisterShutdown = dependencies.registerShutdown(requestShutdown);
if (shutdownPromise !== undefined) unregisterShutdown();
return 0;
};
+11 -13
View File
@@ -314,19 +314,17 @@ describe("artifact graph provider", () => {
const client = new ArtifactProvider().createClient(
target(root, "directory"),
);
const [first, secondPage] = await Promise.all([
client.execute(
"inventory_artifact",
artifactInventoryInputSchema.parse({ occurrence_limit: 500 }),
),
client.execute(
"inventory_artifact",
artifactInventoryInputSchema.parse({
occurrence_offset: 500,
occurrence_limit: 500,
}),
),
]);
const first = await client.execute(
"inventory_artifact",
artifactInventoryInputSchema.parse({ occurrence_limit: 500 }),
);
const secondPage = await client.execute(
"inventory_artifact",
artifactInventoryInputSchema.parse({
occurrence_offset: 500,
occurrence_limit: 500,
}),
);
expect(first.ok && secondPage.ok).toBe(true);
if (!first.ok || !secondPage.ok) return;
const left = artifactInventoryResultSchema.parse(first.value.result);
-107
View File
@@ -254,55 +254,6 @@ 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[] = [];
@@ -414,64 +365,6 @@ 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>>();
+11 -189
View File
@@ -1,9 +1,11 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { z } from "zod";
const execFileAsync = promisify(execFile);
const bridgePath = fileURLToPath(
new URL("../bridge/hopper_bridge.py", import.meta.url),
);
@@ -47,7 +49,6 @@ const probeResultSchema = z.union([
matchResultSchema,
searchResultSchema,
]);
type ProbeResult = z.infer<typeof probeResultSchema>;
type ProbeInput =
| {
@@ -62,194 +63,15 @@ type ProbeInput =
readonly params: Readonly<Record<string, string | number | boolean>>;
};
interface PendingProbe {
readonly resolve: (result: ProbeResult) => void;
readonly reject: (error: Error) => void;
timeout: NodeJS.Timeout | undefined;
}
const PROBE_TIMEOUT_MS = 3_000;
const PROBE_BUFFER_LIMIT = 1_024 * 1_024;
const pendingProbes: PendingProbe[] = [];
let probeProcess: ChildProcessWithoutNullStreams | undefined;
let probeStdout = "";
let probeStderr = "";
let probeFailure: Error | undefined;
let stoppingProbe = false;
const failureWithStderr = (message: string): Error => {
const stderr = probeStderr.trim();
return new Error(stderr.length === 0 ? message : `${message}: ${stderr}`);
const probe = async (input: ProbeInput) => {
const result = await execFileAsync(
"python3",
[probePath, bridgePath, JSON.stringify(input)],
{ timeout: 3_000, maxBuffer: 1024 * 1024 },
);
return probeResultSchema.parse(JSON.parse(result.stdout));
};
const rejectPendingProbes = (error: Error): void => {
for (const pending of pendingProbes.splice(0)) {
if (pending.timeout !== undefined) clearTimeout(pending.timeout);
pending.reject(error);
}
};
const failProbeProcess = (error: Error): void => {
probeFailure ??= error;
rejectPendingProbes(probeFailure);
if (
!stoppingProbe &&
probeProcess?.exitCode === null &&
probeProcess.signalCode === null
)
probeProcess.kill("SIGKILL");
};
const acceptProbeLine = (line: string): void => {
const pending = pendingProbes.shift();
if (pending === undefined) {
failProbeProcess(
failureWithStderr("Python probe returned an extra result"),
);
return;
}
if (pending.timeout !== undefined) clearTimeout(pending.timeout);
try {
const decoded: unknown = JSON.parse(line);
pending.resolve(probeResultSchema.parse(decoded));
} catch (cause: unknown) {
const error =
cause instanceof Error
? cause
: failureWithStderr("Python probe returned an invalid result");
pending.reject(error);
failProbeProcess(error);
}
};
const acceptProbeOutput = (chunk: string): void => {
probeStdout += chunk;
if (Buffer.byteLength(probeStdout) > PROBE_BUFFER_LIMIT) {
failProbeProcess(failureWithStderr("Python probe output exceeded 1 MiB"));
return;
}
let newline = probeStdout.indexOf("\n");
while (newline >= 0) {
const line = probeStdout.slice(0, newline).replace(/\r$/u, "");
probeStdout = probeStdout.slice(newline + 1);
acceptProbeLine(line);
newline = probeStdout.indexOf("\n");
}
};
const startProbeProcess = async (): Promise<void> => {
stoppingProbe = false;
probeFailure = undefined;
probeStdout = "";
probeStderr = "";
const child = spawn("python3", [probePath, bridgePath], {
stdio: ["pipe", "pipe", "pipe"],
});
probeProcess = child;
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", acceptProbeOutput);
child.stderr.on("data", (chunk: string) => {
probeStderr += chunk;
if (Buffer.byteLength(probeStderr) > PROBE_BUFFER_LIMIT)
failProbeProcess(new Error("Python probe stderr exceeded 1 MiB"));
});
child.stdin.on("error", (error) => {
if (!stoppingProbe) failProbeProcess(error);
});
child.on("error", (error) => {
if (!stoppingProbe) failProbeProcess(error);
});
child.on("exit", (code, signal) => {
if (!stoppingProbe)
failProbeProcess(
failureWithStderr(
`Python probe exited before responding (code ${String(code)}, signal ${String(signal)})`,
),
);
});
await new Promise<void>((resolve, reject) => {
const onSpawn = (): void => {
child.off("error", onError);
resolve();
};
const onError = (error: Error): void => {
child.off("spawn", onSpawn);
reject(error);
};
child.once("spawn", onSpawn);
child.once("error", onError);
});
};
const hasExited = (child: ChildProcessWithoutNullStreams): boolean =>
child.exitCode !== null || child.signalCode !== null;
const waitForExit = (
child: ChildProcessWithoutNullStreams,
timeoutMs: number,
): Promise<boolean> => {
if (hasExited(child)) return Promise.resolve(true);
return new Promise((resolve) => {
const onExit = (): void => {
clearTimeout(timeout);
resolve(true);
};
const timeout = setTimeout(() => {
child.off("exit", onExit);
resolve(false);
}, timeoutMs);
child.once("exit", onExit);
});
};
const stopProbeProcess = async (): Promise<void> => {
const child = probeProcess;
if (child === undefined) return;
stoppingProbe = true;
rejectPendingProbes(new Error("Python probe stopped before responding"));
if (!hasExited(child)) child.stdin.end();
if (!(await waitForExit(child, 500))) child.kill("SIGTERM");
if (!(await waitForExit(child, 500))) child.kill("SIGKILL");
if (!(await waitForExit(child, 500)))
throw new Error("Python probe did not stop after SIGKILL");
probeProcess = undefined;
};
const probe = (input: ProbeInput): Promise<ProbeResult> => {
const child = probeProcess;
if (probeFailure !== undefined) return Promise.reject(probeFailure);
if (child === undefined || hasExited(child))
return Promise.reject(new Error("Python probe is not running"));
return new Promise((resolve, reject) => {
const pending: PendingProbe = { resolve, reject, timeout: undefined };
pending.timeout = setTimeout(() => {
failProbeProcess(
new Error(
`Python probe request timed out after ${String(PROBE_TIMEOUT_MS)} ms`,
),
);
}, PROBE_TIMEOUT_MS);
pendingProbes.push(pending);
child.stdin.write(`${JSON.stringify(input)}\n`, (error) => {
if (error !== null && error !== undefined) failProbeProcess(error);
});
});
};
beforeAll(startProbeProcess);
beforeEach(async () => {
if (
probeFailure === undefined &&
probeProcess !== undefined &&
!hasExited(probeProcess)
)
return;
await stopProbeProcess();
await startProbeProcess();
});
afterAll(stopProbeProcess);
const invalidRequest = (action: "match" | "search", message: string) => ({
action,
ok: false,
+71 -131
View File
@@ -1,18 +1,18 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdtemp, 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 { afterEach, describe, expect, it } from "vitest";
import {
readProjectPermissionStore,
writeProjectPermissionStore,
} from "../src/application/ProjectPermissionStore.js";
import { approvePolicyRevocation } from "../src/cliPolicyCommands.js";
const execute = promisify(execFile);
const execFileAsync = promisify(execFile);
const CLI_INTEGRATION_TIMEOUT_MS = 60_000;
const roots: string[] = [];
afterEach(async () => {
@@ -21,135 +21,75 @@ afterEach(async () => {
);
});
describe("policy revocation approval", () => {
it("accepts --yes without invoking the interactive prompt", async () => {
const confirm = vi.fn<() => Promise<boolean>>();
describe("policy CLI revocation", () => {
it(
"requires confirmation in structured mode and accepts explicit approval",
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:evidence-read",
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])).ok,
).toBe(true);
const environment = {
...process.env,
REA_PERMISSION_PROJECT_ROOT: project,
REA_PERMISSION_PROJECT_STORE: store,
};
await expect(
approvePolicyRevocation({
approved: true,
interactive: false,
grantId: "grant-1",
confirm,
}),
).resolves.toEqual({ approved: true });
expect(confirm).not.toHaveBeenCalled();
});
const unapproved = await execFileAsync(
process.execPath,
["scripts/rea.mjs", "--json", "policy", "revoke", grant.grant_id],
{ cwd: process.cwd(), env: environment },
).catch((cause: unknown) => cause);
expect(unapproved).toMatchObject({ code: 1, stderr: "" });
expect(
JSON.parse((unapproved as { readonly stdout: string }).stdout),
).toEqual({
error: "ConfirmationRequired",
message: "Policy grant revocation requires confirmation",
grant_id: grant.grant_id,
remediation:
"Verify the grant ID, then rerun the same command with --yes.",
});
expect(await readProjectPermissionStore(store, project)).toMatchObject({
ok: true,
value: { grants: [grant] },
});
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: {
const approved = await execFileAsync(
process.execPath,
[
"scripts/rea.mjs",
"--json",
"policy",
"revoke",
grant.grant_id,
"--yes",
],
{ cwd: process.cwd(), env: environment },
);
expect(JSON.parse(approved.stdout)).toEqual({
revoked: grant.grant_id,
reload: "Send SIGHUP to the REA MCP process",
},
});
expect(await readProjectPermissionStore(store, project)).toMatchObject({
ok: true,
value: { grants: [] },
});
}, 40_000);
});
expect(await readProjectPermissionStore(store, project)).toMatchObject({
ok: true,
value: { grants: [] },
});
},
CLI_INTEGRATION_TIMEOUT_MS,
);
});
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;
}
};
+16 -23
View File
@@ -16,25 +16,6 @@ import {
sessionOutputSchemas,
} from "../src/contracts/toolOutputSchemas.js";
const convertContractJsonSchema = (schema: z.ZodType) =>
z.toJSONSchema(schema, {
target: "draft-07",
unrepresentable: "any",
});
const jsonSchemaCache = new WeakMap<
z.ZodType,
ReturnType<typeof convertContractJsonSchema>
>();
const contractJsonSchema = (schema: z.ZodType) => {
const cached = jsonSchemaCache.get(schema);
if (cached !== undefined) return cached;
const converted = convertContractJsonSchema(schema);
jsonSchemaCache.set(schema, converted);
return converted;
};
const emptySchemaPaths = (value: unknown, path = "$"): string[] => {
if (Array.isArray(value))
return value.flatMap((item, index) =>
@@ -60,8 +41,14 @@ describe("tool contract surface", () => {
];
expect(contracts).toHaveLength(60);
for (const contract of contracts) {
const inputSchema = contractJsonSchema(contract.inputSchema);
const outputSchema = contractJsonSchema(contract.outputSchema);
const inputSchema = z.toJSONSchema(contract.inputSchema, {
target: "draft-07",
unrepresentable: "any",
});
const outputSchema = z.toJSONSchema(contract.outputSchema, {
target: "draft-07",
unrepresentable: "any",
});
expect(inputSchema.type).toBe("object");
expect(outputSchema.type).toBe("object");
expect(typeof contract.annotations.idempotentHint).toBe("boolean");
@@ -141,7 +128,10 @@ describe("tool contract surface", () => {
...ENHANCED_TOOL_CONTRACTS,
...SESSION_TOOL_CONTRACTS,
]) {
const schema = contractJsonSchema(contract.outputSchema);
const schema = z.toJSONSchema(contract.outputSchema, {
target: "draft-07",
unrepresentable: "any",
});
expect(JSON.stringify(schema)).not.toContain('"result":{}');
expect(contract.description.length).toBeGreaterThan(100);
expect(contract.description).toMatch(/[.;]/u);
@@ -160,7 +150,10 @@ describe("tool contract surface", () => {
];
expect(contracts).toHaveLength(78);
for (const contract of contracts) {
const schema = contractJsonSchema(contract.outputSchema);
const schema = z.toJSONSchema(contract.outputSchema, {
target: "draft-07",
unrepresentable: "any",
});
expect(emptySchemaPaths(schema), contract.name).toEqual([]);
}
});
-64
View File
@@ -1,64 +0,0 @@
import { execFile } from "node:child_process";
import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
const execFileAsync = promisify(execFile);
let fixtureRoot: string | undefined;
afterEach(async () => {
if (fixtureRoot !== undefined) {
await rm(fixtureRoot, { recursive: true, force: true });
fixtureRoot = undefined;
}
});
describe("executable dispatcher", () => {
it("reserves production MCP routing for bare mode arguments", async () => {
fixtureRoot = await mkdtemp(join(tmpdir(), "rea-dispatcher-"));
const scripts = join(fixtureRoot, "scripts");
const dist = join(fixtureRoot, "dist");
await Promise.all([mkdir(scripts), mkdir(dist)]);
await Promise.all([
copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")),
writeFile(
join(fixtureRoot, "package.json"),
JSON.stringify({ type: "module", version: "1.0.0" }),
),
writeFile(
join(dist, "main.js"),
'export const runEntrypoint = () => process.stdout.write(JSON.stringify({ adapter: "mcp" }));\n',
),
writeFile(
join(dist, "cli.js"),
'export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n',
),
writeFile(
join(dist, "cliOutput.js"),
"export const sanitizeCliOutput = (output) => output;\n",
),
]);
const invoke = async (args: readonly string[]): Promise<unknown> => {
const { stdout } = await execFileAsync(process.execPath, [
join(scripts, "rea.mjs"),
...args,
]);
return JSON.parse(stdout) as unknown;
};
await expect(invoke(["mcp"])).resolves.toEqual({ adapter: "mcp" });
await expect(invoke(["--mcp"])).resolves.toEqual({ adapter: "mcp" });
await expect(invoke(["mcp", "add"])).resolves.toEqual({
adapter: "cli",
args: ["mcp", "add"],
});
await expect(invoke(["--mcp", "extra"])).resolves.toEqual({
adapter: "cli",
args: ["--mcp", "extra"],
});
});
});
-19
View File
@@ -60,25 +60,6 @@ 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,
+5 -4
View File
@@ -214,11 +214,12 @@ describe("enhanced MCP tools", () => {
["analyze_function", { procedure: "0x1" }],
["trace_feature", { query: "hello", max_operations: 10 }],
] as const;
const results = await Promise.all(
calls.map(async ([name, arguments_]) =>
const results = [];
for (const [name, arguments_] of calls) {
results.push(
jsonResult(await client.callTool({ name, arguments: arguments_ })),
),
);
);
}
expect(results[0]).toMatchObject({ count: 1 });
expect(results[1]).toMatchObject({ count: 1 });
expect(results[2]).toMatchObject({ count: 1 });
-41
View File
@@ -204,47 +204,6 @@ 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",
+2 -8
View File
@@ -58,11 +58,5 @@ def _probe(namespace, payload):
if __name__ == "__main__":
bridge = _load_bridge(sys.argv[1])
if len(sys.argv) >= 3:
print(json.dumps(_probe(bridge, json.loads(sys.argv[2])), sort_keys=True))
else:
for line in sys.stdin:
if not line.strip():
continue
request = json.loads(line)
print(json.dumps(_probe(bridge, request), sort_keys=True), flush=True)
request = json.loads(sys.argv[2])
print(json.dumps(_probe(bridge, request), sort_keys=True))
+8 -2
View File
@@ -1,6 +1,12 @@
import { spawn } from "node:child_process";
const mode = process.argv[2];
const treeRunMarker = process.argv[3];
const treeRunArguments = (childMode) => [
process.argv[1],
childMode,
...(treeRunMarker === undefined ? [] : [treeRunMarker]),
];
if (mode === "interactive") {
process.stdin.setRawMode?.(true);
@@ -32,14 +38,14 @@ if (mode === "interactive") {
process.exit(0);
}, 25);
} else if (mode === "tree-child") {
spawn(process.execPath, [process.argv[1], "tree-grandchild"], {
spawn(process.execPath, treeRunArguments("tree-grandchild"), {
stdio: "ignore",
});
setInterval(() => undefined, 1_000);
} else if (mode === "tree-grandchild") {
setInterval(() => undefined, 1_000);
} else if (mode === "tree") {
spawn(process.execPath, [process.argv[1], "tree-child"], { stdio: "ignore" });
spawn(process.execPath, treeRunArguments("tree-child"), { stdio: "ignore" });
process.stdout.write("tree-ready\n");
setInterval(() => undefined, 1_000);
} else if (mode === "crash") {
+1 -63
View File
@@ -59,17 +59,6 @@ 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) {}
@@ -193,7 +182,7 @@ describe("HopperClient", () => {
it("cancels bridge startup without waiting for the startup timeout", async () => {
const client = new HopperClient({
launcher: new CancelThenFixtureLauncher(),
launcher: new SilentLauncher(),
startupTimeoutMs: 10_000,
});
clients.push(client);
@@ -207,16 +196,6 @@ 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])(
@@ -275,45 +254,4 @@ 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" },
});
});
});
+5 -3
View File
@@ -14,8 +14,8 @@ 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 { changedBehaviorResultSchema } from "../src/domain/changedBehavior.js";
import { jsonObjectSchema, jsonValueSchema } from "../src/domain/jsonValue.js";
import { createServer } from "../src/server/createServer.js";
import { observed } from "./fixtures/analysisExecution.js";
@@ -215,7 +215,7 @@ describe("investigation MCP workflows", () => {
}
});
it("defers input permission for an explicit replay with deleted inputs", async () => {
it("replays a selected complete run after input access is removed", async () => {
const directory = await mkdtemp(
join(tmpdir(), "rea-investigation-replay-"),
);
@@ -252,7 +252,9 @@ describe("investigation MCP workflows", () => {
const result = changedBehaviorResultSchema.parse(
evidence.normalized_result,
);
runId = result.investigation_run?.run_id;
if (result.investigation_run === undefined)
throw new Error("missing investigation run summary");
runId = result.investigation_run.run_id;
} finally {
await close(initial.session, initial.server, initial.client);
}
+234 -66
View File
@@ -21,16 +21,24 @@ import {
readInvestigationWorkspace,
writeInvestigationWorkspace,
} from "../src/application/InvestigationWorkspaceStore.js";
import { changedBehaviorResultSchema } from "../src/domain/changedBehavior.js";
import { compareArtifacts } from "../src/domain/artifactComparison.js";
import { artifactInventoryResultSchema } from "../src/domain/artifactGraph.js";
import {
changedBehaviorResultSchema,
findChangedBehavior,
} from "../src/domain/changedBehavior.js";
import { createEvidence, type Evidence } from "../src/domain/evidence.js";
import { createEvidenceBundle } from "../src/domain/evidenceBundle.js";
import type { EvidenceFilePolicy } from "../src/domain/evidenceBundle.js";
import {
createInvestigationWorkspace,
crossVersionInvestigationInputSchema,
investigationRunSchema,
investigationRunSummarySchema,
parseInvestigationWorkspace,
serializeInvestigationWorkspace,
} from "../src/domain/investigationWorkspace.js";
import type { JsonValue } from "../src/domain/jsonValue.js";
import { ok } from "../src/domain/result.js";
const digestCanonical = (value: unknown): string => {
@@ -55,6 +63,41 @@ const policy = (root: string): EvidenceFilePolicy => ({
maxNodes: 1_000_000,
});
const recreateEvidence = (
evidence: Evidence,
overrides: {
readonly parameters?: Readonly<Record<string, JsonValue>>;
readonly result?: JsonValue;
readonly evidenceLinks?: readonly string[];
},
): Evidence =>
createEvidence(
evidence.subject === null
? undefined
: {
path: evidence.subject.local_path,
sha256: evidence.subject.digest.sha256,
format: evidence.subject.format,
...(evidence.subject.architecture === null
? {}
: { architecture: evidence.subject.architecture }),
},
evidence.provider,
{
predicateType: evidence.predicate_type,
operation: evidence.operation,
parameters: overrides.parameters ?? evidence.parameters,
result: overrides.result ?? evidence.normalized_result,
rawResult: evidence.raw_result,
confidence: evidence.confidence,
authority: evidence.authority,
environment: evidence.environment,
limitations: evidence.limitations,
locations: evidence.locations,
evidenceLinks: overrides.evidenceLinks ?? evidence.evidence_links,
},
);
const fixture = async () => {
directory = await mkdtemp(join(tmpdir(), "rea-workspace-"));
const left = join(directory, "left");
@@ -240,7 +283,7 @@ describe("persistent cross-version investigation workspace", () => {
});
});
it("replays only an explicitly selected and fully verified complete run", async () => {
it("replays an explicitly selected complete run without input access", async () => {
const { left, right, input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
const completed = await runCrossVersionInvestigation(
@@ -250,89 +293,36 @@ describe("persistent cross-version investigation workspace", () => {
);
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" },
});
if (run === undefined) throw new Error("missing completed run");
await Promise.all([
rm(left, { recursive: true, force: true }),
rm(right, { recursive: true, force: true }),
]);
const replayAuthorization = vi.fn(() => Promise.resolve(ok(null)));
const authorizeInputRead = vi.fn(() => Promise.resolve(ok(null)));
await expect(
runCrossVersionInvestigation(
{
...input,
expected_workspace_revision: completed.value.workspace.revision,
replay_run_id: run.run_id,
},
{ ...input, replay_run_id: run.run_id },
policy(directory),
{ inputRoots: [], authorizeInputRead: replayAuthorization },
{ inputRoots: [], authorizeInputRead },
),
).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();
expect(authorizeInputRead).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,
authorizeInputRead,
}),
).resolves.toMatchObject({
ok: false,
@@ -341,7 +331,185 @@ describe("persistent cross-version investigation workspace", () => {
reason: "revision-conflict",
},
});
expect(replayAuthorization).not.toHaveBeenCalled();
expect(authorizeInputRead).not.toHaveBeenCalled();
const byId = new Map(
completed.value.workspace.bundle.records.map((record) => [
record.evidence_id,
record,
]),
);
const evidenceFor = (ids: readonly string[]): Evidence[] =>
ids.map((id) => {
const evidence = byId.get(id);
if (evidence === undefined)
throw new Error("missing inventory fixture Evidence");
return evidence;
});
const leftPages = evidenceFor(run.left_inventory_evidence_ids);
const rightPages = evidenceFor(run.right_inventory_evidence_ids);
const comparison = byId.get(run.comparison_evidence_id ?? "");
const result = byId.get(run.result_evidence_id ?? "");
if (comparison === undefined || result === undefined)
throw new Error("missing completed fixture Evidence");
const forgeWorkspace = (forgedLeftPages: readonly Evidence[]) => {
const comparisonResult = compareArtifacts(
forgedLeftPages,
rightPages,
0,
run.options.change_limit,
);
const forgedComparison = recreateEvidence(comparison, {
parameters: {
left_evidence_ids: forgedLeftPages.map(({ evidence_id: id }) => id),
right_evidence_ids: rightPages.map(({ evidence_id: id }) => id),
offset: 0,
limit: run.options.change_limit,
},
result: comparisonResult,
evidenceLinks: [
...forgedLeftPages.map(({ evidence_id: id }) => id),
...rightPages.map(({ evidence_id: id }) => id),
],
});
const resultLimit = Math.min(run.options.change_limit, 100);
const changed = findChangedBehavior([forgedComparison], 0, resultLimit);
const summary = investigationRunSummarySchema.parse({
schema_version: 1,
workspace_id: completed.value.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: forgedLeftPages.length + rightPages.length,
comparison_evidence_id: forgedComparison.evidence_id,
limitations: run.limitations,
});
const forgedResult = recreateEvidence(result, {
parameters: {
workspace_id: completed.value.workspace.workspace_id,
run_id: run.run_id,
comparison_evidence_ids: [forgedComparison.evidence_id],
offset: 0,
limit: resultLimit,
},
result: { ...changed, investigation_run: summary },
evidenceLinks: changed.evidence_links,
});
const forgedRun = investigationRunSchema.parse({
...run,
left_inventory_evidence_ids: forgedLeftPages.map(
({ evidence_id: id }) => id,
),
comparison_evidence_id: forgedComparison.evidence_id,
result_evidence_id: forgedResult.evidence_id,
});
const records = new Map(
[
...completed.value.workspace.bundle.records,
...forgedLeftPages,
forgedComparison,
forgedResult,
].map((record) => [record.evidence_id, record]),
);
return createInvestigationWorkspace(
input.workspace_name,
createEvidenceBundle([...records.values()]),
[forgedRun],
);
};
const firstPage = leftPages[0]!;
const firstPageResult = artifactInventoryResultSchema.parse(
firstPage.normalized_result,
);
const extraOffset = leftPages.length * input.options.page_size;
const extraPage = recreateEvidence(firstPage, {
parameters: {
...firstPage.parameters,
node_offset: extraOffset,
occurrence_offset: extraOffset,
edge_offset: extraOffset,
},
result: {
...firstPageResult,
nodes: {
...firstPageResult.nodes,
items: [],
offset: extraOffset,
next_offset: null,
},
occurrences: {
...firstPageResult.occurrences,
items: [],
offset: extraOffset,
next_offset: null,
},
edges: {
...firstPageResult.edges,
items: [],
offset: extraOffset,
next_offset: null,
},
},
});
const forgedVariants = [
{
name: "controls",
pages: [
recreateEvidence(firstPage, {
parameters: {
...firstPage.parameters,
max_entries: input.options.max_entries - 1,
},
}),
...leftPages.slice(1),
],
},
{
name: "page-total",
pages: [
recreateEvidence(firstPage, {
result: {
...firstPageResult,
nodes: {
...firstPageResult.nodes,
total: firstPageResult.nodes.total + 1,
},
},
}),
...leftPages.slice(1),
],
},
{ name: "page-count", pages: [...leftPages, extraPage] },
] as const;
for (const variant of forgedVariants) {
const forgedPath = join(directory, `forged-${variant.name}.json`);
expect(
await writeInvestigationWorkspace(
forgeWorkspace(variant.pages),
forgedPath,
null,
policy(directory),
),
).toMatchObject({ ok: true });
await expect(
runCrossVersionInvestigation(
{
...input,
workspace_path: forgedPath,
replay_run_id: run.run_id,
},
policy(directory),
{ inputRoots: [], authorizeInputRead },
),
).resolves.toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "revision-conflict",
},
});
}
expect(authorizeInputRead).not.toHaveBeenCalled();
});
it("enforces shared JSON depth limits while reading workspaces", async () => {
@@ -405,7 +573,7 @@ describe("persistent cross-version investigation workspace", () => {
),
).toMatchObject({ ok: true });
const replayAuthorization = vi.fn(() => Promise.resolve(ok(null)));
const authorizeInputRead = vi.fn(() => Promise.resolve(ok(null)));
await expect(
runCrossVersionInvestigation(
{
@@ -414,7 +582,7 @@ describe("persistent cross-version investigation workspace", () => {
replay_run_id: partialRun.run_id,
},
policy(directory),
{ inputRoots: [], authorizeInputRead: replayAuthorization },
{ inputRoots: [], authorizeInputRead },
),
).resolves.toMatchObject({
ok: false,
@@ -423,7 +591,7 @@ describe("persistent cross-version investigation workspace", () => {
reason: "revision-conflict",
},
});
expect(replayAuthorization).not.toHaveBeenCalled();
expect(authorizeInputRead).not.toHaveBeenCalled();
const resumed = await runCrossVersionInvestigation(
{ ...input, workspace_path: resumePath },
+6 -40
View File
@@ -13,7 +13,6 @@ const dependencies = (
output: string[],
shutdown: Array<() => void>,
exitCodes: number[],
shutdownUnregistrations: string[] = [],
): RuntimeDependencies => ({
env: {},
serve,
@@ -21,7 +20,10 @@ const dependencies = (
setExitCode: (code) => exitCodes.push(code),
registerShutdown: (handler) => {
shutdown.push(handler);
return () => shutdownUnregistrations.push("shutdown");
return () => {
const index = shutdown.indexOf(handler);
if (index >= 0) shutdown.splice(index, 1);
};
},
});
@@ -29,7 +31,6 @@ 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> => {
@@ -47,7 +48,6 @@ describe("MCP runtime errors", () => {
output,
shutdown,
exitCodes,
shutdownUnregistrations,
),
),
).toBe(0);
@@ -58,42 +58,7 @@ 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"]);
expect(shutdown).toEqual([]);
});
it("reports transport startup failure without its cause", async () => {
@@ -129,6 +94,7 @@ describe("MCP runtime errors", () => {
);
shutdown[0]?.();
await nextTurn();
expect(shutdown).toEqual([]);
expect(exitCodes).toEqual([1]);
expect(output).toEqual([
"REA could not close cleanly. End the REA process before starting it again.\n",
-253
View File
@@ -1,253 +0,0 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PermissionAuthority } from "../src/application/PermissionAuthority.js";
import type { ProjectPermissionStore } from "../src/application/ProjectPermissionStore.js";
import type { PermissionGrant } from "../src/domain/permissionPolicy.js";
import { ok } from "../src/domain/result.js";
import { run } from "../src/main.js";
import {
createServer,
type CreateServerOptions,
} from "../src/server/createServer.js";
type RuntimeDependencies = NonNullable<Parameters<typeof run>[0]>;
const directories: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(
directories
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
describe("runtime permission reload", () => {
it("keeps live state unchanged after a late grant failure, then commits together", async () => {
const fixture = await reloadFixture();
const invalid = projectStore(fixture.projectRoot, [
projectGrant("invalid", fixture.outsideRoot),
]);
const valid = projectStore(fixture.projectRoot, [
projectGrant("valid", fixture.newRoot),
]);
const validReadStarted = deferred<void>();
const releaseValidRead = deferred<void>();
let reads = 0;
const runtime = await startRuntime(fixture.env, async () => {
reads += 1;
if (reads === 1) return ok(invalid);
validReadStarted.resolve();
await releaseValidRead.promise;
return ok(valid);
});
configure(fixture.env, fixture.newRoot, true);
runtime.reload();
runtime.reload();
await validReadStarted.promise;
expect(runtime.notifications()).toBe(0);
expect(runtime.options.processPolicy?.enabled).toBe(false);
expect(runtime.options.evidenceFilePolicy?.roots).toEqual([
fixture.oldRoot,
]);
expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(false);
expect(await canRead(runtime.authority, fixture.oldRoot)).toBe(true);
expect(await canRead(runtime.authority, fixture.newRoot)).toBe(false);
releaseValidRead.resolve();
await runtime.notificationCount(1);
expect(runtime.options.processPolicy?.enabled).toBe(true);
expect(runtime.options.evidenceFilePolicy?.roots).toEqual([
fixture.newRoot,
]);
expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(true);
expect(await canRead(runtime.authority, fixture.oldRoot)).toBe(false);
expect(await canRead(runtime.authority, fixture.newRoot)).toBe(true);
});
it("serializes overlapping reloads so an older read cannot commit last", async () => {
const fixture = await reloadFixture();
const firstReadStarted = deferred<void>();
const releaseFirstRead = deferred<void>();
const secondReadStarted = deferred<void>();
let secondStarted = false;
let reads = 0;
const runtime = await startRuntime(fixture.env, async () => {
reads += 1;
if (reads === 1) {
firstReadStarted.resolve();
await releaseFirstRead.promise;
return ok(projectStore(fixture.projectRoot, []));
}
secondStarted = true;
secondReadStarted.resolve();
return ok(projectStore(fixture.projectRoot, []));
});
configure(fixture.env, fixture.newRoot, true);
runtime.reload();
await firstReadStarted.promise;
configure(fixture.env, fixture.latestRoot, false);
runtime.reload();
expect(secondStarted).toBe(false);
releaseFirstRead.resolve();
await secondReadStarted.promise;
await runtime.notificationCount(2);
expect(runtime.options.processPolicy?.enabled).toBe(false);
expect(runtime.options.evidenceFilePolicy?.roots).toEqual([
fixture.latestRoot,
]);
expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(false);
expect(await canRead(runtime.authority, fixture.newRoot)).toBe(false);
expect(await canRead(runtime.authority, fixture.latestRoot)).toBe(true);
});
});
const reloadFixture = async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-main-reload-"));
directories.push(directory);
const oldRoot = join(directory, "old");
const newRoot = join(directory, "new");
const latestRoot = join(directory, "latest");
const outsideRoot = join(directory, "outside");
const projectRoot = join(directory, "project");
await Promise.all(
[oldRoot, newRoot, latestRoot, outsideRoot, projectRoot].map((path) =>
mkdir(path),
),
);
const env: NodeJS.ProcessEnv = {
REA_EVIDENCE_ROOTS_JSON: JSON.stringify([oldRoot]),
REA_PERMISSION_PROJECT_ROOT: projectRoot,
REA_PERMISSION_PROJECT_STORE: join(directory, "permissions.json"),
};
return { env, oldRoot, newRoot, latestRoot, outsideRoot, projectRoot };
};
const configure = (
env: NodeJS.ProcessEnv,
root: string,
enabled: boolean,
): void => {
env.REA_EVIDENCE_ROOTS_JSON = JSON.stringify([root]);
env.REA_PROCESS_CAPTURE_ENABLED = String(enabled);
env.REA_PROCESS_EXECUTABLE_ROOTS_JSON = JSON.stringify([root]);
env.REA_PROCESS_WORKING_ROOTS_JSON = JSON.stringify([root]);
env.REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED = String(enabled);
};
const startRuntime = async (
env: NodeJS.ProcessEnv,
readStore: NonNullable<RuntimeDependencies["readProjectPermissionStore"]>,
) => {
let reload: (() => void) | undefined;
let options: CreateServerOptions | undefined;
let notificationCount = 0;
let notification = deferred<void>();
const waiters = new Map<number, ReturnType<typeof deferred<void>>>();
const result = await run({
env,
serve: (factory) => {
void factory({ era: "legacy" });
return { close: () => Promise.resolve() };
},
writeStderr: () => undefined,
setExitCode: () => undefined,
registerShutdown: () => () => undefined,
registerReload: (handler) => {
reload = handler;
return () => undefined;
},
readProjectPermissionStore: readStore,
createServer: (analysis, session, received) => {
options = received;
const server = createServer(analysis, session, received);
vi.spyOn(server, "sendToolListChanged").mockImplementation(() => {
notificationCount += 1;
notification.resolve(undefined);
notification = deferred<void>();
waiters.get(notificationCount)?.resolve(undefined);
});
return server;
},
});
expect(result).toBe(0);
const authority = options?.permissionAuthority;
if (reload === undefined || options === undefined || authority === undefined)
throw new Error("Runtime reload seam was not initialized");
const capturedOptions = options;
return {
reload,
options: capturedOptions,
authority,
notifications: () => notificationCount,
notificationCount: (count: number) => {
if (notificationCount >= count) return Promise.resolve();
const waiter = deferred<void>();
waiters.set(count, waiter);
return waiter.promise;
},
};
};
const projectStore = (
projectRoot: string,
grants: readonly ReturnType<typeof projectGrant>[],
): ProjectPermissionStore => ({
schema_version: 1,
project_id: `project_${createHash("sha256").update(projectRoot).digest("hex")}`,
project_root: projectRoot,
grants: [...grants],
});
const projectGrant = (id: string, root: string) =>
({
grant_id: `project:${id}`,
capability: "evidence_read",
roots: [root],
executables: [],
environment_names: [],
network: "none",
mount: false,
lifetime: "project",
operation_identity: null,
expires_at: null,
}) satisfies PermissionGrant;
const canRead = async (
authority: PermissionAuthority,
root: string,
): Promise<boolean> =>
(
await authority.explain(
{
capability: "evidence_read",
roots: [root],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `read:${root}`,
},
"read",
)
).ok;
const deferred = <T>() => {
let resolvePromise!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolve: resolvePromise };
};
-79
View File
@@ -7,14 +7,10 @@ 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", () => {
@@ -103,79 +99,4 @@ 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;
+142 -127
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
@@ -40,6 +41,7 @@ const processFixture = fileURLToPath(
new URL("./fixtures/processFidelity.mjs", import.meta.url),
);
const execFileAsync = promisify(execFile);
const PROCESS_CAPTURE_INTEGRATION_TIMEOUT_MS = 20_000;
const emptyCapture = (): ProcessCapture => {
const normalization = {
@@ -879,95 +881,103 @@ describe("process capture adapter", () => {
}
});
it("renders terminal state, records shim invocations, and captures literal checkpoints", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-v3-test-"));
const script = join(root, "scenario.mjs");
await writeFile(
script,
[
'import { spawnSync } from "node:child_process";',
'const result = spawnSync("codex", ["--version"], { encoding: "utf8" });',
'const node = spawnSync("node", ["--version"], { encoding: "utf8" });',
"process.stdout.write(`probe:${result.stdout}`);",
"process.stdout.write(`runtime:${node.stdout}`);",
].join("\n"),
);
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script],
working_directory: root,
filesystem_roots: [root],
checkpoints: [
{
name: "probe_seen",
trigger: { type: "terminal_literal", value: "probe:codex 1.2.3" },
},
],
command_shims: [
{
name: "codex",
routes: [
{
arguments: ["--version"],
outputs: [
{ at_ms: 0, stream: "stdout", data: "codex 1.2.3\n" },
],
termination: { type: "exit", code: 0 },
},
],
},
{
name: "node",
routes: [
{
arguments: ["--version"],
outputs: [
{ at_ms: 0, stream: "stdout", data: "node 9.8.7\n" },
],
termination: { type: "exit", code: 0 },
},
],
},
],
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
},
it(
"renders terminal state, records shim invocations, and captures literal checkpoints",
async () => {
const root = await mkdtemp(join(tmpdir(), "rea-v3-test-"));
const script = join(root, "scenario.mjs");
await writeFile(
script,
[
'import { spawnSync } from "node:child_process";',
'const result = spawnSync("codex", ["--version"], { encoding: "utf8" });',
'const node = spawnSync("node", ["--version"], { encoding: "utf8" });',
"process.stdout.write(`probe:${result.stdout}`);",
"process.stdout.write(`runtime:${node.stdout}`);",
].join("\n"),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.schema_version).toBe(4);
expect(result.value.rendered_frames.at(-1)?.lines.join("\n")).toContain(
"probe:codex 1.2.3",
);
expect(result.value.shim_events).toEqual([
expect.objectContaining({
command: "codex",
arguments: ["--version"],
outcome: "matched",
}),
expect.objectContaining({
command: "node",
arguments: ["--version"],
outcome: "matched",
}),
]);
expect(
result.value.filesystem_checkpoints.map(({ name }) => name),
).toEqual(["before", "probe_seen", "after_settlement"]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script],
working_directory: root,
filesystem_roots: [root],
checkpoints: [
{
name: "probe_seen",
trigger: {
type: "terminal_literal",
value: "probe:codex 1.2.3",
},
},
],
command_shims: [
{
name: "codex",
routes: [
{
arguments: ["--version"],
outputs: [
{ at_ms: 0, stream: "stdout", data: "codex 1.2.3\n" },
],
termination: { type: "exit", code: 0 },
},
],
},
{
name: "node",
routes: [
{
arguments: ["--version"],
outputs: [
{ at_ms: 0, stream: "stdout", data: "node 9.8.7\n" },
],
termination: { type: "exit", code: 0 },
},
],
},
],
timeout_ms: 5_000,
idle_timeout_ms: 5_000,
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
if (!result.ok) throw result.error;
expect(result.value.schema_version).toBe(4);
expect(result.value.rendered_frames.at(-1)?.lines.join("\n")).toContain(
"probe:codex 1.2.3",
);
expect(result.value.shim_events).toEqual([
expect.objectContaining({
command: "codex",
arguments: ["--version"],
outcome: "matched",
}),
expect.objectContaining({
command: "node",
arguments: ["--version"],
outcome: "matched",
}),
]);
expect(
result.value.filesystem_checkpoints.map(({ name }) => name),
).toEqual(["before", "probe_seen", "after_settlement"]);
} finally {
await rm(root, { recursive: true, force: true });
}
},
PROCESS_CAPTURE_INTEGRATION_TIMEOUT_MS,
);
it("does not follow or disclose symlink targets outside declared roots", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-symlink-test-"));
@@ -1148,43 +1158,48 @@ describe("process capture adapter", () => {
]);
});
it("samples and cleans a source-owned child and grandchild process tree", async () => {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [processFixture, "tree"],
working_directory: dirname(processFixture),
timeout_ms: 2_000,
idle_timeout_ms: 2_000,
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [dirname(processFixture)],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
const commands = result.value.process_samples.map(({ command }) => command);
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);
expect(JSON.stringify(result.value.process_samples)).not.toContain(
dirname(processFixture),
);
const { stdout } = await execFileAsync("ps", ["-axo", "command="]);
expect(stdout).not.toContain(`${processFixture} tree-child`);
expect(stdout).not.toContain(`${processFixture} tree-grandchild`);
});
it(
"samples and cleans a source-owned child and grandchild process tree",
async () => {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const runMarker = `rea-tree-${randomUUID()}`;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [processFixture, "tree", runMarker],
working_directory: dirname(processFixture),
timeout_ms: 5_000,
idle_timeout_ms: 5_000,
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [dirname(processFixture)],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
if (!result.ok) throw result.error;
const commands = result.value.process_samples.map(
({ command }) => command,
);
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);
expect(JSON.stringify(result.value.process_samples)).not.toContain(
dirname(processFixture),
);
const { stdout } = await execFileAsync("ps", ["-axo", "command="]);
expect(stdout).not.toContain(runMarker);
},
PROCESS_CAPTURE_INTEGRATION_TIMEOUT_MS,
);
});
-13
View File
@@ -3,7 +3,6 @@ import {
cleanupOwnedProcessGroup,
observeOwnedProcessGroup,
parseProcessEnvironment,
selectCapturedProcessGroupIds,
type ProcessOwnershipHost,
} from "../src/application/ProcessOwnership.js";
@@ -40,18 +39,6 @@ const host = (
};
describe("owned process-group cleanup", () => {
it("excludes sampled groups whose leader was outside the captured tree", () => {
expect(
selectCapturedProcessGroupIds(100, [
{ pid: 100, process_group_id: 42 },
{ pid: 100, process_group_id: 100 },
{ pid: 101, process_group_id: 42 },
{ pid: 102, process_group_id: 102 },
{ pid: 103, process_group_id: 102 },
]),
).toEqual([100, 102]);
});
it("drops nameless Linux environment entries", () => {
expect(
parseProcessEnvironment("=ignored\0REA_PROCESS_RUN_ID=owned\0EMPTY=\0"),
+12 -20
View File
@@ -13,26 +13,18 @@ const sourceRoot = new URL("../src/", import.meta.url);
describe("provider-neutral architecture", () => {
it("keeps Hopper imports behind its adapter and composition root", async () => {
const forbiddenRoots = ["domain", "contracts", "server", "application"];
const inspected = await Promise.all(
forbiddenRoots.map(async (root) => {
const files = await typescriptFiles(
fileURLToPath(new URL(`${root}/`, sourceRoot)),
);
return Promise.all(
files.map(async (file) => {
if (root === "application" && file.endsWith("/runtime.ts"))
return undefined;
const source = await readFile(file, "utf8");
return /from\s+["'][^"']*\/hopper\//u.test(source)
? relative(fileURLToPath(sourceRoot), file)
: undefined;
}),
);
}),
);
const violations = inspected
.flat()
.filter((violation) => violation !== undefined);
const violations: string[] = [];
for (const root of forbiddenRoots) {
for (const file of await typescriptFiles(
fileURLToPath(new URL(`${root}/`, sourceRoot)),
)) {
if (root === "application" && file.endsWith("/runtime.ts")) continue;
const source = await readFile(file, "utf8");
if (/from\s+["'][^"']*\/hopper\//u.test(source)) {
violations.push(relative(fileURLToPath(sourceRoot), file));
}
}
}
expect(violations).toEqual([]);
});
+12 -19
View File
@@ -5,7 +5,6 @@ import {
createHistoricalSourceGraph,
createHistoricalSourceManifest,
historicalSourceGraphSchema,
historicalSourceParseFailureKey,
parseHistoricalSourceGraph,
parseHistoricalSourceManifest,
type HistoricalSourceGraphInput,
@@ -193,26 +192,20 @@ 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];
it("keeps distinct failure reasons for the same path and parser", () => {
const partial = graphInput();
partial.inventory_state = "partial";
partial.parse_failures = [
{ path: "src/main.ts", parser: "babel", reason: "First failure" },
{ path: "src/main.ts", parser: "babel", reason: "Second failure" },
];
expect(createHistoricalSourceGraph(input).parse_failures).toEqual(
input.parse_failures,
expect(createHistoricalSourceGraph(partial).parse_failures).toEqual(
partial.parse_failures,
);
expect(historicalSourceParseFailureKey(malformed)).not.toBe(
historicalSourceParseFailureKey(unexpected),
partial.parse_failures.push(partial.parse_failures[1]!);
expect(() => createHistoricalSourceGraph(partial)).toThrow(
/parse_failures.*unique/iu,
);
});
+1 -25
View File
@@ -6,10 +6,7 @@ import { join } from "node:path";
import { add, commit, init } from "isomorphic-git";
import { describe, expect, it } from "vitest";
import {
importReferenceSource,
normalizeHistoricalSourceParseFailures,
} from "../src/application/ReferenceSourceImport.js";
import { importReferenceSource } from "../src/application/ReferenceSourceImport.js";
import { projectReferenceSourceEntryFailure } from "../src/application/ReferenceSourceImportEntries.js";
import {
projectReferenceSourceImportError,
@@ -55,27 +52,6 @@ const importTree = (
});
describe("reference source import", () => {
it("deduplicates exact parse failures without collapsing distinct reasons", () => {
const malformed = {
path: "src/main.ts",
parser: "babel",
reason: "Malformed input",
};
const unexpected = {
path: "src/main.ts",
parser: "babel",
reason: "Unexpected token",
};
expect(
normalizeHistoricalSourceParseFailures([
unexpected,
malformed,
unexpected,
]),
).toEqual([malformed, unexpected]);
});
it("projects entry failures without low-level reader diagnostics", () => {
for (const [kind, code] of [
["directory", "io"],
-48
View File
@@ -32,54 +32,6 @@ 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");
+44 -31
View File
@@ -19,11 +19,12 @@ class FakeSetupHost implements SetupHost {
hopper: string | undefined;
hopperInstallSucceeds = true;
skill: "installed" | "unchanged" | "failed" = "installed";
skillNeedsInstallation = true;
clients: readonly SetupClient[] = [];
clientResults = new Map<string, ClientConfigurationResult>();
hopperInstalls = 0;
skillInstallations = 0;
configurations = 0;
skillInstalls = 0;
doctorHealthy: boolean | undefined;
linuxDemoRuntimeMissing = false;
unsupportedHopperVersion = false;
@@ -63,14 +64,10 @@ class FakeSetupHost implements SetupHost {
this.clientResults.get(client.name) ?? { status: "configured" },
);
};
clientNeedsConfigure = (client: SetupClient): Promise<boolean> =>
Promise.resolve(
this.clientResults.get(client.name)?.status !== "unchanged",
);
skillNeedsInstall = (): Promise<boolean> =>
Promise.resolve(this.skill !== "unchanged");
skillInstallationNeeded = (): Promise<boolean> =>
Promise.resolve(this.skillNeedsInstallation);
installSkill = (): Promise<"installed" | "unchanged" | "failed"> => {
this.skillInstalls += 1;
this.skillInstallations += 1;
return Promise.resolve(this.skill);
};
doctor = (): Promise<{
@@ -135,6 +132,21 @@ describe("setup workflow", () => {
);
});
it("omits an aligned skill from the plan and does not reinstall it", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skillNeedsInstallation = false;
const result = await runSetup(options(true), host);
expect(result.status).toBe("ready");
expect(result.plannedActions.map(({ kind }) => kind)).not.toContain(
"install_skill",
);
expect(result.appliedActions).toEqual([]);
expect(host.skillInstallations).toBe(0);
});
it("reports ready after an accepted Linux plan installs healthy Hopper", async () => {
const host = new FakeSetupHost("linux");
host.distribution = {
@@ -227,15 +239,17 @@ describe("setup workflow", () => {
expect(host.hopperInstalls).toBe(0);
});
it("omits an aligned managed skill from an otherwise empty plan", async () => {
it("treats --install-hopper as approval, not a healthy reinstall request", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
const result = await runSetup(options(false), host);
const result = await runSetup(options(true, true), host);
expect(result.status).toBe("ready");
expect(result.plannedActions).toEqual([]);
expect(result.appliedActions).toEqual([]);
expect(host.skillInstalls).toBe(0);
expect(result.plannedActions.map(({ kind }) => kind)).not.toContain(
"install_hopper",
);
expect(host.hopperInstalls).toBe(0);
});
it("installs missing Linux demo dependencies for existing Hopper", async () => {
@@ -294,10 +308,6 @@ 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.",
@@ -321,29 +331,32 @@ describe("setup workflow", () => {
expect(result.remediation).not.toContain("internal_client_key");
});
it("records every detected client outcome after an earlier failure", async () => {
it("records later client outcomes after an earlier configuration fails", 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" },
{ name: "before", configPath: "/before.json" },
{ name: "broken", configPath: "/broken.json" },
{ name: "after", configPath: "/after.json" },
];
host.clientResults.set("first", { status: "failed", reason: "write" });
host.clientResults.set("second", { status: "configured" });
host.clientResults.set("third", { status: "failed", reason: "readback" });
host.clientResults.set("broken", { status: "failed", reason: "write" });
const result = await runSetup(options(true), host);
expect(result.status).toBe("needs_human");
expect(host.configurations).toBe(3);
expect(result.clients).toEqual({
first: { status: "failed", reason: "write" },
second: { status: "configured" },
third: { status: "failed", reason: "readback" },
before: { status: "configured" },
broken: { status: "failed", reason: "write" },
after: { status: "configured" },
});
expect(result.appliedActions).toEqual(["configured_second"]);
expect(result.status).toBe("needs_human");
expect(result.remediation).toContain("could not be updated");
expect(result.appliedActions).toEqual([
"configured_before",
"configured_after",
]);
expect(result.remediation).toBe(
"Agent configuration could not be updated. Check file permissions, then rerun setup.",
);
});
it("explains skill installation recovery", async () => {
+1 -70
View File
@@ -1,16 +1,8 @@
import {
lstat,
mkdtemp,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { mkdtemp, readFile, rm, 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;
@@ -21,25 +13,6 @@ 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");
@@ -61,48 +34,6 @@ describe("JSON client configuration transaction", () => {
});
});
it("updates a symlink target without replacing the JSON config symlink", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
const targetPath = join(directory, "managed.json");
const configPath = join(directory, "mcp.json");
const original = '{"theme":"dark"}\n';
await writeFile(targetPath, original);
await symlink(targetPath, configPath);
expect(
await configureJsonClient({ name: "cursor", configPath }),
).toMatchObject({ status: "configured" });
expect((await lstat(configPath)).isSymbolicLink()).toBe(true);
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
expect(JSON.parse(await readFile(targetPath, "utf8"))).toMatchObject({
theme: "dark",
mcpServers: {
rea: {
command: "npx",
args: ["-y", "rea-agents", "mcp"],
},
},
});
expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({
status: "unchanged",
});
});
it("fails before mutation when a JSON config symlink is dangling", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
const configPath = join(directory, "mcp.json");
await symlink(join(directory, "missing.json"), configPath);
expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({
status: "failed",
reason: "path",
});
expect((await lstat(configPath)).isSymbolicLink()).toBe(true);
await expect(
readFile(`${configPath}.rea.backup`, "utf8"),
).rejects.toThrow();
});
it("performs no write or second backup when configuration already matches", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
const configPath = join(directory, "mcp.json");
-87
View File
@@ -4,9 +4,7 @@ import {
mkdtemp,
mkdir,
readFile,
realpath,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -94,44 +92,6 @@ 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);
@@ -171,52 +131,6 @@ 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);
@@ -314,7 +228,6 @@ 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 }),
};
+14 -6
View File
@@ -2,10 +2,8 @@ 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 {
canonicalSkillNeedsInstall,
installCanonicalSkill,
} from "../src/application/Setup.js";
import { installCanonicalSkill } from "../src/application/Setup.js";
import { canonicalSkillInstallationNeeded } from "../src/application/SetupSkill.js";
import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
const roots: string[] = [];
@@ -26,7 +24,6 @@ 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",
@@ -36,7 +33,18 @@ 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 canonicalSkillInstallationNeeded(home)).toBe(false);
expect(await installCanonicalSkill(home)).toBe("unchanged");
});
it("reports a missing or stale canonical skill as needing installation", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-skill-plan-test-"));
roots.push(home);
const destination = join(home, ".agents/skills/rea-analysis/SKILL.md");
expect(await canonicalSkillInstallationNeeded(home)).toBe(true);
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, "stale managed skill\n");
expect(await canonicalSkillInstallationNeeded(home)).toBe(true);
});
});
+3 -47
View File
@@ -61,62 +61,18 @@ describe("CLI upgrade", () => {
expect(host.installs).toBe(1);
});
it("does not mutate when the registry check is unavailable", async () => {
it("continues with npm latest when the registry check is unavailable", async () => {
const host = new FakeUpgradeHost();
host.latest = undefined;
expect(await runUpgrade("0.5.0", host)).toMatchObject({
status: "failed",
latestVersion: null,
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",
latestVersion: null,
versionCheck: "unavailable",
});
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();
-2
View File
@@ -1,9 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": true,
"rootDir": "src",
"tsBuildInfoFile": "node_modules/.cache/rea-tsconfig-build.tsbuildinfo",
"types": ["node"]
},
"include": ["src/**/*.ts"],
+2 -34
View File
@@ -1,43 +1,11 @@
import { availableParallelism, tmpdir } from "node:os";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { defineConfig } from "vitest/config";
const TEST_FILES = ["tests/**/*.test.ts"];
const PROCESS_CAPTURE_TEST = "tests/processCapture.test.ts";
const SERIAL_INTEGRATION_TESTS = [
"tests/browserCli.test.ts",
"tests/cliOutput.test.ts",
PROCESS_CAPTURE_TEST,
"tests/runtime.test.ts",
];
const dualCoreProjects =
availableParallelism() === 2
? {
maxWorkers: 2,
projects: [
{
test: {
name: "parallel",
include: TEST_FILES,
exclude: SERIAL_INTEGRATION_TESTS,
},
},
{
test: {
name: "serial-integration",
include: SERIAL_INTEGRATION_TESTS,
fileParallelism: false,
},
},
],
}
: {};
export default defineConfig({
test: {
include: TEST_FILES,
...dualCoreProjects,
include: ["tests/**/*.test.ts"],
retry: 2,
reporters: ["default", "verbose"],
coverage: {