Compare commits

..
Author SHA1 Message Date
morlutoandGitHub 90ba7b03da Merge pull request #208 from morluto/release-please--branches--main--components--rea-agents
chore(main): release rea-agents 1.6.0
2026-07-15 07:07:46 +08:00
github-actions[bot]andGitHub 557c3bea0f chore(main): release rea-agents 1.6.0 2026-07-14 23:03:57 +00:00
morlutoandGitHub b348b2c987 Merge pull request #234 from morluto/rollup/batch-fixes
fix: rollup batch of fixes
2026-07-15 07:03:15 +08:00
morluto cfa110ccb4 fix(verify): reconcile PR-212/215/228 package E2E expectations
- Restore Linux hopper guards in scripts/verify-package.mjs so the packaged E2E handles the unsupported_hopper_build response on Linux.
- Keep the config symlink lifecycle check from PR-225 by preserving the pre-existing `existing` key when the failure-recovery test rewrites the cursor symlink target.
- Guard the packaged search command behind process.platform !== "linux".
- Adapt MCP current/batch verification to the Linux "not supported for unattended Linux analysis" rejection.
2026-07-14 23:00:11 +00:00
morluto 0a430c12c1 fix(test): adapt mainReload shutdown seam to registerShutdown signature 2026-07-14 22:29:25 +00:00
morluto d81687f383 fix(setup): omit aligned skill from plan 2026-07-14 22:25:48 +00:00
morluto 6397e54169 fix(cli): confirm project grant revocation 2026-07-14 22:25:47 +00:00
morluto 69744a421a fix(setup): configure every detected client 2026-07-14 22:25:43 +00:00
morluto 71072d86b1 fix(runtime): unregister shutdown handlers 2026-07-14 22:24:03 +00:00
morluto e02cb2f877 fix(reference): preserve distinct parse failures 2026-07-14 22:24:03 +00:00
morluto c4930f4820 fix(browser): support page-scoped CDP transports 2026-07-14 22:24:02 +00:00
morluto 8bd0e5f1ea fix(cli): restrict production MCP dispatch 2026-07-14 22:24:02 +00:00
morluto e98413c3d5 fix(session): reopen replaced targets 2026-07-14 22:24:01 +00:00
morluto c7918024bc fix(evidence): enforce the combined record limit 2026-07-14 22:24:01 +00:00
morluto c69fe2c897 fix(doctor): honor explicit Hopper launcher 2026-07-14 22:24:00 +00:00
morluto a204840630 fix(upgrade): prevent version downgrades 2026-07-14 22:24:00 +00:00
morluto 85285f1563 fix(setup): preserve client config symlinks 2026-07-14 22:23:59 +00:00
morluto 43766fda69 fix(session): isolate availability observers 2026-07-14 22:23:58 +00:00
morluto f471a63c87 fix(hopper): cancel startup before closing 2026-07-14 22:23:58 +00:00
morluto 7112e1e6bf fix(application): add explicit investigation replay 2026-07-14 22:23:47 +00:00
morluto 49e843e8ff fix(runtime): apply permission reloads atomically 2026-07-14 22:22:51 +00:00
morluto e831a41375 fix(process): gate sampling on initialized PTY roots 2026-07-14 22:22:51 +00:00
morluto fb17156499 fix(tooling): isolate concurrent repository checks 2026-07-14 22:22:44 +00:00
morlutoandGitHub d92441e6b7 Merge branch 'main' into agent/process-sampling-initialization 2026-07-15 06:17:51 +08:00
morlutoandGitHub a3b7de9fa5 Merge pull request #232 from morluto/codex/test-suite-speed
fix(process): stabilize and speed up the test suite
2026-07-15 06:17:36 +08:00
N0zoM1z0andmorluto 7e53dd6ad3 fix(runtime): serialize permission reloads 2026-07-15 06:16:46 +08:00
N0zoM1z0andmorluto 3ae7109faa fix(runtime): apply permission reloads atomically 2026-07-15 06:16:46 +08:00
N0zoM1z0andmorluto 57938a8465 fix(process): gate sampling on initialized PTY root 2026-07-15 06:16:37 +08:00
N0zoM1z0andmorluto ab8c5c1e13 fix(tooling): isolate concurrent repository checks 2026-07-15 06:16:30 +08:00
morlutoandGitHub 8681d817f9 Merge branch 'main' into codex/test-suite-speed 2026-07-15 06:15:52 +08:00
morlutoandGitHub 072ce5aede Merge pull request #233 from morluto/agent/process-zombie-settlement
fix(process): settle exited zombie groups
2026-07-15 06:15:29 +08:00
morlutoandGitHub 95a228ffa7 Merge branch 'main' into agent/process-zombie-settlement 2026-07-15 06:12:17 +08:00
morlutoandGitHub 46e4052028 Merge pull request #213 from morluto/agent/integration-fixes
fix(hopper): select verified Linux demo mode explicitly
2026-07-15 06:01:25 +08:00
morluto a86da4ff3f fix(setup): omit aligned client configurations from plan
The setup plan was still advertising configure_client actions for clients
whose MCP registration already matched the desired command and Hopper
environment. As a result, an otherwise idempotent rerun requested
confirmation for changes that would not be applied.

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