Compare commits

..
Author SHA1 Message Date
morlutoand吴佳浩 1b36fc2d50 fix(scripts): validate package-map ownership and preserve HTML fallback (#10097)
* fix(website): keep the HTML validator when the Markdown route is absent

A caller that prefers Markdown gets the HTML probe with its conditional headers
deleted, so the Markdown route can be evaluated on its own. When the Markdown
sidecar is missing the worker answers with that probe, which never saw the
caller's `If-None-Match`, so a matching HTML ETag returned 200 instead of 304.

Ask the asset service again with the caller's own request before answering with
HTML. The decision moves into `htmlFallback` so the request handler keeps its
complexity below the repository bound.

Verified by executing `node --test website/worker.test.mjs`: without the change
the new case fails with 200 instead of 304, and all four cases pass with it.
`oxlint` reports no findings and `oxfmt --check` passes. The website worker test
is not part of a CI workflow, so this suite runs locally only.

* fix(scripts): resolve package-map imports in the module-boundary guard

The guard resolved only relative, absolute and `file:` specifiers, so every
`#`-prefixed package-map import returned no target and was skipped. A pure-layer
file importing `#javascript-analysis-worker` passed, while the same import
written relatively failed with a `pure-layer` diagnostic: the gate failed open
on the spelling the package map exists to encourage.

Resolve a package-map specifier to the source file that produces its built
output, then apply the same ownership rules. The package map is read once per
repository root.

Verified with the new `scripts/lib/module-boundaries.test.mjs` (2 pass; the
package-map case failed before the change) and by running the complete guard on
this checkout, which still reports 4138 files with no findings, so the new rule
adds no false positive. `oxfmt --check` passes; `oxlint` reports the same single
pre-existing `failedBoundary` complexity warning it reported before this change.
The script tests are not part of a CI workflow, so this suite runs locally only.

* fix(scripts): normalize package targets as file URLs

---------

Co-authored-by: 吴佳浩 <[email protected]>
2026-11-11 08:27:39 -05:00
N0zoM1z0andgensokyo-dev 40d9f1a5e8 test(kuna): cancel inspection scenarios at their owning boundary
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev fbbf94a2c4 test(kuna): cover cancellation during native ownership preparation
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev 4a17bf76ed test(kuna): distinguish prelaunch expiry from captured command output
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev 04042cf540 test(kuna): scope short deadlines to hanging decompile work
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev 2127cff82d test(kuna): acknowledge subprocess phases and join cancelled work
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev 1dc54783ef fix(kuna): preserve cancellation through workspace admission and rollback
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
morluto 48357cb34c fix(settings): expose provider startup limits for binary open (#10105) 2026-11-11 20:19:27 +08:00
morluto ab8e31dc76 test(settings): exercise Windows limits and refresh guidance 2026-11-11 20:16:24 +08:00
morluto 03610972f7 fix: expose file admission settings for affected operations 2026-11-11 20:16:24 +08:00
morluto 44bbd13b55 fix: preserve capture configuration failures 2026-11-11 20:16:24 +08:00
morluto 86fb3e757d fix: expose Windows settling controls for binary opens 2026-11-11 20:16:24 +08:00
morluto 66e85df5c5 fix: preserve cancellation when change-time settles 2026-11-11 20:16:24 +08:00
morluto 5bb34ce522 fix: scope change-time settings to Windows 2026-11-11 20:16:24 +08:00
morluto f4867e60fa fix: preserve Cutter cancellation before limit admission 2026-11-11 20:16:24 +08:00
morluto ef754ce717 fix: retain invalid runtime seeds for long-lived providers 2026-11-11 20:16:24 +08:00
N0zoM1z0andgensokyo-dev 64d1a57aef fix(kuna): report runtime recovery after resetting invalid deadline seeds
Both function inventory and project queries now retain runtime editability and name the recoverable setting when reset exposes an invalid launch seed. Four provider-boundary regressions fail before the fix and pass afterward.

Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 20:16:24 +08:00
N0zoM1z0andgensokyo-dev 47bcd9c129 docs(settings): retain runtime recovery guidance after stack integration
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 20:16:24 +08:00
N0zoM1z0andgensokyo-dev 11bc738198 fix(settings): preserve runtime contracts while restacking operation limits
Retain the original stack merge resolutions for runtime update modes, migrated guard exceptions, deadline ownership and boundary expectations on the merged settings base.

Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 20:16:24 +08:00
morluto 6b102e61b3 fix(gdb): isolate console output admission from control requests 2026-11-11 20:16:24 +08:00
morluto 73ee767656 fix(gdb): preserve invalid command configuration errors 2026-11-11 20:16:24 +08:00
morluto 2aa5ba836d fix(kuna): retain failed inventory admission as result 2026-11-11 20:16:24 +08:00
morluto c4e3e48574 fix(rizin): return command configuration failures 2026-11-11 20:16:24 +08:00
morluto 527c7c5d8b fix(rizin): reserve command capacity after limit validation 2026-11-11 20:16:24 +08:00
morluto 3ccb35a8b5 fix(kuna): admit regex settings before acquisition 2026-11-11 20:16:24 +08:00
morluto e675f9b29d fix(hopper): retain startup admission failures in result 2026-11-11 20:16:24 +08:00
morluto 4361cad981 fix(hopper): defer startup setting until admission 2026-11-11 20:16:24 +08:00
morluto 925df00caa fix(ghidra): scope runtime limits to applicable operations 2026-11-11 20:16:24 +08:00
morluto e8908507c8 fix(settings): preserve regex cancellation and capability truth 2026-11-11 20:16:24 +08:00
morluto 40b6f1ab21 fix(kuna): preserve run ownership on deadline refusal 2026-11-11 20:16:24 +08:00
Cursor Agent 0bf161f1bc fix(kuna): resolve the function deadline before taking a run slot
An invalid REA_KUNA_TIMEOUT_MS throws once its runtime override is reset. That check now happens before either shared-slot acquisition, so a failed independent decompile cannot leave the provider-wide pool blocked.
2026-11-11 20:16:24 +08:00
Cursor Agent 5ba464527b fix(limits): reject invalid runtime seeds and keep the value an operation used
A malformed migrated launch seed stays on the config and the operation view reports configuration_invalid unless a snapshot override is active. Kuna, regex, GDB, and Rizin keep the deadline or heap accepted when the work started, and reattributed resource errors copy the structured limit.
2026-11-11 20:16:24 +08:00
morluto 125de0bdc4 fix(kuna): retain the deadline owned by project export 2026-11-11 20:16:24 +08:00
morluto b3dcddc115 fix(providers): preserve bridge limits through error attribution 2026-11-11 20:16:24 +08:00
Cursor Agent 57dd7f26b1 fix(limits): carry runtime ceilings into Hopper and pwntools recovery
A runtime bridge ceiling is injected into the Hopper bootstrap. Pwntools input-limit guidance names the layer that selected the ceiling.
2026-11-11 20:16:24 +08:00
Cursor Agent b9be864801 fix(settings): parse runtime launch seeds at the operation boundary
Startup no longer rejects a runtime-owned REA_ seed, so update_analysis_settings can replace it. Debugger construction keeps only the restart-only caps, Kuna stores the launch baseline rather than a cached override, and the advertised operations include GDB close, Rizin close, Kuna list_procedures, and the stable-file ChangeTime callers.
2026-11-11 20:16:24 +08:00
Cursor Agent 6ebd033dff chore(plugin): regenerate the skill bundle from the authored skill
The committed Cursor plugin copy had missed the parameter update mode and the runtime bridge ceiling.
2026-11-11 20:16:24 +08:00
Cursor Agent 559f725509 fix(jeb): keep the deadline limit when a timeout is reattributed
The provider copies the selected jeb.mcp_timeout_ms limit onto the operation timeout, so callers can read its source and update mode.
2026-11-11 20:16:24 +08:00
Cursor Agent db5a0e0f4a test(jeb): assert the timeout limit source instead of a restart instruction
A launch-selected JEB deadline reports source setting and update_mode runtime. The recovery tells the caller to use update_analysis_settings.
2026-11-11 20:16:24 +08:00
Cursor Agent aefdacd697 test(pwntools): expect parameter when the caller selects the input limit
A request-sourced pwntools input limit is a parameter update, so update_analysis_settings cannot change that call.
2026-11-11 20:16:24 +08:00
Cursor Agent e0dcdab1a9 fix(settings): advertise every bridge operation and prefer accepted overrides
The bridge ceiling lists every Hopper and Ghidra bridge operation plus the Cutter calls, and a test fails when a registry entry is missing. ChangeTime observation and the other launch-seed parsers read an accepted override before rejecting an invalid value.
2026-11-11 20:16:24 +08:00
Cursor Agent 2bac42cde5 fix(settings): apply runtime overrides before provider limit validation
JEB, Kuna, Windows, debugger, and Cutter consult an accepted override before rejecting an invalid launch value. JEB and Hopper startup deadlines keep default, setting, or runtime as the limit source, and a Kuna timeout without its own limit uses the selected project deadline source.
2026-11-11 20:16:24 +08:00
Cursor Agent 1613f2b777 fix(settings): keep limit provenance through wrappers and Cutter inspection
Startup timeout wrapping keeps the debugger setting limit. Pwntools and Kuna failures report the selected source. Cutter session and command operations can inspect the bridge ceiling.
2026-11-11 20:16:24 +08:00
Cursor Agent 649b9c089c fix(settings): enforce runtime caps on each GDB command and Cutter request
GDB reads the accepted command-output cap when it admits a command. Cutter uses that same accepted bridge ceiling for mismatch and request checks. Kuna project-timeout metadata includes the operations that wait on the shared export.
2026-11-11 20:16:24 +08:00
Cursor Agent b0541c80b3 feat(settings): read per-call REA_ limits from the operation snapshot
Part of #9881. Move the limits checked on each call into update_analysis_settings, keep their REA_ seeds and bounds, and reject updates outside the allowed range.
2026-11-11 20:16:24 +08:00
morluto 9924bf3445 test(mcp): own presentation fixture cleanup (#10103) 2026-11-11 19:50:51 +08:00
N0zoM1z0andgensokyo-dev 91b11f62c9 fix(settings): preserve syntax guard contracts across stack integration
Retain existing merge resolutions for update modes, source-scan failures and external-reader fixtures while replaying the settings layer on the merged inventory. Preserve parent provenance, candidate counts and presentation-test resource bounds.

Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 19:48:33 +08:00
morluto 1626461ec1 docs: clarify limit update modes and rejected settings 2026-11-11 19:48:33 +08:00
morluto dc521dc0d9 fix(settings): omit unknown pending restart state 2026-11-11 19:48:33 +08:00
morluto 804784720a fix(ci): fail when limit guard cannot scan sources 2026-11-11 19:48:33 +08:00
morluto 6194524d00 fix(settings): avoid inventing pending restart state 2026-11-11 19:48:33 +08:00
Cursor Agent 88202cbf67 fix(settings): keep invocation recovery on the launch seed
A one-shot CLI failure tells the caller to set the launch environment for the next REA process. update_analysis_settings stays on the continuing session. Reused JavaScript workers also keep the limit source that selected them.
2026-11-11 19:48:33 +08:00
Cursor Agent 073dda2a0a docs(settings): list parameter as an update_mode
The changelog and the limit migration now name runtime, restart, and parameter. parameter is the mode for a value this call supplied.
2026-11-11 19:48:33 +08:00
Cursor Agent 0ae186e196 fix(settings): classify caller-selected limits as parameter updates
analysis_timeout_ms, max_heap_mb, and command_timeout_ms keep the source chosen by the caller, the runtime override, or the built-in default. A request value reports update_mode parameter because update_analysis_settings cannot change that call.
2026-11-11 19:48:33 +08:00
Cursor Agent 58a1216a57 fix(settings): mark per-call parameters and keep historical receipts
artifact_limits and web-network budgets report update_mode parameter because update_analysis_settings cannot change them. The legacy-name scan no longer reads docs, and the 2026-11-09 Ghidra receipts are restored to the fields they recorded.
2026-11-11 19:48:33 +08:00
Cursor Agent 9bcc95a801 fix(settings): keep Windows update mode on configuration errors
Invalid ChangeTime settings now carry update_mode and pending_restart through the configuration error and its projection, including startup parsing. Runtime recoveries name update_analysis_settings instead of telling the caller to restart REA.
2026-11-11 19:48:33 +08:00
Cursor Agent be20bdfb26 feat(settings)!: replace effect with update_mode and pending_restart
Callers read update_mode to see whether update_analysis_settings can change a limit, and pending_restart only when a configured value is not active in this process.
2026-11-11 19:48:33 +08:00
Cursor Agent aee1178839 feat(settings)!: report when a limit change takes effect
Part of #9881. Add effect immediate or next_process and keep rea_restart_required, restart_required, and restartRequired as compatibility aliases.
2026-11-11 19:48:33 +08:00
N0zoM1z0andgensokyo-dev cc6c52b886 test(mcp): retain measured full-catalog execution budget
The prepared full/full fixture takes 31.73 seconds on two CPU cores; restoring a 20 second child deadline causes a real bounded-run failure. Keep the 60 second child budget and 70 second outer deadline.

Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 19:39:06 +08:00
N0zoM1z0andgensokyo-dev 3ac05e14ba docs(limits): distinguish source candidates from external controls
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 19:39:06 +08:00
morluto 3038caad2b test(mcp): restore presentation subprocess deadline 2026-11-11 19:39:06 +08:00
N0zoM1z0andgensokyo-dev d65cd41b78 test(mcp): align presentation deadlines and cancel unfinished child
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 19:39:06 +08:00
morluto 3769fe8077 fix(ci): reject mutable setting spreads 2026-11-11 19:39:06 +08:00
morluto 021b5de719 fix(ci): parse runtime setting seeds as syntax 2026-11-11 19:39:06 +08:00
morluto 6e2e5a0aca fix: preserve numeric limit evidence and uncertainty 2026-11-11 19:39:06 +08:00
morluto 694e8c4f7b fix(ci): classify direct numeric value expressions 2026-11-11 19:39:06 +08:00
morluto 729d1a2138 test(ghidra): avoid restoring source grep assertion 2026-11-11 19:39:06 +08:00
morluto bc01caea10 fix(ci): remove stale temporary limit exceptions 2026-11-11 19:39:06 +08:00
Cursor Agent 646daa1d29 fix(limits): classify parsed property reads and ignore template prose
A numeric parser now reads property and element accesses in its argument, so Number(process.env.REA_ARCHIVE_ENTRY_LIMIT) is a producer without a schema key. Template discovery keeps generated assignments and no longer treats a diagnostic sentence as a read.
2026-11-11 19:39:06 +08:00
Cursor Agent 3f0c77c1f5 fix(limits): type discovered limit reads as path and lifecycle 2026-11-11 19:39:06 +08:00
Cursor Agent a24e0156fe fix(limits): keep temporary per-call names on an explicit allowlist
New per-call registry entries are rejected. Inventory checks require a table row, and the Electron argv handoff is process protocol rather than a caller setting.
2026-11-11 19:39:06 +08:00
Cursor Agent ead6cc83d2 fix(limits): record construction reads and schema-owned numeric names
A constructor copy is not a per-call read. Numeric limits also come from a number schema or a numeric parse, and the inventory no longer lists path and provider constants.
2026-11-11 19:39:06 +08:00
Cursor Agent 07d6fad807 test(mcp): give the catalog presentation child a 60s bound
A loaded shard killed the full-catalog child at its 20s limit.
2026-11-11 19:39:06 +08:00
Cursor Agent 157d495029 test(ghidra): restore the bridge script read for the context contract
The main merge dropped the fs import that the file-level assertion uses.
2026-11-11 19:39:06 +08:00
Cursor Agent 575a6c486e fix(limits): read destructured environment keys and keep the pre-install guard dependency-free
Object bindings, in-checks, and object keys that a spread can copy are producers. The architecture guard skips the TypeScript parse until typescript6 is installed, and the inventory links stay inside the published site.
2026-11-11 19:39:06 +08:00
Cursor Agent b1f8a9bba8 fix(limits): discover numeric limit reads from the syntax tree
Property access, element access, and helper-call arguments are producers. Comments and array literals are excluded by the tree, so a later argument such as read(environment, "REA_NEW_MAX_BYTES") stays classified.
2026-11-11 19:39:06 +08:00
Cursor Agent 7fe465505f fix(limits): keep bracket-access reads in numeric-limit discovery
A quoted name after an identifier, call, or optional chain is a producer. Only declaration arrays and comments stay out of discovery.
2026-11-11 19:39:06 +08:00
Cursor Agent f2d55aba7b fix(limits): ignore allowlists and require tracked limits to be settings
Discovery no longer treats comments or registration-list entries as producers. The three limits moving on their own PRs must join ANALYSIS_SETTINGS; the exception registry cannot classify them.
2026-11-11 19:39:06 +08:00
Cursor Agent 710d2af41c fix(limits): classify endpoint candidates and ignore registry declarations
The numeric-limit check now treats REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES as a count limit and does not count registry text as a producer.
2026-11-11 19:39:06 +08:00
Cursor Agent 3b39991b02 ci(limits): inventory numeric REA_ limits and check them
Part of #9881. Record every numeric REA_ limit and fail static checks when one is neither a runtime analysis setting nor a documented exception with a reason.
2026-11-11 19:39:06 +08:00
229 changed files with 8915 additions and 10693 deletions

No files matched your search

@@ -108,11 +108,13 @@ is not a recommendation to change it. Do not invent a setting. Host memory and
- Capacity, when the result is `resource_constraint` and `retryable` is true:
wait for the active admission to finish, then retry within the reported
limits. Do not restart REA to free the slot.
- `configuration_invalid`: correct the named setting in the environment that
starts REA (the shell or the MCP registration), then restart REA. Run
`rea doctor` to see the `runtime-configuration:` check. Re-run `rea setup`
for that client when the registration must carry the new setting, then
reconnect.
- `configuration_invalid`: when the error reports `update_mode: "runtime"`,
call `update_analysis_settings` for that setting. When it reports
`update_mode: "parameter"`, change that call's argument; the setter does not
accept it. Otherwise correct the named setting in the environment that starts
REA (the shell or the MCP registration) and start REA again. Run `rea doctor`
to see the `runtime-configuration:` check. Re-run `rea setup` for that client
when the registration must carry the new setting, then reconnect.
- `invalid_request`: correct the reported fields from the current schema. Do
not repeat the unchanged call.
@@ -539,8 +539,7 @@ the framing LF still fits. Cutter measures, signs, and writes a fitting
response in bounded chunks. The bridge searches only the newly read bytes
for the framing LF. The client unescapes each socket chunk into one response
string before parsing it, so this ceiling does not keep the paged wire frame
and another concatenated copy. Invalid values are configuration errors and do not fall back to 16777216. Set the variable in the environment that starts REA, restart REA,
and start Hopper or Cutter from that same environment. An out-of-band bridge
and another concatenated copy. Invalid values are configuration errors and do not fall back to 16777216. `bridge.max_request_bytes` can be changed at runtime with `update_analysis_settings`. The launch seed is `REA_BRIDGE_MAX_REQUEST_BYTES`. Start Hopper or Cutter from that same environment so the bridge keeps the ceiling it started with until that bridge starts again. An out-of-band bridge
without the setting stays at 16777216 bytes. The client reports that mismatch
during discovery only after the hello proves a live authenticated bridge, and
does not send a status request or the command. A stale descriptor is ignored.
@@ -108,11 +108,13 @@ is not a recommendation to change it. Do not invent a setting. Host memory and
- Capacity, when the result is `resource_constraint` and `retryable` is true:
wait for the active admission to finish, then retry within the reported
limits. Do not restart REA to free the slot.
- `configuration_invalid`: correct the named setting in the environment that
starts REA (the shell or the MCP registration), then restart REA. Run
`rea doctor` to see the `runtime-configuration:` check. Re-run `rea setup`
for that client when the registration must carry the new setting, then
reconnect.
- `configuration_invalid`: when the error reports `update_mode: "runtime"`,
call `update_analysis_settings` for that setting. When it reports
`update_mode: "parameter"`, change that call's argument; the setter does not
accept it. Otherwise correct the named setting in the environment that starts
REA (the shell or the MCP registration) and start REA again. Run `rea doctor`
to see the `runtime-configuration:` check. Re-run `rea setup` for that client
when the registration must carry the new setting, then reconnect.
- `invalid_request`: correct the reported fields from the current schema. Do
not repeat the unchanged call.
@@ -539,8 +539,7 @@ the framing LF still fits. Cutter measures, signs, and writes a fitting
response in bounded chunks. The bridge searches only the newly read bytes
for the framing LF. The client unescapes each socket chunk into one response
string before parsing it, so this ceiling does not keep the paged wire frame
and another concatenated copy. Invalid values are configuration errors and do not fall back to 16777216. Set the variable in the environment that starts REA, restart REA,
and start Hopper or Cutter from that same environment. An out-of-band bridge
and another concatenated copy. Invalid values are configuration errors and do not fall back to 16777216. `bridge.max_request_bytes` can be changed at runtime with `update_analysis_settings`. The launch seed is `REA_BRIDGE_MAX_REQUEST_BYTES`. Start Hopper or Cutter from that same environment so the bridge keeps the ceiling it started with until that bridge starts again. An out-of-band bridge
without the setting stays at 16777216 bytes. The client reports that mismatch
during discovery only after the hello proves a live authenticated bridge, and
does not send a status request or the command. A stale descriptor is ignored.
+2
View File
@@ -1,5 +1,7 @@
# Changelog
The next release changes analysis-setting output (#9885). `update_analysis_settings`, `inspect_analysis_settings`, and limit errors no longer include `rea_restart_required`, `restart_required`, `restartRequired`, or `effect`. Read `update_mode` (`runtime`, `restart`, or `parameter`) and `pending_restart`. `runtime` means the setter can change the active value. `restart` means this process keeps the value it started with. `parameter` means this call supplied the value, so `update_analysis_settings` cannot change it; artifact reader limits, web-network capture budgets, and an explicit `analysis_timeout_ms`, `max_heap_mb`, or `command_timeout_ms` use it. There is no compatibility alias. Release Please should copy this migration into that release's breaking-changes section.
## [6.8.0](https://github.com/morluto/rea/compare/rea-agents-6.7.0...rea-agents-6.8.0) (2026-10-25)
### Highlights
+5
View File
@@ -363,6 +363,11 @@ export const documentationSections = [
source: "design/limits-and-recovery.md",
route: "/design/limits-and-recovery",
},
{
text: "REA limit inventory",
source: "design/rea-limit-inventory.md",
route: "/design/rea-limit-inventory",
},
{
text: "MCP tool design",
source: "development/tool-design.md",
+1
View File
@@ -112,6 +112,7 @@ product pages. The VitePress documentation source is this `docs/` tree.
## Development and verification
- [Settings and recovery](design/limits-and-recovery.md)
- [REA limit inventory](design/rea-limit-inventory.md)
- [Flutter budget calibration](verification/flutter-budget-calibration.md)
- [Source-map budget calibration](verification/source-map-budget-calibration.md)
- [Source-location delivery qualification](verification/source-location-delivery.md)
+44 -13
View File
@@ -5,7 +5,9 @@ who owns each value, when an update takes effect, how providers enforce it, and
how to extend the system. The public tool contract is in
[MCP contracts](../reference/mcp-contracts.md#editable-analysis-settings); recorded runtime
checks and their limitations are in the
[verification report](../verification/runtime-analysis-settings.md).
[verification report](../verification/runtime-analysis-settings.md). The
[limit inventory](rea-limit-inventory.md) lists every numeric `REA_` limit, which
of them are runtime settings, and which stay restart-only.
## Problem and design boundary
@@ -129,12 +131,14 @@ Windows ChangeTime admission waits out a fresh timestamp before recording a
baseline. `REA_WINDOWS_CHANGE_TIME_DRIFT_MS` (default 2000 ms, floor 1 ms,
maximum 60000 ms), `REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS` (default 25 ms,
floor 1 ms, maximum 1000 ms), and `REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS`
(default 40, floor 1, maximum 1000) are launch settings. Unset keeps those
defaults. An invalid value is a configuration error: it names the setting, the
rejected value, source `setting`, the default, the floor, and the maximum, and
says to set a positive decimal integer in the environment that starts REA and
then restart REA. There is no runtime override. Setup forwards an explicit
selection. When the poll budget runs out, admission keeps the last observation
(default 40, floor 1, maximum 1000) are runtime settings. Unset keeps those
defaults. An invalid launch value is a configuration error: it names the
setting, the rejected value, source `setting`, the default, the floor, the
maximum, and `update_mode: "runtime"`. It omits `pending_restart` because
the rejected value is not an accepted configuration. Change the
active value with `update_analysis_settings`. The launch seed must be a
positive decimal integer in that range. Setup forwards an explicit selection.
When the poll budget runs out, admission keeps the last observation
instead of failing the readable file. Raising a setting only changes how long
that wait can last. It does not change Windows ChangeTime itself.
@@ -255,8 +259,8 @@ are runtime-wide, not scoped to a target or an individual connection.
### Failure and retry
An ASAR admission error supplies `details.limit` with the exact setting,
effective value, units, bounds, scope, application point, restart requirement
and, when known, required bytes and value source. The agent can immediately call:
effective value, units, bounds, scope, application point, `update_mode`,
`pending_restart` and, when known, required bytes and value source. The agent can immediately call:
```json
{
@@ -273,13 +277,34 @@ that argument; raising a runtime default cannot override it.
The resource owner constructs `details.limit` from the registry plus the budget
it actually enforced. Metadata includes `agent_editable: true`,
`scope: "rea-runtime"`, and `rea_restart_required: false`. Optional
`scope: "rea-runtime"`, `update_mode`, and `pending_restart`. `update_mode`
is `runtime` when `update_analysis_settings` can change the active value,
`restart` when this process keeps the value it started with, and `parameter`
when the budget is adjustable through a per-call argument. Its `source` records
whether the reported value came from a request or a default. An explicit `analysis_timeout_ms`,
`max_heap_mb`, or `command_timeout_ms`, plus artifact reader limits and
web-network capture budgets, use `parameter`. `pending_restart` is false when
that reported value is the one this process is using. Optional
`required_value`, `source`, and `parameter` fields are supplied only where known
or applicable. Do not infer a source because a value happens to equal a default.
Error copying, attribution, redaction, and delivery must preserve these facts.
Human-readable remediation explains the same failure; clients should not need
to parse that sentence to identify an editable setting.
This output is breaking. Results and errors no longer carry the previous
restart boolean or a single effect field, and there is no compatibility alias.
Read `update_mode` to see whether the setter can change the active value, and
`pending_restart` to see whether a configured value is waiting. A configuration
error for a Windows ChangeTime setting carries `update_mode` on
`details.settings`. Its rejected value does not establish an accepted pending
configuration, so `pending_restart` is omitted. The removed field names
are listed in the changelog.
An inspected setting whose launch value is invalid and which has no runtime
override likewise omits `pending_restart`: it has no accepted configured value
to classify. Setting a valid runtime override reports `pending_restart: false`;
resetting it restores the invalid launch state and the omission.
`scope` and `applies_at` describe a continuing caller, such as an MCP
connection. A one-shot invocation, such as a CLI command, has released its
provider session and exited its runtime before its caller reads the failure,
@@ -288,8 +313,11 @@ so its adapter projects with an explicit `"invocation"` caller lifetime.
`scope: "rea-invocation"`, `applies_at: "next_invocation"` and the launch
`environment` seed when one exists. `agent_editable: true` still holds, because
the caller chooses that environment value or per-call `parameter` for the new
invocation; `rea_restart_required: false` means no running REA process needs a
restart. A producer keeps guidance that holds only while its caller retains the
invocation; `update_mode: "runtime"` and `pending_restart: false` mean the
reported value is active in this process. `update_mode: "restart"` means
`update_analysis_settings` does not change it. `update_mode: "parameter"` means
the budget is adjustable through a per-call argument; `source` identifies where
the reported value came from. A producer keeps guidance that holds only while its caller retains the
session, such as "the Ghidra session remains usable" or a same-session runtime
update, in `continuingCallerGuidance`; the shared presentation adds it only for
a continuing caller. The producer's own message and remediation must stay true
@@ -499,7 +527,10 @@ own `expanded_bytes` controls its independently enforced bundle expansion.
Failures preserve `details.reader_limit` with the exact parameter, effective
value, units, supported range, required value when known, next-call scope and
`can_increase`. When `can_increase` is true, raise that field and retry the same
`can_increase`. `update_mode` is `parameter`: the caller selects the value on
the next call, and `update_analysis_settings` does not accept it.
`pending_restart` is false. Web-network capture budgets use the same mode.
When `can_increase` is true, raise that field and retry the same
complete artifact. At the supported maximum, or when the required representation
exceeds it, the failure is not retryable by increasing that budget; inspect the
declared dimensions and input validity, or explicitly choose a suitable reader.
+244
View File
@@ -0,0 +1,244 @@
# REA limit inventory
Audit for [#9881](https://github.com/morluto/rea/issues/9881). Counts below are
from this revision. The inventory includes numeric `REA_` launch limits and
the fixed reconnect control generated into the Hopper bridge.
`scripts/verify/rea-numeric-limits.mjs` checks recognized TypeScript source
reads (tests excluded) against the environment seeds of
[`ANALYSIS_SETTINGS`](https://github.com/morluto/rea/blob/main/src/domain/analysisSettingDefinitions.ts) or an
exception in [`numericLimitRegistry.ts`](https://github.com/morluto/rea/blob/main/src/domain/numericLimitRegistry.ts)
with a reason. Static syntax recognition cannot prove arbitrary dynamic reads
or how often a function executes.
The Hopper demo-dialog timeout is read by `scripts/hopper-demo-x11.py`, and
the reconnect control is generated by `src/hopper/BridgeLauncher.ts` for
`bridge/hopper_bridge.py`. Their registry entries identify behavioral witness
tests. Node lint checks those paths exist but reports their behavior as
unverified; it does not execute Python or prove either reader. Run
`npm run test:focused -- tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts tests/boundary/providers/hopper/hopperBridgeReconnect.test.ts`
to exercise the witnesses. The reconnect witness exercises the bridge consumer
with an injected timeout; `npm run test:local -- src/hopper/bridgeBootstrapLimit.test.ts`
verifies the separate launcher output. Neither test is run by Node lint. A
skipped Python test leaves its behavior unverified.
`update_mode` is `runtime` when `update_analysis_settings` can change the active
value, `restart` when this process keeps the value it started with, and
`parameter` when the budget is adjustable through a per-call argument; `source`
separately identifies where the reported value came from.
`pending_restart` is true only when an accepted configured value is not the one
this process is using. A rejected Windows ChangeTime launch value does not
establish an accepted configuration, so that failure omits `pending_restart`.
The other values reported here are in force, so `pending_restart` is false. Prose that only says to
restart REA is marked `prose`. `mcp_server_restart_required` is a
server-identity alignment state in `src/serverIdentity.ts`, not a setting field.
Three numeric limits are absent from this tree and are being moved into the
runtime system on their own PRs. This audit does not change them:
| Name | Tracking |
| -------------------------------------------- | -------- |
| `REA_NTFS_MFT_MAX_FILE_BYTES` | #9868 |
| `REA_REGF_HIVE_MAX_FILE_BYTES` | #9865 |
| `REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES` | #9836 |
When those names land in `src`, the static check requires them to be analysis
settings. They are not on the restart-only list.
## Counts
| Category | Count |
| --------------------------------------------------------------------- | ---------------------------------------------------------------------------: |
| Runtime analysis settings | 28 (25 have an `REA_` seed; 3 are per-call parameters only) |
| Per-call numeric `REA_` limits still read from the launch environment | 0 |
| Restart-only numeric `REA_` limits | 15 |
| Numeric `REA_` candidates recognized in TypeScript source | 38 |
| Numeric `REA_` controls with external or generated-reader provenance | 2 |
| Non-numeric `REA_` settings kept restart-only | 52 caller settings, plus process-protocol names that are not caller settings |
| Hardcoded ceilings left for follow-up | grouped below; none converted in the inventory change |
## Runtime analysis settings
Already adjustable with `update_analysis_settings`. Each call reads the snapshot
accepted for that operation. Launch source is `default` or `setting`; a runtime
override is `source: "runtime"`. The structured field is `update_mode: "runtime"` and `pending_restart: false`.
`applies_at` is the finer point
(`next_operation`, `next_worker`, or `next_startup`).
| Setting | Environment seed | Owner | Default | Floor | Maximum | Read |
| -------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------- | ----------------------------------------- | ---------------: | -------------------------------------: | --------------------- |
| `asar.max_header_bytes` | `REA_ASAR_MAX_HEADER_BYTES` | `src/domain/analysisResourceLimits.ts` | 16777216 | 1 | 268435456 | per operation |
| `asar.max_listing_bytes` | `REA_ASAR_MAX_LISTING_BYTES` | `src/domain/analysisResourceLimits.ts` | 67108864 | 1 | `Number.MAX_SAFE_INTEGER` | per operation |
| `ghidra.value_flow_max_operations` | `REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS` | `src/domain/analysisResourceLimits.ts` | 3000 | 1 | 2147483647 | per operation |
| `ghidra.value_flow_max_inputs_per_operation` | `REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION` | `src/domain/analysisResourceLimits.ts` | 64 | 1 | 2147483647 | per operation |
| `ghidra.value_flow_max_edges` | `REA_GHIDRA_VALUE_FLOW_MAX_EDGES` | `src/domain/analysisResourceLimits.ts` | 12000 | 1 | 2147483647 | per operation |
| `ghidra.decompile_max_payload_mib` | `REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB` | `src/domain/analysisResourceLimits.ts` | unset (`null`, unbounded payload policy) | 1 | 2047 | per operation |
| `ghidra.startup_timeout_ms` | `REA_GHIDRA_STARTUP_TIMEOUT_MS` | `src/domain/analysisResourceLimits.ts` | 330000 | 1 | 2147483647 | next provider startup |
| `hopper.startup_timeout_ms` | `REA_HOPPER_STARTUP_TIMEOUT_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | next provider startup |
| `ghidra.decompile_timeout_seconds` | `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS` | `src/domain/analysisResourceLimits.ts` | unset (`null`, decompile stays unbounded) | 1 | `MAX_GHIDRA_DECOMPILE_TIMEOUT_SECONDS` | per operation |
| `jadx.operation_timeout_ms` | `REA_JADX_OPERATION_TIMEOUT_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | per operation |
| `regex.matching_deadline_ms` | `REA_REGEX_MATCHING_DEADLINE_MS` | `src/domain/analysisResourceLimits.ts` | 5000 | 1 | 2147483647 | per operation |
| `regex.worker_heap_request_mib` | `REA_REGEX_WORKER_HEAP_REQUEST_MIB` | `src/domain/analysisResourceLimits.ts` | 128 | 7 | 16384 | next worker |
| `capture.decoder_old_generation_mib` | `REA_CAPTURE_DECODER_OLD_GENERATION_MIB` | `src/domain/analysisResourceLimits.ts` | 192 | semi-space floor | 16384 | next worker |
| `javascript.analysis_timeout_ms` | none (`analysis_timeout_ms`) | `src/domain/javascript/javascriptAnalysisResourceControls.ts` | owner default | 1 | 2147483647 | per operation |
| `javascript.max_heap_mb` | none (`max_heap_mb`) | `src/domain/javascript/javascriptAnalysisResourceControls.ts` | owner default | owner floor | owner maximum | next worker |
| `wasm.command_timeout_ms` | none (`command_timeout_ms`) | `src/domain/wasm/wasmArtifact.ts` | owner default | 1 | owner maximum | per operation |
| `native.matching_representation_bytes` | `REA_NATIVE_MATCHING_REPRESENTATION_BYTES` | `src/domain/analysisResourceLimits.ts` | 100663296 | 1 | 1073741824 | per operation |
An out-of-range `update_analysis_settings` value is rejected by the setting's
schema. Invalid launch seeds that the global environment parser accepts as
provider-local stay inspectable with `source: "invalid"`.
## Per-call limits in the runtime system
These are checked when the work runs. Each keeps its `REA_` seed, default,
floor, and maximum. The accepted operation snapshot supplies the value.
`update_mode` is `runtime` and `pending_restart` is false for the value in
force. An update outside the range is rejected. A hit names the setting and
reports the current value, source, and range. A rejected Windows ChangeTime launch value omits `pending_restart`.
| Environment | Owner | Default | Floor | Maximum | Applies | Fields |
| ------------------------------------------ | ------------------------------------------- | ----------------------: | ----: | ---------: | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- |
| `REA_BRIDGE_MAX_REQUEST_BYTES` | `src/config/bridgeMaxRequestBytes.ts` | 16777216 | 7464 | 536870887 | each bridge request, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_DEBUGGER_COMMAND_DEADLINE_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | each command, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES` | `src/domain/analysisResourceLimits.ts` | 16777216 | 1 | 89303718 | each command, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_HOPPER_CALLER_SCAN_BUDGET_MS` | `src/domain/analysisResourceLimits.ts` | 2000 | 1 | 2147483647 | each caller scan, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_JEB_MCP_TIMEOUT_MS` | `src/jeb/JebMcpConnection.ts` | 60000 (MCP SDK default) | 1 | 2147483647 | discovery and each tools/call, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_KUNA_PROJECT_TIMEOUT_MS` | `src/kuna/KunaConfiguration.ts` | 1800000 | 1 | 2147483647 | each project export and whole-program scan, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_KUNA_TIMEOUT_MS` | `src/kuna/KunaConfiguration.ts` | 120000 | 1 | 2147483647 | each function decompile, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_PWNTOOLS_MAX_INPUT_BYTES` | `src/native/pwntools/PwntoolsInputLimit.ts` | 33554432 | 1 | 1073741824 | each pwntools decode, unless the call passes `producer_limits.input_bytes`; otherwise the operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_WINDOWS_CHANGE_TIME_DRIFT_MS` | `src/config/windowsChangeTimeSettle.ts` | 2000 | 1 | 60000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch omits `pending_restart` |
| `REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS` | `src/config/windowsChangeTimeSettle.ts` | 25 | 1 | 1000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch omits `pending_restart` |
| `REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS` | `src/config/windowsChangeTimeSettle.ts` | 40 | 1 | 1000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch omits `pending_restart` |
## Restart-only numeric limits
These stay out of `update_analysis_settings`.
| Environment | Owner | Default | Floor | Maximum | Read | Why restart-only |
| ----------------------------------- | -------------------------------------- | ------------------: | -------: | ------------------------: | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `REA_DEBUGGER_MAX_PENDING_COMMANDS` | `src/domain/analysisResourceLimits.ts` | 16 | 1 | 256 | debugger manager construction | Queue capacity of the manager created at startup. `update_mode: "restart"` and `pending_restart: false`. |
| `REA_DEBUGGER_MAX_SESSIONS` | `src/domain/analysisResourceLimits.ts` | 8 | 1 | 64 | debugger manager construction | Session-pool size of the manager created at startup. `update_mode: "restart"` and `pending_restart: false`. |
| `REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS` | `src/domain/hopperStartupFailure.ts` | 10000 | 1 | 2147483647 | Linux private-display adapter start | The helper reads the process environment. Prose restart. |
| `REA_JADX_ACTIVE_PROCESSOR_COUNT` | `src/android/JadxConfiguration.ts` | unset (JVM default) | 1 | 2147483647 | JADX JVM launch | `-XX:ActiveProcessorCount` is fixed for that JVM. Prose on invalid values. |
| `REA_JADX_HEAP_MIB` | `src/android/JadxConfiguration.ts` | unset (JVM default) | 1 | `Number.MAX_SAFE_INTEGER` | JADX JVM launch | `-Xmx` is fixed for that JVM. Prose on invalid values. |
| `REA_KUNA_CACHE_MAX_BYTES` | `src/kuna/KunaConfiguration.ts` | 4294967296 | 1048576 | `Number.MAX_SAFE_INTEGER` | Kuna cache construction | The cache is sized once for the provider. Prose restart. |
| `REA_KUNA_JOBS` | `src/kuna/KunaConfiguration.ts` | 1 | 1 | 32 | Kuna run-slot construction | Worker-pool width for the provider. Prose restart. |
| `REA_MCP_DOCTOR_DEADLINE_MS` | `src/config/mcpStartupBudgets.ts` | 30000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_MCP_FIRST_CATALOG_BUDGET_MS` | `src/config/mcpStartupBudgets.ts` | 20000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_MCP_INITIALIZE_BUDGET_MS` | `src/config/mcpStartupBudgets.ts` | 15000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_MCP_MAX_INPUT_ELEMENTS` | `src/config/mcpInputBudget.ts` | 1024 | 1 | `Number.MAX_SAFE_INTEGER` | MCP server creation | Argument admission for the server process. Prose restart. |
| `REA_MCP_MAX_RESPONSE_BYTES` | `src/config/mcpResponseBudget.ts` | 10485760 when unset | 10485760 | `Number.MAX_SAFE_INTEGER` | MCP server creation | Stdio receive buffer and the paired client buffer. Prose restart. |
| `REA_MCP_TOOLS_LIST_BUDGET_MS` | `src/config/mcpStartupBudgets.ts` | 5000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_RECONNECT_TIMEOUT_SECONDS` | `src/hopper/BridgeLauncher.ts` | 30 | 30 | 30 | written into the Hopper bridge helper | Not a caller setting. It is `HOPPER_SHUTDOWN_TIMEOUT_MS / 1000`. |
| `REA_SWIFTC_COMPILE_TIMEOUT_MS` | `src/config/providerStartupTimeout.ts` | 120000 | 1 | 2147483647 | Darwin ownership-helper reader construction | Captured at process start for that helper compile. Prose restart. |
## Restart-only non-numeric settings
No floor or maximum. They are read from the launch environment at startup.
Recovery text tells the caller to change the launch environment, which takes
effect on the next process. They stay
restart-only because they choose a command, a binary, a filesystem root, the
browser sandbox, secret patterns, the provider, or the shape of the MCP server
or logger.
| Name | Owner | Why |
| ------------------------------------ | -------------------------------------------------- | ------------------------------------------- |
| `REA_ADB_PATH` | `src/adb/AdbConfiguration.ts` | Caller-selected executable path. |
| `REA_AGENT_ENV_COMMAND` | `src/application/DesktopBuildDoctor.ts` | Caller-selected command. |
| `REA_ANALYSIS_PROVIDER` | `src/config/environment.ts` | Provider choice for the process. |
| `REA_APKTOOL_COMMAND` | `src/apktool/ApktoolConfiguration.ts` | Caller-selected command. |
| `REA_BINWALK_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_BROWSER_EXECUTABLE` | `src/application/CapabilityInventory.ts` | Caller-selected browser binary. |
| `REA_BROWSER_NO_SANDBOX` | `src/application/SetupRegistrationEnvironment.ts` | Browser sandbox policy. |
| `REA_CAPINFOS_COMMAND` | `src/pcap/PcapConfiguration.ts` | Caller-selected command. |
| `REA_CUTTER_BRIDGE_DIR` | `src/application/SetupRegistrationEnvironment.ts` | Filesystem root for the Cutter bridge. |
| `REA_DARWIN_MODULE_CACHE_PATH` | `src/application/SetupRegistrationEnvironment.ts` | Filesystem path for the Swift module cache. |
| `REA_DARWIN_SWIFTC_PATH` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected compiler path. |
| `REA_DCONF_COMMAND` | `src/desktop/DconfDatabaseReader.ts` | Caller-selected command. |
| `REA_DOCKER_COMMAND` | `src/application/DesktopBuildDoctor.ts` | Caller-selected command. |
| `REA_EVM_PRLIMIT_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_FIRMWARE_PRLIMIT_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_GDB_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected debugger binary. |
| `REA_GHIDRA_CHECKPOINT_DIR` | `src/config/environment.ts` | Filesystem root. |
| `REA_GHIDRA_COMPILER_SPEC_ID` | `src/config/environment.ts` | Provider analysis choice. |
| `REA_GHIDRA_DISABLED_ANALYZERS` | `src/config/environment.ts` | Provider analysis choice. |
| `REA_GHIDRA_LANGUAGE_ID` | `src/config/environment.ts` | Provider analysis choice. |
| `REA_GHIDRA_NATIVEAOT_JAR` | `src/config/environment.ts` | Caller-selected binary path. |
| `REA_GHIDRA_PROGRAM_NAME` | `src/config/environment.ts` | Provider project selection. |
| `REA_GHIDRA_PROJECT_PATH` | `src/config/environment.ts` | Filesystem root. |
| `REA_GHIDRA_SEED_FILE` | `src/config/environment.ts` | Filesystem path. |
| `REA_IDA_MCP_CONFIG` | `src/config/environment.ts` | Filesystem path of the IDA MCP config. |
| `REA_ILSPY_CMD_PATH` | `src/config/environment.ts` | Caller-selected executable path. |
| `REA_JADX_MCP_JAR` | `src/android/JadxConfiguration.ts` | Caller-selected binary path. |
| `REA_JAVASCRIPT_PRLIMIT_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_JEB_MCP_URL` | `src/config/environment.ts` | Provider endpoint. |
| `REA_KUNA_CACHE_DIR` | `src/kuna/KunaConfiguration.ts` | Filesystem root. |
| `REA_KUNA_COMMAND` | `src/kuna/KunaConfiguration.ts` | Caller-selected executable path. |
| `REA_LOG_LEVEL` | `src/config/environment.ts` | Logger configuration for the process. |
| `REA_MCP_INPUT_SCHEMA_PROFILE` | `src/config/environment.ts` | MCP server schema shape. |
| `REA_MCP_OUTPUT_SCHEMA_PROFILE` | `src/config/environment.ts` | MCP server schema shape. |
| `REA_MCP_TOOL_CATALOG_PROFILE` | `src/config/environment.ts` | MCP server catalog shape. |
| `REA_MCP_TOOL_FAMILIES` | `src/config/environment.ts` | Which tools the MCP server registers. |
| `REA_MITMDUMP_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_OBJDUMP_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_PWNDBG_GDB` | `src/native/pwndbg/PwndbgCoreContext.ts` | Caller-selected debugger binary. |
| `REA_PWNDBG_GDBINIT` | `src/native/pwndbg/PwndbgCoreContext.ts` | Caller-selected file path. |
| `REA_PWNDBG_VENV_PATH` | `src/native/pwndbg/PwndbgCoreContext.ts` | Filesystem root. |
| `REA_PWNTOOLS_PYTHON` | `src/native/pwntools/PwntoolsDecoder.ts` | Caller-selected interpreter path. |
| `REA_REFERENCE_SECRET_PATTERNS_JSON` | `src/config/environment.ts` | Secret-pattern policy. |
| `REA_RIZIN_COMMAND` | `src/rizin/RizinDebugSessionManager.ts` | Caller-selected debugger binary. |
| `REA_SIGROK_COMMAND` | `src/signals/SigrokRecordingProvider.ts` | Caller-selected command. |
| `REA_SIGROK_PRLIMIT_COMMAND` | `src/signals/SigrokRecordingProvider.ts` | Caller-selected command. |
| `REA_TSHARK_COMMAND` | `src/pcap/PcapConfiguration.ts` | Caller-selected command. |
| `REA_TSHARK_PRLIMIT_COMMAND` | `src/pcap/PcapNativeBound.ts` | Caller-selected command. |
| `REA_UNBLOB_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_WABT_BIN_DIRECTORY` | `src/wasm/WabtArtifactProvider.ts` | Filesystem root of the WABT tools. |
| `REA_WAKARU_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_XPROP_COMMAND` | `src/application/desktop/DesktopCaptureFailure.ts` | Caller-selected command. |
`REA_MANAGED_LINUX_HOPPER_LAUNCHER` is a fixed path constant, and
`REA_WORKFLOW_PROVIDER` is a provider identity object. Neither is an
environment setting. Names that look like `REA_` but are process protocol, not
caller settings:
`REA_ELECTRON_CAPTURE_ARGV`, `REA_BUILD_COMMIT`, `REA_BUILD_COMPUTER_REA_ARCHIVE`, `REA_INSTALL_SCRIPT`,
`REA_OWNS_PROCESS_LIFETIME`, `REA_PAYLOAD`, `REA_PROCESS_RUN_ID`, `REA_RUN_ID`,
`REA_SOCKET`, `REA_STATE_FORMAT`, `REA_TARGET_PATH`, `REA_TOKEN`, bridge markers
such as `REA_COMMAND_COMPLETE_`, and the `ERR_REA_WINDOWS_NATIVE` code.
`GHIDRA_INSTALL_DIR`, `JAVA_HOME`, `HOPPER_LAUNCHER_PATH`, `HOPPER_TARGET_PATH`,
`HOPPER_TARGET_KIND`, and `HOPPER_LOADER_ARGS_JSON` are launch configuration
without the `REA_` prefix. Paths and the provider choice among them stay
restart-only for the same reason as the table above.
## Hardcoded ceilings left for follow-up
These are checked per call and tell the caller this release has no runtime
override. They are not `REA_` settings, so the numeric-limit check does not
list them. Each family has its own owner. Turning one into a setting needs that
owner's default, floor, maximum, and an error that carries the setting id,
value, source, and range. Format and protocol ceilings that the
[limits guide](limits-and-recovery.md) already calls fixed stay fixed. In
particular, JSON nesting stops at `MAX_JSON_DEPTH` (1000) and the debugger
control-reply budget stays separate from
`REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES`.
| Family | Examples | Follow-up |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| Native structural input ceilings | PE resources, imports, load config, certificates, exports, initializers, binary hardening, Windows shortcuts | One setting per ceiling, or a shared input budget only where the owners match. |
| Native plist | `MAX_NATIVE_PLIST_BYTES`, decode bytes, nesting depth | Separate from JSON depth. |
| SQLite inspection | concurrent inspections, input bytes, diagnostic bytes | Pool width stays restart-only if it sizes the worker set; byte budgets can become settings. |
| Producer command allowances | fixed command deadline and retained output where the text says there is no runtime override | Distinct from per-call `producer_limits`. |
| Hopper caller-scan retention and page allowances | `HOPPER_CALLER_SCAN_RETENTION_BYTES`, `HOPPER_CALLER_PAGE_ALLOWANCE_BYTES` | Explicitly not raised by the scan time budget. |
| Rizin frame protocol | `MAX_FRAME_BYTES`, queued frames | Protocol bounds, not caller budgets. |
| Debugger detach and control replies | `DETACH_TIMEOUT_MS`, `DEBUGGER_CONTROL_REPLY_BYTES` | Ownership deadlines, not the shared command deadline. |
| Web network capture nesting | `max_nesting_depth` already has a per-call parameter | The fixed depth maximum is the follow-up, not the parameter. |
## Static check
`npm run lint` runs `scripts/verify/rea-numeric-limits.mjs` after
dependencies are installed. `scripts/verify/architecture-guards.mjs` runs the
same check when `typescript6` is already installed; the pre-install CI step
cannot. The check scans `src` excluding tests,
keeps names that are numeric limits, and requires each name to be an
`environment` on an analysis setting or a registry entry with a reason.
The registry accepts only `restart-only`. A limit checked on each call has to
be an analysis setting. The check rejects a `per-call` registry entry.
-44
View File
@@ -1289,50 +1289,6 @@ backing up client settings or starting registration. Its `--agent` and
`--command`/`-c` options accept spaced and equals forms. Values must be nonempty; a following flag does not satisfy a spaced option's
missing value.
Registration command text is a launcher format, not a shell program. ASCII spaces
separate words; single and double quotes delimit adjacent fragments of a word.
Backslashes, tabs, variables and shell punctuation are literal data. Quoted empty
arguments are preserved, including an empty argument after the executable. An
empty executable, unfinished quote or NUL character fails before registration. For example:
```sh
rea mcp add --agent cursor --command '"/path with spaces/server" "" --mcp' --json
```
The registered executable is `/path with spaces/server`, with arguments `""` and
`--mcp`. Windows backslashes use the same literal rules after the invoking shell
has passed the option value. With no `--command`, REA uses its exact current
runtime and entrypoint arguments without converting them to text and parsing them
again. No package acquisition or server execution occurs during registration.
`--no-global` refuses any selection requiring global storage before writing files.
`rea mcp add --schema --json` returns the named input and output JSON Schemas.
Successful and partial registration reports contain the exact `command`, `args`
and a `clients` outcome list. `ok` is true only when every selected client is
configured or unchanged. Failed clients retain actionable errors; cancellation
marks remaining clients `not_started`. A failed client does not erase a successful
sibling's receipt. Existing settings and environment overrides survive refresh;
backups identify the original bytes, and identical repeats create no new backup.
Client IDs are case-insensitive for the helper-compatible catalog; documented
aliases include `droid`/`factory`, `gemini`, `grok`, `kilo`/`kilocode`, `kimi`,
`kiro`, `mastra`, `pi-agent`, `hermes-agent`, `cline-vscode`, and
`codeium`/`cascade`. `github-copilot` selects VS Code; use
`github-copilot-cli` for Copilot CLI. Amp uses `--agent amp`.
Kilo project registration follows its native configuration layers and requires a
verified Git worktree. Selected `KILO_CONFIG`, `KILO_CONFIG_DIR` and
`KILO_CONFIG_CONTENT` overrides remain authoritative. A conflicting higher-priority
source must be updated before registration can succeed; readonly layers are never
copied into the selected file. An unknown project root fails before writing and
does not fall back to global settings.
`rea mcp add --command` accepts stdio launcher text. An HTTP(S) URL in the
executable position is refused before any settings change; configure remote MCP
transport in the client's own settings. Earlier delegated registration could
interpret a URL as remote transport for some clients while storing it as an
executable for Amp. That inconsistent URL interpretation and package acquisition
are removed. Launcher arguments that happen to contain URLs remain arguments.
`rea mcp doctor --json` checks production MCP startup and reports child logs in
`diagnostics`. It drains stderr while retaining at most its first 64 KiB of raw
bytes as text, omitting a final UTF-8 code point if it crosses that byte limit.
@@ -45,11 +45,6 @@ policy can leave ownership established. Setup uses that recorded observation for
its default client selection, then inspects selected configuration again before
planning writes; a missing or unreadable launcher remains unknown.
A retained command does not prove the client selects it. Doctor also checks
that client's native transport selectors; a higher-priority remote selector can
leave the command owned while alignment remains unknown. Metadata that is not a
native connection selector is preserved rather than interpreted as a transport.
`rea mcp doctor --json` tests the production MCP adapter using the runtime
executable and environment of the terminal running doctor. It does not launch
the client's configured command or reproduce the client's environment. If doctor
@@ -749,13 +744,6 @@ and top-level key when it upserts `rea`. WorkBuddy also reads a project-level
user-level file only. Loading this registration and real REA tool calls
through WorkBuddy are verified.
For CodeBuddy and WorkBuddy, REA's write checks cover their documented stdio
entries. If an existing `rea` row contains fields whose native interpretation is
unclassified, setup preserves it and reports unknown write applicability before
changing the file. Doctor retains any established launcher ownership while
reporting alignment as unknown. This does not prevent safe removal of a row
whose REA ownership is established.
## ZCode
For [ZCode](https://zcode.z.ai/en/docs/mcp-services), setup registers `rea` in the nested `mcp.servers` table of the
+11 -29
View File
@@ -1247,35 +1247,17 @@ installation remains unavailable on Windows. Setup never installs Ghidra,
Java, or Python. It preserves valid detected Ghidra/JDK settings in agent
registrations. See [Windows Ghidra P0](../providers/windows-ghidra-p0.md) for provider diagnostics.
`rea mcp add` registers the selected server launcher directly in client settings.
It does not acquire a package or start the server. Omit `--command` to register
the runtime and REA entrypoint currently executing the CLI. Use `--agent` to
select clients; repeat it for a mixed selection. With no selection, REA uses
detected user-level clients and Amp; if none of the other supported clients are
detected, it selects their full registration catalog and Amp.
REA preserves existing environment overrides, client policy, peer servers and
unrelated settings. A changed existing file gets a private backup; an identical
repeat keeps the file and creates no new backup. The result reports exact
`command` and `args`, each selected client's effective scope, and configured,
unchanged, failed or not-started outcomes. A partial failure returns a nonzero
status while retaining successful client outcomes and recovery paths.
`--no-global` writes only project-level settings. A selection containing Amp or
another client without project storage fails before any writes; select those
clients in a separate global registration. Managed `rea setup` remains available
for its supported clients and additional setup tasks. Registration preserves
Windows launcher paths and arguments as data. Windows ACL privacy cannot be
verified through POSIX mode bits; Fx results distinguish requested Windows modes
from verified POSIX modes. Controlled source tests do not establish real Windows
client behavior.
`rea mcp add --command` accepts stdio launcher text. An HTTP(S) URL in the
executable position is refused before any settings change; configure remote MCP
transport in the client's own settings. Earlier delegated registration could
interpret a URL as remote transport for some clients while storing it as an
executable for Amp. That inconsistent URL interpretation and package acquisition
are removed. Launcher arguments that happen to contain URLs remain arguments.
The separate `rea mcp add` builtin delegates most clients to Incur's package-runner
child launcher. Run that acquisition from a trusted directory; prefer managed
`rea setup` when supported. That child launcher can fail on Windows with `MCP_ADD_FAILED: spawn npx ENOENT`
before registering a client. REA's setup path is unaffected. For a client supported
by setup, review `rea setup --client opencode --skill=false --dry-run --json`
(substitute its client ID), then use the same scoped command without `--dry-run`
and approve the displayed plan. Non-interactive application additionally needs
`--yes`. This configures setup's user-level client location; it does not replace
`mcp add --no-global`, arbitrary `--command` registrations, or unsupported clients.
The builtin launcher defect remains pending a dependency fix; a successful setup
run does not verify that `mcp add` works on Windows.
When `GHIDRA_INSTALL_DIR` is unset, doctor and the Ghidra provider also search
`PATH` for `ghidra` or `ghidraRun`, resolve the selected launcher through its
+3 -1
View File
@@ -462,7 +462,9 @@ most 64 procedures and retains at most 4 MiB of encoded scan results. A composed
workflow reuses its own index and clears it when the workflow finishes. The
default cooperative scan budget is
2,000 ms; set `REA_HOPPER_CALLER_SCAN_BUDGET_MS` to a positive integer number of
milliseconds before starting REA to change it. The default limits additional
milliseconds before starting REA to change the launch value, or use
`update_analysis_settings` with `hopper.caller_scan_budget_ms` for newly accepted
operations. The default limits additional
work on the serial Hopper bridge; larger documents may need a higher setting.
Results report the examined procedure count and any budget interruption.
An individual page, native caller list, and target instruction list each have
+7 -5
View File
@@ -59,7 +59,7 @@ Pseudocode read from a parallel project export includes a prototype-context
limitation in its Evidence, including in function dossiers and composed
workflows. Early single-function results have their own source limitation.
A single-function decompile, inventory, string, graph or xref run that exceeds its deadline fails as `provider_timeout`, naming the setting that owns the deadline and that raising it takes a restart. A project export that exceeds `REA_KUNA_PROJECT_TIMEOUT_MS` is an export failure instead: requests that need the export report `capability_unavailable` with the export's own retry guidance.
A single-function decompile, inventory, string, graph or xref run that exceeds its deadline fails as `provider_timeout`, naming the setting that owns the deadline. Change the reported budget for newly accepted operations with `update_analysis_settings` using `kuna.function_timeout_ms` or `kuna.project_timeout_ms`. A project export that exceeds `REA_KUNA_PROJECT_TIMEOUT_MS` is an export failure instead: requests that need the export report `capability_unavailable` with the export's own retry guidance.
Windows is not supported yet. Supported targets are ELF and thin Mach-O executables for x86-64, AArch64 and ARM; PE and universal Mach-O are rejected with a typed reason.
@@ -90,7 +90,7 @@ The first text or assembly request in a session looks for this target's export i
- Meanwhile, pseudocode comes from one `kuna decompile --addr` run. Name and address lookups, and listings, use the inventory the export takes with `kuna functions --json` before it decompiles. The session runs `kuna functions` itself, under `REA_KUNA_TIMEOUT_MS`, only when no export is building (an inventory-only request does not start one) or the export ends without an inventory.
- Requests for the same function share one run; it stops when its last waiting request is cancelled. Each run analyzes the whole image again in its own process, so one REA process runs at most as many at once as the host has cores beyond `REA_KUNA_JOBS` (at least one). Further requests wait in order, and a waiting request is answered from the export if it finishes first. A run whose stop REA cannot prove keeps its slot until `close_binary` proves it stopped.
- The session keeps each completed single-function text, up to 16 MiB in all with the least recently used dropped first, and answers repeats from it until the export can. After a failed export that lasts for the session.
- Assembly and data names wait for the export; progress notifications report the wait every ten seconds. A session does not rebuild a failed export: requests that need it fail with `retryable: false`, the export's failure, its captured Kuna output and what to change before reopening the target. REA does not expose per-function records from a failed project export, even if the retained Kuna diagnostic refers to them. Single-function fallback Evidence reports the failed state and this limitation. If cleanup remains incomplete, its error takes precedence and `close_binary` retries the retained resources. A setting such as `REA_KUNA_PROJECT_TIMEOUT_MS` or `REA_KUNA_JOBS` is read when REA starts, so changing it also needs a restart. Pseudocode keeps using single-function runs.
- Assembly and data names wait for the export; progress notifications report the wait every ten seconds. A session does not rebuild a failed export: requests that need it fail with `retryable: false`, the export's failure, its captured Kuna output and what to change before reopening the target. REA does not expose per-function records from a failed project export, even if the retained Kuna diagnostic refers to them. Single-function fallback Evidence reports the failed state and this limitation. If cleanup remains incomplete, its error takes precedence and `close_binary` retries the retained resources. Change the deadline for a newly accepted project export with `update_analysis_settings` using `kuna.project_timeout_ms`; a failed session does not restart its export, so close and reopen the target to start another. `REA_KUNA_JOBS` is read when REA starts, so changing it requires a restart. Pseudocode keeps using single-function runs.
- A single-function answer says so in its limitations. It lacks the export's whole-program prototypes, so a later request can return different text for the same function.
Pseudocode, assembly, `analyze_function` and data-name answers record where their text came from in `raw_result.source`: `single-function`, or `project-export` with the cache directory. `procedure_pseudo_code` and `procedure_assembly` answers from the export also name the file, byte span and the file's SHA-256. Combined `analyze_function` answers retain both facet records in `raw_result.source.files`, each with its file, digest and byte span. Both texts come from one export; losing that project retries the complete report. Listings and procedure lookups carry no `raw_result`; omitted [Mach-O data entries](#mach-o-data-entries) are reported in their limitations. Agents can open or search the export's `.c` and `.asm` there directly when that is cheaper than calling a tool per function. Text read that way is target-derived, like any decompiler output, but it bypasses the untrusted-content marker that REA's tool results carry.
@@ -99,7 +99,7 @@ The background export belongs to the session. `close_binary`, or the end of a CL
The assembly label index is scanned once per export reader. Cancelling a request stops its wait without interrupting other readers; closing the session aborts and drains the shared scan before releasing its files.
Regex procedure and string search uses ECMAScript Unicode matching in a cancellable worker with a matching deadline and a worker old-generation heap. Literal matching remains the default. Cancellation or a matching deadline leaves the session available. The deadline is `REA_REGEX_MATCHING_DEADLINE_MS`, a positive decimal integer from 1 to 2147483647 ms. Unset uses 5000 ms. A larger value is refused because Node schedules a delay past that signed 32-bit bound as 1 ms. Invalid values are configuration errors and do not fall back to the default. The setting is read from the environment that starts REA, so a change needs a restart. A deadline failure names the effective milliseconds, whether they came from the default or the setting, the default, and the maximum. The worker old-generation heap does not raise it. The worker heap request is `REA_REGEX_WORKER_HEAP_REQUEST_MIB`, a decimal integer from 7 to 16384 MiB. Unset uses 128 MiB. A request below 7 MiB aborts the worker during startup, and a larger value is refused. Invalid values are configuration errors and do not fall back to 128. The setting is read from the environment that starts REA, so a change needs a restart. A heap failure names the effective request, whether it came from the default or the setting, the default, the floor, and the maximum. A positive Node.js `--max-old-space-size` on the process that starts REA, including `NODE_OPTIONS`, overrides that request and sets the effective heap. `--max-old-space-size-percentage` takes precedence over that flag in either order. REA does not turn the percentage into a MiB, so the effective heap stays unestablished instead of the worker heap request or the absolute flag. The reported `NODE_OPTIONS` value is the process initial environment when that block can be read. A later edit to the environment is not the value Node applied, and an unreadable initial value leaves the effective heap unestablished. The launch Node options are the initial command line on Linux when that buffer still has a program entry, not a later edit to `process.execArgv`. A preload can clear that array after V8 has applied the flags. Setting `process.title` or `--title` overwrites the command line, so a buffer without a program entry leaves the effective heap unestablished. `--env-file` or `--env-file-if-exists` can supply `NODE_OPTIONS` when the initial environment has none. That file is not part of the initial environment block, so the effective heap stays unestablished instead of the worker heap request. An initial `NODE_OPTIONS` is still used, because Node does not let the file replace it. A positive `--max-heap-size`, including the underscore form and launch `NODE_OPTIONS` on Node versions that allow it, sets the total heap rather than the old generation and can be lower than the worker heap request. The effective old generation stays unestablished while that value is in effect. A zero or empty value clears it. `--experimental-config-file` or `--experimental-default-config-file` can set `nodeOptions`, including a heap percentage, from a file that is not read back. The effective heap stays unestablished when either flag is present, including when an absolute `--max-old-space-size` is also on the command line or in the recovered `NODE_OPTIONS`. Node accepts the config path as `=` or the next argument.
Regex procedure and string search uses ECMAScript Unicode matching in a cancellable worker with a matching deadline and a worker old-generation heap. Literal matching remains the default. Cancellation or a matching deadline leaves the session available. The deadline is `REA_REGEX_MATCHING_DEADLINE_MS`, a positive decimal integer from 1 to 2147483647 ms. Unset uses 5000 ms. A larger value is refused because Node schedules a delay past that signed 32-bit bound as 1 ms. Invalid values are configuration errors and do not fall back to the default. Change the budget for newly accepted searches using `update_analysis_settings` and `regex.matching_deadline_ms`; changing the launch environment requires a restart. A deadline failure names the effective milliseconds, known source, default, and maximum. The worker old-generation heap does not raise it. The worker heap request is `REA_REGEX_WORKER_HEAP_REQUEST_MIB`, a decimal integer from 7 to 16384 MiB. Unset uses 128 MiB. A request below 7 MiB aborts the worker during startup, and a larger value is refused. Invalid values are configuration errors and do not fall back to 128. Change the request for workers of newly accepted searches using `update_analysis_settings` and `regex.worker_heap_request_mib`; changing the launch environment requires a restart. A heap failure names the effective request, known source, default, floor, and maximum. A positive Node.js `--max-old-space-size` on the process that starts REA, including `NODE_OPTIONS`, overrides that request and sets the effective heap. `--max-old-space-size-percentage` takes precedence over that flag in either order. REA does not turn the percentage into a MiB, so the effective heap stays unestablished instead of the worker heap request or the absolute flag. The reported `NODE_OPTIONS` value is the process initial environment when that block can be read. A later edit to the environment is not the value Node applied, and an unreadable initial value leaves the effective heap unestablished. The launch Node options are the initial command line on Linux when that buffer still has a program entry, not a later edit to `process.execArgv`. A preload can clear that array after V8 has applied the flags. Setting `process.title` or `--title` overwrites the command line, so a buffer without a program entry leaves the effective heap unestablished. `--env-file` or `--env-file-if-exists` can supply `NODE_OPTIONS` when the initial environment has none. That file is not part of the initial environment block, so the effective heap stays unestablished instead of the worker heap request. An initial `NODE_OPTIONS` is still used, because Node does not let the file replace it. A positive `--max-heap-size`, including the underscore form and launch `NODE_OPTIONS` on Node versions that allow it, sets the total heap rather than the old generation and can be lower than the worker heap request. The effective old generation stays unestablished while that value is in effect. A zero or empty value clears it. `--experimental-config-file` or `--experimental-default-config-file` can set `nodeOptions`, including a heap percentage, from a file that is not read back. The effective heap stays unestablished when either flag is present, including when an absolute `--max-old-space-size` is also on the command line or in the recovered `NODE_OPTIONS`. Node accepts the config path as `=` or the next argument.
Single-function subprocess output and each cached source facet share a 16 MiB buffered-text capacity. Cached spans are checked before allocation; larger project-export spans remain in the local export and the error identifies the file and range. A single-function run does not by itself retain a complete project export. `analyze_function` with `detail: "complete"` (the CLI default) returns the complete dossier inline, which can still be large; later retained views do not remove that first transfer. The MCP default retains the dossier and returns its summary, so page the facets you need through `inspect_analysis_view`, or prefer the individual text tools for a focused question.
@@ -196,5 +196,7 @@ The lane compiles a small source-owned fixture with the host C compiler and veri
Failed runs also report process-lineage evidence and exit unsuccessfully. If provider or transport cleanup is unconfirmed, the report says `cleanup: "incomplete"` and records `retained_workspace`; that directory is preserved for investigation and cleanup retries.
Doctor reports an invalid Kuna cache, job count, or timeout setting with that
setting's correction. Correct or unset the named setting and restart REA; these
configuration rejections do not require replacing the selected Kuna executable.
setting's correction. Cache size and job count stay with the process that
started them. The two timeouts can be changed at runtime with
`update_analysis_settings`. These configuration rejections do not require
replacing the selected Kuna executable.
+2 -1
View File
@@ -65,7 +65,8 @@ escaping, before allocating the serialized request or sending it. The framing
newline is excluded. An oversized request returns `resource_constraint` with
the effective setting, its source, and the exact payload byte count.
Set the value in the environment that starts REA, then restart REA to apply it.
`bridge.max_request_bytes` can be changed at runtime with
`update_analysis_settings`. The launch seed is `REA_BRIDGE_MAX_REQUEST_BYTES`.
This setting is enforced by the REA client; the Java bridge has no corresponding
request ceiling.
+4 -2
View File
@@ -35,8 +35,10 @@ Catalog discovery and each `tools/call` share `REA_JEB_MCP_TIMEOUT_MS`. Set it
in REA's launch environment to an integer from 1 to 2147483647 milliseconds,
for example `REA_JEB_MCP_TIMEOUT_MS=300000` for a five-minute budget. Omitted
means the pinned MCP client SDK default of 60000 ms (`@modelcontextprotocol/client`
2.3.1). Restart REA and reconnect to apply a change. Raising the setting raises
both phases. It does not make one end-to-end deadline across a whole CLI or MCP
2.3.1). Change the budget for subsequent operations with
`update_analysis_settings` using `jeb.mcp_timeout_ms`. To change the launch
value, update REA's environment and restart. Raising the setting raises both
phases. It does not make one end-to-end deadline across a whole CLI or MCP
operation: discovery and each `tools/call` each get that budget. A request that
reaches its budget returns a typed timeout naming the phase and the effective
milliseconds, while caller cancellation remains a cancellation. Allow execution,
+7 -4
View File
@@ -107,10 +107,13 @@ GDB and Rizin share these settings in the environment that launches REA:
| `REA_DEBUGGER_MAX_SESSIONS` | 8 | 1 to 64 |
| `REA_DEBUGGER_MAX_PENDING_COMMANDS` | 16 | 1 to 256 |
Unset settings keep the defaults. Invalid selections fail configuration instead
of falling back. Restart REA after changing its launch environment; these are
not `update_analysis_settings` options. For MCP, set the values in the client's
REA registration and restart that server.
Unset settings keep the defaults. Invalid values are refused when the setting
is used instead of falling back. Change the command deadline and output budget
for subsequent operations with `update_analysis_settings` using
`debugger.command_deadline_ms` and `debugger.max_command_output_bytes`. Session
and pending-command capacities require a restart. To change the launch values,
update REA's environment and restart; for MCP, edit the client registration and
restart that server.
These settings also apply to one-shot `rea debug-with-gdb` and
`rea debug-with-rizin` commands. They do not change the fixed initial-prompt
+1 -1
View File
@@ -151,7 +151,7 @@
"build:termux:run": "npm run compile:termux && npm run artifacts:skills",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"lint": "node scripts/ci/static-check-report.mjs lint && npm run verify:module-boundaries",
"lint": "node scripts/ci/static-check-report.mjs lint && npm run verify:module-boundaries && node scripts/verify/rea-numeric-limits.mjs",
"lint:details": "oxlint && npm run verify:module-boundaries",
"lint:fix": "oxlint --fix && npm run verify:module-boundaries",
"knip": "knip --cache",
+55 -2
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -29,8 +30,60 @@ const CENTRALIZED_CONSTRUCTOR_OWNER =
const repositoryPath = (root, path) =>
relative(root, path).replaceAll("\\", "/");
const sourceTarget = (file, specifier) => {
const packageImports = new Map();
/** Read the package map once per repository root. */
const readPackageImports = (root) => {
if (!packageImports.has(root)) {
try {
const manifest = JSON.parse(
readFileSync(resolve(root, "package.json"), "utf8"),
);
packageImports.set(root, manifest.imports ?? {});
} catch (cause) {
throw new Error(
`Cannot read package imports from ${resolve(root, "package.json")}: ${cause.message}`,
{ cause },
);
}
}
return packageImports.get(root);
};
/**
* The own source file a package-map specifier names.
*
* The map points at built output under `dist/`, so the same ownership rule is
* applied to the source file that produces it. Without this the gate silently
* accepts every `#`-prefixed import, which is the spelling the package map exists
* to encourage.
*/
const packageMapTarget = (root, specifier) => {
const target = readPackageImports(root)[specifier];
if (typeof target !== "string")
throw new Error(
`Cannot resolve package import ${JSON.stringify(specifier)}: this boundary check supports exact string mappings only; use a relative source import for conditional, array, pattern, or undeclared mappings.`,
);
const source = target.startsWith("./")
? repositoryPath(
root,
fileURLToPath(
new URL(target, pathToFileURL(resolve(root, "package.json"))),
),
)
.replace(/^dist\//u, "src/")
.replace(/\.(?:js|mjs|cjs)$/u, ".ts")
: undefined;
if (source === undefined || !source.startsWith("src/"))
throw new Error(
`Cannot resolve package import ${JSON.stringify(specifier)} to a source owner from ${JSON.stringify(target)}; use a relative source import for this boundary check.`,
);
return resolve(root, source);
};
const sourceTarget = (root, file, specifier) => {
if (specifier.startsWith("file:")) return fileURLToPath(specifier);
if (specifier.startsWith("#")) return packageMapTarget(root, specifier);
if (isAbsolute(specifier)) return resolve(specifier);
return specifier.startsWith(".")
? fileURLToPath(new URL(specifier, pathToFileURL(file)))
@@ -101,7 +154,7 @@ export const inspectModuleBoundaries = (file, source, root) => {
const importer = repositoryPath(root, absoluteFile);
const violations = [];
for (const { specifier, line, typeOnly } of readImportSpecifiers(source)) {
const resolved = sourceTarget(absoluteFile, specifier);
const resolved = sourceTarget(root, absoluteFile, specifier);
if (resolved === undefined) continue;
const target = repositoryPath(root, resolved);
const boundary = failedBoundary(importer, target, typeOnly);
+176
View File
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { test } from "node:test";
import { inspectModuleBoundaries } from "./module-boundaries.mjs";
const fixture = async () => {
const root = await mkdtemp(join(tmpdir(), "rea-module-boundaries-"));
await mkdir(join(root, "src/domain"), { recursive: true });
await mkdir(join(root, "src/composition"), { recursive: true });
await writeFile(
join(root, "src/composition/worker.ts"),
"export default 1;\n",
);
await writeFile(
join(root, "package.json"),
JSON.stringify({ imports: { "#worker": "./dist/composition/worker.js" } }),
);
return root;
};
await test("reports a pure-layer composition import written relatively", async () => {
const root = await fixture();
try {
const violations = inspectModuleBoundaries(
"src/domain/probe.ts",
'import worker from "../composition/worker.js";\nexport default worker;\n',
root,
);
assert.deepEqual(
violations.map(({ boundary, target }) => ({ boundary, target })),
[{ boundary: "pure-layer", target: "src/composition/worker.js" }],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
await test("reports a pure-layer composition import written through the package map", async () => {
const root = await fixture();
try {
const violations = inspectModuleBoundaries(
"src/domain/probe.ts",
'import worker from "#worker";\nexport default worker;\n',
root,
);
assert.deepEqual(
violations.map(({ boundary, target }) => ({ boundary, target })),
[{ boundary: "pure-layer", target: "src/composition/worker.ts" }],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
for (const [name, specifier, imports] of [
[
"conditional",
"#worker",
{
"#worker": {
import: "./dist/composition/worker.js",
default: "./dist/composition/other.js",
},
},
],
["array", "#worker", { "#worker": ["./dist/composition/worker.js"] }],
["pattern", "#workers/worker", { "#workers/*": "./dist/composition/*.js" }],
]) {
await test(`refuses an unsupported valid ${name} package map rather than skipping its edge`, async () => {
const root = await fixture();
try {
await writeFile(join(root, "package.json"), JSON.stringify({ imports }));
const file = "src/domain/probe.ts";
await writeFile(
join(root, file),
`import ${JSON.stringify(specifier)};\n`,
);
// Resolve only: no worker module or authored target source is executed.
const resolved = spawnSync(
process.execPath,
[
"--experimental-import-meta-resolve",
"--input-type=module",
"-e",
"console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
specifier,
pathToFileURL(join(root, file)).href,
],
{ encoding: "utf8" },
);
if (resolved.error !== undefined) throw resolved.error;
assert.equal(resolved.status, 0, resolved.stderr);
assert.equal(
resolved.stdout.trim(),
pathToFileURL(join(root, "dist/composition/worker.js")).href,
);
const checked = spawnSync(
process.execPath,
[
fileURLToPath(
new URL("../verify-module-boundaries.mjs", import.meta.url),
),
file,
],
{ cwd: root, encoding: "utf8" },
);
if (checked.error !== undefined) throw checked.error;
assert.equal(checked.status, 1, checked.stdout);
assert.match(checked.stderr, /Could not inspect src\/domain\/probe.ts/u);
assert.match(checked.stderr, /Cannot resolve package import/u);
assert.match(checked.stderr, /relative source import/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
for (const target of [
"./dist/composition/worker.js",
"./dist/%63omposition/worker.js",
"./%64ist/composition/worker.js",
]) {
await test(`checks the URL-normalized source owner of ${target}`, async () => {
const root = await fixture();
try {
await writeFile(
join(root, "package.json"),
JSON.stringify({ imports: { "#worker": target } }),
);
await mkdir(join(root, "src/application"), { recursive: true });
const file = "src/application/probe.ts";
await writeFile(join(root, file), 'import "#worker";\n');
const resolved = spawnSync(
process.execPath,
[
"--experimental-import-meta-resolve",
"--input-type=module",
"-e",
"console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
"#worker",
pathToFileURL(join(root, file)).href,
],
{ encoding: "utf8" },
);
if (resolved.error !== undefined) throw resolved.error;
assert.equal(resolved.status, 0, resolved.stderr);
assert.equal(
fileURLToPath(resolved.stdout.trim()),
join(root, "dist/composition/worker.js"),
);
const checked = spawnSync(
process.execPath,
[
fileURLToPath(
new URL("../verify-module-boundaries.mjs", import.meta.url),
),
file,
],
{ cwd: root, encoding: "utf8" },
);
if (checked.error !== undefined) throw checked.error;
assert.equal(checked.status, 1, checked.stdout);
assert.match(
checked.stderr,
/application-composition: #worker resolves to src\/composition\/worker.ts/u,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
+4 -3
View File
@@ -159,14 +159,15 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
!isShellCompletion &&
builtinArguments.isProjectScopedAmpRegistration(cliArgs)
) {
// Amp has global settings only. Share the parsed scope fact with the
// registration workflow and refuse before loading the command runtime.
// Incur's Amp writer ignores --no-global and always writes the global
// settings file, so fail closed before it runs rather than reporting
// a project registration that never happened.
//
// A caller that asked for a structured document gets the refusal in
// that document, through the same pre-execution error renderer the
// output-argument branch uses; anything else keeps the plain message.
const refusalMessage =
"Amp does not support project-scoped registration. Run without --no-global to register Amp globally, or select a client with project settings. No client settings were changed.";
"Amp does not support project-scoped registration: 'rea mcp add --no-global' with Amp would write the global Amp settings file instead of a project file. Run without --no-global to register Amp globally.";
const refusalFormat = outputOptions.format;
if (
refusalFormat === "json" ||
+21
View File
@@ -1,3 +1,5 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
@@ -193,4 +195,23 @@ if (failures.length > 0) {
console.error(`Architecture guards failed:\n\n${failures.join("\n\n")}\n`);
process.exit(1);
}
// CI runs this file before `npm ci`. The numeric-limit parser needs
// typescript6, so that check waits until dependencies exist. `npm run lint`
// runs it after install.
const typescript6 = new URL(
"../../node_modules/typescript6/package.json",
import.meta.url,
);
if (!existsSync(typescript6)) {
console.log(
"Numeric REA_ limit check waits until dependencies are installed.",
);
} else {
const numericLimits = spawnSync(
process.execPath,
[fileURLToPath(new URL("./rea-numeric-limits.mjs", import.meta.url))],
{ stdio: "inherit" },
);
if (numericLimits.status !== 0) process.exit(numericLimits.status ?? 1);
}
console.log(`Architecture guards passed across ${files.length} source files.`);
+66
View File
@@ -0,0 +1,66 @@
/** Relative path of the registry. Its text is a declaration, not a producer. */
export const NUMERIC_LIMIT_DECLARATION: "src/domain/numericLimitRegistry.ts";
/** Limits that must become analysis settings and cannot be registry exceptions. */
export const REQUIRED_RUNTIME_LIMITS: readonly [
"REA_NTFS_MFT_MAX_FILE_BYTES",
"REA_REGF_HIVE_MAX_FILE_BYTES",
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
];
/** True for a numeric `REA_` limit, including the documented endpoint-candidate count. */
export function isNumericLimit(name: string): boolean;
/** Statically declared environment seeds owned by ANALYSIS_SETTINGS. */
export function parseRuntimeSettingEnvironments(source: string): Set<string>;
/** Detect stale or newly introduced temporary per-call exceptions. */
export function perCallLimitFailures(
listed: ReadonlyMap<string, string>,
temporary: ReadonlySet<string>,
): string[];
/** Parse static registry declarations, including external-reader provenance. */
export function parseNumericLimitRegistry(source: string): Array<{
name: string;
update_mode: "restart";
policy: "restart-only" | "per-call";
read: "startup" | "per-call" | "construction";
reason: string;
provenance?: {
kind: "external-reader" | "generated-control";
source: string;
witness: string;
generator?: string;
generatorWitness?: string;
};
}>;
/** Producer paths for numeric limits. Registry text, comments, and allowlists are omitted. */
export function discoverNumericLimitUses(
files: readonly { readonly path: string; readonly source: string }[],
): Map<
string,
{
readonly path: string;
read: "module" | "construction" | "unknown";
readonly sites: Array<{
readonly path: string;
readonly read: "module" | "construction" | "unknown";
readonly offset: number;
readonly evidence: "direct" | "candidate";
}>;
}
> & {
readonly unresolved: readonly {
readonly path: string;
readonly offset: number;
readonly reason: string;
}[];
};
/** Why a tracked limit is not an analysis setting, when it is present. */
export function requiredRuntimeLimitFailure(
name: string,
presence: { readonly inRuntime: boolean; readonly inRegistry: boolean },
): string | undefined;
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -36,7 +36,8 @@ it("names REA_JADX_OPERATION_TIMEOUT_MS when the operation deadline elapses", ()
applies_at: "next_operation",
agent_editable: true,
scope: "rea-runtime",
rea_restart_required: false,
update_mode: "runtime",
pending_restart: false,
},
});
expect(projected).toMatchObject({
+288 -2
View File
@@ -1,8 +1,20 @@
import { expect, it } from "vitest";
import { AnalysisSettings } from "./AnalysisSettings.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
applyWindowsChangeTimeSnapshot,
resolveWindowsChangeTimeSettle,
} from "../config/windowsChangeTimeSettle.js";
import { pwntoolsInputBytes } from "../native/pwntools/PwntoolsInputLimit.js";
import { resolveBridgeMaxRequestBytes } from "../config/bridgeMaxRequestBytes.js";
import { resolveCaptureDecoderHeap } from "../config/captureDecoderHeap.js";
import { resolveRegexMatchingDeadline } from "../config/regexMatchingDeadline.js";
import { resolveRegexWorkerHeapRequest } from "../config/regexWorkerHeapRequest.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
runtimeAnalysisConfig,
runtimeAsarHeaderAdmission,
runtimeCaptureDecoderHeap,
} from "../config/runtimeAnalysisSettings.js";
import { parseConfig } from "../config/parseConfig.js";
@@ -151,6 +163,17 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp
REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB: "64",
};
const store = new AnalysisSettings(environment);
for (const provider of ["ghidra", "hopper"] as const)
expect(
store
.inspect({ provider, operation: "open_binary" })
.settings.map(({ setting }) => setting),
).toEqual(
expect.arrayContaining([
`${provider}.startup_timeout_ms`,
"bridge.max_request_bytes",
]),
);
const parsed = parseConfig(environment);
if (!parsed.ok) throw parsed.error;
const config = runtimeAnalysisConfig(parsed.value, store.read);
@@ -159,7 +182,13 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp
store
.inspect({ provider: "ghidra", operation: "search_strings" })
.settings.map(({ setting }) => setting),
).toEqual(["ghidra.startup_timeout_ms"]);
).toEqual(["ghidra.startup_timeout_ms", "bridge.max_request_bytes"]);
for (const operation of ["list_cutter_sessions", "cutter_command"] as const)
expect(
store
.inspect({ provider: "cutter", operation })
.settings.map(({ setting }) => setting),
).toContain("bridge.max_request_bytes");
expect(
store
.inspect({ provider: "ida", operation: "search_strings" })
@@ -169,7 +198,51 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp
store
.inspect({ operation: "inventory_artifact" })
.settings.map(({ setting }) => setting),
).toEqual(["asar.max_listing_bytes", "asar.max_header_bytes"]);
).toEqual([
"asar.max_listing_bytes",
"asar.max_header_bytes",
"windows.change_time_drift_ms",
"windows.change_time_settle_poll_ms",
"windows.change_time_settle_max_polls",
]);
for (const operation of [
"open_binary",
"export_web_scripts",
"inspect_analysis_view",
"inspect_android_package",
"capture_process_scenario",
]) {
expect(
store.inspect({ operation }).settings.map(({ setting }) => setting),
).toEqual(
expect.arrayContaining([
"windows.change_time_drift_ms",
"windows.change_time_settle_poll_ms",
"windows.change_time_settle_max_polls",
]),
);
}
expect(
store
.inspect({ provider: "jadx", operation: "inspect_android_package" })
.settings.map(({ setting }) => setting),
).toEqual(
expect.arrayContaining([
"windows.change_time_drift_ms",
"windows.change_time_settle_poll_ms",
"windows.change_time_settle_max_polls",
]),
);
for (const operation of [
"procedure_pseudo_code",
"procedure_assembly",
"analyze_function",
] as const)
expect(
store
.inspect({ provider: "kuna", operation })
.settings.map(({ setting }) => setting),
).toContain("kuna.project_timeout_ms");
expect(
store.update({
changes: [
@@ -217,3 +290,216 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp
).toBe(true);
expect(config.ghidraDecompileTimeoutSeconds).toBe(7);
});
it("rejects an out-of-range per-call limit and reads an accepted value from the operation snapshot", () => {
const store = new AnalysisSettings({});
const rejected = store.update({
changes: [
{ setting: "windows.change_time_drift_ms", action: "set", value: 0 },
],
});
expect(rejected.ok).toBe(false);
if (!rejected.ok)
expect(projectAnalysisError(rejected.error)).toMatchObject({
code: "invalid_request",
});
expect(
store.update({
changes: [
{ setting: "bridge.max_request_bytes", action: "set", value: 1 },
],
}).ok,
).toBe(false);
expect(
store.update({
changes: [
{
setting: "windows.change_time_settle_max_polls",
action: "set",
value: 1001,
},
],
}).ok,
).toBe(false);
const accepted = store.update({
changes: [
{ setting: "windows.change_time_drift_ms", action: "set", value: 5000 },
{ setting: "pwntools.max_input_bytes", action: "set", value: 65536 },
{ setting: "bridge.max_request_bytes", action: "set", value: 33554432 },
],
});
expect(accepted.ok).toBe(true);
if (!accepted.ok) return;
expect(accepted.value).toMatchObject({
pending_restart: false,
});
const parsed = parseConfig({});
if (!parsed.ok) throw parsed.error;
const live = runtimeAnalysisConfig(parsed.value, store.read);
store.run(() => {
const windows = resolveWindowsChangeTimeSettle({});
if (!windows.ok) throw windows.error;
expect(applyWindowsChangeTimeSnapshot(windows.value)).toMatchObject({
driftMs: 5000,
driftSource: "runtime",
pollSource: "default",
});
expect(pwntoolsInputBytes({})).toMatchObject({
configured: true,
bytes: 65536,
source: "runtime",
});
expect(live.bridgeMaxRequestBytes).toMatchObject({
maximumBytes: 33554432,
source: "runtime",
});
expect(live.hopperStartupTimeoutSource).toBe("default");
});
expect(pwntoolsInputBytes({})).toMatchObject({
bytes: 32 * 1024 * 1024,
source: "default",
});
});
it("reads an accepted override before an invalid launch seed", () => {
const resolved = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: {
"bridge.max_request_bytes": 8192,
"capture.decoder_old_generation_mib": 64,
"regex.matching_deadline_ms": 250,
"regex.worker_heap_request_mib": 128,
"asar.max_header_bytes": 8192,
"asar.max_listing_bytes": 4096,
},
},
() => ({
bridge: resolveBridgeMaxRequestBytes({
REA_BRIDGE_MAX_REQUEST_BYTES: "nope",
}),
capture: resolveCaptureDecoderHeap({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "0",
}),
deadline: resolveRegexMatchingDeadline({
REA_REGEX_MATCHING_DEADLINE_MS: "nope",
}),
heap: resolveRegexWorkerHeapRequest({
REA_REGEX_WORKER_HEAP_REQUEST_MIB: "1",
}),
asar: runtimeAsarHeaderAdmission({
REA_ASAR_MAX_HEADER_BYTES: "nope",
REA_ASAR_MAX_LISTING_BYTES: "0",
}),
}),
);
expect(resolved.bridge).toMatchObject({
maximumBytes: 8192,
source: "runtime",
});
expect(resolved.capture).toMatchObject({
oldGenerationMiB: 64,
source: "runtime",
});
expect(resolved.deadline).toMatchObject({
deadlineMs: 250,
source: "runtime",
});
expect(resolved.heap).toMatchObject({ heapMiB: 128, source: "runtime" });
expect(resolved.asar).toMatchObject({
maximumBytes: 8192,
source: "runtime",
listing: { maximumBytes: 4096, source: "runtime" },
});
});
it("keeps the Hopper startup deadline source through a runtime override", () => {
const parsed = parseConfig({ REA_HOPPER_STARTUP_TIMEOUT_MS: "45000" });
if (!parsed.ok) throw parsed.error;
const store = new AnalysisSettings({});
const live = runtimeAnalysisConfig(parsed.value, store.read);
expect(live.hopperStartupTimeoutSource).toBe("setting");
expect(
store.update({
changes: [
{ setting: "hopper.startup_timeout_ms", action: "set", value: 8000 },
],
}).ok,
).toBe(true);
store.run(() => {
expect(live.hopperStartupTimeoutMs).toBe(8000);
expect(live.hopperStartupTimeoutSource).toBe("runtime");
});
});
it("retains invalid ASAR seeds across long-lived views and override resets", async () => {
const environment = {
REA_ASAR_MAX_HEADER_BYTES: "invalid",
REA_ASAR_MAX_LISTING_BYTES: "invalid",
};
const store = new AnalysisSettings(environment);
const admission = runtimeAsarHeaderAdmission(environment, store.read);
expect(() => admission.maximumBytes).toThrow("REA_ASAR_MAX_HEADER_BYTES");
expect(() => admission.listing?.source).toThrow("REA_ASAR_MAX_LISTING_BYTES");
store.update({
changes: [
{ setting: "asar.max_header_bytes", action: "set", value: 8192 },
{ setting: "asar.max_listing_bytes", action: "set", value: 4096 },
],
});
let release!: () => void;
const barrier = new Promise<void>((resolve) => {
release = resolve;
});
const accepted = store.run(async () => {
await barrier;
expect(admission).toMatchObject({
maximumBytes: 8192,
source: "runtime",
listing: { maximumBytes: 4096, source: "runtime" },
});
});
store.update({
changes: [
{ setting: "asar.max_header_bytes", action: "reset" },
{ setting: "asar.max_listing_bytes", action: "reset" },
],
});
release();
await accepted;
for (const read of [() => admission.maximumBytes, () => admission.source])
expect(read).toThrow("REA_ASAR_MAX_HEADER_BYTES");
for (const read of [
() => admission.listing?.maximumBytes,
() => admission.listing?.source,
])
expect(read).toThrow("REA_ASAR_MAX_LISTING_BYTES");
});
it("retains an invalid capture heap seed through override and reset", () => {
const environment = { REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "invalid" };
const store = new AnalysisSettings(environment);
const heap = runtimeCaptureDecoderHeap(environment, store.read);
expect(() => heap.oldGenerationMiB).toThrow(
"REA_CAPTURE_DECODER_OLD_GENERATION_MIB",
);
store.update({
changes: [
{
setting: "capture.decoder_old_generation_mib",
action: "set",
value: 64,
},
],
});
expect(heap).toMatchObject({ oldGenerationMiB: 64, source: "runtime" });
store.update({
changes: [
{ setting: "capture.decoder_old_generation_mib", action: "reset" },
],
});
expect(() => heap.oldGenerationMiB).toThrow(
"REA_CAPTURE_DECODER_OLD_GENERATION_MIB",
);
expect(() => heap.source).toThrow("REA_CAPTURE_DECODER_OLD_GENERATION_MIB");
});
+9 -2
View File
@@ -1,3 +1,4 @@
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { launchAnalysisSetting } from "../config/analysisSettings.js";
import { AsyncLocalStorage } from "node:async_hooks";
@@ -42,7 +43,9 @@ export class AnalysisSettings {
readonly read = (): AnalysisSettingsSnapshot =>
this.#accepted.getStore() ?? this.#current;
run<Value>(operation: () => Value): Value {
return this.#accepted.run(this.#current, operation);
return this.#accepted.run(this.#current, () =>
runWithAnalysisOperationSnapshot(this.#current, operation),
);
}
inspect(input: z.infer<typeof inspectAnalysisSettingsInputSchema> = {}) {
return {
@@ -134,7 +137,7 @@ export class AnalysisSettings {
};
}),
active_work_changed: false as const,
rea_restart_required: false as const,
pending_restart: false as const,
});
}
#describe(setting: AnalysisSettingId) {
@@ -156,6 +159,10 @@ export class AnalysisSettings {
minimum: definition.minimum,
maximum: definition.maximum,
applies_at: definition.appliesAt,
update_mode: definition.updateMode,
...(override === undefined && !launch.ok
? {}
: { pending_restart: false as const }),
provider: definition.provider,
operations: [...definition.operations],
...(definition.environment === undefined
@@ -1,142 +0,0 @@
import { expect, it } from "vitest";
import {
clientServerPath,
parseClientConfiguration,
serializeClientConfiguration,
withClientServers,
type ClientConfigurationFormat,
} from "./ClientConfigurationDocument.js";
const replaceLauncher = (source: string, format: ClientConfigurationFormat) => {
const parsed = parseClientConfiguration(source, format);
const existing = parsed.servers.rea;
if (typeof existing !== "object" || existing === null)
throw new Error("fixture registration missing");
const document = withClientServers(parsed, {
...parsed.servers,
rea: { ...existing, command: "new-rea" },
});
const result = serializeClientConfiguration(document, format, source, [
clientServerPath(parsed, "rea"),
]);
if (!result.ok) throw new Error("launcher replacement refused");
return result.value;
};
it.each(["json", "fx", "copilot_cli", "copilot_cli_project"] as const)(
"%s retains opaque number and string tokens inside and outside the edited row",
(format) => {
const source = `{"metadata":{"opaque":9007199254740993},"mcpServers":{"peer":{"opaque":9007199254740993},"rea":{"command":"old-rea","metadata":{"opaque":9007199254740993,"nested":[{"number":1e+03,"negative":-0,"text":"\\u0061"}]}}}}`;
const output = replaceLauncher(source, format);
expect(output.match(/9007199254740993/gu)).toHaveLength(3);
expect(output).not.toContain("9007199254740992");
expect(output).toContain("1e+03");
expect(output).toContain('"negative":-0');
expect(output).toContain('"\\u0061"');
expect(parseClientConfiguration(output, format).servers.rea).toMatchObject({
command: "new-rea",
});
},
);
it.each(["fx", "copilot_cli", "copilot_cli_project"] as const)(
"%s strips only non-native JSON syntax while retaining opaque source tokens",
(format) => {
const source = `\uFEFF{/*root*/"metadata":{"opaque":9007199254740993,},"mcpServers":{"rea":{"command":"old-rea",// row\n"opaque":9007199254740993,"text":"/*literal*/",},},}`;
const output = replaceLauncher(source, format);
expect(() => JSON.parse(output)).not.toThrow();
expect(output.match(/9007199254740993/gu)).toHaveLength(2);
expect(output).toContain('"/*literal*/"');
expect(output).not.toContain("/*root*/");
expect(output).not.toContain("// row");
},
);
it("retains JSONC comments, trailing commas, BOM and untouched token spellings", () => {
const source = `\uFEFF{/*root*/"mcpServers":{"rea":{"command":"old-rea",/*owned metadata*/"opaque":9007199254740993,},},}`;
const output = replaceLauncher(source, "json");
expect(output.startsWith("\uFEFF")).toBe(true);
expect(output).toContain("/*root*/");
expect(output).toContain("/*owned metadata*/");
expect(output).toContain("9007199254740993");
expect(output).toMatch(/,\s*\}/u);
});
it("retains nested array tokens while changing siblings, shrinking and extending arrays", () => {
const source = `{"mcpServers":{"rea":{"command":"rea","items":[{"opaque":9007199254740993,"label":"old"},2,3],"grow":[9007199254740993]}}}`;
const parsed = parseClientConfiguration(source, "json");
const result = serializeClientConfiguration(
withClientServers(parsed, {
rea: {
command: "rea",
items: [{ opaque: 9007199254740992, label: "new" }],
grow: [9007199254740992, 4],
},
}),
"json",
source,
[clientServerPath(parsed, "rea")],
);
if (!result.ok) throw new Error("nested edit refused");
expect(result.value.match(/9007199254740993/gu)).toHaveLength(2);
expect(JSON.parse(result.value).mcpServers.rea).toMatchObject({
items: [{ label: "new" }],
grow: [9007199254740992, 4],
});
});
it("retains source tokens when the caller supplies a whole edited document", () => {
const source = `{"opaque":9007199254740993,"mcpServers":{"rea":{"command":"old"}}}`;
const parsed = parseClientConfiguration(source, "fx");
const result = serializeClientConfiguration(
withClientServers(parsed, { rea: { command: "new" } }),
"fx",
source,
);
if (!result.ok) throw new Error("whole document edit refused");
expect(result.value).toContain("9007199254740993");
expect(JSON.parse(result.value).mcpServers.rea.command).toBe("new");
});
it("removes all repeated nested fields without activating an earlier value", () => {
const source = `{"mcpServers":{"rea":{"remove":1,"remove":2,"retain":9007199254740993,"command":"old"}}}`;
const parsed = parseClientConfiguration(source, "json");
const result = serializeClientConfiguration(
withClientServers(parsed, {
rea: {
retain: 9007199254740992,
command: "new",
added: { enabled: true },
},
}),
"json",
source,
[clientServerPath(parsed, "rea")],
);
if (!result.ok) throw new Error("owned row edit refused");
expect(result.value).not.toContain('"remove"');
expect(result.value).toContain("9007199254740993");
expect(JSON.parse(result.value).mcpServers.rea).toMatchObject({
command: "new",
added: { enabled: true },
});
});
it.each([
["replace all members", { replacement: true }],
["remove all members", {}],
["replace array contents", { items: [5] }],
["empty arrays", { items: [] }],
])("keeps collection separators valid when editing %s", (_name, rea) => {
const source = `{"mcpServers":{"rea":{"items":[1,2,],"old":3,}}}`;
const parsed = parseClientConfiguration(source, "fx");
const result = serializeClientConfiguration(
withClientServers(parsed, { rea }),
"fx",
source,
[clientServerPath(parsed, "rea")],
);
if (!result.ok) throw new Error("collection edit refused");
expect(JSON.parse(result.value).mcpServers.rea).toEqual(rea);
});
+90 -314
View File
@@ -1,9 +1,4 @@
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import {
parseGooseConfigurationYaml,
stringifyGooseConfigurationYaml,
editGooseConfigurationYaml,
} from "./GooseConfigurationYaml.js";
import { isDeepStrictEqual } from "node:util";
import {
@@ -36,22 +31,8 @@ import { PRODUCT_IDENTITY } from "../identity.js";
import type { SetupClient } from "./SupportedClients.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
export type ClientConfigurationFormat =
| NonNullable<SetupClient["format"]>
| "amp"
| "fx"
| "zed"
| "kilo"
| "goose"
| "copilot_cli_project";
type ClientServersKey =
| "mcp_servers"
| "mcpServers"
| "mcp"
| "servers"
| "amp.mcpServers"
| "context_servers"
| "extensions";
export type ClientConfigurationFormat = NonNullable<SetupClient["format"]>;
type ClientServersKey = "mcp_servers" | "mcpServers" | "mcp" | "servers";
/**
* Registration entry dialect. OpenCode V2 reads V1 `mcp.<name>` entries, but a
@@ -64,16 +45,13 @@ export type ClientRegistrationDialect =
/** Validated client document and its server table, preserving unrelated settings. */
export interface ClientConfigurationDocument {
readonly document: Record<string, unknown>;
/** A repairable source syntax is admitted for preservation but must be rewritten
* before this client's strict reader can load the published registration. */
readonly requiresRewrite?: true;
/** Server table that registrations are written to. */
readonly servers: Record<string, unknown>;
/** Key path of `servers` in `document`. */
readonly serversPath: readonly string[];
readonly dialect: ClientRegistrationDialect;
/** Member holding explicit environment overrides in a server registration. */
readonly environmentKey: "env" | "environment" | "envs";
readonly environmentKey: "env" | "environment";
/**
* OpenCode V1 server members beside a native V2 `mcp.servers` table. OpenCode
* still loads them, and a native entry with the same name takes precedence.
@@ -122,14 +100,7 @@ export const clientConfigurationServersKey = (
return "mcp_servers";
case "opencode":
case "zcode":
case "kilo":
return "mcp";
case "amp":
return "amp.mcpServers";
case "zed":
return "context_servers";
case "goose":
return "extensions";
case "vscode":
return "servers";
default:
@@ -191,11 +162,6 @@ const parseDocument = (
// holds no settings to preserve.
if (isEmptyClientConfigurationText(text)) return {};
// Hermes stores MCP servers in `mcp_servers` inside a YAML document.
if (format === "goose")
return requireObject(
parseGooseConfigurationYaml(text),
"The configuration root",
);
if (format === "hermes")
return requireObject(
parseHermesConfigurationYaml(text),
@@ -263,27 +229,6 @@ export const parseZcodeFallbackConfiguration = (
const OPENCODE_V2_MCP_SETTINGS = new Set(["servers", "timeout"]);
/** Parse a client document and reject malformed roots or MCP server tables. */
const strictJsonNeedsRewrite = (text: string): boolean => {
try {
JSON.parse(text);
return false;
} catch {
return true;
}
};
/** Fx's native writer preserves a sole profile table and rejects hidden peers. */
const fxConfigurationServersKey = (
document: Record<string, unknown>,
): "mcp" | "mcpServers" => {
const legacy = Object.hasOwn(document, "mcp");
if (legacy && Object.hasOwn(document, "mcpServers"))
throw new ClientConfigurationShapeError(
'Fx profile contains both "mcp" and "mcpServers". Consolidate these into one server table before registering so existing servers remain visible.',
);
return legacy ? "mcp" : "mcpServers";
};
export const parseClientConfiguration = (
text: string,
format: ClientConfigurationFormat | undefined,
@@ -294,57 +239,8 @@ export const parseClientConfiguration = (
if (format === "zcode_fallback") return parseZcodeFallbackConfiguration(text);
if (format === "dsh") return parseDshCordisDocument(text);
const document = parseDocument(text, format);
const requiresRewrite: true | undefined =
(format === "copilot_cli_project" ||
format === "copilot_cli" ||
format === "fx") &&
strictJsonNeedsRewrite(text)
? true
: undefined;
if (
format === "copilot_cli_project" &&
!Object.hasOwn(document, "mcpServers")
) {
const serverLike = (value: unknown): boolean => {
const entry = clientConfigurationRecordSchema.safeParse(value);
return (
entry.success &&
["command", "url", "type"].some(
(key) => typeof entry.data[key] === "string",
)
);
};
const wrapper = clientConfigurationRecordSchema.safeParse(document.servers);
if (
wrapper.success &&
!serverLike(wrapper.data) &&
Object.values(wrapper.data).some(serverLike)
)
throw new ClientConfigurationShapeError(
'Copilot CLI reads mcpServers or a bare server map, not the VS Code "servers" wrapper. Select --agent vscode for that document.',
);
const entries = Object.values(document);
if (entries.length > 0 && entries.every(serverLike))
return {
document,
...(requiresRewrite === undefined ? {} : { requiresRewrite }),
servers: document,
serversPath: [],
dialect: format,
environmentKey: "env",
legacyServers: {},
};
}
const environmentKey =
format === "opencode" || format === "fx" || format === "kilo"
? "environment"
: format === "goose"
? "envs"
: "env";
const serversKey =
format === "fx"
? fxConfigurationServersKey(document)
: clientConfigurationServersKey(format);
const environmentKey = format === "opencode" ? "environment" : "env";
const serversKey = clientConfigurationServersKey(format);
const value = document[serversKey];
const servers =
value === undefined ? {} : requireObject(value, `"${serversKey}"`);
@@ -377,7 +273,6 @@ export const parseClientConfiguration = (
)
return {
document,
...(requiresRewrite === undefined ? {} : { requiresRewrite }),
servers,
serversPath: [serversKey],
dialect: format,
@@ -425,18 +320,6 @@ export const effectiveClientServer = (
: registration;
};
/** Preserve source policy when editing its native table. A legacy OpenCode
* entry moving to the V2 table first uses that owner's activation migration. */
export const clientServerForEdit = (
parsed: ClientConfigurationDocument,
name: string,
): unknown =>
Object.hasOwn(parsed.servers, name)
? parsed.servers[name]
: parsed.dialect === "opencode_v2"
? effectiveClientServer(parsed, name)
: undefined;
/**
* Return `parsed.document` with `servers` at its server table. A legacy OpenCode
* V1 member named in `removeLegacy` is removed beside a native V2 table.
@@ -456,7 +339,6 @@ export const withClientServers = (
),
)
: editDshDocument(parsed);
if (parsed.serversPath.length === 0) return { ...servers };
const [key = "", nested] = parsed.serversPath;
if (nested === undefined) return { ...parsed.document, [key]: servers };
// A fresh document may not hold the outer table yet (for example a new
@@ -1654,11 +1536,11 @@ const applyFormattedJsonEdit = (text: string, edit: Edit): string => {
};
/** Remove selected member spans and only the separators needed by kept members. */
const jsonChildRemovalEdits = (
const deleteJsonMembers = (
text: string,
object: Node,
properties: readonly Node[],
): Edit[] => {
): string => {
const siblings = object.children ?? [];
const removed = new Set(properties);
const anyKept = siblings.some((member) => !removed.has(member));
@@ -1679,113 +1561,6 @@ const jsonChildRemovalEdits = (
if (separator.kind === JSON_COMMA && !keepSeparator)
edits.push({ offset: separator.offset, length: 1, content: "" });
}
return edits;
};
const deleteJsonMembers = (
text: string,
object: Node,
properties: readonly Node[],
): string => applyEdits(text, jsonChildRemovalEdits(text, object, properties));
/**
* Edit values through their source nodes. Parsed numbers are useful for deciding
* whether a field changed, but serializing unchanged values would discard their
* original precision and spelling. Only effective object members are updated;
* removing a nested field removes every repeat, as replacing its owning object
* did, so an earlier field cannot become active.
*/
const jsonValueEdits = (text: string, node: Node, value: unknown): Edit[] => {
if (node.type === "object") {
const record = clientConfigurationRecordSchema.safeParse(value);
if (record.success) {
const members = node.children ?? [];
const removed = members.filter((member) => {
const key: unknown = member.children?.[0]?.value;
return (
typeof key === "string" &&
(!Object.hasOwn(record.data, key) || record.data[key] === undefined)
);
});
const edits = jsonChildRemovalEdits(text, node, removed);
const added: string[] = [];
for (const [key, next] of Object.entries(record.data)) {
if (next === undefined) continue;
const child = effectiveJsonProperty(node, key)?.children?.[1];
if (child === undefined)
added.push(`${JSON.stringify(key)}:${JSON.stringify(next)}`);
else edits.push(...jsonValueEdits(text, child, next));
}
if (added.length > 0)
edits.push({
offset: node.offset + 1,
length: 0,
content:
added.join(",") +
(members.some((member) => !removed.includes(member)) ? "," : ""),
});
return edits;
}
}
if (node.type === "array" && Array.isArray(value)) {
const children = node.children ?? [];
const edits = jsonChildRemovalEdits(
text,
node,
children.slice(value.length),
);
for (const [index, child] of children.slice(0, value.length).entries())
edits.push(...jsonValueEdits(text, child, value[index] ?? null));
if (value.length > children.length) {
const previous = children.at(-1);
edits.push({
offset:
previous === undefined
? node.offset + 1
: previous.offset + previous.length,
length: 0,
content:
(previous === undefined ? "" : ",") +
value
.slice(children.length)
.map((entry) => JSON.stringify(entry ?? null))
.join(","),
});
}
return edits;
}
return Object.is(node.value, value)
? []
: [
{
offset: node.offset,
length: node.length,
content: JSON.stringify(value),
},
];
};
// Token codes are checked against jsonc-parser's ambient enum.
const JSON_CLOSE_BRACE = 2 satisfies SyntaxKind;
const JSON_CLOSE_BRACKET = 4 satisfies SyntaxKind;
const JSON_LINE_COMMENT = 12 satisfies SyntaxKind;
const JSON_BLOCK_COMMENT = 13 satisfies SyntaxKind;
/** Remove JSONC syntax without decoding and re-encoding source value tokens. */
const strictJsonSyntax = (source: string): string => {
const text = source.startsWith("\uFEFF") ? source.slice(1) : source;
const scanner = createScanner(text, false);
const edits: Edit[] = [];
for (let token = scanner.scan(); token !== JSON_EOF; token = scanner.scan()) {
const offset = scanner.getTokenOffset();
if (token === JSON_LINE_COMMENT || token === JSON_BLOCK_COMMENT)
edits.push({ offset, length: scanner.getTokenLength(), content: " " });
else if (token === JSON_COMMA) {
const next = nextJsoncToken(text, offset + 1);
if (next.kind === JSON_CLOSE_BRACE || next.kind === JSON_CLOSE_BRACKET)
edits.push({ offset, length: 1, content: "" });
}
}
return applyEdits(text, edits);
};
@@ -1840,7 +1615,13 @@ const editJsonMember = (
continue;
}
if (value !== undefined)
return ok(applyEdits(text, jsonValueEdits(text, member, value)));
return ok(
applyFormattedJsonEdit(text, {
offset: member.offset,
length: member.length,
content: JSON.stringify(value),
}),
);
if (
object.children?.some(
(sibling) => sibling !== property && isJsonMember(sibling, key),
@@ -1891,29 +1672,17 @@ export const serializeClientConfiguration = (
return ok(serializeGrokConfiguration(document, originalText));
if (format === "hermes")
return ok(serializeHermesConfiguration(document, originalText));
if (format === "goose") {
if (originalText === undefined || editedPaths === undefined)
return ok(stringifyGooseConfigurationYaml(document));
let text = originalText;
for (const path of editedPaths)
text = editGooseConfigurationYaml(text, path, valueAt(document, path));
return ok(text);
}
if (format === "dsh") return ok(serializeDshDocument(document));
// An empty original holds no comments or settings to preserve. Writing a
// fresh document also keeps a lone BOM from shifting past the closing brace.
if (
originalText === undefined ||
isEmptyClientConfigurationText(originalText)
isEmptyClientConfigurationText(originalText) ||
editedPaths === undefined
)
return ok(`${JSON.stringify(document, null, 2)}\n`);
let text = originalText;
if (editedPaths === undefined) {
const root = parseTree(text, [], { allowTrailingComma: true });
if (root === undefined) throw new TypeError("JSON document is missing");
text = applyEdits(text, jsonValueEdits(text, root, document));
}
for (const path of editedPaths ?? []) {
for (const path of editedPaths) {
const edited = editJsonMember(
text,
path,
@@ -1927,13 +1696,7 @@ export const serializeClientConfiguration = (
if (!edited.ok) return edited;
text = edited.value;
}
return ok(
format === "copilot_cli_project" ||
format === "copilot_cli" ||
format === "fx"
? strictJsonSyntax(text)
: text,
);
return ok(text);
};
/** Key path of a named registration in the server table. */
@@ -1980,70 +1743,83 @@ const serializeHermesConfiguration = (
};
/** Build the stdio entry shape expected by one client's configuration dialect. */
/** Native launcher fields shared by setup and explicit MCP registration. */
export const clientRegistrationLauncherEntry = (
format: ClientRegistrationDialect,
command: readonly string[],
): Record<string, unknown> => {
const [executable = "rea", ...args] = command;
switch (format) {
case "opencode":
case "opencode_v2":
case "fx":
case "kilo":
return { type: "local", command: [...command] };
case "goose":
return { type: "stdio", cmd: executable, args };
case "vscode":
case "omp":
case "pi":
case "copilot_cli":
case "copilot_cli_project":
return { type: "stdio", command: executable, args };
case "commandcode":
return { transport: "stdio", command: executable, args };
default:
return { command: executable, args };
}
};
/** Setup defaults and explicit environment extend the same native launcher facts. */
export const clientRegistrationEntry = (
format: ClientRegistrationDialect,
command: readonly string[],
environment: Readonly<Record<string, string>>,
): Record<string, unknown> => {
if (format === "dsh") return dshClientRegistrationEntry(command, environment);
const defaults =
format === "opencode_v2"
? { disabled: false }
: format === "copilot_cli"
? { tools: ["*"] }
: format === "goose"
? {
name: PRODUCT_IDENTITY.mcpServerKey,
description: "",
envs: {},
enabled: true,
timeout: 300,
}
: ["opencode", "commandcode", "hermes", "fx", "kilo"].includes(format)
? { enabled: true }
: {};
const environmentKey = ["opencode", "opencode_v2", "fx", "kilo"].includes(
format,
)
? "environment"
: format === "goose"
? "envs"
: "env";
return {
...clientRegistrationLauncherEntry(format, command),
...defaults,
...(Object.keys(environment).length === 0
? {}
: { [environmentKey]: { ...environment } }),
};
const [executable = "rea", ...args] = command;
switch (format) {
case "opencode":
return {
type: "local",
command: [...command],
enabled: true,
...(Object.keys(environment).length === 0
? {}
: { environment: { ...environment } }),
};
case "kimi":
case "lm_studio":
// Both infer stdio from command; Kimi uses `transport`, not `type`.
return {
command: executable,
args,
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
};
case "opencode_v2":
// Override lower-priority disabled flags when refreshing a V2 registration.
return {
type: "local",
command: [...command],
disabled: false,
...(Object.keys(environment).length === 0
? {}
: { environment: { ...environment } }),
};
case "vscode":
case "omp":
case "pi":
return {
type: "stdio",
command: executable,
args,
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
};
case "copilot_cli":
return {
type: "stdio",
command: executable,
args,
tools: ["*"],
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
};
case "commandcode":
return {
transport: "stdio",
enabled: true,
command: executable,
args,
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
};
case "dsh":
return dshClientRegistrationEntry(command, environment);
case "hermes":
// Hermes reads stdio servers from `mcp_servers.<name>` and connects them
// unless `enabled` is false, so the entry states it explicitly.
return {
command: executable,
args,
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
enabled: true,
};
default:
return {
command: executable,
args,
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
};
}
};
/** Parse one Gemini policy at its source boundary before consolidating scopes. */
+1 -10
View File
@@ -5,7 +5,6 @@ import {
effectiveClientServer,
parseClientConfiguration,
type ClientConfigurationDocument,
type ClientConfigurationFormat,
type ClientRegistrationDialect,
} from "./ClientConfigurationDocument.js";
import type { SetupClient } from "./SupportedClients.js";
@@ -89,7 +88,7 @@ export const readConfigurationText = async (
/** Read and parse one configuration at its source boundary. */
export const readConfigurationFile = async (
path: string,
format: ClientConfigurationFormat | undefined,
format: SetupClient["format"],
resources?: ArtifactResourceScope,
): Promise<
Result<ClientConfigurationDocument | undefined, ClientConfigurationFileError>
@@ -236,14 +235,6 @@ export const effectiveClientConfiguration = (
return document === undefined ? [] : [document];
},
);
return mergeClientConfigurationDocuments(documents, platform);
};
/** Merge admitted layers without re-reading or changing their source documents. */
export const mergeClientConfigurationDocuments = (
documents: readonly ClientConfigurationDocument[],
platform: NodeJS.Platform = process.platform,
): ClientConfigurationDocument | undefined => {
const last = documents.at(-1);
if (last === undefined) return undefined;
if (documents.length === 1) return last;
@@ -64,8 +64,6 @@ export class ClientConfigurationWriteError extends Error {
export interface ClientConfigurationSelection {
readonly requestedPath: string;
readonly state: Stats | undefined;
/** Required by fx's trusted private profile. */
readonly requiredMode?: 0o600;
}
/** The exact staged file retained through publication, verification and recovery. */
@@ -121,20 +119,6 @@ export class ClientConfigurationPublication {
staging.setFile(owner);
await owner.handle.writeFile(contents, { encoding: "utf8" });
await preserveConfigurationAttributes(owner, input, destination);
if (selection?.requiredMode !== undefined) {
await owner.handle.chmod(selection.requiredMode);
if (
process.platform !== "win32" &&
((await owner.handle.stat()).mode & 0o777) !== selection.requiredMode
)
throw new ClientConfigurationWriteError(
destination,
"preserve_mode",
new Error(
"The required private profile mode could not be verified",
),
);
}
await owner.handle.sync();
if ((await owner.handle.stat()).ino === 0)
throw new RegularFileChangedError(stagedPath);
+4 -231
View File
@@ -1,39 +1,10 @@
import { expect, it } from "vitest";
import {
parseClientRegistrationPolicy,
parseClientRegistrationLauncher,
replaceClientRegistrationLauncher,
} from "./ClientRegistration.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
parseClientConfiguration,
clientConfigurationRecordSchema,
} from "./ClientConfigurationDocument.js";
import {
supportedClientRegistrationProfile,
type SetupClient,
} from "./SupportedClients.js";
import { mcpClientRegistrationProfile } from "./McpRegistrationCatalog.js";
import { admitClientStdioConnection } from "./ClientRegistrationConnection.js";
import type { ClientRegistrationTarget } from "./ClientRegistrationProfile.js";
const zedTarget: ClientRegistrationTarget = {
dialect: "zed",
profile: mcpClientRegistrationProfile("zed"),
};
const gooseTarget: ClientRegistrationTarget = {
dialect: "goose",
profile: mcpClientRegistrationProfile("goose"),
};
const cursorTarget: ClientRegistrationTarget = {
dialect: "json",
profile: mcpClientRegistrationProfile("cursor"),
};
import { parseClientRegistration } from "./ClientRegistration.js";
import type { SetupClient } from "./SupportedClients.js";
const client: SetupClient = {
name: "opencode",
registrationProfile: supportedClientRegistrationProfile("opencode"),
format: "opencode",
configPath: "/fixture/opencode.json",
};
@@ -42,7 +13,7 @@ it("preserves every OpenCode environment key while normalizing its launcher", ()
const input = JSON.parse(
'{"type":"local","command":["rea","mcp"],"environment":{"__proto__":"operator","constructor":"selected","normal":"value"}}',
);
const result = parseClientRegistrationPolicy(input, client);
const result = parseClientRegistration(input, client);
if (!result.ok) throw result.error;
expect(result.value.command).toBe("rea");
expect(result.value.args).toEqual(["mcp"]);
@@ -57,208 +28,10 @@ it.each([42, null, {}])(
"rejects malformed OpenCode environment values at __proto__: %j",
(value) => {
const environment = Object.fromEntries([["__proto__", value]]);
const result = parseClientRegistrationPolicy(
const result = parseClientRegistration(
{ type: "local", command: ["rea", "mcp"], environment },
client,
);
expect(result.ok).toBe(false);
},
);
it("preserves Zed source metadata while replacing its native launcher", () => {
const previous = {
source: "operator-metadata",
command: "old",
args: ["old"],
env: { KEEP: "1" },
custom: { retained: true },
};
const updated = replaceClientRegistrationLauncher(
zedTarget,
["fixture", ""],
previous,
);
if (!updated.ok) throw updated.error;
expect(updated.value).toEqual({
...previous,
command: "fixture",
args: [""],
});
const fresh = replaceClientRegistrationLauncher(
zedTarget,
["fixture"],
undefined,
);
if (!fresh.ok) throw fresh.error;
expect(fresh.value).not.toHaveProperty("source");
});
it("normalizes Goose's single env alias without emitting duplicate native fields", () => {
const env = JSON.parse(
'{"KEEP":"1","__proto__":"selected","constructor":"owned"}',
);
const previous = {
cmd: "old",
args: [],
env,
enabled: false,
custom: { retained: true },
};
const before = structuredClone(previous);
const result = replaceClientRegistrationLauncher(
gooseTarget,
["fixture", ""],
previous,
);
if (!result.ok) throw result.error;
expect(result.value.envs).toEqual(env);
expect(result.value).not.toHaveProperty("env");
const canonical = clientConfigurationRecordSchema.parse(result.value.envs);
expect(Object.hasOwn(canonical, "__proto__")).toBe(true);
expect(result.value).toMatchObject({
enabled: false,
custom: { retained: true },
});
expect(previous).toEqual(before);
});
it.each([{ envs: { KEEP: "1" } }, {}])(
"keeps canonical Goose environment data stable: %j",
(previous) => {
const result = replaceClientRegistrationLauncher(
gooseTarget,
["fixture"],
previous,
);
if (!result.ok) throw result.error;
expect(result.value.envs).toEqual("envs" in previous ? previous.envs : {});
expect(result.value).not.toHaveProperty("env");
},
);
it("keeps a native Goose leading-zero string when normalizing the env alias", () => {
const parsed = parseClientConfiguration(
"extensions:\n rea:\n cmd: old\n env:\n VALUE: 0123\n",
"goose",
);
const result = replaceClientRegistrationLauncher(
gooseTarget,
["fixture"],
parsed.servers.rea,
);
if (!result.ok) throw result.error;
expect(result.value.envs).toEqual({ VALUE: "0123" });
expect(result.value).not.toHaveProperty("env");
});
it("normalizes an explicitly empty Goose env alias to one canonical field", () => {
const result = replaceClientRegistrationLauncher(gooseTarget, ["fixture"], {
env: {},
});
if (!result.ok) throw result.error;
expect(result.value.envs).toEqual({});
expect(result.value).not.toHaveProperty("env");
});
it.each([
{ env: { KEEP: "1" }, envs: { KEEP: "1" } },
{ env: { KEEP: "old" }, envs: { KEEP: "new" } },
])(
"refuses both Goose environment aliases even when their contents agree: %j",
(previous) => {
const result = replaceClientRegistrationLauncher(
gooseTarget,
["fixture"],
previous,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Ambiguous Goose aliases were admitted");
expect(result.error).toBeInstanceOf(ConfigurationError);
expect(result.error.message).toContain("envs or its env alias, not both");
expect(result.error.settings).toEqual([
{
setting: "envs",
constraint: "env and envs declare the same native field",
},
]);
},
);
it.each([null, 42, ["value"], { VALUE: 123 }])(
"refuses a malformed Goose env alias: %j",
(env) => {
const result = replaceClientRegistrationLauncher(gooseTarget, ["fixture"], {
env,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Malformed Goose alias was admitted");
expect(result.error).toBeInstanceOf(ConfigurationError);
expect(result.error.settings).toEqual([
{
setting: "env",
constraint: "Expected a mapping of string environment values",
},
]);
},
);
it("keeps other clients' env field in its own dialect", () => {
const previous = { env: { KEEP: "1" }, command: "old" };
const result = replaceClientRegistrationLauncher(
cursorTarget,
["fixture"],
previous,
);
if (!result.ok) throw result.error;
expect(result.value.env).toEqual({ KEEP: "1" });
expect(result.value).not.toHaveProperty("envs");
});
it.each([
{
target: {
dialect: "opencode",
profile: mcpClientRegistrationProfile("opencode"),
},
value: {
type: "remote",
url: "https://fixture.invalid",
command: ["/fixture/rea", "--mcp"],
},
},
{
target: { dialect: "json", profile: mcpClientRegistrationProfile("cline") },
value: {
transport: {
type: "streamableHttp",
url: "https://fixture.invalid",
command: "/fixture/rea",
args: ["--mcp"],
},
},
},
] satisfies readonly { target: ClientRegistrationTarget; value: unknown }[])(
"retains launcher syntax independently of a non-stdio native transport: $target.profile.nativeClient",
({ target, value }) => {
expect(parseClientRegistrationLauncher(value, target)).toEqual({
ok: true,
value: { command: "/fixture/rea", args: ["--mcp"] },
});
expect(admitClientStdioConnection(value, target).ok).toBe(false);
},
);
it("retains malformed stored argv syntax for ownership while refusing launch readiness", () => {
const value = {
command: "/fixture/rea",
args: ["--mcp", "recorded\0argument"],
};
expect(parseClientRegistrationLauncher(value, cursorTarget)).toEqual({
ok: true,
value,
});
expect(admitClientStdioConnection(value, cursorTarget)).toMatchObject({
ok: false,
error: { kind: "invalid", cause: { _tag: "ConfigurationError" } },
});
});
+47 -214
View File
@@ -13,11 +13,9 @@ import {
import {
clientConfigurationRecordSchema,
clientRegistrationEntry,
clientRegistrationLauncherEntry,
type ClientRegistrationDialect,
} from "./ClientConfigurationDocument.js";
import type { SetupClient } from "./SupportedClients.js";
import type { ClientRegistrationTarget } from "./ClientRegistrationProfile.js";
import {
classifyClientRegistrationCommand,
equivalentClientRegistrationCommands,
@@ -36,16 +34,14 @@ const launcherSchema = z
args: z.array(z.string()).default([]),
})
.passthrough();
const arrayLauncherSchema = z
const opencodeLauncherSchema = z
.object({
type: z.literal("local"),
command: z.array(z.string()).min(1),
})
.passthrough();
const opencodeLauncherSchema = arrayLauncherSchema.extend({
type: z.literal("local"),
});
const normalizeOpencodeLauncher = (
entry: z.output<typeof arrayLauncherSchema>,
entry: z.output<typeof opencodeLauncherSchema>,
): z.output<typeof launcherSchema> => {
const [command = "", ...args] = entry.command;
return { ...entry, command, args };
@@ -56,10 +52,8 @@ const registrationSchema = launcherSchema.extend({
tool_timeout_sec: tomlTimeoutSchema.optional(),
startupTimeoutMs: z.number().positive().optional(),
toolTimeoutMs: z.number().positive().optional(),
// Native transport tags belong to the selected profile's admission owner.
// Other clients may retain arbitrary metadata under these same names.
type: z.unknown().optional(),
transport: z.unknown().optional(),
type: z.string().optional(),
transport: z.string().optional(),
serverName: z.string().optional(),
disabled: z.boolean().optional(),
enabled: z.boolean().optional(),
@@ -106,38 +100,14 @@ const registrationError = (error: z.ZodError): ConfigurationError =>
/** Launcher identity remains usable when unrelated readiness settings are invalid. */
export const parseClientRegistrationLauncher = (
value: unknown,
target: ClientRegistrationTarget,
client: SetupClient,
): Result<{ command: string; args: string[] }, ConfigurationError> => {
const { dialect, profile } = target;
if (profile.connection.kind === "unavailable")
return err(new ConfigurationError(profile.connection.reason));
if (profile.connection.kind === "cline") {
const row = clientConfigurationRecordSchema.safeParse(value);
if (row.success && Object.hasOwn(row.data, "transport")) {
const transport = clientConfigurationRecordSchema.safeParse(
row.data.transport,
);
if (transport.success) value = transport.data;
}
}
const parsed =
dialect === "opencode" ||
dialect === "opencode_v2" ||
dialect === "fx" ||
dialect === "kilo"
? arrayLauncherSchema
client.format === "opencode"
? opencodeLauncherSchema
.transform(normalizeOpencodeLauncher)
.safeParse(value)
: dialect === "goose"
? z
.object({
cmd: z.string().min(1),
args: z.array(z.string()).default([]),
})
.passthrough()
.transform((entry) => ({ command: entry.cmd, args: entry.args }))
.safeParse(value)
: launcherSchema.safeParse(value);
: launcherSchema.safeParse(value);
return parsed.success
? ok({ command: parsed.data.command, args: parsed.data.args })
: err(registrationError(parsed.error));
@@ -148,10 +118,7 @@ export const clientRegistrationOwnership = (
value: unknown,
client: SetupClient,
): ClientRegistrationCommandIdentity => {
const parsed = parseClientRegistrationLauncher(value, {
dialect: client.format ?? "json",
profile: client.registrationProfile,
});
const parsed = parseClientRegistrationLauncher(value, client);
return parsed.ok
? classifyClientRegistrationCommand(
[parsed.value.command, ...parsed.value.args],
@@ -183,8 +150,8 @@ const validateClientPolicy = (
: err(registrationError(policy.error));
};
/** Parse launcher syntax and client policy; native transport selection is admitted separately. */
export const parseClientRegistrationPolicy = (
/** Normalize launcher facts once; policy fields keep their dialect-specific shape. */
export const parseClientRegistration = (
value: unknown,
client: SetupClient,
): Result<ClientRegistration, ConfigurationError> => {
@@ -212,182 +179,51 @@ export const parseClientRegistrationPolicy = (
const parsed = registrationSchema.safeParse(value);
if (!parsed.success) return err(registrationError(parsed.error));
const registration = parsed.data;
const transportInvalid =
((client.format === "kimi" || client.format === "commandcode") &&
registration.transport !== undefined &&
registration.transport !== "stdio") ||
(client.format === "commandcode" && registration.transport === undefined);
const typeInvalid =
((client.format === "vscode" || client.format === "copilot_cli") &&
registration.type !== "stdio") ||
((client.format === "kimi" ||
client.format === "omp" ||
client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio");
if (transportInvalid || typeInvalid)
return err(
new ConfigurationError("Expected an stdio registration", {
settings: [
{
setting: transportInvalid ? "transport" : "type",
constraint: "Expected stdio",
},
],
}),
);
return ok(registration);
};
// These fields select REA's connection and activation. Everything else belongs
// to the caller/client, including future policy keys. Client-specific timeout
// defaults and preservation rules are applied below.
const managedPolicyKeys = new Set([
const managedConnectionKeys = new Set([
"command",
"args",
"env",
"environment",
"type",
"transport",
"enabled",
"disabled",
"enable",
"serverName",
"url",
"httpUrl",
]);
/** Own only fields selected by this native client's admitted profile. */
export const clientRegistrationSourceFacts = (
target: ClientRegistrationTarget,
command: readonly string[],
previous: unknown,
): Result<
{
readonly source: Record<string, unknown> | undefined;
readonly connectionFields: ReadonlySet<string>;
},
ConfigurationError
> => {
const { profile } = target;
const connection = profile.connection;
if (connection.kind === "unavailable")
return err(new ConfigurationError(connection.reason));
const parsed = clientConfigurationRecordSchema.safeParse(previous);
const source = parsed.success ? parsed.data : undefined;
const fields = new Set(
Object.keys(clientRegistrationLauncherEntry(target.dialect, command)),
);
if (connection.kind === "stdio-only") {
const admitted = new Set([...fields, ...connection.documentedFields]);
const unknown = Object.keys(source ?? {}).filter(
(key) => !admitted.has(key),
);
if (
(previous !== undefined && source === undefined) ||
unknown.length > 0 ||
(source !== undefined &&
Object.hasOwn(source, "type") &&
source.type !== "stdio")
)
return err(
new ConfigurationError(
`Write applicability for ${profile.nativeClient} is unknown outside its documented stdio profile. Review the selected row in the client's native settings; no configuration was changed.`,
{
settings: [
{
setting: unknown[0] ?? "type",
constraint:
"Native interpretation is not established by the documented stdio profile",
},
],
},
),
);
} else {
for (const field of connection.kind === "cline"
? ["transport", "type", "transportType", "url"]
: connection.kind === "commandcode"
? ["type", "url"]
: connection.fields)
fields.add(field);
}
return ok({ source, connectionFields: fields });
};
/** Explicit launcher replacement preserves policy and native environment, while
* superseding connection selectors that could keep the old remote transport.
* Goose's single env alias becomes envs; declaring both remains ambiguous. */
export const replaceClientRegistrationLauncher = (
target: ClientRegistrationTarget,
command: readonly string[],
previous: unknown,
inherited?: unknown,
): Result<Record<string, unknown>, ConfigurationError> => {
const { dialect, profile } = target;
const facts = clientRegistrationSourceFacts(target, command, previous);
if (!facts.ok) return facts;
const { source, connectionFields: launcherConnectionKeys } = facts.value;
const retained: Record<string, unknown> =
source === undefined
? {}
: Object.fromEntries(
Object.entries(source).filter(
([key]) => !launcherConnectionKeys.has(key),
),
);
if (dialect === "goose" && Object.hasOwn(retained, "env")) {
if (Object.hasOwn(retained, "envs"))
return err(
new ConfigurationError(
"Goose environment is ambiguous: keep only envs or its env alias, not both.",
{
settings: [
{
setting: "envs",
constraint: "env and envs declare the same native field",
},
],
},
),
);
const alias = jsonRecordSchema(z.string()).safeParse(retained.env);
if (!alias.success)
return err(
new ConfigurationError("Invalid Goose env alias", {
cause: alias.error,
settings: [
{
setting: "env",
constraint: "Expected a mapping of string environment values",
},
],
}),
);
retained.envs = alias.data;
delete retained.env;
}
const defaults = clientRegistrationEntry(dialect, command, {});
// Layered native clients inherit policy without copying it into the edited
// fragment. Defaults must not override an explicitly configured lower layer.
const inheritedPolicy = clientConfigurationRecordSchema.safeParse(inherited);
if (inheritedPolicy.success)
for (const key of Object.keys(defaults))
if (
!launcherConnectionKeys.has(key) &&
(Object.hasOwn(inheritedPolicy.data, key) ||
(dialect === "opencode" &&
key === "enabled" &&
Object.hasOwn(inheritedPolicy.data, "disabled")))
)
delete defaults[key];
// V1 already accepts a disabled policy. Adding enabled:true would override
// that existing activation dialect when OpenCode migrates the row.
if (
dialect === "opencode" &&
source !== undefined &&
Object.hasOwn(source, "disabled")
)
delete defaults.enabled;
const launcher = clientRegistrationLauncherEntry(dialect, command);
if (profile.connection.kind === "cline" && source !== undefined) {
const nested = clientConfigurationRecordSchema.safeParse(source.transport);
if (nested.success) {
for (const key of Object.keys(launcher)) delete defaults[key];
const retainedTransport = Object.fromEntries(
Object.entries(nested.data).filter(
([key]) =>
key !== "type" && key !== "url" && !Object.hasOwn(launcher, key),
),
);
return ok({
...defaults,
...retained,
transport: {
...retainedTransport,
type: "stdio",
...launcher,
},
});
}
}
return ok({
...defaults,
...retained,
...launcher,
});
};
/** Whether a rea setup repair for this target preserves the row's
* `enable: false` instead of dropping it. Shared registration doubles as
* ZCode's fallback, so the activation fact belongs to every reader; a repair
@@ -409,13 +245,11 @@ export const refreshClientRegistration = (
previous: unknown,
timeoutInputs: McpToolTimeoutInputs,
): Result<Record<string, unknown>, ConfigurationError> => {
const target = { dialect, profile: client.registrationProfile };
const facts = clientRegistrationSourceFacts(target, command, previous);
if (!facts.ok) return facts;
const existing = facts.value.source ?? {};
const source = clientConfigurationRecordSchema.safeParse(previous);
const existing = source.success ? source.data : {};
const policy = validateClientPolicy(existing, client);
if (!policy.ok) return policy;
const prior = parseClientRegistrationLauncher(existing, target);
const prior = parseClientRegistrationLauncher(existing, client);
const priorCommand = prior.ok
? [prior.value.command, ...prior.value.args]
: undefined;
@@ -428,8 +262,7 @@ export const refreshClientRegistration = (
...clientRegistrationEntry(dialect, launcher, environment),
...Object.fromEntries(
Object.entries(existing).filter(
([key]) =>
!facts.value.connectionFields.has(key) && !managedPolicyKeys.has(key),
([key]) => !managedConnectionKeys.has(key),
),
),
};
@@ -1,160 +0,0 @@
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import { processArgumentSchema } from "../domain/process/processArgument.js";
import { clientConfigurationRecordSchema } from "./ClientConfigurationDocument.js";
import {
clientRegistrationSourceFacts,
parseClientRegistrationLauncher,
} from "./ClientRegistration.js";
import type {
ClientRegistrationTarget,
NativeStdioIncompatibility,
} from "./ClientRegistrationProfile.js";
export interface NativeStdioAdmissionIssue {
readonly kind: "invalid" | "unknown";
readonly cause: ConfigurationError;
}
/** Evaluate only the native conditions recorded by the selected client owner. */
const incompatible = (
condition: NativeStdioIncompatibility,
source: Record<string, unknown>,
): boolean => {
if (condition.kind === "all")
return condition.conditions.every((item) => incompatible(item, source));
const value = Object.hasOwn(source, condition.field)
? source[condition.field]
: undefined;
switch (condition.kind) {
case "truthy":
return Boolean(value);
case "non-nullish":
return value !== undefined && value !== null;
case "present":
return Object.hasOwn(source, condition.field);
case "missing":
return value === undefined;
case "nullish":
return value === undefined || value === null;
case "string":
return typeof value === "string";
case "invalid-string":
return value !== undefined && typeof value !== "string";
case "equals":
return value === condition.value;
case "required-enum":
return typeof value !== "string" || !condition.values.includes(value);
case "optional-enum":
return (
value !== undefined &&
(typeof value !== "string" || !condition.values.includes(value))
);
}
};
/** Admit selected stdio separately from ownership of a retained launcher field. */
export const admitClientStdioConnection = (
value: unknown,
target: ClientRegistrationTarget,
): Result<{ command: string; args: string[] }, NativeStdioAdmissionIssue> => {
const parsed = clientConfigurationRecordSchema.safeParse(value);
if (!parsed.success)
return err({
kind: "invalid",
cause: new ConfigurationError(
"The native client connection is not an admitted server mapping.",
),
});
const source = parsed.data;
const connection = target.profile.connection;
if (connection.kind === "unavailable")
return err({
kind: "unknown",
cause: new ConfigurationError(connection.reason),
});
if (connection.kind === "commandcode") {
// Published file-loader semantics; its add-json management parser differs.
const selector = source.transport ?? source.type ?? "";
if (typeof selector !== "string")
return err({
kind: "invalid",
cause: new ConfigurationError(
"The selected native CommandCode transport tag must be text.",
),
});
const transport = selector.toLowerCase();
if (
transport === "http" ||
transport === "sse" ||
(transport !== "stdio" && source.url && !source.command)
)
return err({
kind: "unknown",
cause: new ConfigurationError(
"The native CommandCode configuration selects a remote connection rather than the retained stdio launcher.",
),
});
}
if (connection.kind === "flat") {
const conflict = connection.stdio.find((item) =>
incompatible(item, source),
);
if (conflict !== undefined)
return err({
kind:
conflict.kind === "required-enum" || conflict.kind === "optional-enum"
? "invalid"
: "unknown",
cause: new ConfigurationError(
`The native ${target.profile.nativeClient} connection does not establish the retained stdio launcher as selected. Repair its native transport selectors before treating the registration as aligned.`,
{
settings: [
{
setting:
conflict.kind === "all" ? "native transport" : conflict.field,
constraint:
"Native connection selection does not establish stdio",
},
],
},
),
});
}
if (connection.kind === "cline") {
const nested = clientConfigurationRecordSchema.safeParse(source.transport);
const type = nested.success
? nested.data.type
: (source.type ?? source.transportType);
if (type !== undefined && type !== "stdio")
return err({
kind: "unknown",
cause: new ConfigurationError(
"The native Cline transport does not select stdio.",
),
});
}
const launcher = parseClientRegistrationLauncher(value, target);
if (!launcher.ok) return err({ kind: "invalid", cause: launcher.error });
if (
[launcher.value.command, ...launcher.value.args].some(
(word) => !processArgumentSchema.safeParse(word).success,
)
)
return err({
kind: "invalid",
cause: new ConfigurationError(
"The stored stdio executable or arguments contain NUL and cannot be launched. The existing registration was retained; correct its process arguments in the client settings.",
),
});
if (connection.kind === "stdio-only") {
const applicability = clientRegistrationSourceFacts(
target,
[launcher.value.command, ...launcher.value.args],
value,
);
if (!applicability.ok)
return err({ kind: "unknown", cause: applicability.error });
}
return ok(launcher.value);
};
@@ -1,8 +1,13 @@
import { launchAnalysisSetting } from "../config/analysisSettings.js";
import {
parseEnvironment,
parseEnvironmentValues,
type Environment,
} from "../config/environment.js";
import {
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
} from "../domain/analysisResourceLimits.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -318,6 +323,56 @@ const validatedDetails = (
return ok(environmentDetails(environment, validated.value, unresolved));
};
const admittedTimeout = (
id: Parameters<typeof launchAnalysisSetting>[0],
environment: Readonly<Record<string, string | undefined>>,
fallback: number,
): number => {
const parsed = launchAnalysisSetting(id, environment);
return parsed.ok && parsed.value.value !== null
? parsed.value.value
: fallback;
};
const registrationTimeoutInputs = (
environment: Readonly<Record<string, string>>,
): Pick<
McpToolTimeoutInputs,
| "ghidraStartupTimeoutMs"
| "hopperStartupTimeoutMs"
| "ghidraDecompileTimeoutMs"
| "jebMcpTimeoutMs"
> => {
const decompile = launchAnalysisSetting(
"ghidra.decompile_timeout_seconds",
environment,
);
const jeb = launchAnalysisSetting("jeb.mcp_timeout_ms", environment);
return {
ghidraStartupTimeoutMs: admittedTimeout(
"ghidra.startup_timeout_ms",
environment,
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
),
hopperStartupTimeoutMs: admittedTimeout(
"hopper.startup_timeout_ms",
environment,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
),
...(decompile.ok &&
decompile.value.source === "setting" &&
decompile.value.value !== null
? { ghidraDecompileTimeoutMs: decompile.value.value * 1000 }
: {}),
...(jeb.ok &&
jeb.value.source === "setting" &&
jeb.value.value !== null &&
environment.REA_JEB_MCP_TIMEOUT_MS !== undefined
? { jebMcpTimeoutMs: jeb.value.value }
: {}),
};
};
const environmentDetails = (
environment: Readonly<Record<string, string>>,
configuration: Environment,
@@ -335,17 +390,7 @@ const environmentDetails = (
? undefined
: {
analysisProvider: configuration.REA_ANALYSIS_PROVIDER,
ghidraStartupTimeoutMs: configuration.REA_GHIDRA_STARTUP_TIMEOUT_MS,
...(configuration.REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS === undefined
? {}
: {
ghidraDecompileTimeoutMs:
configuration.REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS * 1000,
}),
hopperStartupTimeoutMs: configuration.REA_HOPPER_STARTUP_TIMEOUT_MS,
...(configuration.REA_JEB_MCP_TIMEOUT_MS === undefined
? {}
: { jebMcpTimeoutMs: configuration.REA_JEB_MCP_TIMEOUT_MS }),
...registrationTimeoutInputs(environment),
},
});
@@ -1,53 +0,0 @@
import type { ClientRegistrationDialect } from "./ClientConfigurationDocument.js";
/** Proven native conditions incompatible with selecting the retained stdio launcher. */
export type NativeStdioIncompatibility =
| {
readonly kind:
| "truthy"
| "non-nullish"
| "present"
| "missing"
| "nullish"
| "string"
| "invalid-string";
readonly field: string;
}
| {
readonly kind: "required-enum" | "optional-enum";
readonly field: string;
readonly values: readonly string[];
}
| { readonly kind: "equals"; readonly field: string; readonly value: string }
| {
readonly kind: "all";
readonly conditions: readonly NativeStdioIncompatibility[];
};
/** Native connection semantics, separate from the format storing the document. */
export type ClientRegistrationConnectionProfile =
| {
readonly kind: "flat";
/** Proven native selectors/aliases in addition to canonical stdio fields. */
readonly fields: readonly string[];
readonly stdio: readonly NativeStdioIncompatibility[];
}
| { readonly kind: "cline" }
| { readonly kind: "commandcode" }
| {
readonly kind: "stdio-only";
/** Documented local members; other row members leave write applicability unknown. */
readonly documentedFields: readonly string[];
}
| { readonly kind: "unavailable"; readonly reason: string };
/** Resolved by the client definition owner, never inferred from display text. */
export interface ClientRegistrationProfile {
readonly nativeClient: string;
readonly connection: ClientRegistrationConnectionProfile;
}
export interface ClientRegistrationTarget {
readonly dialect: ClientRegistrationDialect;
readonly profile: ClientRegistrationProfile;
}
+20 -46
View File
@@ -47,13 +47,12 @@ import type {
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
parseClientRegistrationPolicy,
parseClientRegistration,
parseClientRegistrationLauncher,
clientRegistrationOwnershipRemediation,
setupPreservesDisabledActivation,
type ClientRegistration,
} from "./ClientRegistration.js";
import { admitClientStdioConnection } from "./ClientRegistrationConnection.js";
interface ClientRegistrationStatusBase {
readonly client: string;
@@ -283,16 +282,10 @@ export const readClientRegistrationInspections = async (
format: "zcode_fallback",
}
: client;
const parsedRegistration = parseClientRegistrationPolicy(
raw,
effectiveClient,
);
const parsedRegistration = parseClientRegistration(raw, effectiveClient);
if (!parsedRegistration.ok) {
// Invalid readiness settings do not erase a usable launcher identity.
const launcher = parseClientRegistrationLauncher(raw, {
dialect: parsed.dialect,
profile: client.registrationProfile,
});
const launcher = parseClientRegistrationLauncher(raw, effectiveClient);
const commandIdentity = launcher.ok
? inspectClientRegistrationCommand(
[launcher.value.command, ...launcher.value.args],
@@ -318,24 +311,6 @@ export const readClientRegistrationInspections = async (
command,
currentCommandPath,
);
const nativeApplicability = admitClientStdioConnection(raw, {
dialect: parsed.dialect,
profile: client.registrationProfile,
});
if (
!nativeApplicability.ok &&
nativeApplicability.error.kind === "invalid"
) {
record({
client: client.name,
config_path: configPath,
command: [],
command_identity: commandFacts.identity,
state: "invalid",
remediation: nativeApplicability.error.cause.message,
});
continue;
}
const registrationEnvironment =
compactInputSchemaByDefault(client) || client.name === "codex"
? await clientRegistrationEnvironmentDetailsWithBudget(
@@ -369,19 +344,17 @@ export const readClientRegistrationInspections = async (
const packageVersionDrift =
packageVersion !== undefined &&
packageVersion !== PRODUCT_IDENTITY.packageVersion;
const alignment = !nativeApplicability.ok
? "unknown"
: registrationAligned({
registration,
client: effectiveClient,
commandFacts,
inputSchemaProfileSelected:
registrationEnvironment?.ok === true &&
snapshotEnvironment(
registrationEnvironment.value.values,
options.platform,
).REA_MCP_INPUT_SCHEMA_PROFILE !== undefined,
});
const alignment = registrationAligned({
registration,
client: effectiveClient,
commandFacts,
inputSchemaProfileSelected:
registrationEnvironment?.ok === true &&
snapshotEnvironment(
registrationEnvironment.value.values,
options.platform,
).REA_MCP_INPUT_SCHEMA_PROFILE !== undefined,
});
record(
configuredStatus(
effectiveClient,
@@ -402,9 +375,6 @@ export const readClientRegistrationInspections = async (
configPath,
])
: undefined) ??
(!nativeApplicability.ok
? nativeApplicability.error.cause.message
: undefined) ??
policyBlock ??
(packageVersionDrift
? packageVersionRemediation(packageReference ?? "", client)
@@ -539,8 +509,12 @@ const registrationAligned = ({
return "stale";
if (compactInputSchemaByDefault(client) && !inputSchemaProfileSelected)
return "stale";
// Native transport is admitted separately; the Harness route must name REA.
if (client.format === "dsh" && registration.serverName !== "rea")
// A Harness registration only connects as the rea stdio server; another
// server name or transport leaves the session without REA tools.
if (
client.format === "dsh" &&
(registration.serverName !== "rea" || registration.transport !== "stdio")
)
return "stale";
// Legacy package runners remain owned, but setup migrates them to the selected installation.
if (commandFacts.package !== undefined) return "stale";
@@ -1,343 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseDocument } from "yaml";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
editGooseConfigurationYaml,
GooseConfigurationYamlError,
parseGooseConfigurationYaml,
stringifyGooseConfigurationYaml,
} from "./GooseConfigurationYaml.js";
const extension = {
name: "rea",
cmd: "/fixture/server path",
args: ["", "--mcp", "a'b"],
envs: { MODE: "off" },
type: "stdio",
enabled: true,
timeout: 300,
};
describe("Goose YAML 1.2 source facts", () => {
it("keeps native strings and exact large integer facts distinct", () => {
const source =
"on: off\ndate: 2026-11-11\nleading: 0123\nlarge: 9007199254740993\nbool: true\nnil: null\nquoted: 'true'\n";
expect(parseGooseConfigurationYaml(source)).toEqual({
on: "off",
date: "2026-11-11",
leading: "0123",
large: 9007199254740993n,
bool: true,
nil: null,
quoted: "true",
});
const updated = editGooseConfigurationYaml(
source,
["extensions", "rea"],
extension,
);
expect(updated).toContain("large: 9007199254740993");
expect(updated).toContain("leading: 0123");
expect(parseGooseConfigurationYaml(updated)).toMatchObject({
leading: "0123",
large: 9007199254740993n,
});
});
it("uses core scalar and literal merge-key facts even with a YAML 1.1 directive", () => {
const source =
"%YAML 1.1\n---\ndefaults: &defaults {MODE: off}\nselected: {<<: *defaults}\n";
const original = {
defaults: { MODE: "off" },
selected: { "<<": { MODE: "off" } },
};
expect(parseGooseConfigurationYaml(source)).toEqual(original);
expect(
parseGooseConfigurationYaml(
editGooseConfigurationYaml(source, ["extensions", "rea"], extension),
),
).toEqual({ ...original, extensions: { rea: extension } });
});
it.each(["", "# settings note\n", "---\n# settings note\n"])(
"admits an unwritten mapping root and keeps its comments: %j",
(source) => {
expect(parseGooseConfigurationYaml(source)).toEqual({});
const updated = editGooseConfigurationYaml(
source,
["extensions", "rea"],
extension,
);
expect(parseGooseConfigurationYaml(updated)).toEqual({
extensions: { rea: extension },
});
if (source.includes("settings note"))
expect(updated).toContain("# settings note");
},
);
it.each([
"null\n",
"false\n",
"[]\n",
"extensions: null\n",
"extensions: []\n",
])(
"refuses a source shape that cannot hold the selected extension: %j",
(source) => {
expect(() =>
editGooseConfigurationYaml(source, ["extensions", "rea"], extension),
).toThrow(ConfigurationError);
},
);
it.each([
[
"extensions:\n rea: {cmd: first}\n rea: {cmd: second}\n",
["extensions", "rea"],
],
["extensions: {}\nextensions: {}\n", ["extensions"]],
[
"extensions:\n rea:\n cmd: first\n cmd: second\n",
["extensions", "rea", "cmd"],
],
] as const)(
"reports duplicate source members with their owned key path",
(source, path) => {
let failure: unknown;
try {
parseGooseConfigurationYaml(source);
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(GooseConfigurationYamlError);
expect(failure).toMatchObject({ kind: "shadowed_member", path });
expect(() =>
editGooseConfigurationYaml(source, ["extensions", "rea"], extension),
).toThrow(GooseConfigurationYamlError);
},
);
it.each([
"extensions: [\n",
"custom: !unknown tag\n",
"extensions: *absent\n",
])(
"retains malformed or unresolved YAML as a typed refusal: %j",
(source) => {
expect(() => parseGooseConfigurationYaml(source)).toThrow(
ConfigurationError,
);
expect(() =>
editGooseConfigurationYaml(source, ["extensions", "rea"], extension),
).toThrow(ConfigurationError);
},
);
it.each([
"value: &loop {self: *loop}\n",
"extensions: &loop {rea: {args: [*loop]}}\n",
])(
"refuses cyclic source aliases before producing a recursive configuration value",
(source) => {
expect(() => parseGooseConfigurationYaml(source)).toThrow(
ConfigurationError,
);
expect(() =>
editGooseConfigurationYaml(source, ["extensions", "rea"], extension),
).toThrow(ConfigurationError);
},
);
});
describe("Goose extension edits", () => {
it("retains comments, unrelated settings and exact cmd/args/envs", () => {
const source =
"# config note\nprovider: fixture # provider note\nprompt: |\n keep this long prompt and formatting\nextensions:\n other: {cmd: peer, args: [off]} # peer note\n rea: # registration note\n cmd: old # executable note\n args: # arguments note\n - old # argument note\n envs: # environment note\n MODE: old # mode note\n";
const updated = editGooseConfigurationYaml(
source,
["extensions", "rea"],
extension,
);
expect(parseGooseConfigurationYaml(updated)).toEqual({
provider: "fixture",
prompt: "keep this long prompt and formatting\n",
extensions: { other: { cmd: "peer", args: ["off"] }, rea: extension },
});
for (const note of [
"config note",
"provider note",
"peer note",
"registration note",
"executable note",
"arguments note",
"argument note",
"environment note",
"mode note",
])
expect(updated.split(note)).toHaveLength(2);
expect(updated).toContain(
"prompt: |\n keep this long prompt and formatting\n",
);
expect(parseDocument(updated, { schema: "core" }).errors).toEqual([]);
});
it("moves a unique argument's comment with its value without duplicating removed annotations", () => {
const source =
"extensions:\n rea:\n cmd: old\n args:\n - --mcp # protocol\n - dropped # removed\n";
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
cmd: "new",
args: ["", "--mcp"],
});
expect(updated).toMatch(/- --mcp # protocol/);
expect(updated.split("# removed")).toHaveLength(2);
expect(parseGooseConfigurationYaml(updated)).toEqual({
extensions: { rea: { cmd: "new", args: ["", "--mcp"] } },
});
});
it.each([undefined, extension])(
"preserves surviving consumers of a replaced anchored entry: %j",
(value) => {
const source =
"defaults: &defaults {MODE: off}\nextensions:\n rea: &server\n cmd: old\n args: [--old]\n envs: *defaults\n other: *server # independent peer\nobserver: *defaults\n";
const updated = editGooseConfigurationYaml(
source,
["extensions", "rea"],
value,
);
expect(parseGooseConfigurationYaml(updated)).toEqual({
defaults: { MODE: "off" },
extensions: {
...(value === undefined ? {} : { rea: value }),
other: { cmd: "old", args: ["--old"], envs: { MODE: "off" } },
},
observer: { MODE: "off" },
});
expect(updated).toContain("# independent peer");
},
);
it("edits an aliased extensions table without mutating its original owner", () => {
const source =
"defaults: &servers\n peer: {cmd: old}\nextensions: *servers # extension table\n";
const updated = editGooseConfigurationYaml(
source,
["extensions", "rea"],
extension,
);
expect(parseGooseConfigurationYaml(updated)).toEqual({
defaults: { peer: { cmd: "old" } },
extensions: { peer: { cmd: "old" }, rea: extension },
});
expect(updated).toContain("# extension table");
});
it("deleting a missing extension leaves the original document intact", () => {
const source = "# exact original\nprovider: fixture\n";
expect(
editGooseConfigurationYaml(source, ["extensions", "rea"], undefined),
).toBe(source);
});
it("serializes fresh values with a YAML 1.2 scalar round trip", () => {
const document = {
provider: "off",
extensions: { rea: extension },
date: "2026-11-11",
large: 9007199254740993n,
};
const text = stringifyGooseConfigurationYaml(document);
expect(parseGooseConfigurationYaml(text)).toEqual(document);
expect(
parseDocument(text, { schema: "core", intAsBigInt: true }).errors,
).toEqual([]);
});
});
describe("pinned Goose serde_yaml scalar resolution", () => {
it.each([
["0123", "0123"],
["-0123", "-0123"],
["+0123", "+0123"],
["00", "00"],
["0", 0],
["+0", 0],
["-0", 0],
["-12", -12],
["0b11", 3],
["-0b11", -3],
["+0b11", 3],
["0o17", 15],
["-0o17", -15],
["+0o17", 15],
["0xAf", 175],
["-0xAf", -175],
["+0xAf", 175],
["0XAF", "0XAF"],
["1_000", "1_000"],
["0b1_0", "0b1_0"],
["0123.0", 123],
["0123e2", 12300],
[".5", 0.5],
["1.", 1],
["-1e-3", -0.001],
["1e309", "1e309"],
["-1e309", "-1e309"],
[".inf", Infinity],
["-.INF", -Infinity],
["+.Inf", Infinity],
[".nan", NaN],
["+.nan", "+.nan"],
["on", "on"],
["off", "off"],
["true", true],
["FALSE", false],
["2026-11-11", "2026-11-11"],
["9223372036854775807", 9223372036854775807n],
["-9223372036854775808", -9223372036854775808n],
["18446744073709551615", 18446744073709551615n],
[
"340282366920938463463374607431768211456",
Number("340282366920938463463374607431768211456"),
],
["9".repeat(400), "9".repeat(400)],
["!!int +0b11", 3],
["!!float 0123", 123],
["!!float 1", 1],
["!!int |-\n 12", 12],
["!!float |-\n 1.5", 1.5],
["!!bool TRUE", true],
["!!null NULL", null],
["'0123'", "0123"],
["!!str 0b11", "0b11"],
])(
"admits native scalar %s without changing its fact",
(source, expected) => {
expect(parseGooseConfigurationYaml(`value: ${source}\n`)).toEqual({
value: expected,
});
},
);
it.each([
"!!int 0123",
"!!int -0123",
"!!bool on",
'!!null ""',
"!!int |\n 12\n",
"!!float |\n 1.5\n",
"!!int 1_000",
"!!int 0XAF",
"!!float 1e309",
"!!float 0xAf",
"!!int 18446744073709551616",
"18446744073709551616",
"-9223372036854775809",
])(
"rejects scalar %s that the native Value visitor cannot admit",
(source) => {
expect(() => parseGooseConfigurationYaml(`value: ${source}\n`)).toThrow();
},
);
});
-291
View File
@@ -1,291 +0,0 @@
import {
Document,
isAlias,
isMap,
isScalar,
isSeq,
parseDocument,
type Tags,
type ScalarTag,
} from "yaml";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
copyYamlAliasSource,
preserveYamlAliasValues,
} from "./YamlConfigurationAliases.js";
import {
retainYamlConfigurationSource,
retainYamlScalarSource,
} from "./YamlConfigurationSource.js";
// Goose 1.59.0 loads Mapping/Value through serde_yaml 0.9.34. Its
// scalar resolver differs from the YAML core schema (notably 0123 and 0b11).
// https://github.com/dtolnay/serde-yaml/blob/2009506d33767dfc88e979d6bc0d53d09f941c94/src/de.rs#L858-L1150
const nativeIntegerSyntax =
/^[+-]?(?:0|[1-9][0-9]*|0b[01]+|0o[0-7]+|0x[0-9a-fA-F]+)$/;
const leadingZeroDigits = /^[+-]?0[0-9]+$/;
const finiteFloatSyntax =
/^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/;
const nativeFloatSyntax =
/^(?![+-]?0[0-9]+$)(?:[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|[+-]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/;
const minimumI64 = -(1n << 63n);
const maximumU64 = (1n << 64n) - 1n;
const minimumI128 = -(1n << 127n);
const maximumU128 = (1n << 128n) - 1n;
/** Undefined means visit_int falls through; Value does not accept i128/u128. */
const nativeGooseInteger = (source: string): bigint | undefined => {
if (!nativeIntegerSyntax.test(source)) return undefined;
const negative = source.startsWith("-");
const unsigned = source.replace(/^[+-]/, "");
const prefix = /^0[box]/.exec(unsigned)?.[0] ?? "";
const radix =
prefix === "0b" ? 2 : prefix === "0o" ? 8 : prefix === "0x" ? 16 : 10;
const digits = unsigned.slice(prefix.length).replace(/^0+/, "") || "0";
const maximumMagnitude = negative ? -minimumI128 : maximumU128;
// Rust's bounded integer readers fall through before a wider allocation.
if (digits.length > maximumMagnitude.toString(radix).length) return undefined;
const magnitude = BigInt(`${prefix}${digits}`);
const value = negative ? -magnitude : magnitude;
if (value < minimumI128 || value > maximumU128) return undefined;
if (value < minimumI64 || value > maximumU64)
throw new TypeError(
"Goose serde_yaml Value cannot represent an i128/u128 integer",
);
return value;
};
const nativeGooseFloat = (source: string): number | undefined => {
if (/^[+]?\.(?:inf|Inf|INF)$/.test(source)) return Infinity;
if (/^-\.(?:inf|Inf|INF)$/.test(source)) return -Infinity;
if (/^\.(?:nan|NaN|NAN)$/.test(source)) return NaN;
if (!finiteFloatSyntax.test(source)) return undefined;
const value = Number(source);
return Number.isFinite(value) ? value : undefined;
};
const implicitGooseNumber = (source: string): bigint | number | string => {
const integer = nativeGooseInteger(source);
if (integer !== undefined) return integer;
if (leadingZeroDigits.test(source)) return source;
return nativeGooseFloat(source) ?? source;
};
const gooseScalarTags = (tags: Tags): Tags => {
const scalar = (name: string): ScalarTag => {
const tag = tags.find(
(candidate): candidate is ScalarTag =>
typeof candidate !== "string" &&
candidate.collection === undefined &&
candidate.tag === `tag:yaml.org,2002:${name}` &&
candidate.format === undefined,
);
if (tag === undefined)
throw new Error(`Missing YAML core ${name} scalar tag`);
return tag;
};
const integer = scalar("int");
const float = scalar("float");
const explicit = (
tag: ScalarTag,
resolve: (source: string) => unknown,
): ScalarTag => {
const { test: _test, ...rest } = tag;
return { ...rest, default: false, resolve };
};
return [
...tags.filter(
(tag) =>
typeof tag === "string" ||
(tag.tag !== integer.tag && tag.tag !== float.tag),
),
retainYamlScalarSource({
...integer,
test: nativeIntegerSyntax,
resolve: implicitGooseNumber,
}),
retainYamlScalarSource({
...float,
test: nativeFloatSyntax,
resolve: implicitGooseNumber,
}),
explicit(integer, (source) => {
const value = nativeGooseInteger(source);
if (value === undefined)
throw new TypeError("Invalid Goose integer scalar");
return value;
}),
explicit(float, (source) => {
const value = nativeGooseFloat(source);
if (value === undefined)
throw new TypeError("Invalid Goose float scalar");
return value;
}),
explicit(scalar("bool"), (source) => {
if (/^(?:true|True|TRUE)$/.test(source)) return true;
if (/^(?:false|False|FALSE)$/.test(source)) return false;
throw new TypeError("Invalid Goose boolean scalar");
}),
explicit(scalar("null"), (source) => {
if (/^(?:null|Null|NULL|~)$/.test(source)) return null;
throw new TypeError("Invalid Goose null scalar");
}),
];
};
const gooseYamlOptions = {
version: "1.2",
schema: "core",
intAsBigInt: true,
logLevel: "error",
customTags: gooseScalarTags,
} as const;
/** A duplicate YAML member cannot establish one selected configuration value. */
export class GooseConfigurationYamlError extends ConfigurationError {
readonly kind = "shadowed_member";
constructor(
readonly path: readonly string[],
cause: unknown,
) {
super(
`Goose YAML contains a duplicate mapping member at ${path.join(".") || "the document root"}`,
{ cause },
);
}
}
const emptyRoot = (root: Document["contents"]): boolean =>
root === null ||
(isScalar(root) &&
root.value === null &&
root.source === "" &&
root.tag === undefined &&
root.anchor === undefined);
/** Recover a diagnostic key path from the parser-owned duplicate position. */
const duplicatePath = (
node: unknown,
offset: number,
path: readonly string[] = [],
): readonly string[] | undefined => {
if (isMap(node)) {
for (const pair of node.items) {
const key = isScalar(pair.key) ? pair.key : undefined;
const next = key === undefined ? path : [...path, String(key.value)];
if (key?.range?.[0] === offset) return next;
const nested = duplicatePath(pair.value, offset, next);
if (nested !== undefined) return nested;
}
} else if (isSeq(node)) {
for (const [index, item] of node.items.entries()) {
const nested = duplicatePath(item, offset, [...path, String(index)]);
if (nested !== undefined) return nested;
}
}
return undefined;
};
const parseGooseDocument = (text: string): Document => {
const document = parseDocument(text, gooseYamlOptions);
const failure = document.errors[0] ?? document.warnings[0];
if (failure?.code === "DUPLICATE_KEY")
throw new GooseConfigurationYamlError(
duplicatePath(document.contents, failure.pos[0]) ?? [],
failure,
);
if (failure !== undefined)
throw new ConfigurationError("Invalid or unsupported Goose YAML", {
cause: failure,
});
return document;
};
const gooseDocumentValue = (document: Document): unknown => {
if (emptyRoot(document.contents)) return {};
try {
return document.toJS({
reviver: (_key, value) =>
typeof value === "bigint" &&
value >= BigInt(Number.MIN_SAFE_INTEGER) &&
value <= BigInt(Number.MAX_SAFE_INTEGER)
? Number(value)
: value,
});
} catch (cause: unknown) {
throw new ConfigurationError("Goose YAML values could not be resolved", {
cause,
});
}
};
/** Parse Goose's native scalar facts without replacing exact 64-bit integers. */
export const parseGooseConfigurationYaml = (text: string): unknown =>
gooseDocumentValue(parseGooseDocument(text));
export const stringifyGooseConfigurationYaml = (
document: Record<string, unknown>,
): string =>
new Document(document, gooseYamlOptions).toString({ lineWidth: 0 });
/** Edit one extension member while retaining unrelated YAML nodes and comments. */
export const editGooseConfigurationYaml = (
text: string,
path: readonly string[],
value: unknown,
): string => {
const document = parseGooseDocument(text);
// Resolve against the original graph before editing, including the YAML
// library's native alias-expansion admission.
gooseDocumentValue(document);
const root = document.contents;
if (emptyRoot(root)) {
const map = document.createNode({});
if (isScalar(root))
map.commentBefore = [root.commentBefore, root.comment]
.filter((comment) => comment !== undefined)
.join("\n");
document.contents = map;
}
if (!isMap(document.contents))
throw new ConfigurationError("Goose configuration root must be a mapping");
for (let length = 1; length < path.length; length++) {
const parent = path.slice(0, length);
let previous = document.getIn(parent, true);
if (isAlias(previous)) {
previous = copyYamlAliasSource(previous, document);
document.setIn(parent, previous);
}
if (previous === undefined && !document.hasIn(parent)) {
if (value === undefined) return text;
document.setIn(parent, document.createNode({}));
} else if (!isMap(previous))
throw new ConfigurationError(
`Goose configuration ${parent.join(".")} must be a mapping`,
);
}
const previous = document.getIn(path, true);
const parent = document.getIn(path.slice(0, -1), true);
const removedKey =
value === undefined && isMap(parent)
? parent.items.find(
(pair) => isScalar(pair.key) && pair.key.value === path.at(-1),
)?.key
: undefined;
const retained = preserveYamlAliasValues(document, [previous, removedKey]);
if (value === undefined) {
if (!document.deleteIn(path)) return text;
} else
document.setIn(
path,
isMap(previous) || isSeq(previous)
? retainYamlConfigurationSource(
previous,
document.createNode(value),
retained,
)
: value,
);
return document.toString({ lineWidth: 0 });
};
@@ -4,7 +4,6 @@ import {
isMap,
isNode,
isSeq,
isScalar,
visit,
type Document,
type Node,
@@ -34,7 +33,7 @@ const copyAliasComments = (source: Alias, copy: Node): Node => {
const aliasTarget = (source: Alias, context: AliasCopies): Node => {
const target = context.targets.get(source);
if (target === undefined)
throw new TypeError("Cannot preserve an unresolved YAML alias");
throw new TypeError("Cannot preserve an unresolved Hermes YAML alias");
return target;
};
@@ -42,7 +41,7 @@ const aliasTarget = (source: Alias, context: AliasCopies): Node => {
const validateAliasValue = (source: Node, context: AliasCopies): void => {
if (context.validated.has(source)) return;
if (context.visiting.has(source))
throw new TypeError("Cannot preserve cyclic YAML aliases");
throw new TypeError("Cannot preserve cyclic Hermes YAML aliases");
context.visiting.add(source);
visit(source, {
Alias: (_key, node) =>
@@ -63,7 +62,7 @@ const newAnchorName = (anchor: string, context: AliasCopies): string => {
/** Reuse the surviving owner; a later same-name definition must not capture it. */
const survivingAlias = (target: Node, context: AliasCopies): Alias => {
if (isAlias(target) || target.anchor === undefined)
throw new TypeError("Invalid YAML alias target");
throw new TypeError("Invalid Hermes YAML alias target");
if (!context.reusedNames.has(target.anchor)) return new Alias(target.anchor);
let name = context.survivingNames.get(target);
if (name === undefined) {
@@ -89,7 +88,7 @@ const copyAliasValue = (
);
}
if (context.visiting.has(source))
throw new TypeError("Cannot preserve cyclic YAML aliases");
throw new TypeError("Cannot preserve cyclic Hermes YAML aliases");
const retained = context.retained.get(source);
if (
retained !== undefined &&
@@ -99,7 +98,7 @@ const copyAliasValue = (
return new Alias(retained.anchor);
const copy = cloned ?? source.clone();
if (!isNode(copy) || isAlias(copy))
throw new TypeError("Invalid YAML value copy");
throw new TypeError("Invalid Hermes YAML value copy");
context.copied.add(source);
if (source.anchor !== undefined) {
copy.anchor = newAnchorName(source.anchor, context);
@@ -127,7 +126,7 @@ const copyAliasValue = (
};
/** Preserve other consumers before removing anchored configuration nodes. */
export const preserveYamlAliasValues = (
export const preserveHermesAliasValues = (
document: Document,
previous: readonly unknown[],
): ReadonlySet<Node> => {
@@ -195,40 +194,3 @@ export const preserveYamlAliasValues = (
});
return context.copied;
};
/** Copy original bindings so moved aliases cannot bind to later anchors. */
export const copyYamlAliasSource = (
source: unknown,
document: Document,
visiting = new Set<Node>(),
): unknown => {
const node = isAlias(source) ? source.resolve(document) : source;
if (isAlias(source) && node === undefined)
throw new TypeError("Cannot edit an unresolved YAML alias");
if (!isNode(node)) return node;
if (visiting.has(node))
throw new TypeError("Cannot edit cyclic YAML aliases");
visiting.add(node);
const copied = node.clone();
if (isScalar(copied) || isMap(copied) || isSeq(copied)) delete copied.anchor;
if (isSeq(node) && isSeq(copied))
copied.items = node.items.map((item) =>
copyYamlAliasSource(item, document, visiting),
);
else if (isMap(node) && isMap(copied))
copied.items.forEach((pair, index) => {
const original = node.items[index];
if (original === undefined) return;
pair.key = copyYamlAliasSource(original.key, document, visiting);
pair.value = copyYamlAliasSource(original.value, document, visiting);
});
if (isAlias(source)) {
delete copied.commentBefore;
delete copied.comment;
if (source.commentBefore !== undefined)
copied.commentBefore = source.commentBefore;
if (source.comment !== undefined) copied.comment = source.comment;
}
visiting.delete(node);
return copied;
};
+181 -18
View File
@@ -1,24 +1,19 @@
import { retainYamlSequenceAnnotations } from "./YamlSequenceAnnotations.js";
import {
isAlias,
isMap,
isNode,
isScalar,
isSeq,
parseDocument,
Document,
Schema,
type ParsedNode,
type Node,
type ScalarTag,
type Tags,
} from "yaml";
import {
preserveYamlAliasValues,
copyYamlAliasSource,
} from "./YamlConfigurationAliases.js";
import {
retainYamlConfigurationSource,
retainYamlScalarSource,
yamlNumericValuesEqual,
} from "./YamlConfigurationSource.js";
import { preserveHermesAliasValues } from "./HermesConfigurationAliases.js";
const SOURCE_SCALAR_TAGS = new Set([
"tag:yaml.org,2002:int",
@@ -167,6 +162,65 @@ const numericValue = (value: unknown): bigint | number | undefined => {
return undefined;
};
const numericValuesEqual = (
left: bigint | number,
right: bigint | number,
): boolean => {
if (typeof left === "bigint" && typeof right === "bigint")
return left === right;
if (typeof left === "number" && typeof right === "number")
return left === right || (Number.isNaN(left) && Number.isNaN(right));
const integer = typeof left === "bigint" ? left : right;
const number = typeof left === "number" ? left : right;
return (
Number.isFinite(number) &&
Number.isInteger(number) &&
BigInt(number) === integer
);
};
const sameResolvedScalarValue = (left: unknown, right: unknown): boolean =>
Object.is(left, right) ||
(left instanceof Date &&
right instanceof Date &&
left.getTime() === right.getTime());
/**
* Write a parsed scalar with its source spelling while that spelling still
* reads as the node's value. Hermes reads config.yaml as YAML 1.1 with exact
* integers, so re-printing a JavaScript number changes what it reads: a
* Discord channel ID loses its low digits, `0600` (octal) becomes `600`, and
* `0.0000001` becomes `1e-7`, which YAML 1.1 does not define as a float.
* Timestamp source also retains precision and offsets absent from JS Dates.
*/
const keepScalarSource = (tag: Tags[number]): Tags[number] => {
if (typeof tag === "string" || tag.collection !== undefined) return tag;
const { stringify } = tag;
if (!SOURCE_SCALAR_TAGS.has(tag.tag) || stringify === undefined) return tag;
return {
...tag,
stringify: (item, context, ...rest) => {
const { source } = item;
if (source === undefined) return stringify(item, context, ...rest);
const reader = context.doc.schema.tags.find(
(candidate): candidate is ScalarTag =>
candidate.default === true && candidate.test?.test(source) === true,
);
const read = reader?.resolve(
source,
() => undefined,
context.doc.options,
);
return sameResolvedScalarValue(
isScalar(read) ? read.value : read,
item.value,
)
? source
: stringify(item, context, ...rest);
},
};
};
const createHermesYamlOptions = () => {
const timestamps = new Map<string, HermesTimestampFacts | undefined>();
const timestampFacts = (source: string): HermesTimestampFacts | undefined => {
@@ -214,7 +268,7 @@ const createHermesYamlOptions = () => {
const right = hermesKey(rightNode);
if (left.kind !== right.kind) return false;
if (left.kind === "numeric" && right.kind === "numeric")
return yamlNumericValuesEqual(left.value, right.value);
return numericValuesEqual(left.value, right.value);
return left.value === right.value;
};
return {
@@ -235,11 +289,7 @@ const createHermesYamlOptions = () => {
: tag,
)
: [...tags, hermesTimestampTag];
return withHermesTimestamp.map((tag) =>
typeof tag !== "string" && SOURCE_SCALAR_TAGS.has(tag.tag)
? retainYamlScalarSource(tag)
: tag,
);
return withHermesTimestamp.map(keepScalarSource);
},
uniqueKeys: hermesKeysEqual,
};
@@ -281,6 +331,119 @@ export const parseHermesConfigurationYaml = (text: string): unknown => {
});
};
/** Keep source annotations and unchanged scalars when updating a value. */
const retainHermesSource = (
previous: unknown,
next: Node,
retainedSources: ReadonlySet<Node>,
): Node => {
if (
isScalar(previous) &&
isScalar(next) &&
(previous.value === next.value ||
(typeof previous.value === "bigint" &&
typeof next.value === "number" &&
numericValuesEqual(previous.value, next.value)))
) {
if (!retainedSources.has(previous)) return previous;
const copied = previous.clone();
if (!isScalar(copied)) throw new TypeError("Invalid Hermes scalar copy");
delete copied.anchor;
delete copied.comment;
delete copied.commentBefore;
delete copied.spaceBefore;
return copied;
}
// An anchor moved to a surviving consumer owns its original comments.
// Otherwise retain annotations on the replacement without retaining the
// old anchor, tag, or scalar spelling for the changed value.
if (isNode(previous) && !retainedSources.has(previous)) {
if (previous.comment !== undefined) next.comment = previous.comment;
if (previous.commentBefore !== undefined)
next.commentBefore = previous.commentBefore;
if (previous.spaceBefore !== undefined)
next.spaceBefore = previous.spaceBefore;
}
if (isMap(previous) && isMap(next)) {
const entries = new Map(
previous.items.flatMap((pair) =>
isScalar(pair.key) && typeof pair.key.value === "string"
? [[pair.key.value, pair] as const]
: [],
),
);
for (const pair of next.items) {
const key = isScalar(pair.key) ? pair.key.value : undefined;
const prior = typeof key === "string" ? entries.get(key) : undefined;
if (prior === undefined) continue;
pair.key = isNode(pair.key)
? retainHermesSource(prior.key, pair.key, retainedSources)
: prior.key;
if (isNode(pair.value))
pair.value = retainHermesSource(
prior.value,
pair.value,
retainedSources,
);
}
} else if (isSeq(previous) && isSeq(next)) {
const stringItems = (items: readonly unknown[]) =>
items.every((item) => isScalar(item) && typeof item.value === "string");
if (stringItems(previous.items) && stringItems(next.items))
retainYamlSequenceAnnotations(
previous,
next,
(item) => (isScalar(item) ? item.value : undefined),
(source, target) => retainHermesSource(source, target, retainedSources),
(source) => !retainedSources.has(source),
);
else
next.items = next.items.map((item, index) =>
isNode(item)
? retainHermesSource(previous.items[index], item, retainedSources)
: item,
);
}
return next;
};
/** Copy original bindings so moved aliases cannot bind to later anchors. */
const copyHermesAliasSource = (
source: unknown,
document: Document,
visiting = new Set<Node>(),
): unknown => {
const node = isAlias(source) ? source.resolve(document) : source;
if (isAlias(source) && node === undefined)
throw new TypeError("Cannot edit an unresolved Hermes YAML alias");
if (!isNode(node)) return node;
if (visiting.has(node))
throw new TypeError("Cannot edit cyclic Hermes YAML aliases");
visiting.add(node);
const copied = node.clone();
if (isScalar(copied) || isMap(copied) || isSeq(copied)) delete copied.anchor;
if (isSeq(node) && isSeq(copied))
copied.items = node.items.map((item) =>
copyHermesAliasSource(item, document, visiting),
);
else if (isMap(node) && isMap(copied))
copied.items.forEach((pair, index) => {
const original = node.items[index];
if (original === undefined) return;
pair.key = copyHermesAliasSource(original.key, document, visiting);
pair.value = copyHermesAliasSource(original.value, document, visiting);
});
if (isAlias(source)) {
delete copied.commentBefore;
delete copied.comment;
if (source.commentBefore !== undefined)
copied.commentBefore = source.commentBefore;
if (source.comment !== undefined) copied.comment = source.comment;
}
visiting.delete(node);
return copied;
};
/** Set or delete one value in a Hermes `config.yaml`, keeping the rest as written. */
export const editHermesConfigurationYaml = (
text: string,
@@ -302,7 +465,7 @@ export const editHermesConfigurationYaml = (
const parent = path.slice(0, length);
const previous = document.getIn(parent, true);
if (isAlias(previous)) {
const copied = copyYamlAliasSource(previous, document);
const copied = copyHermesAliasSource(previous, document);
if (isMap(copied)) {
// Edit a private mapping, keeping the alias source and its consumers.
document.setIn(parent, copied);
@@ -321,7 +484,7 @@ export const editHermesConfigurationYaml = (
(pair) => isScalar(pair.key) && pair.key.value === path.at(-1),
)?.key
: undefined;
const retainedSources = preserveYamlAliasValues(document, [
const retainedSources = preserveHermesAliasValues(document, [
previous,
removedKey,
]);
@@ -333,7 +496,7 @@ export const editHermesConfigurationYaml = (
document.setIn(
path,
isMap(previous) || isSeq(previous)
? retainYamlConfigurationSource(
? retainHermesSource(
previous,
document.createNode(value),
retainedSources,
@@ -1,502 +0,0 @@
import { readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { registerMcpClient } from "./McpClientRegistration.js";
import { parseGooseConfigurationYaml } from "./GooseConfigurationYaml.js";
import { clientConfigurationRecordSchema } from "./ClientConfigurationDocument.js";
import {
mcpClientRegistrationProfile,
type McpRegistrationClient,
} from "./McpRegistrationCatalog.js";
let resources: ArtifactResourceScope;
beforeEach(() => {
resources = new ArtifactResourceScope();
});
afterEach(async () => {
await resources.close();
});
const launcher = {
command: "/fixture/server path",
args: ["", "a'b", "--mcp"],
};
const clientFor = (
path: string,
format: McpRegistrationClient["format"],
id: McpRegistrationClient["id"],
): McpRegistrationClient => ({
id,
displayName: id,
registrationProfile: mcpClientRegistrationProfile(id),
configPath: path,
format,
scope: "global",
});
const settingsFixture = async (original: string) => {
const directory = await createTestTempDirectory("rea-native-registration-");
const path = join(directory, "settings.json");
await writeFile(path, original);
return { directory, path };
};
it.each([
{
format: "json",
id: "cursor",
text: '{"mcpServers":{"rea":{"url":"https://fixture.invalid","type":"http","env":{"KEEP":"1"},"enabled":false}}}',
expected: {
type: undefined,
command: launcher.command,
args: launcher.args,
env: { KEEP: "1" },
enabled: false,
},
},
{
format: "fx",
id: "fx",
text: '{"mcp":{"rea":{"url":"https://fixture.invalid","type":"http","environment":{"KEEP":"1"},"enabled":false}}}',
expected: {
type: "local",
command: [launcher.command, ...launcher.args],
environment: { KEEP: "1" },
enabled: false,
},
},
{
format: "kilo",
id: "kilo-code",
text: '{"mcp":{"rea":{"url":"https://fixture.invalid","type":"remote","environment":{"KEEP":"1"},"enabled":false}}}',
expected: {
type: "local",
command: [launcher.command, ...launcher.args],
environment: { KEEP: "1" },
enabled: false,
},
},
{
format: "zed",
id: "zed",
text: '{"context_servers":{"rea":{"url":"https://fixture.invalid","type":"http","env":{"KEEP":"1"},"enabled":false}}}',
expected: {
source: undefined,
type: "http",
command: launcher.command,
args: launcher.args,
env: { KEEP: "1" },
enabled: false,
},
},
] as const)(
"publishes native $format launcher facts without retaining obsolete remote selection",
async ({ format, id, text, expected }) => {
const { path } = await settingsFixture(text);
const result = await registerMcpClient(
clientFor(path, format, id),
launcher,
undefined,
resources,
);
expect(result.ok ? result.value : result.error).toMatchObject({
status: "configured",
});
const document = JSON.parse(await readFile(path, "utf8"));
const key =
format === "json"
? "mcpServers"
: format === "zed"
? "context_servers"
: "mcp";
const entry = document[key].rea;
expect(entry).not.toHaveProperty("url");
for (const [key, value] of Object.entries(expected)) {
if (value === undefined) expect(entry).not.toHaveProperty(key);
else expect(entry[key]).toEqual(value);
}
},
);
it("secures fx permissions on owned directory and staged file, including a semantic no-op", async () => {
const { path, directory } = await settingsFixture(
'{"mcp":{"rea":{"type":"local","command":["/fixture/server path","","a\'b","--mcp"],"enabled":true}}}',
);
const { chmod } = await import("node:fs/promises");
await chmod(directory, 0o755);
await chmod(path, 0o644);
const client = {
...clientFor(path, "fx", "fx"),
privateProfile: true,
} as const;
const result = await registerMcpClient(
client,
launcher,
undefined,
resources,
);
expect(result).toMatchObject({
ok: true,
value: {
status: "configured",
permissions: {
kind: "verified_posix_modes",
directoryMode: 0o700,
fileMode: 0o600,
},
},
});
expect((await stat(directory)).mode & 0o777).toBe(0o700);
expect((await stat(path)).mode & 0o777).toBe(0o600);
const before = await stat(path);
expect(
await registerMcpClient(client, launcher, undefined, resources),
).toMatchObject({
ok: true,
value: { status: "unchanged" },
});
expect((await stat(path)).ino).toBe(before.ino);
});
it.each([
{
name: "V1 explicit disable",
text: '{"mcp":{"rea":{"type":"local","command":["old"],"enabled":false,"environment":{"KEEP":"1"}}}}',
native: false,
},
{
name: "V1 disabled flag without enabled override",
text: '{"mcp":{"rea":{"type":"local","command":["old"],"disabled":true,"environment":{"KEEP":"1"}}}}',
native: false,
disabledOnly: true,
},
{
name: "native V2 explicit disable",
text: '{"mcp":{"servers":{"rea":{"type":"local","command":["old"],"disabled":true,"environment":{"KEEP":"1"}}}}}',
native: true,
},
{
name: "V1 disable migrated into native V2",
text: '{"mcp":{"rea":{"type":"local","command":["old"],"enabled":false,"environment":{"KEEP":"1"}},"servers":{"peer":{"type":"local","command":["peer"]}}}}',
native: true,
},
])(
"retains OpenCode activation policy from $name",
async ({ text, native, ...control }) => {
const { path } = await settingsFixture(text);
const result = await registerMcpClient(
clientFor(path, "opencode", "opencode"),
launcher,
undefined,
resources,
);
expect(result.ok ? result.value : result.error).toMatchObject({
status: "configured",
});
const document = JSON.parse(await readFile(path, "utf8"));
const entry = native ? document.mcp.servers.rea : document.mcp.rea;
expect(entry.command).toEqual([launcher.command, ...launcher.args]);
expect(entry.environment).toEqual({ KEEP: "1" });
if (native) {
expect(entry.disabled).toBe(true);
expect(document.mcp).not.toHaveProperty("rea");
} else if ("disabledOnly" in control) {
expect(entry.disabled).toBe(true);
expect(entry.enabled).not.toBe(true);
} else expect(entry.enabled).toBe(false);
},
);
it.each([
{ name: "empty entry", entry: "{}", enabled: true, envs: {} },
{
name: "explicit policy",
entry: '{ enabled: false, timeout: 15, envs: { KEEP: "1" } }',
enabled: false,
envs: { KEEP: "1" },
},
{
name: "native leading-zero string through the environment alias",
entry: "{ enabled: false, env: { KEEP: 0123 } }",
enabled: false,
envs: { KEEP: "0123" },
},
])(
"retains native Goose defaults and explicit policy for $name",
async ({ entry, enabled, envs }) => {
const { path } = await settingsFixture(
`extensions:\n rea: ${entry}\nfeature: on\n`,
);
expect(
await registerMcpClient(
clientFor(path, "goose", "goose"),
launcher,
undefined,
resources,
),
).toMatchObject({ ok: true });
const document = clientConfigurationRecordSchema.parse(
parseGooseConfigurationYaml(await readFile(path, "utf8")),
);
expect(document.feature).toBe("on");
expect(document.extensions).toMatchObject({
rea: {
name: "rea",
type: "stdio",
cmd: launcher.command,
args: launcher.args,
enabled,
envs,
},
});
expect(document.extensions).not.toHaveProperty("rea.env");
},
);
it.runIf(process.platform === "win32")(
"reports native Windows mode requests without claiming ACL verification",
async () => {
const { path } = await settingsFixture("{}");
const client = {
...clientFor(path, "fx", "fx"),
privateProfile: true,
} as const;
const result = await registerMcpClient(
client,
launcher,
undefined,
resources,
);
expect(result).toMatchObject({
ok: true,
value: {
permissions: {
kind: "requested_windows_modes",
requestedDirectoryMode: 0o700,
requestedFileMode: 0o600,
aclVerification: "unavailable",
},
},
});
const published = await readFile(path, "utf8");
expect(JSON.parse(published).mcp.rea.command).toEqual([
launcher.command,
...launcher.args,
]);
const before = await stat(path);
expect(
await registerMcpClient(client, launcher, undefined, resources),
).toMatchObject({ ok: true, value: { status: "unchanged" } });
expect(await readFile(path, "utf8")).toBe(published);
expect((await stat(path)).ino).toBe(before.ino);
},
);
it.each([
{ format: "copilot_cli_project", scope: "project" },
{ format: "copilot_cli", scope: "global" },
] as const)(
"rewrites admitted $scope Copilot JSONC to strict JSON even when its launcher already matches",
async ({ format, scope }) => {
const entry = {
type: "stdio",
command: launcher.command,
args: launcher.args,
env: { KEEP: "1" },
tools: ["fixture"],
};
const original = `{\n// retained settings need native strict JSON\n"mcpServers":{"rea":${JSON.stringify(entry)},},\n}\n`;
const { path } = await settingsFixture(original);
const client = {
...clientFor(path, format, "github-copilot-cli"),
scope,
} as const;
const first = await registerMcpClient(
client,
launcher,
undefined,
resources,
);
expect(first).toMatchObject({ ok: true, value: { status: "configured" } });
expect(JSON.parse(await readFile(path, "utf8"))).toEqual({
mcpServers: { rea: entry },
});
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original);
const beforeRepeat = await stat(path);
expect(
await registerMcpClient(client, launcher, undefined, resources),
).toMatchObject({ ok: true, value: { status: "unchanged" } });
expect((await stat(path)).ino).toBe(beforeRepeat.ino);
},
);
it("retains the private directory close owner during a semantic no-op", async () => {
const { path } = await settingsFixture("{}");
const client = {
...clientFor(path, "fx", "fx"),
privateProfile: true,
} as const;
expect(
await registerMcpClient(client, launcher, undefined, resources),
).toMatchObject({ ok: true });
const originalClose = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
if ((await this.handle.stat()).isDirectory())
throw new Error("fixture private directory close denied");
return originalClose.call(this);
});
try {
const result = await registerMcpClient(
client,
launcher,
undefined,
resources,
);
expect(result).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
expect(resources.hasPending).toBe(true);
await expect(resources.close()).rejects.toBeDefined();
expect(resources.hasPending).toBe(true);
} finally {
close.mockRestore();
}
await resources.close();
expect(resources.hasPending).toBe(false);
});
// Fx v0.0.16's profile reader chooses mcp over mcpServers. Its native writer
// keeps the sole existing key, creates mcpServers, and refuses both keys.
it.each(["mcp", "mcpServers"])(
"preserves Fx peers and selected metadata in a sole %s profile table",
async (key) => {
const previous = {
type: "local",
command: ["old", "old-argument"],
environment: { KEEP: "1" },
enabled: false,
custom: { retained: true },
};
const original = {
[key]: { rea: previous, peer: { command: "peer" } },
theme: "dark",
};
const { path } = await settingsFixture(JSON.stringify(original));
const result = await registerMcpClient(
clientFor(path, "fx", "fx"),
launcher,
undefined,
resources,
);
expect(result).toMatchObject({ ok: true, value: { status: "configured" } });
const document = JSON.parse(await readFile(path, "utf8"));
expect(document).toEqual({
...original,
[key]: {
peer: { command: "peer" },
rea: { ...previous, command: [launcher.command, ...launcher.args] },
},
});
// Match the independent native reader's selected table, including its peers.
const nativeServers = document.mcp ?? document.mcpServers;
expect(nativeServers.peer).toEqual({ command: "peer" });
expect(nativeServers.rea.environment).toEqual({ KEEP: "1" });
expect(nativeServers.rea.enabled).toBe(false);
expect(nativeServers.rea.command).toEqual([
launcher.command,
...launcher.args,
]);
const before = await stat(path);
expect(
await registerMcpClient(
clientFor(path, "fx", "fx"),
launcher,
undefined,
resources,
),
).toMatchObject({ ok: true, value: { status: "unchanged" } });
expect((await stat(path)).ino).toBe(before.ino);
},
);
it("creates Fx registrations in the native writer's mcpServers profile table", async () => {
const { path } = await settingsFixture("{}");
expect(
await registerMcpClient(
clientFor(path, "fx", "fx"),
launcher,
undefined,
resources,
),
).toMatchObject({ ok: true });
expect(JSON.parse(await readFile(path, "utf8"))).toEqual({
mcpServers: {
rea: {
type: "local",
command: [launcher.command, ...launcher.args],
enabled: true,
},
},
});
});
it.each([
{ mcp: {}, mcpServers: {} },
{
mcp: { legacy: { command: "legacy" } },
mcpServers: { peer: { command: "peer" } },
},
])(
"refuses ambiguous Fx profile tables before backup or publication",
async (document) => {
const original = JSON.stringify(document);
const { path } = await settingsFixture(original);
const result = await registerMcpClient(
clientFor(path, "fx", "fx"),
launcher,
undefined,
resources,
);
expect(result.ok).toBe(false);
if (result.ok)
throw new Error("Expected ambiguous native profile admission to fail");
expect(result.error.message).toContain("mcp");
expect(result.error.message).toContain("mcpServers");
expect(await readFile(path, "utf8")).toBe(original);
await expect(readFile(`${path}.rea.backup`, "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
},
);
it.each(["mcp", "mcpServers"])(
"rewrites Fx %s JSONC to native JSON even when the launcher already matches",
async (key) => {
const entry = {
type: "local",
command: [launcher.command, ...launcher.args],
enabled: false,
environment: { KEEP: "1" },
};
const original = `{\n// retained source needs strict Fx JSON\n"${key}":{"rea":${JSON.stringify(entry)},"peer":{"command":"peer"},},\n"theme":"dark",\n}\n`;
const { path } = await settingsFixture(original);
const client = clientFor(path, "fx", "fx");
expect(
await registerMcpClient(client, launcher, undefined, resources),
).toMatchObject({ ok: true, value: { status: "configured" } });
expect(JSON.parse(await readFile(path, "utf8"))).toEqual({
[key]: { rea: entry, peer: { command: "peer" } },
theme: "dark",
});
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original);
const beforeRepeat = await stat(path);
expect(
await registerMcpClient(client, launcher, undefined, resources),
).toMatchObject({ ok: true, value: { status: "unchanged" } });
expect((await stat(path)).ino).toBe(beforeRepeat.ino);
},
);
@@ -1,321 +0,0 @@
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
effectiveClientServer,
parseClientConfiguration,
} from "./ClientConfigurationDocument.js";
import { mergeClientConfigurationDocuments } from "./ClientConfigurationFiles.js";
import { registerMcpClient } from "./McpClientRegistration.js";
import {
mcpClientRegistrationProfile,
type McpRegistrationClient,
} from "./McpRegistrationCatalog.js";
let resources: ArtifactResourceScope;
beforeEach(() => {
resources = new ArtifactResourceScope();
});
afterEach(async () => {
await resources.close();
});
const launcher = { command: "/fixture/server path", args: ["", "--mcp"] };
const write = async (path: string, value: unknown): Promise<string> => {
await mkdir(dirname(path), { recursive: true });
const text = `${JSON.stringify(value)}\n`;
await writeFile(path, text);
return text;
};
const clientFor = (
configPath: string,
configPaths: readonly string[],
format: "kilo" | "opencode" = "kilo",
): McpRegistrationClient => ({
id: format === "kilo" ? "kilo-code" : "opencode",
registrationProfile: mcpClientRegistrationProfile(
format === "kilo" ? "kilo-code" : "opencode",
),
displayName: format === "kilo" ? "Kilo Code" : "OpenCode",
configPath,
configPaths,
format,
scope: "project",
});
const effective = (
texts: readonly string[],
format: "kilo" | "opencode" = "kilo",
) => {
const parsed = mergeClientConfigurationDocuments(
texts.map((text) => parseClientConfiguration(text, format)),
);
if (parsed === undefined)
throw new Error("Fixture has no configuration documents");
return effectiveClientServer(parsed, "rea");
};
it("retains lower Kilo disable/environment policy without copying its source into the write target", async () => {
const root = await createTestTempDirectory("rea-registration-layer-");
const missing = join(root, "absent", "config.json");
const lower = join(root, "lower", "kilo.json");
const target = join(root, "project", "kilo.json");
const lowerText = await write(lower, {
mcp: {
rea: {
type: "local",
command: ["old"],
enabled: false,
environment: { LOWER: "1" },
custom: { source: "lower" },
},
},
});
const targetDocument = {
theme: "target",
mcp: { peer: { type: "local", command: ["peer"] } },
};
const originalTarget = await write(target, targetDocument);
const client = clientFor(target, [missing, lower, target]);
expect(
await registerMcpClient(client, launcher, undefined, resources),
).toMatchObject({ ok: true });
const targetText = await readFile(target, "utf8");
expect(JSON.parse(targetText)).toEqual({
...targetDocument,
mcp: {
...targetDocument.mcp,
rea: { type: "local", command: [launcher.command, ...launcher.args] },
},
});
expect(effective([lowerText, targetText])).toMatchObject({
command: [launcher.command, ...launcher.args],
enabled: false,
environment: { LOWER: "1" },
custom: { source: "lower" },
});
expect(await readFile(lower, "utf8")).toBe(lowerText);
expect(await readFile(`${target}.rea.backup`, "utf8")).toBe(originalTarget);
await expect(readFile(`${lower}.rea.backup`, "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
it("refuses a higher Kilo file launcher before creating the lower selected target", async () => {
const root = await createTestTempDirectory(
"rea-registration-layer-conflict-",
);
const target = join(root, "project", "kilo.json");
const higher = join(root, "outside-project", "kilo.json");
const higherText = await write(higher, {
mcp: {
rea: { type: "local", command: ["higher-server", "higher-argument"] },
},
});
const result = await registerMcpClient(
clientFor(target, [join(root, "absent", "config.json"), target, higher]),
launcher,
undefined,
resources,
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "ConfigurationError" },
});
await expect(lstat(target)).rejects.toMatchObject({ code: "ENOENT" });
await expect(lstat(dirname(target))).rejects.toMatchObject({
code: "ENOENT",
});
expect(await readFile(higher, "utf8")).toBe(higherText);
await expect(readFile(`${higher}.rea.backup`, "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
it("refuses a conflicting KILO_CONFIG_CONTENT launcher without mutating the inline source or creating a target", async () => {
const root = await createTestTempDirectory(
"rea-registration-inline-conflict-",
);
const target = join(root, "project", "kilo.json");
const inline = {
origin: "KILO_CONFIG_CONTENT",
parsed: parseClientConfiguration(
'{"mcp":{"rea":{"type":"local","command":["inline-server"]}}}',
"kilo",
),
} as const;
const beforeInline = structuredClone(inline);
const result = await registerMcpClient(
{ ...clientFor(target, [target]), inlineConfiguration: inline },
launcher,
undefined,
resources,
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "ConfigurationError",
diagnostics: { inline_origin: "KILO_CONFIG_CONTENT" },
},
});
await expect(lstat(target)).rejects.toMatchObject({ code: "ENOENT" });
await expect(lstat(dirname(target))).rejects.toMatchObject({
code: "ENOENT",
});
expect(inline).toEqual(beforeInline);
});
it("retains harmless higher file and inline metadata as read-only inherited facts", async () => {
const root = await createTestTempDirectory(
"rea-registration-higher-metadata-",
);
const target = join(root, "project", "kilo.json");
const higher = join(root, "outside-project", "kilo.json");
const own = {
type: "local",
command: ["old"],
enabled: true,
environment: { OWN: "1" },
custom: { own: true },
};
const targetDocument = {
theme: "target",
mcp: { rea: own, peer: { command: ["peer"] } },
};
await write(target, targetDocument);
const higherText = await write(higher, {
mcp: {
rea: {
enabled: false,
environment: { HIGHER: "1" },
custom: { higher: true },
},
},
});
const inlineText =
'{"mcp":{"rea":{"environment":{"INLINE":"1"},"custom":{"inline":true}}}}';
const inline = {
origin: "KILO_CONFIG_CONTENT",
parsed: parseClientConfiguration(inlineText, "kilo"),
} as const;
const beforeInline = structuredClone(inline);
const client = {
...clientFor(target, [target, higher]),
inlineConfiguration: inline,
};
expect(
await registerMcpClient(client, launcher, undefined, resources),
).toMatchObject({ ok: true });
const targetText = await readFile(target, "utf8");
expect(JSON.parse(targetText)).toEqual({
...targetDocument,
mcp: {
...targetDocument.mcp,
rea: { ...own, command: [launcher.command, ...launcher.args] },
},
});
expect(effective([targetText, higherText, inlineText])).toMatchObject({
command: [launcher.command, ...launcher.args],
enabled: false,
environment: { OWN: "1", HIGHER: "1", INLINE: "1" },
custom: { own: true, higher: true, inline: true },
});
expect(await readFile(higher, "utf8")).toBe(higherText);
expect(inline).toEqual(beforeInline);
});
it("uses the effective OpenCode V2 dialect in its target fragment while preserving lower policy", async () => {
const root = await createTestTempDirectory(
"rea-registration-native-dialect-",
);
const lower = join(root, "lower", "opencode.json");
const target = join(root, "project", "opencode.json");
const lowerText = await write(lower, {
mcp: {
servers: {
rea: {
type: "local",
command: ["old"],
disabled: true,
environment: { LOWER: "1" },
},
},
},
});
const peer = { type: "local", command: ["peer"] };
await write(target, { mcp: { peer } });
expect(
await registerMcpClient(
clientFor(target, [lower, target], "opencode"),
launcher,
undefined,
resources,
),
).toMatchObject({ ok: true });
const targetText = await readFile(target, "utf8");
expect(JSON.parse(targetText)).toEqual({
mcp: {
peer,
servers: {
rea: { type: "local", command: [launcher.command, ...launcher.args] },
},
},
});
expect(effective([lowerText, targetText], "opencode")).toMatchObject({
command: [launcher.command, ...launcher.args],
disabled: true,
environment: { LOWER: "1" },
});
expect(await readFile(lower, "utf8")).toBe(lowerText);
});
it("publishes through the last repeated target layer without copying intervening read-only policy", async () => {
const root = await createTestTempDirectory(
"rea-registration-repeated-layer-",
);
const target = join(root, "selected", "kilo.json");
const middle = join(root, "middle", "kilo.json");
const targetDocument = {
theme: "target",
mcp: { peer: { command: ["peer"] } },
};
const originalTarget = await write(target, targetDocument);
const middleText = await write(middle, {
mcp: {
rea: {
type: "local",
command: ["middle-old"],
enabled: false,
environment: { MIDDLE: "1" },
custom: { readOnly: true },
},
},
});
const result = await registerMcpClient(
clientFor(target, [target, middle, target]),
launcher,
undefined,
resources,
);
expect(result).toMatchObject({ ok: true, value: { status: "configured" } });
const targetText = await readFile(target, "utf8");
expect(JSON.parse(targetText)).toEqual({
...targetDocument,
mcp: {
...targetDocument.mcp,
rea: { type: "local", command: [launcher.command, ...launcher.args] },
},
});
expect(effective([targetText, middleText, targetText])).toMatchObject({
command: [launcher.command, ...launcher.args],
enabled: false,
environment: { MIDDLE: "1" },
custom: { readOnly: true },
});
expect(await readFile(middle, "utf8")).toBe(middleText);
expect(await readFile(`${target}.rea.backup`, "utf8")).toBe(originalTarget);
await expect(readFile(`${middle}.rea.backup`, "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
@@ -1,221 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { beforeEach, afterEach, expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { clientConfigurationRecordSchema } from "./ClientConfigurationDocument.js";
import { parseGooseConfigurationYaml } from "./GooseConfigurationYaml.js";
import { registerMcpClient } from "./McpClientRegistration.js";
import {
mcpClientRegistrationProfile,
type McpRegistrationClient,
} from "./McpRegistrationCatalog.js";
let resources: ArtifactResourceScope;
beforeEach(() => {
resources = new ArtifactResourceScope();
});
afterEach(async () => {
await resources.close();
});
const launcher = { command: "/fixture/server path", args: ["", "--mcp"] };
const clientFor = (
path: string,
format: McpRegistrationClient["format"],
id: McpRegistrationClient["id"],
): McpRegistrationClient => ({
id,
displayName: id,
configPath: path,
scope: "global",
format,
registrationProfile: mcpClientRegistrationProfile(id),
});
const settingsFixture = async (original: string) => {
const directory = await createTestTempDirectory("rea-native-fields-");
const path = join(directory, "settings.json");
await writeFile(path, original);
return { path };
};
it.each([
{
format: "json",
id: "cursor",
text: '{"mcpServers":{"rea":{"command":"old","cmd":"operator-metadata","uri":"local-evidence"}}}',
key: "mcpServers",
metadata: { cmd: "operator-metadata", uri: "local-evidence" },
},
{
format: "zed",
id: "zed",
text: '{"context_servers":{"rea":{"command":"old","cmd":"operator-metadata","uri":"local-evidence"}}}',
key: "context_servers",
metadata: { cmd: "operator-metadata", uri: "local-evidence" },
},
{
format: "goose",
id: "goose",
text: "extensions:\n rea: {cmd: old, command: operator-metadata}\n",
key: "extensions",
metadata: { command: "operator-metadata" },
},
] as const)(
"preserves non-selector $format metadata through native document publication",
async ({ format, id, text, key, metadata }) => {
const { path } = await settingsFixture(text);
const result = await registerMcpClient(
clientFor(path, format, id),
launcher,
undefined,
resources,
);
expect(result).toMatchObject({ ok: true, value: { status: "configured" } });
const content = await readFile(path, "utf8");
const document = clientConfigurationRecordSchema.parse(
format === "goose"
? parseGooseConfigurationYaml(content)
: JSON.parse(content),
);
const servers = clientConfigurationRecordSchema.parse(document[key]);
expect(servers.rea).toMatchObject(metadata);
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(text);
},
);
it.each([
{
id: "gemini-cli",
previous: {
command: "old",
httpUrl: "https://fixture.invalid/http",
url: "https://fixture.invalid/sse",
cmd: "metadata",
uri: "metadata",
},
removed: ["httpUrl", "url"],
retained: { cmd: "metadata", uri: "metadata" },
},
{
id: "antigravity",
previous: {
command: "old",
serverUrl: "https://fixture.invalid",
httpUrl: "operator-metadata",
},
removed: ["serverUrl"],
retained: { httpUrl: "operator-metadata" },
},
{
id: "mcporter",
previous: {
command: "old",
executable: "old-alias",
baseUrl: "https://fixture.invalid/base",
base_url: "https://fixture.invalid/base2",
url: "https://fixture.invalid/url",
serverUrl: "https://fixture.invalid/server",
server_url: "https://fixture.invalid/server2",
uri: "metadata",
},
removed: [
"baseUrl",
"base_url",
"url",
"serverUrl",
"server_url",
"executable",
],
retained: { uri: "metadata" },
},
] as const)(
"supersedes $id native remote aliases while preserving ignored metadata",
async ({ id, previous, removed, retained }) => {
const original = JSON.stringify({ mcpServers: { rea: previous } }) + "\n";
const { path } = await settingsFixture(original);
expect(
await registerMcpClient(
clientFor(path, "json", id),
launcher,
undefined,
resources,
),
).toMatchObject({ ok: true });
const document = JSON.parse(await readFile(path, "utf8"));
expect(document.mcpServers.rea).toMatchObject({
command: launcher.command,
args: launcher.args,
...retained,
});
for (const field of removed)
expect(document.mcpServers.rea).not.toHaveProperty(field);
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original);
},
);
it.each([
{
id: "cline",
transport: {
type: "stdio",
command: "old",
args: ["old"],
env: { KEEP: "selected" },
cwd: "/fixture/selected-directory",
custom: { retained: true },
},
preserved: {
env: { KEEP: "selected" },
cwd: "/fixture/selected-directory",
custom: { retained: true },
},
},
{
id: "cline-cli",
transport: {
type: "streamableHttp",
url: "https://fixture.invalid",
headers: { "X-Selected": "fixture" },
custom: { retained: true },
},
preserved: {
headers: { "X-Selected": "fixture" },
custom: { retained: true },
},
},
] as const)(
"replaces the selected $id nested transport and retains native launch metadata",
async ({ id, transport, preserved }) => {
const original =
JSON.stringify({
mcpServers: {
rea: {
transport,
metadata: { operator: true },
cmd: "top-level-evidence",
uri: "top-level-evidence",
},
},
}) + "\n";
const { path } = await settingsFixture(original);
expect(
await registerMcpClient(
clientFor(path, "json", id),
launcher,
undefined,
resources,
),
).toMatchObject({ ok: true });
const document = JSON.parse(await readFile(path, "utf8"));
expect(document.mcpServers.rea).toEqual({
transport: {
...preserved,
type: "stdio",
command: launcher.command,
args: launcher.args,
},
metadata: { operator: true },
cmd: "top-level-evidence",
uri: "top-level-evidence",
});
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original);
},
);
@@ -1,463 +0,0 @@
import {
readFile,
readdir,
rename,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { join, sep } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { ok } from "../domain/result.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
let resources: ArtifactResourceScope;
beforeEach(() => {
resources = new ArtifactResourceScope();
});
import { OwnedStagedOutput } from "../filesystem/OwnedStagedOutput.js";
import {
registerMcpClient,
registerMcpClients,
} from "./McpClientRegistration.js";
import {
mcpClientRegistrationProfile,
type McpRegistrationClient,
} from "./McpRegistrationCatalog.js";
import { withClientConfigurationTransaction } from "./ClientConfigurationTransaction.js";
const clientFor = (
path: string,
format: McpRegistrationClient["format"] = "amp",
id: McpRegistrationClient["id"] = "amp",
): McpRegistrationClient => ({
id,
registrationProfile: mcpClientRegistrationProfile(id),
displayName: id,
configPath: path,
format,
scope: "global",
});
const register = (
path: string,
launcher: { command: string; args: readonly string[] },
signal?: AbortSignal,
) => registerMcpClient(clientFor(path), launcher, signal, resources);
import { ClientConfigurationPublication } from "./ClientConfigurationPublication.js";
import * as regularReads from "./RegularFileRead.js";
const launcher = {
command: "/fixture/server path",
args: ["", "a'b", "--mcp"],
};
const settingsFixture = async (original?: string) => {
const directory = await createTestTempDirectory("rea-amp-writer-");
const path = join(directory, "settings.json");
if (original !== undefined) await writeFile(path, original);
return { directory, path };
};
afterEach(async () => {
vi.restoreAllMocks();
await resources.close();
});
describe("Selected argv and client metadata preservation", () => {
it("preserves every peer and entry metadata while publishing exact argv with a private backup", async () => {
const document = {
"amp.mcpServers": {
rea: {
command: "old",
args: ["old"],
env: { KEEP: "value" },
enabled: false,
custom: [1, null],
},
other: { url: "http://fixture.invalid", custom: true },
["__proto__"]: { metadata: "preserved own key" },
},
theme: "dark",
nested: { value: [false, "preserved"] },
};
const original = JSON.stringify(document);
const { path, directory } = await settingsFixture(original);
const result = await register(path, launcher);
expect(result).toEqual(
ok({ status: "configured", backupPath: `${path}.rea.backup` }),
);
expect(JSON.parse(await readFile(path, "utf8"))).toEqual({
...document,
"amp.mcpServers": {
...document["amp.mcpServers"],
rea: { ...document["amp.mcpServers"].rea, ...launcher },
},
});
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original);
expect((await stat(`${path}.rea.backup`)).mode & 0o777).toBe(0o600);
expect(
(await readdir(directory)).some((name) =>
name.startsWith(".rea-config-"),
),
).toBe(false);
const beforeRepeat = await stat(path);
const contents = await readdir(directory);
expect(await register(path, launcher)).toEqual(ok({ status: "unchanged" }));
expect(await stat(path)).toMatchObject({
ino: beforeRepeat.ino,
mtimeMs: beforeRepeat.mtimeMs,
});
expect(await readdir(directory)).toEqual(contents);
});
it("creates absent parent directories and replaces scalar entries only when explicitly requested", async () => {
const fixture = await settingsFixture();
const path = join(fixture.directory, "new", "settings.json");
expect(await register(path, launcher)).toEqual(
ok({ status: "configured" }),
);
expect(JSON.parse(await readFile(path, "utf8"))).toEqual({
"amp.mcpServers": { rea: launcher },
});
const scalar = '{"amp.mcpServers":{"rea":false,"other":7}}';
await writeFile(fixture.path, scalar);
expect((await register(fixture.path, launcher)).ok).toBe(true);
expect(
JSON.parse(await readFile(fixture.path, "utf8"))["amp.mcpServers"],
).toEqual({ rea: launcher, other: 7 });
expect(await readFile(`${fixture.path}.rea.backup`, "utf8")).toBe(scalar);
});
it.each([
"{",
"null",
"[]",
'{"amp.mcpServers":null}',
'{"amp.mcpServers":[]}',
])(
"refuses malformed settings %s before backups or writes",
async (original) => {
const { path, directory } = await settingsFixture(original);
const result = await register(path, launcher);
expect(result.ok).toBe(false);
expect(await readFile(path, "utf8")).toBe(original);
expect(await readdir(directory)).toEqual(["settings.json"]);
},
);
it("rejects an empty executable before file writes", async () => {
const { path, directory } = await settingsFixture();
const result = await register(path, { command: "", args: [] });
expect(result.ok).toBe(false);
expect(await readdir(directory)).toEqual([]);
});
it("retains a first backup and selects a matching recovery snapshot for later edits", async () => {
const { path } = await settingsFixture('{"theme":"first"}');
await register(path, launcher);
const secondOriginal = await readFile(path, "utf8");
const result = await register(path, { command: "next", args: [] });
expect(result.ok && result.value.backupPath).toMatch(/\.recovery$/);
if (!result.ok || result.value.backupPath === undefined)
throw new Error("Missing recovery backup");
expect(await readFile(result.value.backupPath, "utf8")).toBe(
secondOriginal,
);
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(
'{"theme":"first"}',
);
});
it("refuses malformed UTF-8 and an occupied backup without replacing source facts", async () => {
const { path, directory } = await settingsFixture();
const invalid = Buffer.from([0x7b, 0xff, 0x7d]);
await writeFile(path, invalid);
expect((await register(path, launcher)).ok).toBe(false);
expect((await readFile(path)).equals(invalid)).toBe(true);
expect(await readdir(directory)).toEqual(["settings.json"]);
await writeFile(path, "{}");
await symlink(path, `${path}.rea.backup`);
expect((await register(path, launcher)).ok).toBe(false);
expect(await readFile(path, "utf8")).toBe("{}");
});
});
describe("Registration cancellation and mixed outcomes", () => {
it("checks cancellation before side effects and after publication settles", async () => {
const { path, directory } = await settingsFixture();
const before = new AbortController();
before.abort("before registration");
expect(await register(path, launcher, before.signal)).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(await readdir(directory)).toEqual([]);
const after = new AbortController();
const write = ClientConfigurationPublication.write;
vi.spyOn(ClientConfigurationPublication, "write").mockImplementation(
async (...args) => {
const publication = await write(...args);
after.abort("after publication");
return publication;
},
);
const result = await register(path, launcher, after.signal);
expect(result.ok ? undefined : result.error).toMatchObject({
_tag: "AnalysisCancelledError",
diagnostics: { configured: true },
});
expect(JSON.parse(await readFile(path, "utf8"))).toEqual({
"amp.mcpServers": { rea: launcher },
});
expect(await readdir(directory)).toEqual(["settings.json"]);
});
it("cancels queued registration after the existing transaction settles", async () => {
const { path } = await settingsFixture("{}");
let unblock!: () => void;
let entered!: () => void;
const blocked = new Promise<void>((resolve) => {
unblock = resolve;
});
const admitted = new Promise<void>((resolve) => {
entered = resolve;
});
const first = withClientConfigurationTransaction(path, async () => {
entered();
await blocked;
});
await admitted;
const controller = new AbortController();
const queued = register(path, launcher, controller.signal);
controller.abort("queued cancellation");
unblock();
await first;
expect(await queued).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(await readFile(path, "utf8")).toBe("{}");
});
it("keeps successful siblings after ordinary failures and marks work not started after cancellation", async () => {
const bad = await settingsFixture("null");
const good = await settingsFixture("{}");
const clients = [
clientFor(bad.path),
clientFor(good.path, "json", "cursor"),
];
const outcomes = await registerMcpClients(
clients,
launcher,
undefined,
resources,
);
expect(outcomes).toMatchObject([
{ status: "completed", result: { ok: false } },
{
status: "completed",
result: { ok: true, value: { status: "configured" } },
},
]);
const controller = new AbortController();
controller.abort("before batch");
const cancelled = await registerMcpClients(
clients,
launcher,
controller.signal,
resources,
);
expect(cancelled).toMatchObject([
{
status: "completed",
result: { ok: false, error: { _tag: "AnalysisCancelledError" } },
},
{ status: "not_started" },
]);
expect(await readFile(bad.path, "utf8")).toBe("null");
const late = new AbortController();
const write = ClientConfigurationPublication.write;
vi.spyOn(ClientConfigurationPublication, "write").mockImplementation(
async (...args) => {
const publication = await write(...args);
late.abort("published first sibling");
return publication;
},
);
const partial = await registerMcpClients(
[clients[1]!, clients[0]!],
{ command: "next", args: [] },
late.signal,
resources,
);
expect(partial).toMatchObject([
{
status: "completed",
result: {
ok: false,
error: {
_tag: "AnalysisCancelledError",
diagnostics: { configured: true },
},
},
},
{ status: "not_started" },
]);
expect(
JSON.parse(await readFile(good.path, "utf8")).mcpServers.rea.command,
).toBe("next");
});
});
describe("Client registration publication recovery", () => {
it("restores original bytes when owned readback fails", async () => {
const original = '{"theme":"keep"}';
const { path, directory } = await settingsFixture(original);
vi.spyOn(regularReads, "readRegularFileText").mockRejectedValueOnce(
new Error("fixture readback denied"),
);
const result = await register(path, launcher);
expect(result.ok).toBe(false);
expect(await readFile(path, "utf8")).toBe(original);
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original);
expect(
(await readdir(directory)).some((name) =>
name.startsWith(".rea-config-"),
),
).toBe(false);
});
it("preserves an external replacement instead of rolling it back", async () => {
const { path } = await settingsFixture('{"theme":"keep"}');
const external = '{"theme":"external"}';
const write = ClientConfigurationPublication.write;
vi.spyOn(ClientConfigurationPublication, "write").mockImplementation(
async (...args) => {
const publication = await write(...args);
await writeFile(path, external);
return publication;
},
);
expect((await register(path, launcher)).ok).toBe(false);
expect(await readFile(path, "utf8")).toBe(external);
});
it("retains failed staging cleanup after projection while a later layered sibling settles", async () => {
const first = await settingsFixture();
const second = await settingsFixture("{}");
const lower = join(second.directory, "lower.json");
await writeFile(
lower,
'{"mcp":{"peer":{"type":"local","command":["peer"]}}}',
);
const originalClose = OwnedStagedOutput.prototype.close;
const close = vi
.spyOn(OwnedStagedOutput.prototype, "close")
.mockImplementation(async function (this: OwnedStagedOutput) {
if (this.directory.startsWith(first.directory + sep))
throw new Error("fixture first staging cleanup denied");
return originalClose.call(this);
});
// Observe forbidden nested admission without leaving a self-waiting test.
const originalRun = resources.run.bind(resources);
let activeAdmission = false;
let nestedAdmissions = 0;
const admission = vi
.spyOn(resources, "run")
.mockImplementation(
async <Value>(operation: () => Promise<Value>): Promise<Value> => {
if (activeAdmission) {
nestedAdmissions++;
throw new Error(
"A retained caller scope must not be admitted recursively",
);
}
activeAdmission = true;
try {
return await originalRun(operation);
} finally {
activeAdmission = false;
}
},
);
try {
let projected: readonly string[];
{
const clients = [
clientFor(first.path),
{
...clientFor(second.path, "kilo", "kilo-code"),
configPaths: [lower, second.path],
},
];
const outcomes = await resources.run(() =>
registerMcpClients(clients, launcher, undefined, resources),
);
expect(outcomes).toMatchObject([
{
status: "completed",
result: { ok: false, error: { cleanupIncomplete: true } },
},
{
status: "completed",
result: { ok: true, value: { status: "configured" } },
},
]);
projected = outcomes.map((outcome) =>
outcome.status === "not_started"
? "not_started"
: outcome.result.ok
? outcome.result.value.status
: outcome.result.error._tag,
);
}
expect(projected).toEqual(["ConfigurationError", "configured"]);
expect(nestedAdmissions).toBe(0);
expect(resources.hasPending).toBe(true);
await expect(resources.close()).rejects.toBeDefined();
expect(resources.hasPending).toBe(true);
close.mockRestore();
await resources.close();
expect(resources.hasPending).toBe(false);
expect(await readdir(first.directory)).toEqual(["settings.json"]);
expect(
JSON.parse(await readFile(second.path, "utf8")).mcp.rea.command,
).toEqual([launcher.command, ...launcher.args]);
} finally {
close.mockRestore();
admission.mockRestore();
}
});
it("resolves an owned configuration symlink to its selected file", async () => {
const { path, directory } = await settingsFixture("{}");
const alias = join(directory, "alias.json");
await symlink(path, alias);
const result = await register(alias, launcher);
expect(result.ok).toBe(true);
expect(
JSON.parse(await readFile(path, "utf8"))["amp.mcpServers"].rea,
).toEqual(launcher);
expect(await readFile(alias, "utf8")).toBe(await readFile(path, "utf8"));
});
});
it("refuses a semantic no-op when the admitted file was replaced with identical bytes", async () => {
const original = JSON.stringify({ "amp.mcpServers": { rea: launcher } });
const { path } = await settingsFixture(original);
const read = regularReads.readRegularFileTextSnapshot;
let reads = 0;
let replaced = false;
vi.spyOn(regularReads, "readRegularFileTextSnapshot").mockImplementation(
async (...args) => {
if (++reads === 2) {
await writeFile(`${path}.replacement`, original);
await rename(`${path}.replacement`, path);
replaced = true;
}
return read(...args);
},
);
expect(await register(path, launcher)).toMatchObject({ ok: false });
expect(replaced).toBe(true);
expect(await readFile(path, "utf8")).toBe(original);
});
-416
View File
@@ -1,416 +0,0 @@
import { constants } from "node:fs";
import { lstat, mkdir, open } from "node:fs/promises";
import { dirname } from "node:path";
import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
} from "../artifacts/AnalysisCleanupLease.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import type { ClientRegistrationCommand } from "../domain/clientRegistrationCommand.js";
import {
sameRegularFileState,
RegularFileChangedError,
} from "../filesystem/RegularFile.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { writeClientRegistrationBackup } from "./ClientRegistrationBackup.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import {
ClientConfigurationPublicationFailure,
isClientConfigurationCleanupFailure,
retryClientConfigurationCleanup,
} from "./ClientConfigurationCleanup.js";
import { clientConfigurationValuesEqual } from "./ClientConfigurationDocument.js";
import { ClientConfigurationPublication } from "./ClientConfigurationPublication.js";
import { withClientConfigurationTransaction } from "./ClientConfigurationTransaction.js";
import {
prepareRegistration,
verifyEffectiveRegistration,
} from "./McpRegistrationConfiguration.js";
import type { McpRegistrationClient } from "./McpRegistrationCatalog.js";
import {
readRegularFileText,
readRegularFileTextSnapshot,
} from "./RegularFileRead.js";
export interface McpClientRegistrationReceipt {
readonly status: "configured" | "unchanged";
readonly backupPath?: string;
readonly permissions?:
| {
readonly kind: "verified_posix_modes";
readonly directoryMode: 0o700;
readonly fileMode: 0o600;
}
| {
readonly kind: "requested_windows_modes";
readonly requestedDirectoryMode: 0o700;
readonly requestedFileMode: 0o600;
readonly aclVerification: "unavailable";
};
}
export type McpClientRegistrationFailure =
| ConfigurationError
| AnalysisCancelledError;
interface OutcomeLabels {
readonly client: McpRegistrationClient["id"];
readonly configPath: string;
readonly scope: McpRegistrationClient["scope"];
}
export type McpClientRegistrationOutcome = OutcomeLabels &
(
| {
readonly status: "completed";
readonly result: Result<
McpClientRegistrationReceipt,
McpClientRegistrationFailure
>;
}
| { readonly status: "not_started" }
);
/** Preserve every selected client's outcome, including work never started after cancellation. */
export const registerMcpClients = async (
clients: readonly McpRegistrationClient[],
launcher: ClientRegistrationCommand,
signal: AbortSignal | undefined,
resources: ArtifactResourceScope,
): Promise<readonly McpClientRegistrationOutcome[]> => {
const outcomes: McpClientRegistrationOutcome[] = [];
let cancelled = false;
for (const client of clients) {
const labels = {
client: client.id,
configPath: client.configPath,
scope: client.scope,
};
if (cancelled) {
outcomes.push({ ...labels, status: "not_started" });
continue;
}
const result = await registerMcpClient(client, launcher, signal, resources);
outcomes.push({ ...labels, status: "completed", result });
cancelled = !result.ok && result.error instanceof AnalysisCancelledError;
}
return outcomes;
};
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
/** One selected document owner admits, merges and verifies exact launcher facts. */
export const registerMcpClient = async (
client: McpRegistrationClient,
launcher: ClientRegistrationCommand,
signal: AbortSignal | undefined,
resources: ArtifactResourceScope,
): Promise<
Result<McpClientRegistrationReceipt, McpClientRegistrationFailure>
> => {
const settingsPath = client.configPath;
let backupPath: string | undefined;
let configured = false;
const checkCancellation = (): void => {
if (signal?.aborted === true)
throw new AnalysisCancelledError("MCP registration", {
cause: signal.reason,
diagnostics: {
client: client.id,
settings_path: settingsPath,
configured,
...(backupPath === undefined ? {} : { backup_path: backupPath }),
},
});
};
try {
checkCancellation();
// The caller supplies canonical argv, not a native client-shaped entry.
if (launcher.command.length === 0)
throw new ConfigurationError(
"The registration needs a nonempty executable.",
);
const path = await resolveClientConfigTransactionPath(settingsPath);
if (path === undefined)
throw new ConfigurationError(
`Client settings path could not be verified: ${settingsPath}`,
);
return await withClientConfigurationTransaction(path, async () => {
checkCancellation();
const prepared = await prepareRegistration(path, client, launcher);
const { snapshot, original, contents } = prepared;
checkCancellation();
const unchanged = await unchangedRegistration(
prepared,
checkCancellation,
resources,
);
checkCancellation();
if (unchanged !== undefined) return ok(unchanged);
if (original !== undefined) {
const backup = await writeClientRegistrationBackup(path, original);
if (!backup.ok)
throw new ConfigurationError(backup.error.detail, {
diagnostics: {
settings_path: settingsPath,
backup_path: backup.error.path,
},
});
backupPath = backup.value;
}
checkCancellation();
await mkdir(dirname(path), {
recursive: true,
...(client.privateProfile === true ? { mode: 0o700 } : {}),
});
checkCancellation();
if (client.privateProfile === true && process.platform !== "win32")
await securePrivateDirectory(
dirname(path),
checkCancellation,
resources,
);
checkCancellation();
const publication = await ClientConfigurationPublication.write(
path,
contents,
original,
{
requestedPath: settingsPath,
state: snapshot?.state,
...(client.privateProfile === true
? { requiredMode: 0o600 as const }
: {}),
},
);
// Cancellation cannot abandon published bytes, verification or retained cleanup.
await verifyRegistration(publication, prepared, backupPath, resources);
configured = true;
checkCancellation();
return ok({
status: "configured",
...(backupPath === undefined ? {} : { backupPath }),
...(client.privateProfile === true
? { permissions: privateProfilePermissions() }
: {}),
});
});
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) return err(cause);
configured ||=
cause instanceof ClientConfigurationPublicationFailure &&
cause.outcome.kind === "completed";
return err(
await registrationFailure(cause, settingsPath, resources, {
backupPath,
configured,
}),
);
}
};
const registrationFailure = async (
cause: unknown,
path: string,
resources: ArtifactResourceScope,
progress: {
readonly backupPath: string | undefined;
readonly configured: boolean;
},
): Promise<ConfigurationError> => {
const { backupPath, configured } = progress;
if (cause instanceof ConfigurationError) return cause;
const cleanup = isClientConfigurationCleanupFailure(cause)
? await retryClientConfigurationCleanup(cause, resources)
: undefined;
const primary = cleanup?.primary ?? cause;
const detail = primary instanceof Error ? primary.message : String(primary);
const error = new ConfigurationError(
`Could not register MCP settings at ${path}: ${detail}`,
{
cause,
...(cleanup?.cleanup === undefined ? {} : { cleanup: cleanup.cleanup }),
diagnostics: {
settings_path: path,
configured,
...(backupPath === undefined ? {} : { backup_path: backupPath }),
},
},
);
if (cleanup !== undefined)
new AnalysisCleanupLeaseGroup(error, cleanup.cleanupLeases);
return error;
};
/** A semantic no-op is usable only while the admitted physical snapshot remains current. */
const unchangedRegistration = async (
prepared: Awaited<ReturnType<typeof prepareRegistration>>,
checkCancellation: () => void,
resources: ArtifactResourceScope,
): Promise<McpClientRegistrationReceipt | undefined> => {
const { client, path, snapshot, parsed, updated } = prepared;
if (
parsed.requiresRewrite === true ||
!clientConfigurationValuesEqual(parsed.document, updated) ||
(client.privateProfile === true &&
process.platform !== "win32" &&
((snapshot?.state.mode ?? 0) & 0o777) !== 0o600)
)
return undefined;
const current = await readRegularFileTextSnapshot(path, {
symlinks: "reject",
});
if (
snapshot === undefined ||
current.text !== snapshot.text ||
!sameRegularFileState(snapshot.state, current.state)
)
throw new RegularFileChangedError(path);
checkCancellation();
await verifyEffectiveRegistration(prepared, current.text);
const permissionsChanged =
client.privateProfile === true && process.platform !== "win32"
? await securePrivateDirectory(
dirname(path),
checkCancellation,
resources,
)
: false;
return {
status: permissionsChanged ? "configured" : "unchanged",
...(client.privateProfile === true
? { permissions: privateProfilePermissions() }
: {}),
};
};
const verifyRegistration = async (
publication: ClientConfigurationPublication,
prepared: Awaited<ReturnType<typeof prepareRegistration>>,
backupPath: string | undefined,
resources: ArtifactResourceScope,
): Promise<void> => {
const { original, client } = prepared;
const settingsPath = client.configPath;
await publication.verify(async () => {
try {
const readback = await readRegularFileText(publication.path, {
symlinks: "reject",
});
await publication.assertCurrent(readback);
await verifyEffectiveRegistration(prepared, readback);
} catch (cause: unknown) {
if (cause instanceof RegularFileChangedError || isMissing(cause))
throw cause;
const failure = await registrationFailure(
cause,
settingsPath,
resources,
{ backupPath, configured: false },
);
try {
await publication.restore(original);
} catch (rollbackCause: unknown) {
throw new ConfigurationError(
"Client settings verification failed and restoration could not be verified",
{
cause: rollbackCause,
cleanupPrimary: failure,
diagnostics: {
settings_path: settingsPath,
...(backupPath === undefined ? {} : { backup_path: backupPath }),
},
},
);
}
throw failure;
}
});
};
/** fx's profile permissions are applied through the selected directory descriptor. */
const securePrivateDirectory = async (
path: string,
checkCancellation: () => void,
resources: ArtifactResourceScope,
): Promise<boolean> => {
const owner = new OwnedFileHandle(
await open(
path,
constants.O_RDONLY |
(constants.O_DIRECTORY ?? 0) |
(constants.O_NOFOLLOW ?? 0),
),
);
const lease = AnalysisCleanupLease.forCloseOwner(owner, path);
let primary: unknown;
let changed = false;
try {
const before = await owner.handle.stat();
const named = await lstat(path);
if (
!before.isDirectory() ||
named.isSymbolicLink() ||
before.ino === 0 ||
before.dev !== named.dev ||
before.ino !== named.ino ||
!named.isDirectory()
)
throw new RegularFileChangedError(path);
changed = (before.mode & 0o777) !== 0o700;
checkCancellation();
if (changed) await owner.handle.chmod(0o700);
const after = await owner.handle.stat();
const current = await lstat(path);
if (
(after.mode & 0o777) !== 0o700 ||
current.isSymbolicLink() ||
!current.isDirectory() ||
current.dev !== after.dev ||
current.ino !== after.ino
)
throw new RegularFileChangedError(path);
} catch (cause: unknown) {
primary = cause;
}
try {
await lease.release();
} catch (cause: unknown) {
const error = new ConfigurationError(
`Private client directory cleanup failed at ${path}`,
{
cause:
primary === undefined
? cause
: new AggregateError(
[primary, cause],
"Private profile permission operation and directory cleanup failed",
),
cleanup: {
reason: "Private profile directory handle could not be closed",
resources: [path],
},
},
);
new AnalysisCleanupLeaseGroup(error, [lease]);
resources.retain({ kind: "lease", resource: path, lease });
throw error;
}
if (primary !== undefined) throw primary;
return changed;
};
/** Mode requests cannot establish Windows ACL privacy. */
const privateProfilePermissions = (): NonNullable<
McpClientRegistrationReceipt["permissions"]
> =>
process.platform === "win32"
? {
kind: "requested_windows_modes",
requestedDirectoryMode: 0o700,
requestedFileMode: 0o600,
aclVerification: "unavailable",
}
: { kind: "verified_posix_modes", directoryMode: 0o700, fileMode: 0o600 };
@@ -1,524 +0,0 @@
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { createTestProcessRegistry } from "../../tests/support/process/processFixture.js";
import { resolveMcpRegistrationClientId } from "./McpRegistrationCatalog.js";
it.each([
["cline-vscode", "cline"],
["codeium", "windsurf"],
["cascade", "windsurf"],
["droid", "factory-droid"],
["factory", "factory-droid"],
["gemini", "gemini-cli"],
["github-copilot", "vscode"],
["grok", "grok-build"],
["hermes-agent", "hermes"],
["kilo", "kilo-code"],
["kilocode", "kilo-code"],
["kimi", "kimi-code"],
["kiro", "kiro-cli"],
["mastra", "mastracode"],
["pi-agent", "pi"],
])("retains helper alias %s as %s", (alias, expected) => {
expect(resolveMcpRegistrationClientId(alias.toUpperCase())).toBe(expected);
});
it("does not treat prototype names or unknown strings as client identities", () => {
expect(resolveMcpRegistrationClientId("__proto__")).toBeUndefined();
expect(resolveMcpRegistrationClientId("constructor")).toBeUndefined();
expect(resolveMcpRegistrationClientId("unknown-client")).toBeUndefined();
});
const clientSchema = z.object({
id: z.string(),
displayName: z.string(),
configPath: z.string(),
configPaths: z.array(z.string()).optional(),
scope: z.enum(["global", "project"]),
format: z.string(),
privateProfile: z.literal(true).optional(),
inlineConfiguration: z
.object({ origin: z.literal("KILO_CONFIG_CONTENT"), parsed: z.unknown() })
.optional(),
});
const resultSchema = z.discriminatedUnion("ok", [
z.object({ ok: z.literal(true), value: z.array(clientSchema) }),
z.object({
ok: z.literal(false),
error: z.object({ tag: z.string(), message: z.string() }),
}),
]);
const catalogObservationsSchema = z.object({
empty: resultSchema,
layeredKilo: resultSchema,
layeredOpenCode: resultSchema,
explicit: resultSchema,
detected: resultSchema,
project: resultSchema,
onlyGlobal: resultSchema,
defaultProject: resultSchema,
projectAmp: resultSchema,
unknown: resultSchema,
blockedCandidate: resultSchema.nullable(),
unknownDetection: resultSchema,
cancelled: resultSchema,
ancestorLayers: resultSchema.nullable(),
selectedLayers: resultSchema,
projectReadonly: resultSchema.nullable(),
noRepository: resultSchema.nullable(),
disabled: resultSchema.nullable(),
invalidInline: resultSchema,
invalidGit: resultSchema.nullable(),
unterminatedGit: resultSchema.nullable(),
linkedWorktree: resultSchema.nullable(),
missingTarget: resultSchema.nullable(),
cancelledGit: resultSchema.nullable(),
repeatedFile: resultSchema,
repeatedDirectory: resultSchema,
whitespaceWorktree: resultSchema.nullable(),
});
const FAKE_GIT_PROGRAM = String.raw`const fs = require("node:fs");
const state = JSON.parse(fs.readFileSync(process.env.REA_TEST_GIT_STATE,"utf8"));
fs.appendFileSync(process.env.REA_TEST_GIT_CALLS,JSON.stringify(process.argv.slice(2))+"\n");
if (state.invalid) process.stdout.write(Buffer.from([255,10]));
else if (state.unterminated) process.stdout.write(state.root);
else if (state.hang) { fs.writeFileSync(state.hang,"ready"); setInterval(()=>{},1000); }
else {
const key=process.argv[3];
process.stdout.write(key==="--show-toplevel"?state.root+"\n":key==="--git-dir"?state.git+"\n":key==="--git-common-dir"?state.common+"\n":"worktree "+state.primary+"\0HEAD inert\0\0");
}
`;
const CATALOG_PROGRAM = `
import { homedir } from 'node:os';
import { join, relative, isAbsolute, delimiter } from 'node:path';
import { mkdir, writeFile, rm, realpath, readFile, lstat } from 'node:fs/promises';
const home = process.env.HOME;
if (!home || await realpath(home) !== await realpath(homedir())) throw Error('Fixture HOME not selected before catalog import.');
for (const key of ['USERPROFILE','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','APPDATA','LOCALAPPDATA']) {
const path = relative(home, process.env[key]);
if (path.startsWith('..') || isAbsolute(path)) throw Error('Fixture location escaped: ' + key);
}
const { resolveMcpRegistrationClients } = await import(${JSON.stringify(new URL("./McpRegistrationCatalog.ts", import.meta.url).href)});
const { ArtifactResourceScope } = await import(${JSON.stringify(new URL("../artifacts/ArtifactResourceScope.ts", import.meta.url).href)});
const resources = new ArtifactResourceScope();
try {
const posix = process.platform !== 'win32';
const cwd = join(home, 'project');
await mkdir(cwd);
const selection = { agents: [], global: true, home, cwd, platform: process.platform, environment: process.env };
const observe = async (changes = {}) => {
const result = await resources.run(() => resolveMcpRegistrationClients({ ...selection, ...changes }, resources));
if (!result.ok) return { ok: false, error: { tag: result.error._tag, message: result.error.message } };
for (const client of result.value) for (const source of [client.configPath,...client.configPaths ?? []]) {
const path = relative(home, source);
if (path.startsWith('..') || isAbsolute(path)) throw Error('Catalog path escaped temporary HOME: ' + client.id);
}
return result;
};
// Only the authored POSIX Git executable and newline-path controls are omitted on Windows.
const observeProject = changes => posix ? observe({agents:['kilo'],global:false,...changes}) : null;
const empty = await observe();
const layeredRoot = join(home, 'layered', 'kilo');
await mkdir(layeredRoot, { recursive: true });
for (const name of ['config.json','kilo.json','kilo.jsonc','opencode.json','opencode.jsonc']) await writeFile(join(layeredRoot,name),'{}');
const layeredKilo = await observe({ agents:['kilo'], environment:{...process.env,XDG_CONFIG_HOME:join(home,'layered')} });
const layeredOpenCode = await observe({ agents:['opencode'] });
const customCodex = join(home, 'selected-codex');
const explicit = await observe({ agents: ['CODEX','Amp','gemini','fx','codeium','windsurf'], environment: { ...process.env, CODEX_HOME: customCodex } });
await mkdir(join(home, '.claude'));
await mkdir(join(home, '.cursor'));
const detected = await observe();
const gitBin = join(home, 'git-bin');
await mkdir(gitBin);
const gitState = join(home, 'git-state.json');
const gitCalls = join(home, 'git-calls.jsonl');
await mkdir(join(cwd, '.git'));
const gitProgram = ${JSON.stringify("#!" + process.execPath + "\n" + FAKE_GIT_PROGRAM)};
await writeFile(join(gitBin,'git'),gitProgram,{mode:0o755});
const gitEnvironment = {...process.env, PATH:gitBin+delimiter+process.env.PATH, REA_TEST_GIT_STATE:gitState, REA_TEST_GIT_CALLS:gitCalls};
const state = {root:cwd,git:join(cwd,'.git'),common:join(cwd,'.git')};
await writeFile(gitState,JSON.stringify(state));
selection.environment = gitEnvironment;
await mkdir(join(cwd, '.github'));
await writeFile(join(cwd, '.github', 'mcp.json'), '{}');
await mkdir(join(cwd, '.kilocode'));
await writeFile(join(cwd, '.kilocode', 'kilo.jsonc'), '{}');
const project = await observe({ agents: ['github-copilot-cli',...(posix?['kilo']:[]),'opencode'], global: false });
const nested = join(cwd,'nested');
await mkdir(join(nested,'.kilocode'),{recursive:true});
await writeFile(join(cwd,'kilo.json'),'{}');
await writeFile(join(nested,'kilo.jsonc'),'{}');
await writeFile(join(cwd,'opencode.json'),'{}');
await writeFile(join(nested,'opencode.jsonc'),'{}');
await writeFile(join(nested,'.kilocode','kilo.jsonc'),'{}');
const ancestorLayers = await observeProject({cwd:nested});
const overrideFile = join(home,'override.json');
const overrideDirectory = join(home,'override-dir');
await writeFile(overrideFile,'{}');
await mkdir(overrideDirectory);
await writeFile(join(overrideDirectory,'kilo.jsonc'),'{}');
await writeFile(join(overrideDirectory,'opencode.json'),'{}');
const selectedLayers = await observe({agents:['kilo'],environment:{...gitEnvironment,KILO_CONFIG:overrideFile,KILO_CONFIG_DIR:overrideDirectory,KILO_CONFIG_CONTENT:'{"metadata":"selected","mcp":{"peer":{"type":"local","command":["peer"]}}}'}});
const projectReadonly = await observeProject({cwd:nested,environment:{...gitEnvironment,KILO_CONFIG:overrideFile,KILO_CONFIG_DIR:overrideDirectory,KILO_CONFIG_CONTENT:'{"metadata":"readonly"}'}});
const noRepository = await observeProject({cwd:home});
const disabled = await observeProject({environment:{...gitEnvironment,KILO_DISABLE_PROJECT_CONFIG:'true'}});
const invalidInline = await observe({agents:['kilo'],environment:{...gitEnvironment,KILO_CONFIG_CONTENT:'{'}});
await writeFile(gitState,JSON.stringify({...state,invalid:true}));
const invalidGit = await observeProject({});
await writeFile(gitState,JSON.stringify({...state,unterminated:true}));
const unterminatedGit = await observeProject({});
const primary = join(home,'primary');
await mkdir(join(primary,'.git'),{recursive:true});
await mkdir(join(primary,'nested','.kilo'),{recursive:true});
await writeFile(join(primary,'nested','.kilo','opencode.json'),'{}');
await writeFile(gitState,JSON.stringify({...state,common:join(primary,'.git'),primary}));
const linkedWorktree = await observeProject({cwd:nested});
await writeFile(gitState,JSON.stringify(state));
const emptyRoot = join(home,'empty-worktree');
await mkdir(join(emptyRoot,'.git'),{recursive:true});
await writeFile(gitState,JSON.stringify({root:emptyRoot,git:join(emptyRoot,'.git'),common:join(emptyRoot,'.git')}));
const missingTarget = await observeProject({cwd:emptyRoot,environment:{...gitEnvironment,KILO_CONFIG_DIR:overrideDirectory}});
let cancelledGit = null;
if(posix) {
const ready = join(home,'git-ready');
await writeFile(gitState,JSON.stringify({...state,hang:ready}));
const duringGit = new AbortController();
const cancellation = observeProject({signal:duringGit.signal});
try {
const end = Date.now()+3000;
while(true) { try { await lstat(ready); break; } catch(error) { if(error.code!=='ENOENT')throw error; } if(Date.now()>=end)throw Error('Inert Git did not become ready');await new Promise(resolve=>setTimeout(resolve,10)); }
} finally { duringGit.abort({fixture:'during-readonly-git'}); }
cancelledGit = await cancellation;
await writeFile(gitState,JSON.stringify(state));
const gitQueries = (await readFile(gitCalls,'utf8')).trimEnd().split('\\n').map(line=>JSON.parse(line));
for(const args of gitQueries)if(![['rev-parse','--show-toplevel'],['rev-parse','--git-dir'],['rev-parse','--git-common-dir'],['worktree','list','--porcelain','-z']].some(expected=>JSON.stringify(expected)===JSON.stringify(args)))throw Error('Unexpected Git command');
}
let whitespaceWorktree = null;
if(posix) {
const whitespaceRoot = join(home,' worktree'+String.fromCharCode(10)+'with-space ');
await mkdir(join(whitespaceRoot,'.git'),{recursive:true});
await writeFile(gitState,JSON.stringify({root:whitespaceRoot,git:join(whitespaceRoot,'.git'),common:join(whitespaceRoot,'.git')}));
whitespaceWorktree = await observeProject({cwd:whitespaceRoot});
await writeFile(gitState,JSON.stringify(state));
}
const repeatedFile = await observe({agents:['kilo'],environment:{...gitEnvironment,XDG_CONFIG_HOME:join(home,'layered'),KILO_CONFIG:join(layeredRoot,'kilo.json')}});
await mkdir(join(home,'.kilocode'));
await mkdir(join(home,'.kilo'));
await writeFile(join(home,'.kilo','opencode.json'),'{}');
const repeatedDirectory = await observe({agents:['kilo'],environment:{...gitEnvironment,KILO_CONFIG_DIR:join(home,'.kilocode')}});
const onlyGlobal = await observe({ agents: ['cursor','goose'], global: false });
const defaultProject = await observe({ global: false });
const projectAmp = await observe({ agents: ['amp'], global: false });
const unknown = await observe({ agents: ['unknown-client'] });
await writeFile(join(cwd, '.kilo'), 'blocks candidate directory');
const blockedCandidate = await observeProject({});
await writeFile(join(home, '.cline'), 'blocks detection directory');
const unknownDetection = await observe();
const controller = new AbortController(); controller.abort({ fixture: 'cancel' });
const cancelled = await observe({ agents: ['amp'], signal: controller.signal });
console.log(JSON.stringify({ empty, layeredKilo, layeredOpenCode, explicit, detected, project, onlyGlobal, defaultProject, projectAmp, unknown, blockedCandidate, unknownDetection, cancelled, ancestorLayers, selectedLayers, projectReadonly, noRepository, disabled, invalidInline, invalidGit, unterminatedGit, linkedWorktree, missingTarget, cancelledGit, repeatedFile, repeatedDirectory, whitespaceWorktree }));
} finally { await resources.close(); if(resources.hasPending) throw Error("Git cleanup remained owned."); }
`;
const REVIEWED_CLIENT_IDS = [
"antigravity",
"cline",
"cline-cli",
"claude-code",
"claude-desktop",
"codex",
"cursor",
"factory-droid",
"fx",
"gemini-cli",
"goose",
"github-copilot-cli",
"grok-build",
"hermes",
"junie",
"kilo-code",
"kimi-code",
"kiro-cli",
"mastracode",
"mcporter",
"opencode",
"pi",
"vscode",
"windsurf",
"zed",
"amp",
];
it("resolves selected-environment paths and scopes without external catalog readers", async () => {
const home = await createTestTempDirectory("rea-registration-catalog-");
const processes = createTestProcessRegistry();
try {
await mkdir(join(home, "config"));
const child = await processes.run(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", CATALOG_PROGRAM],
{
timeoutMs: 50_000,
env: {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
WINDIR: process.env.WINDIR,
TMPDIR: process.env.TMPDIR,
TMP: process.env.TMP,
TEMP: process.env.TEMP,
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, "config"),
XDG_DATA_HOME: join(home, "data"),
XDG_CACHE_HOME: join(home, "cache"),
XDG_STATE_HOME: join(home, "state"),
APPDATA: join(home, "appdata"),
LOCALAPPDATA: join(home, "localappdata"),
},
},
);
expect(child.exitCode, child.stderr).toBe(0);
const result = catalogObservationsSchema.parse(JSON.parse(child.stdout));
assertKiloNativeLayers(result, home);
expect(result.empty.ok).toBe(true);
if (!result.empty.ok) throw new Error(result.empty.error.message);
expect(result.empty.value.map((client) => client.id)).toEqual(
REVIEWED_CLIENT_IDS,
);
expect(result.layeredKilo.ok).toBe(true);
if (!result.layeredKilo.ok)
throw new Error(result.layeredKilo.error.message);
const kiloLayers = [
"config.json",
"kilo.json",
"kilo.jsonc",
"opencode.json",
"opencode.jsonc",
].map((name) => join(home, "layered", "kilo", name));
expect(result.layeredKilo.value[0]).toMatchObject({
configPath: kiloLayers.at(-1),
configPaths: kiloLayers,
});
expect(result.layeredOpenCode.ok).toBe(true);
if (!result.layeredOpenCode.ok)
throw new Error(result.layeredOpenCode.error.message);
expect(result.layeredOpenCode.value[0]?.configPaths).toEqual(
["config.json", "opencode.json", "opencode.jsonc"].map((name) =>
join(home, "config", "opencode", name),
),
);
expect(result.explicit.ok).toBe(true);
if (!result.explicit.ok) throw new Error(result.explicit.error.message);
expect(result.explicit.value.map((client) => client.id)).toEqual([
"codex",
"amp",
"gemini-cli",
"fx",
"windsurf",
]);
expect(result.explicit.value[0]?.configPath).toBe(
join(home, "selected-codex", "config.toml"),
);
expect(
result.explicit.value.find((client) => client.id === "fx"),
).toMatchObject({ format: "fx", privateProfile: true });
expect(result.detected.ok).toBe(true);
if (!result.detected.ok) throw new Error(result.detected.error.message);
expect(result.detected.value.map((client) => client.id)).toEqual([
"claude-code",
"cursor",
"amp",
]);
expect(result.project.ok, JSON.stringify(result.project)).toBe(true);
if (!result.project.ok) throw new Error(result.project.error.message);
expect(result.project.value[0]?.format).toBe("copilot_cli_project");
expect(
result.project.value.map(({ configPath, scope }) => ({
configPath,
scope,
})),
).toEqual([
{
configPath: join(home, "project", ".github", "mcp.json"),
scope: "project",
},
...(process.platform === "win32"
? []
: [
{
configPath: join(home, "project", ".kilocode", "kilo.jsonc"),
scope: "project",
},
]),
{
configPath: join(home, "project", "opencode.jsonc"),
scope: "project",
},
]);
for (const refused of [
result.onlyGlobal,
result.defaultProject,
result.projectAmp,
result.unknown,
...(result.blockedCandidate === null ? [] : [result.blockedCandidate]),
result.unknownDetection,
]) {
expect(refused.ok).toBe(false);
if (refused.ok) throw new Error("Expected preflight refusal.");
expect(refused.error.tag).toBe("ConfigurationError");
}
expect(result.cancelled).toMatchObject({
ok: false,
error: { tag: "AnalysisCancelledError" },
});
} finally {
await processes.shutdown();
await rm(home, { recursive: true, force: true });
}
}, 60_000);
const assertKiloNativeLayers = (
result: z.infer<typeof catalogObservationsSchema>,
home: string,
): void => {
if (process.platform === "win32") {
for (const name of [
"ancestorLayers",
"projectReadonly",
"noRepository",
"disabled",
"invalidGit",
"unterminatedGit",
"linkedWorktree",
"missingTarget",
"cancelledGit",
"whitespaceWorktree",
"blockedCandidate",
] as const)
expect(result[name]).toBeNull();
return;
}
const client = (
name:
| "ancestorLayers"
| "selectedLayers"
| "projectReadonly"
| "linkedWorktree",
) => {
const value = result[name];
if (value === null) throw Error(`Missing POSIX ${name} observation`);
if (!value.ok) throw Error(value.error.message);
const entry = value.value[0];
if (!entry) throw Error(`No ${name} client`);
return entry;
};
const project = join(home, "project");
const native = [
join(project, "kilo.json"),
join(project, "nested", "kilo.jsonc"),
join(project, "opencode.json"),
join(project, "nested", "opencode.jsonc"),
join(project, "nested", ".kilocode", "kilo.jsonc"),
join(project, ".kilocode", "kilo.jsonc"),
];
expect(client("ancestorLayers")).toMatchObject({
configPath: native.at(-1),
configPaths: native,
});
expect(client("selectedLayers")).toMatchObject({
configPath: join(home, "override-dir", "opencode.json"),
configPaths: [
join(home, "override.json"),
join(home, "override-dir", "kilo.jsonc"),
join(home, "override-dir", "opencode.json"),
],
inlineConfiguration: { origin: "KILO_CONFIG_CONTENT" },
});
expect(client("projectReadonly")).toMatchObject({
configPath: native.at(-1),
configPaths: [
join(home, "override.json"),
...native,
join(home, "override-dir", "kilo.jsonc"),
join(home, "override-dir", "opencode.json"),
],
});
expect(client("linkedWorktree")).toMatchObject({
configPath: native.at(-1),
configPaths: [
...native.slice(0, 4),
join(home, "primary", "nested", ".kilo", "opencode.json"),
...native.slice(4),
],
});
if (result.missingTarget === null)
throw Error("Missing POSIX planned target observation");
expect(result.missingTarget.ok).toBe(true);
if (!result.missingTarget.ok) throw Error(result.missingTarget.error.message);
const planned = join(home, "empty-worktree", ".kilo", "kilo.jsonc");
expect(result.missingTarget.value[0]).toMatchObject({
configPath: planned,
configPaths: [
planned,
join(home, "override-dir", "kilo.jsonc"),
join(home, "override-dir", "opencode.json"),
],
});
expect(result.cancelledGit).toMatchObject({
ok: false,
error: { tag: "AnalysisCancelledError" },
});
for (const name of [
"repeatedFile",
"repeatedDirectory",
"whitespaceWorktree",
] as const) {
const entry = result[name];
if (entry === null) throw Error(`Missing POSIX ${name} observation`);
if (!entry.ok) throw Error(entry.error.message);
}
if (result.repeatedFile.ok)
expect(result.repeatedFile.value[0]).toMatchObject({
configPath: join(home, "layered", "kilo", "kilo.json"),
configPaths: [
...[
"config.json",
"kilo.json",
"kilo.jsonc",
"opencode.json",
"opencode.jsonc",
].map((name) => join(home, "layered", "kilo", name)),
join(home, "layered", "kilo", "kilo.json"),
],
});
if (result.repeatedDirectory.ok)
expect(result.repeatedDirectory.value[0]).toMatchObject({
configPath: join(home, ".kilocode", "kilo.json"),
configPaths: [
join(home, ".kilocode", "kilo.json"),
join(home, ".kilo", "opencode.json"),
],
});
if (result.whitespaceWorktree?.ok)
expect(result.whitespaceWorktree.value[0]?.configPath).toBe(
join(home, " worktree\nwith-space ", ".kilo", "kilo.jsonc"),
);
for (const name of [
"noRepository",
"disabled",
"invalidInline",
"invalidGit",
"unterminatedGit",
] as const)
expect(result[name]).toMatchObject({
ok: false,
error: { tag: "ConfigurationError" },
});
};
-529
View File
@@ -1,529 +0,0 @@
import {
kiloLocation,
kiloProjectLocation,
} from "./McpRegistrationKiloLocations.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import type { ClientConfigurationFormat } from "./ClientConfigurationDocument.js";
import {
supportedClients,
supportedClientRegistrationProfile,
} from "./SupportedClients.js";
import type {
ClientRegistrationConnectionProfile,
ClientRegistrationProfile,
NativeStdioIncompatibility,
} from "./ClientRegistrationProfile.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
selectedPath,
fromSetup,
present,
fixedProjectLocation,
clineLocation,
clineCliLocation,
fxLocation,
gooseLocation,
junieLocation,
kiroLocation,
mastraLocation,
mcporterLocation,
zedLocation,
ampLocation,
type McpRegistrationLocationContext,
type McpRegistrationGlobalLocation,
type McpRegistrationLocation,
type McpRegistrationInlineConfiguration,
} from "./McpRegistrationLocations.js";
interface ClientDescriptorBase {
readonly displayName: string;
readonly format: ClientConfigurationFormat;
readonly globalLocation: (
context: McpRegistrationLocationContext,
) => Promise<McpRegistrationGlobalLocation>;
readonly projectLocation?: (
context: McpRegistrationLocationContext,
) => Promise<McpRegistrationLocation>;
readonly projectFormat?: ClientConfigurationFormat;
}
type ClientDescriptor = ClientDescriptorBase &
(
| {
readonly registrationProfile: ClientRegistrationProfile;
readonly registrationConnection?: never;
}
| {
readonly registrationConnection: ClientRegistrationConnectionProfile;
readonly registrationProfile?: never;
}
);
/** mcporter getUrl's native nullish priority; an empty first value suppresses later aliases. */
const MCPORTER_URL_FIELDS = [
"baseUrl",
"base_url",
"url",
"serverUrl",
"server_url",
] as const;
const MCPORTER_STDIO_INCOMPATIBILITIES: readonly NativeStdioIncompatibility[] =
[
// RawEntrySchema admits only optional strings, including an empty string.
...MCPORTER_URL_FIELDS.map((field): NativeStdioIncompatibility => ({
kind: "invalid-string",
field,
})),
...MCPORTER_URL_FIELDS.map((field, index): NativeStdioIncompatibility =>
index === 0
? { kind: "truthy", field }
: {
kind: "all",
conditions: [
...MCPORTER_URL_FIELDS.slice(0, index).map(
(prior): NativeStdioIncompatibility => ({
kind: "nullish",
field: prior,
}),
),
{ kind: "truthy", field },
],
},
),
];
/** Reviewed add-mcp 2.6.1 client contracts plus Incur's separate Amp contract. */
export const MCP_REGISTRATION_CLIENTS = {
antigravity: {
displayName: "Antigravity",
format: "json",
globalLocation: fromSetup("antigravity"),
registrationProfile: supportedClientRegistrationProfile("antigravity"),
},
cline: {
displayName: "Cline VSCode Extension",
format: "json",
globalLocation: clineLocation,
registrationConnection: { kind: "cline" },
},
"cline-cli": {
displayName: "Cline CLI",
format: "json",
globalLocation: clineCliLocation,
registrationConnection: { kind: "cline" },
},
"claude-code": {
displayName: "Claude Code",
format: "claude_code",
globalLocation: fromSetup("claude_code"),
registrationProfile: supportedClientRegistrationProfile("claude_code"),
projectLocation: fixedProjectLocation([".mcp.json"]),
},
"claude-desktop": {
displayName: "Claude Desktop",
format: "json",
globalLocation: fromSetup("claude_desktop"),
registrationProfile: supportedClientRegistrationProfile("claude_desktop"),
},
codex: {
displayName: "Codex",
format: "toml",
globalLocation: fromSetup("codex"),
registrationProfile: supportedClientRegistrationProfile("codex"),
projectLocation: fixedProjectLocation([".codex/config.toml"]),
},
cursor: {
displayName: "Cursor",
format: "json",
globalLocation: fromSetup("cursor"),
registrationProfile: supportedClientRegistrationProfile("cursor"),
projectLocation: fixedProjectLocation([".cursor/mcp.json"]),
},
"factory-droid": {
displayName: "Factory Droid",
format: "json",
globalLocation: fromSetup("droid"),
registrationProfile: supportedClientRegistrationProfile("droid"),
projectLocation: fixedProjectLocation([".factory/mcp.json"]),
},
fx: {
displayName: "fx",
format: "fx",
globalLocation: fxLocation,
registrationConnection: {
kind: "flat",
fields: ["url", "args"],
stdio: [
{ kind: "optional-enum", field: "type", values: ["local", "stdio"] },
{
kind: "all",
conditions: [
{ kind: "missing", field: "type" },
{ kind: "present", field: "url" },
],
},
],
},
},
"gemini-cli": {
displayName: "Gemini CLI",
format: "json",
globalLocation: fromSetup("gemini_cli"),
registrationProfile: supportedClientRegistrationProfile("gemini_cli"),
projectLocation: fixedProjectLocation([".gemini/settings.json"]),
},
goose: {
displayName: "Goose",
format: "goose",
globalLocation: gooseLocation,
registrationConnection: {
kind: "flat",
fields: ["uri"],
stdio: [{ kind: "required-enum", field: "type", values: ["stdio"] }],
},
},
"github-copilot-cli": {
displayName: "GitHub Copilot CLI",
format: "copilot_cli",
globalLocation: fromSetup("copilot_cli"),
registrationProfile: supportedClientRegistrationProfile("copilot_cli"),
projectLocation: fixedProjectLocation([".mcp.json", ".github/mcp.json"]),
projectFormat: "copilot_cli_project",
},
"grok-build": {
displayName: "Grok Build",
format: "grok",
globalLocation: fromSetup("grok_build"),
registrationProfile: supportedClientRegistrationProfile("grok_build"),
projectLocation: fixedProjectLocation([".grok/config.toml"]),
},
hermes: {
displayName: "Hermes Agent",
format: "hermes",
globalLocation: fromSetup("hermes"),
registrationProfile: supportedClientRegistrationProfile("hermes"),
},
junie: {
displayName: "Junie",
format: "json",
globalLocation: junieLocation,
projectLocation: fixedProjectLocation([".junie/mcp/mcp.json"]),
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [{ kind: "present", field: "url" }],
},
},
"kilo-code": {
displayName: "Kilo Code",
format: "kilo",
globalLocation: kiloLocation,
projectLocation: kiloProjectLocation,
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [{ kind: "required-enum", field: "type", values: ["local"] }],
},
},
"kimi-code": {
displayName: "Kimi Code",
format: "kimi",
globalLocation: fromSetup("kimi"),
registrationProfile: supportedClientRegistrationProfile("kimi"),
projectLocation: fixedProjectLocation([".kimi-code/mcp.json"]),
},
"kiro-cli": {
displayName: "Kiro CLI",
format: "json",
globalLocation: kiroLocation,
projectLocation: fixedProjectLocation([".kiro/settings/mcp.json"]),
registrationConnection: {
kind: "flat",
fields: ["type", "url"],
stdio: [
{ kind: "optional-enum", field: "type", values: ["stdio"] },
{ kind: "present", field: "url" },
],
},
},
mastracode: {
displayName: "Mastra Code",
format: "json",
globalLocation: mastraLocation,
projectLocation: fixedProjectLocation([".mastracode/mcp.json"]),
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [{ kind: "string", field: "url" }],
},
},
mcporter: {
displayName: "MCPorter",
format: "json",
globalLocation: mcporterLocation,
projectLocation: fixedProjectLocation(["config/mcporter.json"]),
registrationConnection: {
kind: "flat",
fields: [...MCPORTER_URL_FIELDS, "executable"],
stdio: MCPORTER_STDIO_INCOMPATIBILITIES,
},
},
opencode: {
displayName: "OpenCode",
format: "opencode",
globalLocation: fromSetup("opencode"),
registrationProfile: supportedClientRegistrationProfile("opencode"),
projectLocation: fixedProjectLocation([
"opencode.jsonc",
"opencode.json",
".opencode/opencode.jsonc",
".opencode/opencode.json",
]),
},
pi: {
displayName: "Pi",
format: "pi",
globalLocation: fromSetup("pi"),
registrationProfile: supportedClientRegistrationProfile("pi"),
projectLocation: fixedProjectLocation([".pi/mcp.json"]),
},
vscode: {
displayName: "VS Code",
format: "vscode",
globalLocation: fromSetup("vscode"),
registrationProfile: supportedClientRegistrationProfile("vscode"),
projectLocation: fixedProjectLocation([".vscode/mcp.json"]),
},
windsurf: {
displayName: "Windsurf",
format: "json",
globalLocation: fromSetup("windsurf"),
registrationProfile: supportedClientRegistrationProfile("windsurf"),
},
zed: {
displayName: "Zed",
format: "zed",
globalLocation: zedLocation,
projectLocation: fixedProjectLocation([".zed/settings.json"]),
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [{ kind: "present", field: "url" }],
},
},
amp: {
displayName: "Amp",
format: "amp",
globalLocation: ampLocation,
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [{ kind: "present", field: "url" }],
},
},
} as const satisfies Record<string, ClientDescriptor>;
export type McpRegistrationClientId = keyof typeof MCP_REGISTRATION_CLIENTS;
const ALIASES = {
"cline-vscode": "cline",
codeium: "windsurf",
cascade: "windsurf",
droid: "factory-droid",
factory: "factory-droid",
gemini: "gemini-cli",
"github-copilot": "vscode",
grok: "grok-build",
"hermes-agent": "hermes",
kilo: "kilo-code",
kilocode: "kilo-code",
kimi: "kimi-code",
kiro: "kiro-cli",
mastra: "mastracode",
"pi-agent": "pi",
} as const satisfies Record<string, McpRegistrationClientId>;
const isClientId = (value: string): value is McpRegistrationClientId =>
Object.hasOwn(MCP_REGISTRATION_CLIENTS, value);
export const resolveMcpRegistrationClientId = (
input: string,
): McpRegistrationClientId | undefined => {
const lower = input.toLowerCase();
if (isClientId(lower)) return lower;
return Object.entries(ALIASES).find(([alias]) => alias === lower)?.[1];
};
export interface McpRegistrationClient {
readonly registrationProfile: ClientRegistrationProfile;
readonly id: McpRegistrationClientId;
readonly displayName: string;
readonly configPath: string;
/** Complete known file layers in native precedence; configPath alone grants write authority. */
readonly configPaths?: readonly string[];
readonly inlineConfiguration?: McpRegistrationInlineConfiguration;
readonly scope: "global" | "project";
readonly format: ClientConfigurationFormat;
readonly privateProfile?: true;
}
export interface McpRegistrationSelection {
readonly agents: readonly string[];
readonly global: boolean;
readonly cwd: string;
readonly home: string;
readonly platform: NodeJS.Platform;
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly signal?: AbortSignal;
}
type CatalogFailure = ConfigurationError | AnalysisCancelledError;
const checkCancellation = (signal: AbortSignal | undefined): void => {
if (signal?.aborted)
throw new AnalysisCancelledError("MCP registration selection", {
cause: signal.reason,
});
};
const selectedIds = (agents: readonly string[]): McpRegistrationClientId[] => {
const ids: McpRegistrationClientId[] = [];
for (const agent of agents) {
const id = resolveMcpRegistrationClientId(agent);
if (id === undefined)
throw new ConfigurationError(
`Unknown MCP client ${agent}. Select a supported --agent value.`,
);
if (!ids.includes(id)) ids.push(id);
}
return ids;
};
/** Existing setup bindings retain their exact profile; additional clients bind the descriptor key. */
export const mcpClientRegistrationProfile = (
id: McpRegistrationClientId,
): ClientRegistrationProfile => {
const descriptor: ClientDescriptor = MCP_REGISTRATION_CLIENTS[id];
return (
descriptor.registrationProfile ?? {
nativeClient: id,
connection: descriptor.registrationConnection,
}
);
};
const projectTarget = async (
id: McpRegistrationClientId,
context: McpRegistrationLocationContext,
): Promise<McpRegistrationClient> => {
const descriptor: ClientDescriptor = MCP_REGISTRATION_CLIENTS[id];
if (descriptor.projectLocation === undefined)
throw new ConfigurationError(
`${descriptor.displayName} has no project-scoped registration. Remove --no-global or select a project-capable client; no settings were written.`,
);
const resolved = await descriptor.projectLocation(context);
return {
id,
displayName: descriptor.displayName,
registrationProfile: mcpClientRegistrationProfile(id),
...resolved,
scope: "project",
format: descriptor.projectFormat ?? descriptor.format,
};
};
const globalTarget = async (
id: McpRegistrationClientId,
context: McpRegistrationLocationContext,
): Promise<{ client: McpRegistrationClient; markers: readonly string[] }> => {
const descriptor: ClientDescriptor = MCP_REGISTRATION_CLIENTS[id];
const resolved = await descriptor.globalLocation(context);
return {
client: {
id,
displayName: descriptor.displayName,
registrationProfile: mcpClientRegistrationProfile(id),
configPath: selectedPath(resolved.configPath, context),
...(resolved.configPaths === undefined
? {}
: {
configPaths: resolved.configPaths.map((path) =>
selectedPath(path, context),
),
}),
scope: "global",
format: descriptor.format,
...(resolved.inlineConfiguration === undefined
? {}
: { inlineConfiguration: resolved.inlineConfiguration }),
...(id === "fx" ? { privateProfile: true } : {}),
},
markers: resolved.markers.map((marker) => selectedPath(marker, context)),
};
};
/** Resolve all selected paths before any registration writer receives authority. */
export const resolveMcpRegistrationClients = async (
selection: McpRegistrationSelection,
resources: ArtifactResourceScope,
): Promise<Result<readonly McpRegistrationClient[], CatalogFailure>> => {
try {
checkCancellation(selection.signal);
const ids = selectedIds(selection.agents);
const context: McpRegistrationLocationContext = {
...selection,
resources,
setup: selection.global
? supportedClients(
selection.home,
selection.platform,
selection.environment,
)
: [],
};
// Default selection includes global-only Amp and cannot become project writes.
if (!selection.global && ids.length === 0)
throw new ConfigurationError(
"Default MCP registration includes global-only Amp. Select project-capable --agent values or remove --no-global; no settings were written.",
);
if (ids.length > 0) {
const clients: McpRegistrationClient[] = [];
for (const id of ids) {
checkCancellation(selection.signal);
clients.push(
selection.global
? (await globalTarget(id, context)).client
: await projectTarget(id, context),
);
}
checkCancellation(selection.signal);
return ok(clients);
}
const all: McpRegistrationClient[] = [];
const detected: McpRegistrationClient[] = [];
for (const id of Object.keys(MCP_REGISTRATION_CLIENTS).filter(isClientId)) {
checkCancellation(selection.signal);
if (id === "amp") continue;
const { client, markers } = await globalTarget(id, context);
all.push(client);
// Examine every marker: unreadability cannot turn a partial observation into absence.
const statuses: boolean[] = [];
for (const marker of markers) statuses.push(await present(marker));
if (statuses.some(Boolean)) detected.push(client);
}
const amp = (await globalTarget("amp", context)).client;
checkCancellation(selection.signal);
return ok([...(detected.length === 0 ? all : detected), amp]);
} catch (cause: unknown) {
return err(
cause instanceof ConfigurationError ||
cause instanceof AnalysisCancelledError
? cause
: new ConfigurationError(
"Could not resolve MCP registration locations.",
{ cause },
),
);
}
};
@@ -1,194 +0,0 @@
import { ConfigurationError } from "../domain/configurationErrors.js";
import type { ClientRegistrationCommand } from "../domain/clientRegistrationCommand.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import {
clientConfigurationValuesEqual,
clientServerPath,
clientServerForEdit,
legacyClientServerPath,
effectiveClientServer,
parseClientConfiguration,
serializeClientConfiguration,
withClientServers,
type ClientConfigurationDocument,
} from "./ClientConfigurationDocument.js";
import {
clientConfigurationForWrite,
mergeClientConfigurationDocuments,
readConfigurationFile,
} from "./ClientConfigurationFiles.js";
import { replaceClientRegistrationLauncher } from "./ClientRegistration.js";
import { admitClientStdioConnection } from "./ClientRegistrationConnection.js";
import type { McpRegistrationClient } from "./McpRegistrationCatalog.js";
import { readRegularFileTextSnapshot } from "./RegularFileRead.js";
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
export const prepareRegistration = async (
path: string,
client: McpRegistrationClient,
launcher: ClientRegistrationCommand,
) => {
const snapshot = await readRegularFileTextSnapshot(path, {
symlinks: "reject",
}).catch((cause: unknown) => {
if (isMissing(cause)) return undefined;
throw cause;
});
const original = snapshot?.text;
const empty = serializeClientConfiguration({}, client.format);
if (!empty.ok)
throw new ConfigurationError(
"An empty client configuration could not be serialized.",
);
const target = parseClientConfiguration(
original ?? empty.value,
client.format,
);
const layers = await readRegistrationLayers(client, target);
const parsed = clientConfigurationForWrite(target, layers);
const paths = client.configPaths ?? [client.configPath];
const targetIndex = paths.lastIndexOf(client.configPath);
const inherited = mergeClientConfigurationDocuments(
layers.slice(0, targetIndex),
);
const desired = replaceClientRegistrationLauncher(
{ dialect: parsed.dialect, profile: client.registrationProfile },
[launcher.command, ...launcher.args],
clientServerForEdit(parsed, PRODUCT_IDENTITY.mcpServerKey),
inherited === undefined
? undefined
: effectiveClientServer(inherited, PRODUCT_IDENTITY.mcpServerKey),
);
if (!desired.ok) throw desired.error;
const updated = withClientServers(
parsed,
{
...parsed.servers,
[PRODUCT_IDENTITY.mcpServerKey]: desired.value,
},
PRODUCT_IDENTITY.mcpServerKey,
);
const legacy = legacyClientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey);
const serialized = serializeClientConfiguration(
updated,
client.format,
original,
[
clientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey),
...(legacy === undefined ? [] : [legacy]),
],
{ removeShadowedMembersAt: legacy === undefined ? [] : [legacy] },
);
if (!serialized.ok)
throw new ConfigurationError(
`Client configuration member is shadowed at ${serialized.error.path.join(".")}.`,
);
const candidate = parseClientConfiguration(serialized.value, client.format);
// Native loaders may visit one file at multiple precedence positions. A
// physical update changes every occurrence without deduplicating its order.
const candidateLayers = layers.map((document, index) =>
paths[index] === client.configPath ? candidate : document,
);
assertEffectiveLauncher(client, candidateLayers, launcher);
return {
path,
client,
launcher,
snapshot,
original,
parsed,
updated,
contents: serialized.value,
};
};
/** File layers and inline authority remain distinct; only the selected target is replaced. */
const readRegistrationLayers = async (
client: McpRegistrationClient,
target: ClientConfigurationDocument,
): Promise<ClientConfigurationDocument[]> => {
const layers: ClientConfigurationDocument[] = [];
const paths = client.configPaths ?? [client.configPath];
if (!paths.includes(client.configPath))
throw new ConfigurationError(
"The selected configuration is missing from the native load order.",
);
for (const path of paths) {
if (path === client.configPath) {
layers.push(target);
continue;
}
const read = await readConfigurationFile(path, client.format);
if (!read.ok)
throw (
read.error.cleanupFailure ?? new ConfigurationError(read.error.detail)
);
// Retain an empty layer for missing files so target positions do not shift.
if (read.value !== undefined) layers.push(read.value);
else {
const empty = serializeClientConfiguration({}, client.format);
if (!empty.ok)
throw new ConfigurationError(
"An empty native configuration could not be serialized.",
);
layers.push(parseClientConfiguration(empty.value, client.format));
}
}
if (client.inlineConfiguration !== undefined)
layers.push(client.inlineConfiguration.parsed);
return layers;
};
const assertEffectiveLauncher = (
client: McpRegistrationClient,
layers: readonly ClientConfigurationDocument[],
launcher: ClientRegistrationCommand,
): void => {
const effective = mergeClientConfigurationDocuments(layers);
const admitted = admitClientStdioConnection(
effective === undefined
? undefined
: effectiveClientServer(effective, PRODUCT_IDENTITY.mcpServerKey),
{
dialect: effective?.dialect ?? client.format,
profile: client.registrationProfile,
},
);
if (
!admitted.ok ||
admitted.value.command !== launcher.command ||
!clientConfigurationValuesEqual(admitted.value.args, launcher.args)
)
throw new ConfigurationError(
admitted.ok
? "A higher-priority configuration overrides the selected stdio launcher. Update that source before registering this command."
: admitted.error.cause.message,
{
...(admitted.ok ? {} : { cause: admitted.error.cause }),
diagnostics: {
settings_path: client.configPath,
...(client.inlineConfiguration === undefined
? {}
: { inline_origin: client.inlineConfiguration.origin }),
},
},
);
};
/** Re-admit native layers around the caller's identity-verified target snapshot. */
export const verifyEffectiveRegistration = async (
prepared: Awaited<ReturnType<typeof prepareRegistration>>,
targetText: string,
): Promise<void> => {
const { client, launcher } = prepared;
assertEffectiveLauncher(
client,
await readRegistrationLayers(
client,
parseClientConfiguration(targetText, client.format),
),
launcher,
);
};
-121
View File
@@ -1,121 +0,0 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import * as owned from "../process/OwnedCommand.js";
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
import { resolveKiloRegistrationWorktree } from "./McpRegistrationGit.js";
import type { McpRegistrationLocationContext } from "./McpRegistrationLocations.js";
let resources: ArtifactResourceScope;
beforeEach(() => {
resources = new ArtifactResourceScope();
vi.spyOn(owned, "prepareOwnedCommand").mockResolvedValue(undefined);
vi.spyOn(owned, "runOwnedCommand").mockRejectedValue(
new Error("An inert Git outcome must be selected by the fixture"),
);
});
afterEach(async () => {
try {
await resources.close();
} finally {
vi.mocked(owned.prepareOwnedCommand).mockRestore();
vi.mocked(owned.runOwnedCommand).mockRestore();
}
});
const context = async (
signal?: AbortSignal,
): Promise<McpRegistrationLocationContext> => {
const root = await createTestTempDirectory("rea-registration-git-");
await mkdir(join(root, ".git"));
return {
cwd: root,
home: root,
platform: process.platform,
environment: {},
setup: [],
resources,
...(signal === undefined ? {} : { signal }),
};
};
it.each(["process", "timeout", "output-limit"] as const)(
"keeps observed %s failure primary when cancellation arrives during Git completion",
async (reason) => {
const abort = new AbortController();
const opaque = { operator: "cancel", detail: new Error("opaque reason") };
const failure = new owned.OwnedCommandFailure(
reason,
"Physical Git observation failed",
null,
null,
{ cancellation: { reason: opaque }, cause: new Error("physical cause") },
);
vi.mocked(owned.runOwnedCommand).mockImplementation(async () => {
abort.abort(opaque);
throw failure;
});
const observed: unknown = await resolveKiloRegistrationWorktree(
await context(abort.signal),
).catch((cause: unknown) => cause);
expect(observed).toBeInstanceOf(ConfigurationError);
if (!(observed instanceof ConfigurationError))
throw new Error("Physical Git failure lost its primary classification");
expect(observed.cause).toBe(failure);
expect(observed.secondaryFailures).toHaveLength(1);
expect(observed.secondaryFailures[0]).toBeInstanceOf(
AnalysisCancelledError,
);
expect(observed.secondaryFailures[0]?.cause).toBe(opaque);
expect(failure.cancellation?.reason).toBe(opaque);
},
);
it("retains primary cancellation and its opaque caller reason", async () => {
const opaque = { operator: "cancel", detail: new Error("opaque reason") };
vi.mocked(owned.runOwnedCommand).mockRejectedValue(
new owned.OwnedCommandFailure(
"cancelled",
"Git observation cancelled",
null,
null,
{
cancellation: { reason: opaque },
},
),
);
const observed: unknown = await resolveKiloRegistrationWorktree(
await context(),
).catch((cause: unknown) => cause);
expect(observed).toBeInstanceOf(AnalysisCancelledError);
if (!(observed instanceof AnalysisCancelledError))
throw new Error("Primary cancellation lost its classification");
expect(observed.cause).toBe(opaque);
});
it("retains the exact incomplete UTF-8 snapshot behind the Git observation failure", async () => {
const snapshot: ProviderProcessSnapshot = {
stdout: { text: "\uFFFD", invalidUtf8: true, bytes: 1, observedBytes: 1 },
stderr: { text: "native diagnostic", bytes: 17, observedBytes: 17 },
exitCode: 0,
signal: null,
};
vi.mocked(owned.runOwnedCommand).mockResolvedValue(snapshot);
const observed: unknown = await resolveKiloRegistrationWorktree(
await context(),
).catch((cause: unknown) => cause);
expect(observed).toBeInstanceOf(ConfigurationError);
if (!(observed instanceof ConfigurationError))
throw new Error(
"Incomplete Git observation lost its configuration failure",
);
expect(observed.cause).toBeInstanceOf(Error);
if (!(observed.cause instanceof Error))
throw new Error("Incomplete Git observation lost its immediate failure");
expect(observed.cause.cause).toBe(snapshot);
});
-268
View File
@@ -1,268 +0,0 @@
import { randomUUID } from "node:crypto";
import { lstat, realpath } from "node:fs/promises";
import { dirname, isAbsolute, relative, sep } from "node:path";
import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
} from "../artifacts/AnalysisCleanupLease.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import {
OwnedCommandFailure,
prepareOwnedCommand,
runOwnedCommand,
} from "../process/OwnedCommand.js";
import type { McpRegistrationLocationContext } from "./McpRegistrationLocations.js";
/** Git's observed active and primary worktree roots; neither grants mutation authority. */
export interface KiloRegistrationWorktree {
readonly activeDirectory: string;
readonly root: string;
readonly primaryRoot: string;
}
export const pathWithin = (root: string, path: string): boolean => {
const part = relative(root, path);
return part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part);
};
const retainGitCleanup = (
cause: unknown,
context: McpRegistrationLocationContext,
): readonly AnalysisCleanupLease[] => {
if (!(cause instanceof OwnedCommandFailure)) return [];
const leases: AnalysisCleanupLease[] = [];
const preparation = cause.preparationFailure;
const cleanup = preparation?.cleanupFailure;
if (cleanup !== undefined) {
leases.push(
new AnalysisCleanupLease(async () => {
if (!cleanup.cleanupIncomplete) return;
if (preparation?.cleanupOwner === undefined) throw cleanup;
await preparation.cleanupOwner.close();
if (cleanup.cleanupIncomplete) throw cleanup;
}, cleanup),
);
}
if (cause.cleanupOwner !== undefined || cause.cleanupFailure !== null) {
const supervisor = cause.cleanupOwner;
leases.push(
new AnalysisCleanupLease(async () => {
if (supervisor === undefined) throw cause;
const stopped = await supervisor.stop();
if (stopped.status === "incomplete")
throw new ConfigurationError(stopped.reason, {
cleanup: { reason: stopped.reason, resources: cause.resources },
});
supervisor.dispose();
}, cause.resources),
);
}
for (const lease of leases)
context.resources.retain({
kind: "lease",
resource: "Kilo read-only Git discovery",
lease,
});
return leases;
};
const gitFailure = (
cause: unknown,
context: McpRegistrationLocationContext,
): ConfigurationError | AnalysisCancelledError => {
const leases = retainGitCleanup(cause, context);
const cleanup =
leases.length === 0
? undefined
: {
reason: "Read-only Git discovery cleanup remains incomplete.",
resources: leases.flatMap((lease) => lease.cleanupResources ?? []),
};
const cancellation =
cause instanceof OwnedCommandFailure ? cause.cancellation : undefined;
const primaryCancellation =
cause instanceof OwnedCommandFailure && cause.reason === "cancelled";
const cancelled =
cancellation === undefined
? undefined
: new AnalysisCancelledError("Kilo project root discovery", {
cause: cancellation.reason,
...(!primaryCancellation || cleanup === undefined ? {} : { cleanup }),
});
const failure =
primaryCancellation && cancelled !== undefined
? cancelled
: new ConfigurationError(
"Kilo project root could not be established by read-only Git metadata. Repair Git or repository access, or register globally; no client settings were written.",
{ cause, ...(cleanup === undefined ? {} : { cleanup }) },
);
if (cancelled !== undefined && failure !== cancelled)
analysisErrorWithCancellation(failure, cancelled);
if (leases.length > 0) new AnalysisCleanupLeaseGroup(failure, leases);
return failure;
};
const gitOutput = async (
context: McpRegistrationLocationContext,
cwd: string,
arguments_: readonly string[],
): Promise<string> => {
try {
await prepareOwnedCommand(context.signal);
const result = await runOwnedCommand(
{
command: "git",
arguments: arguments_,
cwd,
runId: randomUUID(),
expectedCommand: null,
hostEnvironment: context.environment,
sweepTokenOwnedProcesses: true,
},
{ timeoutMs: 10_000, diagnosticBytes: 1024 * 1024 },
{ signal: context.signal },
);
if (
result.stdout.invalidUtf8 ||
result.diagnosticTruncated ||
result.outputIncomplete
)
throw new Error(
"Git did not provide a complete UTF-8 metadata observation.",
{ cause: result },
);
return result.stdout.text;
} catch (cause: unknown) {
throw gitFailure(cause, context);
}
};
/** Each rev-parse invocation returns one path, so embedded whitespace remains path data. */
const gitPath = async (
context: McpRegistrationLocationContext,
cwd: string,
field: string,
): Promise<string> => {
const text = await gitOutput(context, cwd, ["rev-parse", field]);
if (!text.endsWith("\n"))
throw new ConfigurationError(
"Git metadata path has no protocol terminator; Kilo project root remains unknown.",
);
let value = text.slice(0, -1);
if (context.platform === "win32" && value.endsWith("\r"))
value = value.slice(0, -1);
if (value.length === 0)
throw new ConfigurationError(
"Git returned an empty path; Kilo project root remains unknown.",
);
return absoluteCallerPath(value, { cwd, platform: context.platform });
};
const nearestGitDirectory = async (
cwd: string,
): Promise<string | undefined> => {
let directory = cwd;
while (true) {
try {
await lstat(`${directory}${directory.endsWith(sep) ? "" : sep}.git`);
return directory;
} catch (cause: unknown) {
if (
!(cause instanceof Error && "code" in cause && cause.code === "ENOENT")
)
throw cause;
}
const parent = dirname(directory);
if (parent === directory) return undefined;
directory = parent;
}
};
const primaryRoot = async (
context: McpRegistrationLocationContext,
root: string,
gitDirectory: string,
commonDirectory: string,
): Promise<string> => {
if (gitDirectory === commonDirectory) return root;
// Kilo's primaryPaths uses the first non-bare porcelain worktree record.
const listing = await gitOutput(context, root, [
"worktree",
"list",
"--porcelain",
"-z",
]);
const record = listing.split("\0\0", 1)[0]?.split("\0");
const field = record?.find((item) => item.startsWith("worktree "));
if (field === undefined || record?.includes("bare"))
throw new ConfigurationError(
"Kilo primary worktree is unknown; no project settings were written.",
);
const value = field.slice("worktree ".length);
if (value.length === 0 || !listing.includes("\0\0"))
throw new ConfigurationError(
"Git returned incomplete worktree metadata; no project settings were written.",
);
return directoryPath(
absoluteCallerPath(value, { cwd: root, platform: context.platform }),
);
};
const directoryPath = async (path: string): Promise<string> => {
const resolved = await realpath(path);
if (!(await lstat(resolved)).isDirectory())
throw new ConfigurationError(
"Git metadata did not identify a directory; repair repository access or register globally. No settings were written.",
);
return resolved;
};
/** Match the native nearest-.git and authoritative rev-parse worktree boundary. */
export const resolveKiloRegistrationWorktree = async (
context: McpRegistrationLocationContext,
): Promise<KiloRegistrationWorktree> => {
try {
const activeDirectory = await directoryPath(
absoluteCallerPath(context.cwd, { platform: context.platform }),
);
const gitCwd = await nearestGitDirectory(activeDirectory);
if (gitCwd === undefined)
throw new ConfigurationError(
"Kilo project registration needs a verified Git worktree boundary. Open a repository or register globally; no client settings were written.",
);
const root = await directoryPath(
await gitPath(context, gitCwd, "--show-toplevel"),
);
const gitDirectory = await directoryPath(
await gitPath(context, gitCwd, "--git-dir"),
);
const commonDirectory = await directoryPath(
await gitPath(context, gitCwd, "--git-common-dir"),
);
if (!pathWithin(root, activeDirectory))
throw new ConfigurationError(
"Git's worktree does not contain the selected directory; Kilo project scope remains unknown.",
);
return {
activeDirectory,
root,
primaryRoot: await primaryRoot(
context,
root,
gitDirectory,
commonDirectory,
),
};
} catch (cause: unknown) {
if (
cause instanceof ConfigurationError ||
cause instanceof AnalysisCancelledError
)
throw cause;
throw gitFailure(cause, context);
}
};
@@ -1,295 +0,0 @@
import { dirname, join, relative } from "node:path";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { parseClientConfiguration } from "./ClientConfigurationDocument.js";
import {
resolveKiloRegistrationWorktree,
pathWithin,
} from "./McpRegistrationGit.js";
import {
selectedPath,
platformPaths,
present,
type McpRegistrationLocationContext,
type McpRegistrationLocation,
type McpRegistrationGlobalLocation,
type McpRegistrationInlineConfiguration,
} from "./McpRegistrationLocations.js";
/** Names from Kilo v7.8.27 loadGlobal and config-directory merge order. */
const KILO_GLOBAL_FILES = [
"config.json",
"kilo.json",
"kilo.jsonc",
"opencode.json",
"opencode.jsonc",
] as const;
const KILO_DIRECTORY_FILES = [
"kilo.jsonc",
"kilo.json",
"opencode.jsonc",
"opencode.json",
] as const;
interface KiloFileLayer {
readonly path: string;
readonly authority: "global" | "project" | "readonly";
}
interface KiloConfigDirectory {
readonly path: string;
readonly authority: KiloFileLayer["authority"];
}
const observedFiles = async (
directory: string,
names: readonly string[],
authority: KiloFileLayer["authority"],
): Promise<KiloFileLayer[]> => {
const files: KiloFileLayer[] = [];
for (const name of names) {
const path = join(directory, name);
if (await present(path)) files.push({ path, authority });
}
return files;
};
const kiloInline = (
context: McpRegistrationLocationContext,
): McpRegistrationInlineConfiguration | undefined => {
const text = context.environment.KILO_CONFIG_CONTENT;
if (!text) return undefined;
try {
return {
origin: "KILO_CONFIG_CONTENT",
parsed: parseClientConfiguration(text, "kilo"),
};
} catch (cause: unknown) {
throw new ConfigurationError(
"KILO_CONFIG_CONTENT cannot be admitted as Kilo configuration. Repair that selected inline configuration; no settings were written.",
{ cause },
);
}
};
const kiloRoot = (context: McpRegistrationLocationContext): string =>
selectedPath(join(platformPaths(context).config, "kilo"), context);
const kiloHomeDirectories = async (
context: McpRegistrationLocationContext,
): Promise<KiloConfigDirectory[]> => {
const directories: KiloConfigDirectory[] = [];
for (const name of [".kilocode", ".kilo"]) {
const path = selectedPath(join(context.home, name), context);
if (await present(path)) directories.push({ path, authority: "global" });
}
return directories;
};
const directoryFiles = async (
directories: readonly KiloConfigDirectory[],
custom: string | undefined,
planned?: KiloFileLayer,
): Promise<KiloFileLayer[]> => {
const seen = new Set<string>();
const layers: KiloFileLayer[] = [];
for (const directory of directories) {
if (seen.has(directory.path)) continue;
seen.add(directory.path);
if (
!directory.path.endsWith(".kilo") &&
!directory.path.endsWith(".kilocode") &&
directory.path !== custom
)
continue;
if (planned !== undefined && dirname(planned.path) === directory.path)
layers.push(planned);
layers.push(
...(await observedFiles(
directory.path,
KILO_DIRECTORY_FILES,
directory.authority,
)),
);
}
return layers;
};
const kiloExplicitFile = (
context: McpRegistrationLocationContext,
): string | undefined =>
context.environment.KILO_CONFIG
? selectedPath(context.environment.KILO_CONFIG, context)
: undefined;
const kiloExplicitDirectory = (
context: McpRegistrationLocationContext,
): string | undefined =>
context.environment.KILO_CONFIG_DIR
? selectedPath(context.environment.KILO_CONFIG_DIR, context)
: undefined;
export const kiloLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> => {
const root = kiloRoot(context);
const layers = await observedFiles(root, KILO_GLOBAL_FILES, "global");
const explicit = kiloExplicitFile(context);
if (explicit !== undefined) {
await present(explicit);
layers.push({ path: explicit, authority: "global" });
}
const custom = kiloExplicitDirectory(context);
const directories: KiloConfigDirectory[] = [
{ path: root, authority: "global" },
...(await kiloHomeDirectories(context)),
];
if (custom !== undefined)
directories.push({ path: custom, authority: "global" });
const observed = await directoryFiles(directories, custom);
let configPath = observed.at(-1)?.path ?? layers.at(-1)?.path;
if (
custom !== undefined &&
![...layers, ...observed].some((layer) => dirname(layer.path) === custom)
) {
await present(custom);
configPath = join(custom, "kilo.json");
layers.push(
...(await directoryFiles(directories, custom, {
path: configPath,
authority: "global",
})),
);
} else layers.push(...observed);
configPath ??= join(root, "kilo.json");
const inlineConfiguration = kiloInline(context);
return {
configPath,
configPaths:
layers.length === 0 ? [configPath] : layers.map((layer) => layer.path),
...(inlineConfiguration === undefined ? {} : { inlineConfiguration }),
markers: [
root,
join(
platformPaths(context).vscode,
"globalStorage",
"kilocode.kilo-code",
),
],
};
};
const ancestorsThrough = (directory: string, root: string): string[] => {
const directories: string[] = [];
let current = directory;
while (true) {
directories.push(current);
if (current === root) return directories;
const parent = dirname(current);
if (parent === current)
throw new ConfigurationError(
"Kilo worktree boundary is not an ancestor; no project settings were written.",
);
current = parent;
}
};
const projectDirectories = async (
directories: readonly string[],
authority: KiloFileLayer["authority"],
): Promise<KiloConfigDirectory[]> => {
const found: KiloConfigDirectory[] = [];
for (const directory of directories)
for (const name of [".kilocode", ".kilo"]) {
const path = join(directory, name);
if (await present(path)) found.push({ path, authority });
}
return found;
};
/** Native project read order, with global/primary authorities retained read-only. */
export const kiloProjectLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationLocation> => {
if (
["true", "1"].includes(
context.environment.KILO_DISABLE_PROJECT_CONFIG?.toLowerCase() ?? "",
)
)
throw new ConfigurationError(
"KILO_DISABLE_PROJECT_CONFIG disables project configuration. Remove that selected override or register globally; no project settings were written.",
);
const worktree = await resolveKiloRegistrationWorktree(context);
const active = ancestorsThrough(worktree.activeDirectory, worktree.root);
const root = kiloRoot(context);
const layers = await observedFiles(root, KILO_GLOBAL_FILES, "readonly");
const explicit = kiloExplicitFile(context);
if (explicit !== undefined) {
await present(explicit);
layers.push({
path: explicit,
authority: pathWithin(worktree.root, explicit) ? "project" : "readonly",
});
}
// ConfigPaths.files reverses nearest-first [jsonc,json] matches, first Kilo then OpenCode.
for (const name of ["kilo", "opencode"])
for (const directory of [...active].reverse())
layers.push(
...(await observedFiles(
directory,
[`${name}.json`, `${name}.jsonc`],
"project",
)),
);
const primary: KiloConfigDirectory[] = [];
if (worktree.primaryRoot !== worktree.root) {
const mirrored = join(
worktree.primaryRoot,
relative(worktree.root, worktree.activeDirectory),
);
primary.push(
...(await projectDirectories(
ancestorsThrough(mirrored, worktree.primaryRoot),
"readonly",
)),
);
}
const nativeProject = await projectDirectories(active, "project");
const custom = kiloExplicitDirectory(context);
const directories: KiloConfigDirectory[] = [
{ path: root, authority: "readonly" },
...primary,
...nativeProject,
...(await kiloHomeDirectories(context).then((items) =>
items.map((item) => ({ ...item, authority: "readonly" as const })),
)),
];
if (custom !== undefined)
directories.push({
path: custom,
authority: pathWithin(worktree.root, custom) ? "project" : "readonly",
});
const directoryLayers = await directoryFiles(directories, custom);
const writable = [...layers, ...directoryLayers].filter(
(layer) => layer.authority === "project",
);
let configPath = writable.at(-1)?.path;
if (configPath === undefined) {
// Native updateProjectConfig's creation target must enter its future native position.
const planned = join(worktree.activeDirectory, ".kilo");
const activeIndex = directories.findIndex(
(directory) => directory.authority === "project",
);
directories.splice(activeIndex < 0 ? 1 + primary.length : activeIndex, 0, {
path: planned,
authority: "project",
});
configPath = join(planned, "kilo.jsonc");
const ordered: KiloFileLayer[] = [];
const seen = new Set<string>();
for (const directory of directories) {
if (seen.has(directory.path)) continue;
seen.add(directory.path);
if (directory.path === planned)
ordered.push({ path: configPath, authority: "project" });
else ordered.push(...(await directoryFiles([directory], custom)));
}
layers.push(...ordered);
} else layers.push(...directoryLayers);
const inlineConfiguration = kiloInline(context);
return {
configPath,
configPaths: layers.map((layer) => layer.path),
...(inlineConfiguration === undefined ? {} : { inlineConfiguration }),
};
};
-216
View File
@@ -1,216 +0,0 @@
import { lstat } from "node:fs/promises";
import { dirname, join, win32 } from "node:path";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import type { SetupClient, SupportedClientName } from "./SupportedClients.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import type { ClientConfigurationDocument } from "./ClientConfigurationDocument.js";
export interface McpRegistrationLocationContext {
readonly home: string;
readonly cwd: string;
readonly platform: NodeJS.Platform;
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly setup: readonly SetupClient[];
readonly resources: ArtifactResourceScope;
readonly signal?: AbortSignal;
}
export interface McpRegistrationInlineConfiguration {
readonly origin: "KILO_CONFIG_CONTENT";
readonly parsed: ClientConfigurationDocument;
}
export interface McpRegistrationLocation {
readonly configPath: string;
/** Complete known file layers in native precedence; configPath alone grants write authority. */
readonly configPaths?: readonly string[];
readonly inlineConfiguration?: McpRegistrationInlineConfiguration;
}
export interface McpRegistrationGlobalLocation extends McpRegistrationLocation {
readonly markers: readonly string[];
}
export const selectedPath = (
path: string,
context: McpRegistrationLocationContext,
): string =>
absoluteCallerPath(path, { cwd: context.cwd, platform: context.platform });
export const fromSetup =
(name: SupportedClientName) =>
async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> => {
const client = context.setup.find((candidate) => candidate.name === name);
if (client === undefined)
throw new ConfigurationError(
`The registration catalog has no path owner for ${name}.`,
);
if (client.configPathError !== undefined)
throw new ConfigurationError(client.configPathError);
return {
configPath: selectedPath(client.configPath, context),
...(client.configPaths === undefined
? {}
: {
configPaths: client.configPaths.map((path) =>
selectedPath(path, context),
),
}),
markers: [selectedPath(client.markerPath ?? client.configPath, context)],
};
};
export const platformPaths = (context: McpRegistrationLocationContext) => {
const { home, platform, environment } = context;
const config = environment.XDG_CONFIG_HOME || join(home, ".config");
const appSupport =
platform === "win32"
? environment.APPDATA || win32.join(home, "AppData", "Roaming")
: platform === "darwin"
? join(home, "Library", "Application Support")
: config;
return { config, appSupport, vscode: join(appSupport, "Code", "User") };
};
const location = (
path: string,
marker = dirname(path),
): McpRegistrationGlobalLocation => ({
configPath: path,
markers: [marker],
});
export const present = async (path: string): Promise<boolean> => {
try {
await lstat(path);
return true;
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return false;
throw new ConfigurationError(
`Cannot determine the registration location at ${path}. Repair its access or path before registering.`,
{ cause },
);
}
};
export const firstExisting = async (
candidates: readonly string[],
fallback: string,
): Promise<string> => {
for (const candidate of candidates)
if (await present(candidate)) return candidate;
return fallback;
};
export const clineLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> =>
location(
join(
platformPaths(context).vscode,
"globalStorage",
"saoudrizwan.claude-dev",
"settings",
"cline_mcp_settings.json",
),
);
export const clineCliLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> =>
location(
join(
context.environment.CLINE_DIR || join(context.home, ".cline"),
"data",
"settings",
"cline_mcp_settings.json",
),
);
export const gooseLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> => {
const { home, platform } = context;
const path =
platform === "win32"
? join(
platformPaths(context).appSupport,
"Block",
"goose",
"config",
"config.yaml",
)
: join(
platform === "darwin"
? join(home, ".config")
: platformPaths(context).config,
"goose",
"config.yaml",
);
return location(path, path);
};
export const junieLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> => {
const root = context.environment.JUNIE_HOME || join(context.home, ".junie");
return location(join(root, "mcp", "mcp.json"), root);
};
/** Fixed relative target precedence used by clients with a single project document. */
export const fixedProjectLocation =
(candidates: readonly [string, ...string[]]) =>
async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationLocation> => {
const paths = candidates.map((path) => selectedPath(path, context));
return {
configPath: await firstExisting(
paths,
selectedPath(candidates[0], context),
),
};
};
export const kiroLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> =>
location(
join(context.home, ".kiro", "settings", "mcp.json"),
join(context.home, ".kiro"),
);
export const mcporterLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> => {
const root = join(context.home, ".mcporter");
const primary = join(root, "mcporter.json");
return location(
await firstExisting([primary, join(root, "mcporter.jsonc")], primary),
root,
);
};
export const zedLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> =>
location(
join(
platformPaths(context).appSupport,
context.platform === "darwin" || context.platform === "win32"
? "Zed"
: "zed",
"settings.json",
),
);
export const fxLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> =>
location(join(context.home, ".fx", "mcp.json"));
export const mastraLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> =>
location(join(context.home, ".mastracode", "mcp.json"));
export const ampLocation = async (
context: McpRegistrationLocationContext,
): Promise<McpRegistrationGlobalLocation> =>
location(join(context.home, ".config", "amp", "settings.json"));
+20 -137
View File
@@ -10,7 +10,6 @@ import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
import { discoverSetupState } from "./SetupPlan.js";
import type { MsixPackageView } from "./MsixPackageStorage.js";
import {
supportedClientRegistrationProfile,
registrationWriteBlocker,
supportedClients,
type SetupClient,
@@ -36,7 +35,6 @@ const msixClaudeDesktop = (
processView: MsixPackageView["processView"],
): SetupClient => ({
name: "claude_desktop",
registrationProfile: supportedClientRegistrationProfile("claude_desktop"),
displayName: "Claude Desktop",
configPath: PRIVATE_CONFIG,
quitBeforeSetup: true,
@@ -113,12 +111,7 @@ describe("setup workflow", () => {
it("blocks malformed client configuration before Hopper or file mutation", async () => {
const host = new FakeSetupHost();
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
displayName: "Codex",
configPath: "/codex.toml",
},
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
];
host.clientInspections.set("codex", {
status: "invalid",
@@ -144,17 +137,10 @@ describe("setup workflow", () => {
host.clients = [
{
name: "claude_desktop",
registrationProfile:
supportedClientRegistrationProfile("claude_desktop"),
displayName: "Claude Desktop",
configPath: "/claude.json",
},
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
displayName: "Cursor",
configPath: "/cursor.json",
},
{ name: "cursor", displayName: "Cursor", configPath: "/cursor.json" },
];
host.clientInspections.set("claude_desktop", {
status: "invalid",
@@ -177,12 +163,7 @@ describe("setup workflow", () => {
it("plans a client update when Hopper installation changes its desired environment", async () => {
const host = new FakeSetupHost();
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
displayName: "Codex",
configPath: "/codex.toml",
},
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
];
host.clientInspections.set("codex", { status: "already_current" });
@@ -210,13 +191,7 @@ describe("optional Hopper installation failures", () => {
// registrations and skill (#5113).
const host = new FakeSetupHost();
host.hopperInstallSucceeds = false;
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
];
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
const result = await runSetup(
{
...options(true, true),
@@ -237,13 +212,7 @@ describe("optional Hopper installation failures", () => {
it("keeps the Hopper failure when a client write also fails", async () => {
const host = new FakeSetupHost();
host.hopperInstallSucceeds = false;
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
];
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
host.clientResults.set("cursor", { status: "failed", reason: "write" });
const result = await runSetup(
{ ...options(true, true), clientIds: ["cursor"] },
@@ -263,13 +232,7 @@ describe("optional Hopper installation failures", () => {
const host = new FakeSetupHost();
host.hopperInstallSucceeds = false;
host.skill = "failed";
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
];
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
const result = await runSetup(
{
...options(true, true),
@@ -310,13 +273,7 @@ describe("optional Hopper installation failures", () => {
skillFailure.hopperInstallSucceeds = false;
skillFailure.hopperInstallFailure = ready.hopperInstallFailure;
skillFailure.skill = "failed";
skillFailure.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
];
skillFailure.clients = [{ name: "cursor", configPath: "/cursor.json" }];
expect(
await runSetup(
{
@@ -388,13 +345,7 @@ describe("configured Linux Hopper selection", () => {
it("offers shared skill intent when the detected personal copy is current", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{
name: "claude_code",
registrationProfile: supportedClientRegistrationProfile("claude_code"),
configPath: "/claude.json",
},
];
host.clients = [{ name: "claude_code", configPath: "/claude.json" }];
host.skillNeedsInstall = (clients) => Promise.resolve(clients.length === 0);
const result = await runSetup(
{ ...options(false), structured: false },
@@ -429,18 +380,8 @@ describe("setup workflow action selection", () => {
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
displayName: "Codex",
configPath: "/codex.toml",
},
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
displayName: "Cursor",
configPath: "/cursor.json",
},
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
{ name: "cursor", displayName: "Cursor", configPath: "/cursor.json" },
];
const result = await runSetup(
@@ -467,18 +408,8 @@ describe("setup workflow action selection", () => {
host.skill = "unchanged";
host.clients = [];
host.availableClients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
displayName: "Codex",
configPath: "/codex.toml",
},
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
displayName: "Cursor",
configPath: "/cursor.json",
},
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
{ name: "cursor", displayName: "Cursor", configPath: "/cursor.json" },
];
host.clientInspections.set("cursor", {
status: "invalid",
@@ -505,13 +436,7 @@ describe("setup workflow action selection", () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
host.productRegistrations = [
{
client: "codex",
@@ -537,13 +462,7 @@ describe("setup workflow action selection", () => {
const host = new FakeSetupHost("win32");
host.scopedDoctorHealthy = true;
host.skill = "unchanged";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "C:\\config\\codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "C:\\config\\codex.toml" }];
const result = await runSetup(
{ ...options(true), clientIds: ["codex"], installSkill: false },
@@ -593,14 +512,7 @@ describe("setup configuration failure recovery", () => {
] as const)("explains %s configuration recovery", async (reason, message) => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{
name: "internal_client_key",
// Source-only recovery actor models Cursor JSON registration.
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/agent.json",
},
];
host.clients = [{ name: "internal_client_key", configPath: "/agent.json" }];
host.clientResults.set("internal_client_key", { status: "failed", reason });
// Exercise registration recovery without selecting a synthetic skill client.
const result = await runSetup(
@@ -634,13 +546,7 @@ describe("setup configuration failure recovery", () => {
it("names the recovery backup that holds the restored original after a readback failure", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/agent.json",
},
];
host.clients = [{ name: "cursor", configPath: "/agent.json" }];
const backupPath = `/agent.json.rea.backup.${"a".repeat(64)}.recovery`;
host.clientResults.set("cursor", {
status: "failed",
@@ -698,15 +604,9 @@ describe("MSIX Claude Desktop setup", () => {
const vscode = join(ROAMING, "Code", "User", "mcp.json");
host.clients = [
msixClaudeDesktop("redirected"),
{
name: "vscode",
registrationProfile: supportedClientRegistrationProfile("vscode"),
displayName: "VS Code",
configPath: vscode,
},
{ name: "vscode", displayName: "VS Code", configPath: vscode },
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
displayName: "Codex",
configPath: join(PROFILE, ".codex", "config.toml"),
},
@@ -778,12 +678,7 @@ describe("MSIX Claude Desktop setup", () => {
host.hopper = join(PROFILE, "Hopper");
host.registrationCommandBlocker = "Install REA outside the package.";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
displayName: "Codex",
configPath: "/codex.toml",
},
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
];
const blocked = await runSetup(
@@ -809,13 +704,7 @@ describe("setup tool family scope", () => {
const host = new FakeSetupHost();
host.doctorHealthy = true;
host.skill = "unchanged";
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
];
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
const plan = await runSetup(
{
@@ -847,13 +736,7 @@ describe("setup tool family scope", () => {
const host = new FakeSetupHost("darwin");
host.version = "14.5";
host.skill = "unchanged";
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
];
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
const result = await runSetup(
{
+1 -8
View File
@@ -8,7 +8,6 @@ import type {
DesktopBuildSetupHost,
} from "./SetupDesktopBuild.js";
import type { SetupOptions, SetupProviderEnvironment } from "./SetupTypes.js";
import { supportedClientRegistrationProfile } from "./SupportedClients.js";
const ARCHIVE = "/home/ada/.cache/rea/build-computer/rea-6.8.0.tar.xz";
const SETTINGS = {
@@ -40,13 +39,7 @@ const desktopHost = (facts: Partial<DesktopBuildSetupFacts> = {}) => {
},
};
const host = Object.assign(new FakeSetupHost("linux"), { desktopBuild });
host.clients = [
{
name: "claude_code",
registrationProfile: supportedClientRegistrationProfile("claude_code"),
configPath: "/claude.json",
},
];
host.clients = [{ name: "claude_code", configPath: "/claude.json" }];
host.hopper = "/opt/hopper/bin/Hopper";
host.skill = "unchanged";
return { host, calls };
@@ -2,19 +2,12 @@ import { expect, it } from "vitest";
import { runSetup } from "./Setup.js";
import { FakeSetupHost, options } from "./Setup.fixture.js";
import { supportedClientRegistrationProfile } from "./SupportedClients.js";
it("refreshes doctor after approved setup changes", async () => {
const host = new FakeSetupHost();
host.doctorHealthy = false;
host.skill = "unchanged";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.json",
},
];
host.clients = [{ name: "codex", configPath: "/codex.json" }];
const configureClient = host.configureClient;
host.configureClient = (client, providerEnvironment, command) => {
host.doctorHealthy = true;
+21 -141
View File
@@ -8,7 +8,6 @@ import { FakeSetupHost, options } from "./Setup.fixture.js";
import { SetupSkillInstallError } from "./SetupSkill.js";
import { err, ok } from "../domain/result.js";
import { HopperReleaseError } from "./HopperReleaseFeed.js";
import { supportedClientRegistrationProfile } from "./SupportedClients.js";
const linkedSkillFailure = () =>
new SetupSkillInstallError({
@@ -65,18 +64,8 @@ describe("selected skill admission", () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{
name: "claude_code",
registrationProfile:
supportedClientRegistrationProfile("claude_code"),
configPath: "/claude.json",
},
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
format: "toml",
},
{ name: "claude_code", configPath: "/claude.json" },
{ name: "codex", configPath: "/codex.toml", format: "toml" },
];
host.validateSkillDirectories = async (ids) => {
host.skillDirectoryValidations.push(ids);
@@ -120,14 +109,7 @@ describe("selected skill admission", () => {
async ({ acceptAll }) => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{
name: "claude_code",
registrationProfile:
supportedClientRegistrationProfile("claude_code"),
configPath: "/claude.json",
},
];
host.clients = [{ name: "claude_code", configPath: "/claude.json" }];
const offered: string[] = [];
const result = await runSetup(
{ ...options(false), structured: false },
@@ -175,13 +157,7 @@ describe("selected skill admission", () => {
packageFamily: "deb",
hopperSetupSupported: true,
};
host.clients = [
{
name: "claude_code",
registrationProfile: supportedClientRegistrationProfile("claude_code"),
configPath: "/claude.json",
},
];
host.clients = [{ name: "claude_code", configPath: "/claude.json" }];
let linked = false;
host.validateSkillDirectories = async () =>
linked ? err(linkedSkillFailure()) : ok(undefined);
@@ -211,13 +187,7 @@ describe("selected skill admission", () => {
describe("skill plan destinations", () => {
it("plans a Claude-only skill in its personal location", async () => {
const host = new FakeSetupHost();
host.clients = [
{
name: "claude_code",
registrationProfile: supportedClientRegistrationProfile("claude_code"),
configPath: "/claude.json",
},
];
host.clients = [{ name: "claude_code", configPath: "/claude.json" }];
const result = await runSetup(
{ ...options(false), clientIds: ["claude_code"] },
@@ -236,17 +206,8 @@ describe("skill plan destinations", () => {
it("plans both shared and personal skill roots for mixed clients", async () => {
const host = new FakeSetupHost();
host.clients = [
{
name: "claude_code",
registrationProfile: supportedClientRegistrationProfile("claude_code"),
configPath: "/claude.json",
},
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
format: "toml",
},
{ name: "claude_code", configPath: "/claude.json" },
{ name: "codex", configPath: "/codex.toml", format: "toml" },
];
const result = await runSetup(
@@ -271,13 +232,7 @@ describe("setup workflow", () => {
host.registrationCommand = [];
host.registrationCommandBlocker =
"REA could not determine the current executable entrypoint.";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "C:\\config\\codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "C:\\config\\codex.toml" }];
const result = await runSetup(
{ ...options(true), clientIds: ["codex"], installSkill: false },
@@ -292,13 +247,7 @@ describe("setup workflow", () => {
it("keeps a relative Node registration out of setup defaults when cwd happens to match", async () => {
const host = new FakeSetupHost();
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
];
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
host.productRegistrations = [
{
client: "cursor",
@@ -350,25 +299,15 @@ describe("setup client selection", () => {
host.skill = "unchanged";
const codex = {
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
format: "toml" as const,
};
host.clients = [
codex,
{
name: "grok_bot",
registrationProfile: supportedClientRegistrationProfile("grok_bot"),
configPath: "/grokbot",
format: "unsupported",
},
{ name: "grok_bot", configPath: "/grokbot", format: "unsupported" },
];
host.availableClients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
{ name: "cursor", configPath: "/cursor.json" },
...host.clients,
];
@@ -395,20 +334,13 @@ describe("setup client selection", () => {
expect(host.hopperInstalls).toBe(0);
expect(host.skillInstalls).toBe(0);
});
});
describe("automatic client readiness and selection identity", () => {
it.each([undefined, false])(
"omits manual clients from automatic readiness while preserving detection (skill: %s)",
async (installSkill) => {
const host = new FakeSetupHost();
host.clients = [
{
name: "grok_bot",
registrationProfile: supportedClientRegistrationProfile("grok_bot"),
configPath: "/grokbot",
format: "unsupported",
},
{ name: "grok_bot", configPath: "/grokbot", format: "unsupported" },
];
const result = await runSetup(
{
@@ -435,17 +367,8 @@ describe("automatic client readiness and selection identity", () => {
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
format: "toml",
},
{ name: "cursor", configPath: "/cursor.json" },
{ name: "codex", configPath: "/codex.toml", format: "toml" },
];
const inspected: string[] = [];
const configured: string[] = [];
@@ -496,13 +419,7 @@ describe("automatic client readiness and selection identity", () => {
describe("setup plan application", () => {
it("returns a complete plan without mutation", async () => {
const host = new FakeSetupHost();
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
];
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
const result = await runSetup(
{ ...options(false), clientIds: ["cursor"] },
host,
@@ -567,13 +484,7 @@ describe("setup plan application", () => {
it("completes an empty interactive selection without changes", async () => {
const host = new FakeSetupHost();
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
const result = await runSetup(
{ ...options(false), structured: false },
@@ -589,9 +500,7 @@ describe("setup plan application", () => {
expect(host.hopperInstalls).toBe(0);
expect(host.doctorCalls).toBe(2);
});
});
describe("explicit setup authorization and dry runs", () => {
it("requires the Hopper flag for unattended setup", async () => {
const host = new FakeSetupHost();
const result = await runSetup(options(true), host);
@@ -604,13 +513,7 @@ describe("explicit setup authorization and dry runs", () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
const result = await runSetup(
{ ...options(false), dryRun: true, clientIds: ["codex"] },
@@ -663,17 +566,8 @@ describe("explicit setup authorization and dry runs", () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{
name: "claude",
// Source-only planning actor models the Claude Code registration contract.
registrationProfile: supportedClientRegistrationProfile("claude_code"),
configPath: "/claude.json",
},
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
{ name: "claude", configPath: "/claude.json" },
{ name: "cursor", configPath: "/cursor.json" },
];
const result = await runSetup(options(true), host);
expect(result.status).toBe("ready");
@@ -689,7 +583,6 @@ describe("setup workflow lifecycle and provider planning", () => {
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
displayName: "Codex",
configPath: "/codex.toml",
},
@@ -741,13 +634,7 @@ describe("setup workflow lifecycle and provider planning", () => {
host.javaHome = "/usr/lib/jvm/jdk-21";
host.doctorHealthy = true;
host.skill = "unchanged";
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/cursor.json",
},
];
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
const plan = await runSetup(
{ ...options(false), clientIds: ["cursor"] },
@@ -781,13 +668,7 @@ describe("setup workflow lifecycle and provider planning", () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
host.clientResults.set("codex", { status: "unchanged" });
const result = await runSetup(
@@ -813,7 +694,6 @@ describe("setup workflow lifecycle and provider planning", () => {
host.clients = [
{
name: "grok_bot",
registrationProfile: supportedClientRegistrationProfile("grok_bot"),
displayName: "Grok Bot",
configPath: "/home/user/.grokbot",
format: "unsupported",
+17 -118
View File
@@ -18,19 +18,12 @@ import { setupCleanupError, SetupCleanupOwner } from "./SetupCleanupOwner.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok } from "../domain/result.js";
import { SetupSkillInstallError } from "./SetupSkill.js";
import { supportedClientRegistrationProfile } from "./SupportedClients.js";
describe("setup workflow", () => {
it("rejects Node 25 before applying setup actions", async () => {
const host = new FakeSetupHost();
host.nodeVersion = "25.1.0";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
const result = await runSetup(
{ ...options(true, true), installSkill: true, clientIds: ["codex"] },
@@ -48,24 +41,9 @@ describe("setup workflow", () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{
name: "first",
// Source-only recovery actors model Cursor JSON registration.
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/first.json",
},
{
name: "second",
// Source-only recovery actor models Cursor JSON registration.
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/second.json",
},
{
name: "third",
// Source-only recovery actor models Cursor JSON registration.
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/third.json",
},
{ 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" });
@@ -94,13 +72,7 @@ describe("setup workflow", () => {
it("reports a failed config rollback with its destination and recovery backup", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/fixture/codex.json",
},
];
host.clients = [{ name: "codex", configPath: "/fixture/codex.json" }];
host.clientResults.set("codex", {
status: "failed",
reason: "rollback",
@@ -130,9 +102,7 @@ describe("setup workflow", () => {
expect(result.remediation).toContain("permission denied");
expect(result.remediation).toContain("/fixture/codex.json.rea.backup");
});
});
describe("setup recovery and readiness guidance", () => {
it("explains skill installation recovery", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
@@ -181,13 +151,7 @@ describe("setup recovery and readiness guidance", () => {
host.hopper = "/Applications/Hopper";
host.doctorHealthy = false;
host.scopedDoctorHealthy = true;
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
const readinessScope = {
clients: ["codex"],
providers: [] as string[],
@@ -235,13 +199,7 @@ describe("setup failure cleanup projection", () => {
});
}
const host = new SetupHostWithCleanupFailure();
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath,
},
];
host.clients = [{ name: "codex", configPath }];
host.inspectClientConfiguration = async () => {
throw primary;
};
@@ -322,13 +280,7 @@ describe("setup failure cleanup projection", () => {
};
}
const host = new HostWithRetainedResource();
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath,
},
];
host.clients = [{ name: "codex", configPath }];
host.inspectClientConfiguration = async () => {
throw primary;
};
@@ -471,13 +423,7 @@ describe("setup scoped readiness", () => {
const platformHost = new FakeSetupHost("win32");
platformHost.hopper = "/Applications/Hopper";
platformHost.skill = "unchanged";
platformHost.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
},
];
platformHost.clients = [{ name: "codex", configPath: "/codex.toml" }];
const windowsAgent = await runSetup(
{ ...options(true), clientIds: ["codex"] },
platformHost,
@@ -503,13 +449,7 @@ describe("setup scoped readiness", () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
host.productRegistrations = [
{
client: "codex",
@@ -539,13 +479,7 @@ describe("setup scoped readiness", () => {
const host = new FakeSetupHost("win32");
host.hopper = "C:\\Hopper\\Hopper.exe";
host.unsupportedHopperVersion = true;
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "C:\\codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "C:\\codex.toml" }];
host.scopedDoctorHealthy = true;
const result = await runSetup(
@@ -589,13 +523,7 @@ describe("setup scoped readiness", () => {
host.javaHome = "/usr/lib/jvm/jdk-21";
host.doctorHealthy = true;
host.skill = "unchanged";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
const result = await runSetup(
{ ...options(true), clientIds: ["codex"] },
@@ -639,7 +567,6 @@ const unhealthyManualClientHost = (
host.clients = [
{
name: "grok_bot",
registrationProfile: supportedClientRegistrationProfile("grok_bot"),
displayName: "Grok Bot",
configPath: "/fixture/home/.grokbot",
format: "unsupported",
@@ -910,13 +837,7 @@ describe("setup cancellation", () => {
async (completed) => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/fixture/codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "/fixture/codex.toml" }];
const controller = new AbortController();
const reason = new Error("cancel after client write");
host.configureClient = async () => {
@@ -1002,13 +923,7 @@ describe("setup cancellation", () => {
describe("setup cancellation during installation and cleanup", () => {
it("carries the caller's cancellation into the Hopper install", async () => {
const host = new FakeSetupHost("darwin");
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
const controller = new AbortController();
const reason = new Error("stop setup");
let received: AbortSignal | undefined;
@@ -1041,9 +956,7 @@ describe("setup cancellation during installation and cleanup", () => {
});
expect(host.configurations).toBe(0);
});
});
describe("setup cancellation during final cleanup", () => {
it("reports cancellation during final cleanup with the completed setup result", async () => {
const clients = {
codex: { status: "configured", backupPath: "/fixture/codex.toml.bak" },
@@ -1059,16 +972,8 @@ describe("setup cancellation during final cleanup", () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/fixture/codex.toml",
},
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath: "/fixture/cursor.json",
},
{ name: "codex", configPath: "/fixture/codex.toml" },
{ name: "cursor", configPath: "/fixture/cursor.json" },
];
for (const [name, result] of Object.entries(clients))
host.clientResults.set(name, result);
@@ -1115,13 +1020,7 @@ describe("setup cancellation during final cleanup", () => {
const reason = new Error("cancel during final cleanup");
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{
name: "codex",
registrationProfile: supportedClientRegistrationProfile("codex"),
configPath: "/fixture/codex.toml",
},
];
host.clients = [{ name: "codex", configPath: "/fixture/codex.toml" }];
host.clientResults.set("codex", {
status: "configured",
backupPath: "/fixture/codex.bak",
@@ -1,164 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { createTestProcessRegistry } from "../../tests/support/process/processFixture.js";
const CONFIGURATION_PROGRAM = `
import { homedir } from 'node:os';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { mkdir, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises';
const home = process.env.HOME;
if (!home || await realpath(homedir()) !== await realpath(home))
throw Error('Fixture HOME must be selected before importing setup owners.');
const insideHome = (path) => {
const selected = relative(home, resolve(path));
if (selected.startsWith('..') || isAbsolute(selected)) throw Error('Fixture path escapes HOME.');
return path;
};
for (const key of ['USERPROFILE','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','APPDATA','LOCALAPPDATA'])
insideHome(process.env[key] ?? '');
const { configureClientConfiguration, clientConfigurationAligned, inspectClientConfiguration } =
await import(${JSON.stringify(new URL("./SetupClientConfiguration.ts", import.meta.url).href)});
const { currentClientRegistrationCommand } =
await import(${JSON.stringify(new URL("./ClientRegistrationIdentity.ts", import.meta.url).href)});
const { supportedClientRegistrationProfile } =
await import(${JSON.stringify(new URL("./SupportedClients.ts", import.meta.url).href)});
const command = currentClientRegistrationCommand();
if (command.length === 0) throw Error('Fixture process has no owned current entrypoint.');
const exercise = async (name, format) => {
const directory = insideHome(join(home, 'config', name));
const configPath = insideHome(join(directory, 'mcp.json'));
await mkdir(directory, { recursive: true });
const client = { name, format, configPath, registrationProfile: supportedClientRegistrationProfile(name) };
await writeFile(configPath, '{}');
const initial = await configureClientConfiguration(client, {}, command);
if (initial.status !== 'configured') throw Error('Initial fixture configuration failed: ' + JSON.stringify(initial));
const original = JSON.parse(await readFile(configPath, 'utf8'));
const strictControl = {
aligned: await clientConfigurationAligned(client, {}, command),
inspection: (await inspectClientConfiguration(client, {}, command)).status,
status: (await configureClientConfiguration(client, {}, command)).status,
};
const jsonc = '// retained original source\\n' + JSON.stringify(original, null, 2).replace(/}$/, ',\\n}') + '\\n';
await writeFile(configPath, jsonc);
const before = {
aligned: await clientConfigurationAligned(client, {}, command),
inspection: (await inspectClientConfiguration(client, {}, command)).status,
};
const filesBefore = await readdir(directory);
const repaired = await configureClientConfiguration(client, {}, command);
const after = await readFile(configPath, 'utf8');
let strict = false, document;
try { document = JSON.parse(after); strict = true; } catch {}
const afterState = await stat(configPath);
const filesAfter = await readdir(directory);
const repeat = await configureClientConfiguration(client, {}, command);
const repeatState = await stat(configPath);
return {
strictControl, before, status: repaired.status, jsonc, after, strict, original, document,
alignedAfter: await clientConfigurationAligned(client, {}, command),
backup: repaired.backupPath === undefined ? null : await readFile(insideHome(repaired.backupPath), 'utf8'),
repeatStatus: repeat.status,
stableRepeat: afterState.ino === repeatState.ino && afterState.mtimeMs === repeatState.mtimeMs,
filesBefore, filesAfter, filesRepeat: await readdir(directory),
};
};
console.log(JSON.stringify({ command, copilot: await exercise('copilot_cli', 'copilot_cli'), standard: await exercise('cursor', 'json') }));
`;
const observedConfiguration = z.object({
strictControl: z.object({
aligned: z.boolean(),
inspection: z.string(),
status: z.string(),
}),
before: z.object({ aligned: z.boolean(), inspection: z.string() }),
status: z.string(),
jsonc: z.string(),
after: z.string(),
strict: z.boolean(),
original: z.unknown(),
document: z.unknown().optional(),
alignedAfter: z.boolean(),
backup: z.string().nullable(),
repeatStatus: z.string(),
stableRepeat: z.boolean(),
filesBefore: z.array(z.string()),
filesAfter: z.array(z.string()),
filesRepeat: z.array(z.string()),
});
it("repairs equal-launcher Copilot JSONC syntax while strict JSON and standard JSONC remain idempotent", async () => {
const home = await createTestTempDirectory("rea-setup-native-syntax-");
const entrypoint = join(home, "native-syntax.mjs");
await mkdir(join(home, "config"));
await writeFile(entrypoint, CONFIGURATION_PROGRAM);
const processes = createTestProcessRegistry();
try {
const child = await processes.run(
process.execPath,
["--import", "tsx", entrypoint],
{
timeoutMs: 20_000,
env: {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
WINDIR: process.env.WINDIR,
TMPDIR: process.env.TMPDIR,
TMP: process.env.TMP,
TEMP: process.env.TEMP,
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, "config"),
XDG_DATA_HOME: join(home, "data"),
XDG_CACHE_HOME: join(home, "cache"),
XDG_STATE_HOME: join(home, "state"),
APPDATA: join(home, "appdata"),
LOCALAPPDATA: join(home, "localappdata"),
},
},
);
expect(child.exitCode, child.stderr).toBe(0);
const { copilot, standard, command } = z
.object({
command: z.array(z.string()).min(1),
copilot: observedConfiguration,
standard: observedConfiguration,
})
.parse(JSON.parse(child.stdout));
expect(command.at(-1)).toBe("mcp");
expect(copilot.strictControl).toEqual({
aligned: true,
inspection: "already_current",
status: "unchanged",
});
expect(copilot.before).toEqual({ aligned: false, inspection: "update" });
expect(copilot.status).toBe("configured");
expect(copilot.strict).toBe(true);
expect(copilot.document).toEqual(copilot.original);
expect(copilot.backup).toBe(copilot.jsonc);
expect(copilot.alignedAfter).toBe(true);
expect(copilot.repeatStatus).toBe("unchanged");
expect(copilot.stableRepeat).toBe(true);
expect(copilot.filesRepeat).toEqual(copilot.filesAfter);
expect(standard.strictControl).toEqual({
aligned: true,
inspection: "already_current",
status: "unchanged",
});
expect(standard.before).toEqual({
aligned: true,
inspection: "already_current",
});
expect(standard.status).toBe("unchanged");
expect(standard.after).toBe(standard.jsonc);
expect(standard.filesAfter).toEqual(standard.filesBefore);
expect(standard.repeatStatus).toBe("unchanged");
expect(standard.stableRepeat).toBe(true);
} finally {
await processes.shutdown();
}
});
@@ -878,7 +878,6 @@ const registrationCurrent = (
parsed: ClientConfigurationDocument,
desired: unknown,
): boolean =>
parsed.requiresRewrite !== true &&
(parsed.dialect !== "dsh" || dshDocument(parsed.document).ready) &&
clientConfigurationValuesEqual(
parsed.servers[PRODUCT_IDENTITY.mcpServerKey],
+4 -22
View File
@@ -12,7 +12,6 @@ import {
createTestWorkspace,
removeTestWorkspace,
} from "../../tests/support/workspace/workspaceFixture.js";
import { supportedClientRegistrationProfile } from "./SupportedClients.js";
/** Keep a workspace child under its root before any filesystem call. */
const pathInside = (root: string, ...parts: string[]): string => {
@@ -76,13 +75,7 @@ it.skipIf(process.platform === "win32")(
]);
await rm(alias);
const host = new FakeSetupHost();
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath,
},
];
host.clients = [{ name: "cursor", configPath }];
const discovery = await discoverSetupState({
host,
providerConfigured: true,
@@ -146,13 +139,7 @@ it.each([
}),
);
const host = new FakeSetupHost();
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath,
},
];
host.clients = [{ name: "cursor", configPath }];
const discovery = await discoverSetupState({
host,
providerConfigured: true,
@@ -197,14 +184,9 @@ it("offers detected missing and manual clients without treating absent launcher
);
const host = new FakeSetupHost();
host.clients = [
{
name: "cursor",
registrationProfile: supportedClientRegistrationProfile("cursor"),
configPath,
},
{ name: "cursor", configPath },
{
name: "grok_bot",
registrationProfile: supportedClientRegistrationProfile("grok_bot"),
configPath: grokbotPath,
format: "unsupported",
},
@@ -219,7 +201,7 @@ it("offers detected missing and manual clients without treating absent launcher
expect(discovery.defaultClientIds).toEqual([]);
expect(discovery.clientStates).toEqual([
expect.objectContaining({
client: host.clients[0],
client: { name: "cursor", configPath },
status: "needs_configuration",
configured: false,
}),
+72 -305
View File
@@ -1,7 +1,3 @@
import type {
ClientRegistrationConnectionProfile,
ClientRegistrationProfile,
} from "./ClientRegistrationProfile.js";
import { dirname, isAbsolute, join, resolve, win32 } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, lstatSync, readFileSync } from "node:fs";
@@ -21,7 +17,6 @@ import {
/** One supported client configuration location. */
export interface SetupClient {
readonly name: string;
readonly registrationProfile: ClientRegistrationProfile;
readonly displayName?: string;
readonly configPath: string;
/** User configuration files in increasing precedence, including the write target. */
@@ -96,25 +91,25 @@ interface ClientLocation {
readonly msixPackage?: MsixPackageView;
}
type ClientDefinition = ClientDefinitionBase & ClientDefinitionLocation;
type ClientDefinitionLocation =
| {
readonly configPath: ClientPath;
readonly markerPath: ClientPath;
readonly location?: undefined;
}
| {
/** Resolve configuration and marker together when they share probes. */
readonly location: (
context: ClientPathContext,
) => Result<ClientLocation, ClientPathFailure>;
readonly configPath?: undefined;
readonly markerPath?: undefined;
};
type ClientDefinition = ClientDefinitionBase &
(
| {
readonly configPath: ClientPath;
readonly markerPath: ClientPath;
readonly location?: undefined;
}
| {
/** Resolve configuration and marker together when they share probes. */
readonly location: (
context: ClientPathContext,
) => Result<ClientLocation, ClientPathFailure>;
readonly configPath?: undefined;
readonly markerPath?: undefined;
}
);
interface ClientDefinitionBase {
readonly name: string;
readonly registrationProfile: ClientRegistrationProfile;
readonly displayName: string;
readonly discoveryPaths?: (context: ClientPathContext) => readonly string[];
readonly sharedRegistration?: true;
@@ -559,37 +554,10 @@ const devinDirectory = ({ home, platform, env }: ClientPathContext): string =>
const freebuffProfileDirectory = ({ home }: ClientPathContext): string =>
join(home, ".config", "freebuff-desktop");
type ClientDefinitionInput = Omit<ClientDefinitionBase, "registrationProfile"> &
ClientDefinitionLocation & {
readonly registrationConnection: ClientRegistrationConnectionProfile;
};
/** Bind native identity once while retaining the original definition's literal name. */
const defineClient = <const Definition extends ClientDefinitionInput>(
definition: Definition,
) => {
const { registrationConnection, ...metadata } = definition;
return {
...metadata,
registrationProfile: {
nativeClient: definition.name,
connection: registrationConnection,
},
};
};
/** Stable product metadata used to derive setup discovery and documentation. */
export const SUPPORTED_CLIENT_DEFINITIONS = [
defineClient({
{
name: "claude_code",
registrationConnection: {
kind: "flat",
fields: ["type", "url"],
stdio: [
{ kind: "optional-enum", field: "type", values: ["stdio"] },
{ kind: "present", field: "url" },
],
},
displayName: "Claude Code",
skillPath: (context: ClientPathContext) =>
join(claudeCodeMarkerDirectory(context), "skills"),
@@ -597,69 +565,39 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
join(claudeCodeConfigDirectory(context), ".claude.json"),
markerPath: claudeCodeMarkerDirectory,
format: "claude_code",
}),
defineClient({
},
{
name: "claude_desktop",
registrationConnection: {
kind: "flat",
fields: ["type"],
stdio: [{ kind: "optional-enum", field: "type", values: ["stdio"] }],
},
displayName: "Claude Desktop",
location: claudeDesktopLocation,
quitBeforeSetup: true,
format: "json",
}),
defineClient({
},
{
name: "codex",
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [{ kind: "non-nullish", field: "url" }],
},
displayName: "Codex",
configPath: (context: ClientPathContext) =>
appendCallerPath(codexDirectory(context), "config.toml"),
markerPath: codexDirectory,
format: "toml",
}),
defineClient({
},
{
name: "cursor",
registrationConnection: {
kind: "flat",
fields: ["type", "url"],
stdio: [
{ kind: "optional-enum", field: "type", values: ["stdio"] },
{ kind: "present", field: "url" },
],
},
displayName: "Cursor",
configPath: [".cursor", "mcp.json"],
markerPath: [".cursor"],
format: "json",
}),
defineClient({
},
{
name: "codebuddy",
registrationConnection: {
kind: "stdio-only",
documentedFields: ["env", "type"],
},
displayName: "CodeBuddy",
skillPath: [".codebuddy", "skills"],
configPath: [".codebuddy", "mcp.json"],
markerPath: [".codebuddy"],
format: "json",
}),
defineClient({
},
{
name: "gemini_cli",
registrationConnection: {
kind: "flat",
fields: ["type", "url", "httpUrl"],
stdio: [
{ kind: "truthy", field: "httpUrl" },
{ kind: "truthy", field: "url" },
],
},
displayName: "Gemini CLI",
geminiSettings,
configPath: ({ home, env }: ClientPathContext) =>
@@ -669,197 +607,111 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
skillPath: ({ home, env }: ClientPathContext) =>
join(env.GEMINI_CLI_HOME || home, ".agents", "skills"),
format: "json",
}),
defineClient({
},
{
name: "windsurf",
registrationConnection: {
kind: "flat",
fields: ["serverUrl", "url"],
stdio: [
{ kind: "present", field: "serverUrl" },
{ kind: "present", field: "url" },
],
},
displayName: "Windsurf",
configPath: [".codeium", "windsurf", "mcp_config.json"],
markerPath: [".codeium", "windsurf"],
format: "json",
}),
defineClient({
},
{
name: "devin",
registrationConnection: {
kind: "flat",
fields: ["url", "transport"],
stdio: [
{ kind: "optional-enum", field: "transport", values: ["stdio"] },
{ kind: "present", field: "url" },
],
},
displayName: "Devin",
configPath: ({ home, platform, env }: ClientPathContext) =>
join(devinDirectory({ home, platform, env }), "mcp_config.json"),
markerPath: devinDirectory,
format: "json",
}),
defineClient({
},
{
name: "opencode",
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [{ kind: "required-enum", field: "type", values: ["local"] }],
},
displayName: "OpenCode",
configPath: existingOpenCodeConfigPath,
configPaths: openCodeConfigurationPaths,
markerPath: opencodeDirectory,
format: "opencode",
}),
defineClient({
},
{
name: "antigravity",
registrationConnection: {
kind: "flat",
fields: ["serverUrl", "url"],
stdio: [
{ kind: "present", field: "serverUrl" },
{ kind: "present", field: "url" },
],
},
displayName: "Antigravity",
skillPath: [".gemini", "config", "skills"],
configPath: [".gemini", "config", "mcp_config.json"],
markerPath: [".gemini", "config"],
format: "json",
}),
defineClient({
},
{
name: "copilot_cli",
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [
{ kind: "optional-enum", field: "type", values: ["stdio", "local"] },
{ kind: "present", field: "url" },
],
},
displayName: "GitHub Copilot CLI",
configPath: (context: ClientPathContext) =>
join(copilotDirectory(context), "mcp-config.json"),
markerPath: copilotDirectory,
format: "copilot_cli",
}),
defineClient({
},
{
name: "commandcode",
registrationConnection: { kind: "commandcode" },
displayName: "Command Code",
configPath: (context: ClientPathContext) =>
join(commandCodeDirectory(context), "mcp.json"),
markerPath: commandCodeDirectory,
format: "commandcode",
}),
defineClient({
},
{
name: "qwen_code",
registrationConnection: {
kind: "flat",
fields: ["type", "httpUrl", "url"],
stdio: [
{ kind: "truthy", field: "httpUrl" },
{ kind: "truthy", field: "url" },
{ kind: "equals", field: "type", value: "sdk" },
],
},
displayName: "Qwen Code",
configPath: (context: ClientPathContext) =>
join(qwenCodeDirectory(context), "settings.json"),
markerPath: qwenCodeDirectory,
format: "json",
}),
defineClient({
},
{
name: "kimi",
registrationConnection: {
kind: "flat",
fields: ["type", "url", "transport"],
stdio: [
{ kind: "optional-enum", field: "type", values: ["stdio"] },
{ kind: "optional-enum", field: "transport", values: ["stdio"] },
{ kind: "present", field: "url" },
],
},
displayName: "Kimi Code",
configPath: (context: ClientPathContext) =>
join(kimiDirectory(context), "mcp.json"),
markerPath: kimiDirectory,
format: "kimi",
}),
defineClient({
},
{
name: "vscode",
registrationConnection: {
kind: "flat",
fields: ["url", "transport"],
stdio: [
{ kind: "required-enum", field: "type", values: ["stdio"] },
{ kind: "optional-enum", field: "transport", values: ["stdio"] },
{ kind: "present", field: "url" },
],
},
displayName: "VS Code",
configPath: (context: ClientPathContext) =>
join(vscodeUserDirectory(context), "mcp.json"),
markerPath: vscodeUserDirectory,
format: "vscode",
}),
defineClient({
},
{
name: "trae",
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [{ kind: "present", field: "url" }],
},
displayName: "Trae",
skillPath: [".trae", "skills"],
configPath: (context: ClientPathContext) =>
join(traeUserDirectory(context), "mcp.json"),
markerPath: traeUserDirectory,
format: "json",
}),
defineClient({
},
{
name: "workbuddy",
registrationConnection: { kind: "stdio-only", documentedFields: ["env"] },
displayName: "WorkBuddy",
skillPath: [".workbuddy", "skills"],
configPath: [".workbuddy", "mcp.json"],
markerPath: [".workbuddy"],
format: "json",
}),
defineClient({
},
{
name: "lm_studio",
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [{ kind: "present", field: "url" }],
},
displayName: "LM Studio",
location: lmStudioLocation,
format: "lm_studio",
}),
defineClient({
},
{
name: "grok_build",
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [{ kind: "non-nullish", field: "url" }],
},
displayName: "Grok Build",
configPath: (context: ClientPathContext) =>
join(grokDirectory(context), "config.toml"),
markerPath: grokDirectory,
format: "grok",
}),
defineClient({
},
{
name: "omp",
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [{ kind: "optional-enum", field: "type", values: ["stdio"] }],
},
displayName: "OMP",
configPath: (context: ClientPathContext) => {
const directory = ompAgentDirectory(context);
@@ -867,23 +719,9 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
},
markerPath: ompAgentDirectory,
format: "omp",
}),
defineClient({
},
{
name: "pi",
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [
{ kind: "optional-enum", field: "type", values: ["stdio"] },
{
kind: "all",
conditions: [
{ kind: "missing", field: "type" },
{ kind: "string", field: "url" },
],
},
],
},
displayName: "Pi",
configPath: (context: ClientPathContext) =>
(context.platform === "win32" ? win32 : { join }).join(
@@ -892,17 +730,9 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
),
markerPath: piAgentDirectory,
format: "pi",
}),
defineClient({
},
{
name: "hermes",
registrationConnection: {
kind: "flat",
fields: ["url", "transport"],
stdio: [
{ kind: "optional-enum", field: "transport", values: ["stdio"] },
{ kind: "present", field: "url" },
],
},
displayName: "Hermes",
skillPath: (context: ClientPathContext) =>
appendCallerPath(hermesDirectory(context), "skills"),
@@ -910,19 +740,9 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
appendCallerPath(hermesDirectory(context), "config.yaml"),
markerPath: hermesDirectory,
format: "hermes",
}),
defineClient({
},
{
name: "qoder",
registrationConnection: {
kind: "flat",
fields: ["type", "url", "qoder_url", "tcp"],
stdio: [
{ kind: "optional-enum", field: "type", values: ["stdio"] },
{ kind: "present", field: "url" },
{ kind: "present", field: "qoder_url" },
{ kind: "present", field: "tcp" },
],
},
displayName: "Qoder",
skillPath: (context: ClientPathContext) =>
join(qoderDirectory(context), "skills"),
@@ -930,17 +750,9 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
join(qoderDirectory(context), "settings.json"),
markerPath: qoderDirectory,
format: "json",
}),
defineClient({
},
{
name: "droid",
registrationConnection: {
kind: "flat",
fields: ["type", "url"],
stdio: [
{ kind: "optional-enum", field: "type", values: ["stdio"] },
{ kind: "present", field: "url" },
],
},
displayName: "Factory Droid",
skillPath: (context: ClientPathContext) =>
join(factoryDirectory(context), "skills"),
@@ -948,17 +760,9 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
join(factoryDirectory(context), "mcp.json"),
markerPath: factoryDirectory,
format: "json",
}),
defineClient({
},
{
name: "zcode",
registrationConnection: {
kind: "flat",
fields: ["type", "url"],
stdio: [
{ kind: "optional-enum", field: "type", values: ["stdio"] },
{ kind: "present", field: "url" },
],
},
zcodeFallbackPath: ({ home }: ClientPathContext) =>
join(home, ".agents", "mcp.json"),
displayName: "ZCode",
@@ -969,14 +773,9 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
join(home, ".zcode", "cli"),
],
format: "zcode",
}),
defineClient({
},
{
name: "dsh",
registrationConnection: {
kind: "flat",
fields: ["url"],
stdio: [{ kind: "required-enum", field: "transport", values: ["stdio"] }],
},
displayName: "DeepSeek Harness",
// The home Cordis patch applies to every profile; the skill directory is
// shared agent storage Harness discovers.
@@ -985,17 +784,9 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
configPath: dshPatchDirectory,
markerPath: dshHomeDirectory,
format: "dsh",
}),
defineClient({
},
{
name: "freebuff",
registrationConnection: {
kind: "flat",
fields: ["type", "url"],
stdio: [
{ kind: "optional-enum", field: "type", values: ["stdio"] },
{ kind: "present", field: "url" },
],
},
displayName: "FreeBuff",
// FreeBuff discovers personal skills in the shared user agents directory.
skillPath: ({ home }: ClientPathContext) => join(home, ".agents", "skills"),
@@ -1010,36 +801,16 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
// proves neither product is installed; see clientOwnershipEvidencePaths.
sharedRegistration: true,
format: "json",
}),
defineClient({
},
{
name: "grok_bot",
registrationConnection: {
kind: "unavailable",
reason:
"Grok Bot has no supported configuration writer. Configure its MCP connection manually.",
},
displayName: "Grok Bot",
configPath: grokBotDirectory,
markerPath: grokBotDirectory,
format: "unsupported",
}),
},
] as const satisfies readonly ClientDefinition[];
export type SupportedClientName =
(typeof SUPPORTED_CLIENT_DEFINITIONS)[number]["name"];
/** Setup and registration consume the same already-bound native profile object. */
export const supportedClientRegistrationProfile = (
name: SupportedClientName,
): ClientRegistrationProfile => {
const definition = SUPPORTED_CLIENT_DEFINITIONS.find(
(candidate) => candidate.name === name,
);
if (definition === undefined)
throw new Error(`No supported client definition for ${name}`);
return definition.registrationProfile;
};
/**
* Paths whose presence means the product itself is applicable, so doctor and
* setup should inspect the client. Layered sources count: OpenCode still loads
@@ -1138,7 +909,6 @@ export const supportedClients = (
if (!location.ok)
return {
name: definition.name,
registrationProfile: definition.registrationProfile,
displayName: definition.displayName,
format: definition.format,
configPath: location.error.path,
@@ -1150,7 +920,6 @@ export const supportedClients = (
const { configPath, markerPath, msixPackage } = location.value;
return {
name: definition.name,
registrationProfile: definition.registrationProfile,
displayName: definition.displayName,
configPath,
...(definition.geminiSettings === undefined
@@ -1183,7 +952,6 @@ export const supportedClients = (
if (cause instanceof HermesClientPathError)
return {
name: definition.name,
registrationProfile: definition.registrationProfile,
displayName: definition.displayName,
format: definition.format,
configPath: cause.path,
@@ -1192,7 +960,6 @@ export const supportedClients = (
if (definition.name !== "pi") throw cause;
return {
name: definition.name,
registrationProfile: definition.registrationProfile,
displayName: definition.displayName,
format: definition.format,
configPath: env.PI_CODING_AGENT_DIR ?? "",
+11
View File
@@ -17,6 +17,7 @@ import {
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
@@ -323,6 +324,16 @@ const captureReadFailure = (
"Capture read and file-handle close both failed",
{ cause },
);
if (cause instanceof ConfigurationError) {
if (closeFailure === undefined) return cause;
return new ConfigurationError(cause.message, {
cause: causeWithClose,
settings: cause.settings,
...(cause.remediationAction === undefined
? {}
: { remediationAction: cause.remediationAction }),
});
}
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
if (code === "EACCES" || code === "EPERM")
-143
View File
@@ -1,143 +0,0 @@
import {
isMap,
isNode,
isScalar,
isSeq,
type Node,
type ScalarTag,
type Tags,
} from "yaml";
import { retainYamlSequenceAnnotations } from "./YamlSequenceAnnotations.js";
export const yamlNumericValuesEqual = (
left: bigint | number,
right: bigint | number,
): boolean => {
if (typeof left === "bigint" && typeof right === "bigint")
return left === right;
if (typeof left === "number" && typeof right === "number")
return left === right || (Number.isNaN(left) && Number.isNaN(right));
const integer = typeof left === "bigint" ? left : right;
const number = typeof left === "number" ? left : right;
return (
Number.isFinite(number) &&
Number.isInteger(number) &&
BigInt(number) === integer
);
};
/** Keep source annotations and unchanged scalars when updating a value. */
export const retainYamlConfigurationSource = (
previous: unknown,
next: Node,
retainedSources: ReadonlySet<Node>,
): Node => {
if (
isScalar(previous) &&
isScalar(next) &&
(previous.value === next.value ||
(typeof previous.value === "bigint" &&
typeof next.value === "number" &&
yamlNumericValuesEqual(previous.value, next.value)))
) {
if (!retainedSources.has(previous)) return previous;
const copied = previous.clone();
if (!isScalar(copied)) throw new TypeError("Invalid YAML scalar copy");
delete copied.anchor;
delete copied.comment;
delete copied.commentBefore;
delete copied.spaceBefore;
return copied;
}
// An anchor moved to a surviving consumer owns its original comments.
// Otherwise retain annotations on the replacement without retaining the
// old anchor, tag, or scalar spelling for the changed value.
if (isNode(previous) && !retainedSources.has(previous)) {
if (previous.comment !== undefined) next.comment = previous.comment;
if (previous.commentBefore !== undefined)
next.commentBefore = previous.commentBefore;
if (previous.spaceBefore !== undefined)
next.spaceBefore = previous.spaceBefore;
}
if (isMap(previous) && isMap(next)) {
const entries = new Map(
previous.items.flatMap((pair) =>
isScalar(pair.key) && typeof pair.key.value === "string"
? [[pair.key.value, pair] as const]
: [],
),
);
for (const pair of next.items) {
const key = isScalar(pair.key) ? pair.key.value : undefined;
const prior = typeof key === "string" ? entries.get(key) : undefined;
if (prior === undefined) continue;
pair.key = isNode(pair.key)
? retainYamlConfigurationSource(prior.key, pair.key, retainedSources)
: prior.key;
if (isNode(pair.value))
pair.value = retainYamlConfigurationSource(
prior.value,
pair.value,
retainedSources,
);
}
} else if (isSeq(previous) && isSeq(next)) {
const stringItems = (items: readonly unknown[]) =>
items.every((item) => isScalar(item) && typeof item.value === "string");
if (stringItems(previous.items) && stringItems(next.items))
retainYamlSequenceAnnotations(
previous,
next,
(item) => (isScalar(item) ? item.value : undefined),
(source, target) =>
retainYamlConfigurationSource(source, target, retainedSources),
(source) => !retainedSources.has(source),
);
else
next.items = next.items.map((item, index) =>
isNode(item)
? retainYamlConfigurationSource(
previous.items[index],
item,
retainedSources,
)
: item,
);
}
return next;
};
/** Keep a source spelling only while the selected schema resolves its value unchanged. */
const sameResolvedScalarValue = (left: unknown, right: unknown): boolean =>
Object.is(left, right) ||
(left instanceof Date &&
right instanceof Date &&
left.getTime() === right.getTime());
export const retainYamlScalarSource = (tag: Tags[number]): Tags[number] => {
if (typeof tag === "string" || tag.collection !== undefined) return tag;
const { stringify } = tag;
if (stringify === undefined) return tag;
return {
...tag,
stringify: (item, context, ...rest) => {
const { source } = item;
if (source === undefined) return stringify(item, context, ...rest);
const reader = context.doc.schema.tags.find(
(candidate): candidate is ScalarTag =>
candidate.default === true && candidate.test?.test(source) === true,
);
const read = reader?.resolve(
source,
() => undefined,
context.doc.options,
);
return sameResolvedScalarValue(
isScalar(read) ? read.value : read,
item.value,
)
? source
: stringify(item, context, ...rest);
},
};
};
+29 -6
View File
@@ -72,6 +72,7 @@ import {
} from "./BinarySessionExecution.js";
import { closeAnalysisClient } from "../AnalysisClientCleanup.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { ConfigurationError } from "../../domain/configurationErrors.js";
import type { AnalysisCleanupRetry } from "../../domain/analysisErrorRecovery.js";
import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancellation.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
@@ -705,11 +706,27 @@ export class BinarySession
profile = route.profile;
}
const runId = randomUUID();
const client = route.createClient(target, {
runId,
});
let client: ReturnType<typeof route.createClient>;
try {
client = route.createClient(target, {
runId,
});
} catch (cause: unknown) {
if (!(cause instanceof ConfigurationError)) throw cause;
return err(
previous === undefined
? cause
: await this.#restoreAfterFailedOpen(previous, cause),
);
}
const run: SessionRun = { client, route, runId };
const started = await client.execute("health", {}, options);
let started: Awaited<ReturnType<typeof client.execute>>;
try {
started = await client.execute("health", {}, options);
} catch (cause: unknown) {
if (!(cause instanceof ConfigurationError)) throw cause;
return err(await this.#failedOpen(run, previous, cause));
}
if (!started.ok) {
return err(await this.#failedOpen(run, previous, started.error));
}
@@ -1562,8 +1579,14 @@ export class BinarySession
// Stop replay before dispatch, including reads overlapping the write.
if (capability.effects.mutatesArtifact) this.invalidateSnapshot();
const dispatch = this.#runtimeAvailability.dispatch();
const call = active.client.execute(name, arguments_, options);
const result = unrecordedExecution(await call);
let dispatched: Awaited<ReturnType<typeof active.client.execute>>;
try {
dispatched = await active.client.execute(name, arguments_, options);
} catch (cause: unknown) {
if (cause instanceof ConfigurationError) return err(cause);
throw cause;
}
const result = unrecordedExecution(dispatched);
const profiled = bindExecutionTarget(
commitExecutionProfile(name, result, profile),
name,
@@ -1,6 +1,7 @@
import { discoverJavaScriptPreloadPaths } from "./JavaScriptPreloadDiscovery.js";
import { artifactNameForClassification } from "../../domain/artifactNameSelection.js";
import type { AnalysisSettingValues } from "../../domain/analysisSettings.js";
import { selectedAnalysisSetting } from "../../domain/analysisSettingDefinitions.js";
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
import {
DEFAULT_ASAR_HEADER_ADMISSION,
@@ -181,18 +182,24 @@ export const reconstructJavaScriptArtifact = async (
? {}
: { fileNodes: input.max_semantic_nodes_per_file }),
});
const heap = selectedAnalysisSetting(
input.max_heap_mb,
overrides?.["javascript.max_heap_mb"],
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb,
);
const timeout = selectedAnalysisSetting(
input.analysis_timeout_ms,
overrides?.["javascript.analysis_timeout_ms"],
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs,
);
const sourceLimits = {
...(input.semantic_precision === undefined
? {}
: { semanticPrecision: input.semantic_precision }),
heapMb:
input.max_heap_mb ??
overrides?.["javascript.max_heap_mb"] ??
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb,
timeoutMs:
input.analysis_timeout_ms ??
overrides?.["javascript.analysis_timeout_ms"] ??
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs,
heapMb: heap.value,
heapSource: heap.source,
timeoutMs: timeout.value,
timeoutSource: timeout.source,
};
const preloads = await discoverJavaScriptPreloadPaths(
files,
+2
View File
@@ -15,6 +15,8 @@ export type WasmArtifactExecution = Omit<AnalysisExecution, "subject"> & {
/** Effective operation budget, separate from caller-authored Evidence parameters. */
export interface WasmArtifactExecutionOptions extends ExecutionOptions {
readonly commandTimeoutMs?: number;
/** Selection owner for `commandTimeoutMs`. Absent when the caller did not record it. */
readonly commandTimeoutSource?: "default" | "runtime" | "request" | undefined;
}
/** Replaceable offline WASM inspection engine, independent of target execution and network clients. */
@@ -240,6 +240,7 @@ it("gives cancellation precedence over malformed raw input", async () => {
it("uses runtime defaults only for omitted budgets, preserving explicit input and reset", async () => {
const settings = new AnalysisSettings({});
const accepted: number[] = [];
const sources: (string | undefined)[] = [];
const authored: (number | undefined)[] = [];
const service = new WasmArtifactService(
{
@@ -248,6 +249,7 @@ it("uses runtime defaults only for omitted budgets, preserving explicit input an
const value = wasmArtifactFixture();
value.tool_profile.timeout_ms = options!.commandTimeoutMs!;
accepted.push(options!.commandTimeoutMs!);
sources.push(options!.commandTimeoutSource);
authored.push(input.command_timeout_ms);
return ok(wasmArtifactExecution(value));
},
@@ -275,6 +277,7 @@ it("uses runtime defaults only for omitted budgets, preserving explicit input an
});
expect((await settings.run(() => service.inspect({ path }))).ok).toBe(true);
expect(accepted).toEqual([123000, 42000, 30000]);
expect(sources).toEqual(["runtime", "request", "default"]);
expect(authored).toEqual([undefined, 42000, undefined]);
});
+9 -6
View File
@@ -1,4 +1,5 @@
import type { AnalysisSettingsReader } from "../../domain/analysisSettings.js";
import { selectedAnalysisSetting } from "../../domain/analysisSettingDefinitions.js";
import { constants as bufferConstants } from "node:buffer";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import {
@@ -78,13 +79,15 @@ export class WasmArtifactService {
),
);
}
const timeout =
input.value.command_timeout_ms ??
this.settings?.().overrides["wasm.command_timeout_ms"] ??
DEFAULT_WASM_COMMAND_TIMEOUT_MS;
const timeout = selectedAnalysisSetting(
input.value.command_timeout_ms,
this.settings?.().overrides["wasm.command_timeout_ms"],
DEFAULT_WASM_COMMAND_TIMEOUT_MS,
);
const inspected = await this.provider.inspect(input.value, {
...options,
commandTimeoutMs: timeout,
commandTimeoutMs: timeout.value,
commandTimeoutSource: timeout.source,
});
if (!inspected.ok) return inspected;
const execution = inspected.value;
@@ -108,7 +111,7 @@ export class WasmArtifactService {
!report.success ||
report.data.artifact.path !== input.value.path ||
execution.subject.path !== input.value.path ||
report.data.tool_profile.timeout_ms !== timeout ||
report.data.tool_profile.timeout_ms !== timeout.value ||
report.data.tool_profile.output_budget_bytes !==
wasmResourceLimits(input.value).outputBytes ||
!wasmArtifactMatchesSubject(report.data.artifact, execution.subject) ||
@@ -54,7 +54,8 @@ it("preserves adjustable reader admission facts and gives cleanup priority", ()
required_value: 2,
unit: "count",
applies_at: "next_operation",
rea_restart_required: false,
update_mode: "parameter",
pending_restart: false,
},
},
});
+2 -1
View File
@@ -40,7 +40,8 @@ export const artifactReaderLimitFailure = (
applies_at: "next_operation",
agent_editable: true,
can_increase: canIncrease,
rea_restart_required: false,
update_mode: "parameter" as const,
pending_restart: false as const,
},
});
};
+70
View File
@@ -2,6 +2,7 @@ import type { Stats } from "node:fs";
import { expect, it } from "vitest";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { settleChangeTimeBaseline } from "../filesystem/ChangeTimeBaseline.js";
const DRIFT_MS = 2_000;
@@ -229,3 +230,72 @@ it("throws the caller's typed cancellation rather than the raw reason", async ()
}),
).rejects.toBeInstanceOf(Cancelled);
});
it("uses a Windows settle override before rejecting an invalid launch seed", async () => {
const delays: number[] = [];
let reads = 0;
const observed = await runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: {
"windows.change_time_drift_ms": 50,
"windows.change_time_settle_poll_ms": 10,
"windows.change_time_settle_max_polls": 4,
},
},
() =>
settleChangeTimeBaseline(
async () => {
reads += 1;
return statsAt(1_000);
},
{
now: () => 1_010,
sleep: async (delay) => {
delays.push(delay);
},
environment: {
REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "nope",
REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "0",
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "1001",
},
platform: "win32",
},
),
);
expect(observed.ctimeMs).toBe(1_000);
expect(reads).toBe(5);
expect(delays).toEqual([10, 10, 10, 10]);
});
it("does not consume Windows limits on other hosts and preserves cancellation during the read", async () => {
const controller = new AbortController();
const reason = new Error("cancelled during baseline read");
await expect(
settleChangeTimeBaseline(
async () => {
controller.abort(reason);
return statsAt(1_000);
},
{
platform: "linux",
environment: { REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "invalid" },
signal: controller.signal,
},
),
).rejects.toBe(reason);
});
it("preserves cancellation during a Windows read even when the timestamp is stable", async () => {
const controller = new AbortController();
const reason = new Error("cancelled during read");
await expect(
settleChangeTimeBaseline(
async () => {
controller.abort(reason);
return statsAt(1);
},
{ platform: "win32", now: () => 10000, signal: controller.signal },
),
).rejects.toBe(reason);
});
@@ -130,7 +130,12 @@ const sameLimits = (
left: JavaScriptSourceAnalysisLimits,
right: JavaScriptSourceAnalysisLimits,
): boolean => {
if (left.heapMb !== right.heapMb || left.timeoutMs !== right.timeoutMs)
if (
left.heapMb !== right.heapMb ||
left.timeoutMs !== right.timeoutMs ||
left.heapSource !== right.heapSource ||
left.timeoutSource !== right.timeoutSource
)
return false;
const leftPrecision = resolveJavaScriptSemanticPrecision(
left.semanticPrecision,
+2 -1
View File
@@ -1,6 +1,7 @@
import { expect, it } from "vitest";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
captureDecoderHeapContinuingRecovery,
captureDecoderHeapRecovery,
DEFAULT_CAPTURE_DECODER_HEAP,
} from "../../config/captureDecoderHeap.js";
@@ -74,7 +75,7 @@ it.each([
retryable: false,
remediation: {
action: expect.stringContaining(
captureDecoderHeapRecovery(DEFAULT_CAPTURE_DECODER_HEAP),
captureDecoderHeapContinuingRecovery(DEFAULT_CAPTURE_DECODER_HEAP),
),
},
details: {
+9
View File
@@ -30,6 +30,7 @@ import {
type InspectWebNetworkCaptureInput,
} from "../../domain/webNetworkCapture.js";
import {
captureDecoderHeapContinuingRecovery,
captureDecoderHeapRecovery,
DEFAULT_CAPTURE_DECODER_HEAP,
type CaptureDecoderHeap,
@@ -299,6 +300,7 @@ export const captureMemoryFailure = (
heap.oldGenerationMiB,
);
const recovery = captureDecoderHeapRecovery(heap);
const continuingRecovery = captureDecoderHeapContinuingRecovery(heap);
return new AnalysisResourceConstraintError(
OPERATION,
"memory",
@@ -341,6 +343,13 @@ export const captureMemoryFailure = (
}
: {}),
remediationAction: `Use a smaller capture exported by its producer, or another decoder with sufficient capacity for the original capture. Preserve the original for provenance; a subset does not establish complete-capture coverage.${input.format !== "mitmproxy" ? ` ${recovery}` : ""}${input.format !== "mitmproxy" && processMemoryLimitBytes !== null ? ` The Windows Job Object commit limit for this decoder is ${String(processMemoryLimitBytes)} bytes, ${String(heap.oldGenerationMiB)} MiB plus a 512 MiB Bun reserve.` : ""}`,
...(input.format === "mitmproxy"
? {}
: {
continuingCallerGuidance: {
remediationAction: `Use a smaller capture exported by its producer, or another decoder with sufficient capacity for the original capture. Preserve the original for provenance; a subset does not establish complete-capture coverage. ${continuingRecovery}${processMemoryLimitBytes !== null ? ` The Windows Job Object commit limit for this decoder is ${String(processMemoryLimitBytes)} bytes, ${String(heap.oldGenerationMiB)} MiB plus a 512 MiB Bun reserve.` : ""}`,
},
}),
...(cause instanceof OwnedCommandFailure && cause.snapshot !== null
? {
capturedOutput: {
@@ -89,7 +89,8 @@ it.each(["max_input_bytes", "max_reply_bytes", "max_nesting_depth"] as const)(
parameter: control,
effective_value: selected,
source: "request",
rea_restart_required: false,
update_mode: "parameter",
pending_restart: false,
},
},
});
+340 -17
View File
@@ -1,8 +1,7 @@
import { parseClientRegistrationCommand } from "./domain/clientRegistrationCommand.js";
import {
serveMcpRegistration,
closeCliMcpRegistrationResources,
} from "./cli/mcpRegistration.js";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { writeAmpRegistrationBackup } from "./cli/ampRegistrationBackup.js";
import { registerToolFamilies } from "./contracts/toolFamilyIds.js";
import { registerGpuCommands } from "./cli/gpuCommands.js";
import { registerNativeFunctionMatchingCommands } from "./cli/nativeFunctionMatchingCommands.js";
@@ -27,15 +26,27 @@ import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsComman
import { registerFridaCommands } from "./cli/fridaCommands.js";
import type { FridaInstrumentationPort } from "./application/frida/FridaInstrumentationPort.js";
import { Cli, z } from "incur";
import { redactExplicitText } from "./domain/explicitSensitiveValues.js";
import {
adaptCliOutputArgumentsForIncur,
classifyMcpAddDiscovery,
expandMcpAddEqualsArguments,
declaredSensitiveValues,
readMcpAddArguments,
normalizeCliArguments,
splitIncurBuiltinArguments,
} from "./cliBuiltinArguments.js";
import {
parseCliOutputArguments,
renderCliDocument,
renderCliOutputArgumentError,
} from "./cliOutput.js";
import { createLogger } from "./logger.js";
import { parseLogLevel } from "./config/processStart.js";
import { PRODUCT_IDENTITY } from "./identity.js";
import { currentClientRegistrationCommand } from "./application/ClientRegistrationIdentity.js";
import { clientRegistrationCommandText } from "./cli/clientRegistrationCommandText.js";
import { registerSetupCommands } from "./cli/setupCommands.js";
import { registerCoreAnalysisCommands } from "./cli/coreAnalysisCommands.js";
import { registerUtilityCommands } from "./cli/utilityCommands.js";
@@ -92,7 +103,6 @@ export const closeCliResources = async (): Promise<void> => {
const results = await Promise.allSettled([
closeCliJavaScriptResources(),
closeCliGuidanceResources(),
closeCliMcpRegistrationResources(),
]);
const failures = results.flatMap((result) =>
result.status === "rejected" ? [result.reason] : [],
@@ -116,9 +126,6 @@ export const createCli = (
): CliInstance => {
const environment = snapshotEnvironment(selectedEnvironment);
const analysis = createDirectAnalysis(environment);
const defaultRegistration = parseClientRegistrationCommand(
currentClientRegistrationCommand({ environment }),
);
// The executable reports an invalid level before every command except doctor.
// Doctor keeps going so its readiness report can name REA_LOG_LEVEL.
const level =
@@ -139,6 +146,9 @@ export const createCli = (
.describe("Write measured command timing to stderr."),
}),
mcp: {
command: clientRegistrationCommandText(
currentClientRegistrationCommand({ environment }),
),
instructions:
"Ask what software, artifact, protocol, or behavior the user wants to understand, then choose the available investigation capabilities that can produce evidence.",
},
@@ -256,14 +266,327 @@ export const createCli = (
);
cli.serve = (arguments_ = process.argv.slice(2), options) => {
if (complete(arguments_, options ?? {})) return Promise.resolve();
const normalized = normalizeCliArguments(arguments_);
const registration = serveMcpRegistration(
normalized,
environment,
defaultRegistration,
options ?? {},
);
if (registration !== undefined) return registration;
let normalized = normalizeCliArguments(arguments_);
const { command, flags } = splitIncurBuiltinArguments(normalized);
const builtin = command[0] === PRODUCT_IDENTITY.cliBinary ? 1 : 0;
const discovery = classifyMcpAddDiscovery(flags);
if (
command[builtin] === "mcp" &&
command[builtin + 1] === "add" &&
discovery !== "bypass"
) {
const write =
options?.stdout ?? ((text: string) => process.stdout.write(text));
if (discovery === "version") {
write(`${PRODUCT_IDENTITY.packageVersion}\n`);
return Promise.resolve();
}
if (discovery === "schema") {
const output = parseCliOutputArguments(normalized);
if (!output.validation.ok)
write(renderCliOutputArgumentError(output.validation));
else if (output.format === "csv" || output.format === "tsv")
write(
renderCliOutputArgumentError({
ok: false,
format: "json",
code: "UNSUPPORTED_OUTPUT_COMBINATION",
message:
"CSV/TSV output cannot represent the mcp add schema discovery error. Use a document format instead.",
}),
);
else
write(
renderCliDocument(
{
code: "SCHEMA_UNAVAILABLE",
message:
"Schema discovery is not supported for mcp add. Use 'rea mcp add --help' for its options.",
},
output.format,
process.stdout.isTTY === true,
),
);
(
options?.exit ??
((code: number) => {
process.exitCode = code;
})
)(1);
return Promise.resolve();
}
// Validate before reading settings, creating a backup or invoking Incur's
// writer. The same owner supplies the agent selection used below.
const parsedRegistration = readMcpAddArguments(
command.slice(builtin + 2),
);
if (!parsedRegistration.validation.ok) {
const failure = parsedRegistration.validation.error;
const message = redactExplicitText(
failure.kind === "missing_value"
? `Missing value for flag: ${failure.flag}`
: `Unexpected argument: ${failure.token}`,
declaredSensitiveValues(command),
);
const field =
failure.kind === "missing_value" ? failure.field : "arguments";
const output = parseCliOutputArguments(normalized);
if (!output.validation.ok)
write(renderCliOutputArgumentError(output.validation));
else
write(
renderCliDocument(
{
code: "VALIDATION_ERROR",
message,
fieldErrors: [
{
code: "invalid_value",
missing: false,
path: field,
message,
},
],
},
output.format,
process.stdout.isTTY === true,
),
);
(
options?.exit ??
((code: number) => {
process.exitCode = code;
})
)(1);
return Promise.resolve();
}
// Incur's mcp add parser only understands spaced `--agent amp` forms; an
// equals form like `--agent=amp` is silently ignored, which would drop
// the requested agent and register against the default target. Normalize
// equals forms back to spaced forms before handing over to Incur.
// Shared with the project-scope guard through
// `expandMcpAddEqualsArguments`, so both read the same agent selection.
const serveArguments = expandMcpAddEqualsArguments(normalized);
// Incur's Amp registration overwrites an existing rea entry (dropping its
// environment map) and reports success even when the settings file
// prevents a save. Guard that path: reject a settings file REA cannot
// update, back up the file, preserve the caller's environment, and verify
// the registration landed.
// SyncMcp.register also writes Amp when no agents are selected. Mode
// switches return before registration and must not touch its settings.
// Use the same normalized arguments for selection and dispatch, including
// non-Amp equals forms. Incur consumes command values before agent flags.
normalized = serveArguments;
const registration = splitIncurBuiltinArguments(serveArguments);
const { agents } = parsedRegistration;
const ampRequested =
![
"--help",
"-h",
"--llms",
"--llms-full",
"--mcp",
"--update",
"--incur-update-check",
].some((flag) => registration.flags.has(flag)) &&
(agents.length === 0 || agents.includes("amp"));
if (ampRequested) {
const settingsPath = join(homedir(), ".config", "amp", "settings.json");
let priorEnv: unknown;
let guardError: string | undefined;
let settingsText: string | undefined;
let parsedSettings: Record<string, unknown> | undefined;
try {
if (existsSync(settingsPath))
settingsText = readFileSync(settingsPath, "utf8");
} catch (cause) {
guardError = `Unable to read Amp settings.json: ${
cause instanceof Error ? cause.message : String(cause)
}`;
}
if (settingsText !== undefined) {
try {
const parsed: unknown = JSON.parse(settingsText);
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
)
guardError = "Amp settings.json must contain a JSON object.";
else parsedSettings = parsed as Record<string, unknown>;
} catch (cause) {
guardError = `Amp settings.json is not valid JSON: ${
cause instanceof Error ? cause.message : String(cause)
}`;
}
}
if (parsedSettings !== undefined && settingsText !== undefined) {
const servers = parsedSettings["amp.mcpServers"];
if (
servers !== undefined &&
(typeof servers !== "object" ||
servers === null ||
Array.isArray(servers))
)
guardError =
"amp.mcpServers must be an object in Amp settings.json.";
else {
const entry =
typeof servers === "object" && servers !== null
? (servers as Record<string, unknown>)["rea"]
: undefined;
if (typeof entry === "object" && entry !== null)
priorEnv = (entry as Record<string, unknown>)["env"];
}
}
const reportValidationError = (message: string): void => {
const output = parseCliOutputArguments(normalized);
if (!output.validation.ok)
write(renderCliOutputArgumentError(output.validation));
else
write(
renderCliDocument(
{
code: "VALIDATION_ERROR",
message,
fieldErrors: [
{
code: "invalid_value",
missing: false,
path: "amp",
message,
},
],
},
output.format,
process.stdout.isTTY === true,
),
);
(
options?.exit ??
((code: number) => {
process.exitCode = code;
})
)(1);
};
if (guardError !== undefined) {
reportValidationError(`${guardError} Fix it before registering.`);
return Promise.resolve();
}
const runRegistration = (backupPath?: string): Promise<void> => {
let failed = false;
const result = serveStrict(
serveArguments,
{
...options,
exit: (code) => {
failed ||= code !== 0;
if (options?.exit !== undefined) options.exit(code);
else process.exitCode = code;
},
},
(tokens, selected) =>
serveDeclared(tokens, selected, (declared, declaredOptions) =>
serve(
adaptCliOutputArgumentsForIncur(declared),
declaredOptions,
),
),
);
return result.then(() => {
if (failed) return;
let failure: string | undefined;
try {
if (!existsSync(settingsPath))
failure = "Amp settings.json was not created.";
else {
const text = readFileSync(settingsPath, "utf8");
const parsed = JSON.parse(text) as Record<string, unknown>;
const servers = parsed["amp.mcpServers"];
if (
typeof servers !== "object" ||
servers === null ||
Array.isArray(servers) ||
!("rea" in servers)
)
failure =
"Amp settings.json does not contain the rea registration after mcp add.";
else {
const entry = (servers as Record<string, unknown>)["rea"];
if (
priorEnv !== undefined &&
typeof entry === "object" &&
entry !== null &&
(entry as Record<string, unknown>)["env"] === undefined
) {
(entry as Record<string, unknown>)["env"] = priorEnv;
writeFileSync(
settingsPath,
`${JSON.stringify(parsed, null, 2)}\n`,
);
}
}
}
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
if (failure !== undefined) {
const output = parseCliOutputArguments(normalized);
if (output.validation.ok)
write(
renderCliDocument(
{
code: "VALIDATION_ERROR",
message: failure,
fieldErrors: [
{
code: "invalid_value",
missing: false,
path: "amp",
message: failure,
},
],
},
output.format,
process.stdout.isTTY === true,
),
);
(
options?.exit ??
((code: number) => {
process.exitCode = code;
})
)(1);
} else if (backupPath !== undefined)
process.stderr.write(
`Amp MCP registration backup retained at ${backupPath}\n`,
);
});
};
if (settingsText === undefined) return runRegistration();
return writeAmpRegistrationBackup(settingsPath, settingsText).then(
(backup) => {
if (!backup.ok) {
const { kind, path, detail } = backup.error;
reportValidationError(
`Unable to create Amp settings backup (${kind}) at ${path}: ${detail}`,
);
return;
}
return runRegistration(backup.value);
},
(cause: unknown) => {
const detail =
cause instanceof Error ? cause.message : String(cause);
reportValidationError(
`Unable to create Amp settings backup: ${detail}`,
);
},
);
}
}
const result = serveStrict(normalized, options ?? {}, (tokens, selected) =>
serveDeclared(tokens, selected, (declared, declaredOptions) =>
reportUnknownMcpSubcommand(cli.name, declared, declaredOptions)
@@ -4,7 +4,7 @@ import { readFile, writeFile } from "node:fs/promises";
import {
preserveCurrentRecoveryBackup,
type RecoveryBackupPreservationFailure,
} from "./SetupRecoveryBackup.js";
} from "../application/SetupRecoveryBackup.js";
import { err, ok, type Result } from "../domain/result.js";
const errorDetail = (cause: unknown): string =>
@@ -27,14 +27,14 @@ const restrictExistingBackup = (
return err({
kind: "unreadable",
path: backupPath,
detail: `The existing client configuration backup could not be checked: ${errorDetail(cause)}`,
detail: `The existing Amp settings backup could not be checked: ${errorDetail(cause)}`,
});
}
if (!existing.isFile())
return err({
kind: "occupied",
path: backupPath,
detail: "The existing client configuration backup is not a regular file.",
detail: "The existing Amp settings backup is not a regular file.",
});
try {
chmodSync(backupPath, 0o600);
@@ -42,7 +42,7 @@ const restrictExistingBackup = (
return err({
kind: "unreadable",
path: backupPath,
detail: `The existing client configuration backup permissions could not be secured: ${errorDetail(cause)}`,
detail: `The existing Amp settings backup permissions could not be secured: ${errorDetail(cause)}`,
});
}
return ok(undefined);
@@ -58,7 +58,7 @@ const copyPrivateBackup = async (
});
};
export const writeClientRegistrationBackup = async (
export const writeAmpRegistrationBackup = async (
settingsPath: string,
settingsText: string,
): Promise<Result<string, RecoveryBackupPreservationFailure>> => {
+5
View File
@@ -0,0 +1,5 @@
/** Incur takes a command string and splits quoted segments without backslash escapes. */
export const clientRegistrationCommandText = (
command: readonly string[],
): string =>
command.map((argument) => `'${argument.replaceAll("'", `'"'"'`)}'`).join(" ");
+2 -2
View File
@@ -11,7 +11,7 @@ import {
} from "../cliBuiltinArguments.js";
import { CLI_OUTPUT_FORMATS, parseCliOutputArguments } from "../cliOutput.js";
import { bashCompletionScript } from "./bashCompletionRegistration.js";
import { MCP_SUBCOMMANDS } from "./mcpBuiltinCommands.js";
import { MCP_BUILTIN_SUBCOMMANDS } from "./mcpBuiltinCommands.js";
import { MCP_DOCTOR_ALIAS, MCP_DOCTOR_OPTIONS } from "../mcpDoctorOptions.js";
type Commands = Parameters<typeof Completions.complete>[0];
@@ -86,7 +86,7 @@ const completionCommands = (cli: CompletionCli): Commands => {
commands.set("mcp", {
_group: true,
description: "Register as MCP server",
commands: MCP_SUBCOMMANDS,
commands: MCP_BUILTIN_SUBCOMMANDS,
});
commands.set("completions", {
_group: true,
+10 -6
View File
@@ -1,4 +1,4 @@
import { mcpRegistrationOptionsSchema } from "../contracts/mcpRegistration.js";
import { z } from "incur";
import {
adaptCliOutputArgumentsForIncur,
@@ -15,14 +15,18 @@ import type { CliInstance } from "./types.js";
type ServeOptions = NonNullable<Parameters<CliInstance["serve"]>[1]>;
/** Named MCP commands shared by completion and unknown-command guidance. */
export const MCP_SUBCOMMANDS = new Map([
/** Pinned Incur 0.6.1 `mcp` builtin subcommands; Incur does not export them. */
export const MCP_BUILTIN_SUBCOMMANDS = new Map([
[
"add",
{
description: "Register as MCP server",
alias: { command: "c" },
options: mcpRegistrationOptionsSchema,
options: z.object({
agent: z.string().optional(),
command: z.string().optional(),
noGlobal: z.boolean().optional(),
}),
},
],
["doctor", { description: "Validate MCP server startup and tool listing" }],
@@ -66,7 +70,7 @@ export const reportUnknownMcpSubcommand = (
// Incur shows group help for an empty token and owns flag-shaped globals.
!subcommand ||
subcommand.startsWith("-") ||
MCP_SUBCOMMANDS.has(subcommand) ||
MCP_BUILTIN_SUBCOMMANDS.has(subcommand) ||
// Incur serves these builtins before its `mcp` subcommand branch. An
// output flag's value is data, even when it is spelled like a builtin.
incurArguments.some(
@@ -80,7 +84,7 @@ export const reportUnknownMcpSubcommand = (
if (!output.formatExplicit || !output.validation.ok) return false;
const sensitive = declaredSensitiveValues(command);
const text = (value: string): string => redactExplicitText(value, sensitive);
const suggestion = [...MCP_SUBCOMMANDS.keys()].find((candidate) =>
const suggestion = [...MCP_BUILTIN_SUBCOMMANDS.keys()].find((candidate) =>
candidate.startsWith(subcommand.toLowerCase()),
);
const corrected = arguments_.map((token, index) =>
-374
View File
@@ -1,374 +0,0 @@
import { Help, z } from "incur";
import {
registerMcpClients,
type McpClientRegistrationOutcome,
} from "../application/McpClientRegistration.js";
import { resolveMcpRegistrationClients } from "../application/McpRegistrationCatalog.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
classifyMcpAddDiscovery,
declaredSensitiveValues,
readMcpAddArguments,
splitIncurBuiltinArguments,
} from "../cliBuiltinArguments.js";
import {
parseCliOutputArguments,
renderCliDocument,
renderCliOutputArgumentError,
type CliOutputArgumentValidation,
} from "../cliOutput.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import {
mcpRegistrationOptionsSchema,
mcpRegistrationReportSchema,
type McpRegistrationReport,
} from "../contracts/mcpRegistration.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
parseClientRegistrationCommand,
type ClientRegistrationCommand,
type ClientRegistrationCommandIssue,
} from "../domain/clientRegistrationCommand.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { redactExplicitText } from "../domain/explicitSensitiveValues.js";
import { err, ok, type Result } from "../domain/result.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { withCommandCancellation } from "./commandCancellation.js";
import type { CliInstance } from "./types.js";
let retainedResources: ArtifactResourceScope | undefined;
/** Close the exact registration cleanup owners, retaining failures for retry. */
export const closeCliMcpRegistrationResources = async (): Promise<void> => {
const resources = retainedResources;
if (resources === undefined) return;
await resources.close();
if (retainedResources === resources) retainedResources = undefined;
};
type ServeOptions = NonNullable<Parameters<CliInstance["serve"]>[1]>;
interface RegistrationRequest {
readonly agents: readonly string[];
readonly global: boolean;
readonly launcher: ClientRegistrationCommand;
}
interface RegistrationInputIssue {
readonly code: "invalid_value" | "unsupported_transport";
readonly message: string;
readonly field: string;
}
interface RegistrationPresentation {
write(text: string): void;
document(value: unknown): void;
validation(
message: string,
field: string,
error?: AnalysisError,
code?: RegistrationInputIssue["code"],
): void;
fail(): void;
}
const precedingFlags = [
"--llms",
"--llms-full",
"--mcp",
"--update",
"--incur-update-check",
];
/** Route only the named REA registration operation; other Incur commands remain theirs. */
export const serveMcpRegistration = (
arguments_: readonly string[],
environment: Readonly<NodeJS.ProcessEnv>,
defaultCommand: Result<
ClientRegistrationCommand,
ClientRegistrationCommandIssue
>,
options: ServeOptions,
): Promise<void> | undefined => {
const split = splitIncurBuiltinArguments(arguments_);
const offset = split.command[0] === PRODUCT_IDENTITY.cliBinary ? 1 : 0;
if (
split.command[offset] !== "mcp" ||
split.command[offset + 1] !== "add" ||
precedingFlags.some((flag) => split.flags.has(flag))
)
return undefined;
return (async () => {
const presentation = createRegistrationPresentation(
arguments_,
split.command,
options,
);
if (
presentation === undefined ||
serveRegistrationDiscovery(split.flags, presentation)
)
return Promise.resolve();
const request = readRegistrationRequest(
split.command.slice(offset + 2),
defaultCommand,
);
if (!request.ok) {
presentation.validation(
request.error.message,
request.error.field,
undefined,
request.error.code,
);
return Promise.resolve();
}
return withCommandCancellation((signal) =>
executeRegistration(request.value, environment, signal, presentation),
);
})();
};
/** Bind one invocation's rendering, explicit redaction and exit behavior. */
const createRegistrationPresentation = (
arguments_: readonly string[],
command: readonly string[],
options: ServeOptions,
): RegistrationPresentation | undefined => {
const write =
options.stdout ?? ((text: string) => process.stdout.write(text));
const fail = () => {
if (options.exit !== undefined) options.exit(1);
else process.exitCode = 1;
};
const output = parseCliOutputArguments(arguments_);
const validation: CliOutputArgumentValidation =
output.validation.ok && (output.format === "csv" || output.format === "tsv")
? {
ok: false,
format: "json",
code: "UNSUPPORTED_OUTPUT_COMBINATION",
message:
"MCP registration returns a complete document. Use --format json, yaml, md, toon, or jsonl; CSV/TSV requires a selected row collection.",
}
: output.validation;
if (!validation.ok) {
write(renderCliOutputArgumentError(validation));
fail();
return undefined;
}
const document = (value: unknown) => {
write(
renderCliDocument(value, output.format, process.stdout.isTTY === true),
);
};
return {
write,
document,
fail,
validation(message, field, error, code = "invalid_value") {
const retained = redactExplicitText(
message,
declaredSensitiveValues(command),
);
document({
code: "VALIDATION_ERROR",
message: retained,
...(error === undefined ? {} : { error: projectAnalysisError(error) }),
fieldErrors: [
{
code,
missing: false,
path: field,
message: retained,
},
],
});
fail();
},
};
};
/** Discovery never resolves configuration locations or acquires resource owners. */
const serveRegistrationDiscovery = (
flags: ReadonlySet<string>,
presentation: RegistrationPresentation,
): boolean => {
if (flags.has("--help") || flags.has("-h")) {
presentation.write(
`${Help.formatCommand("rea mcp add", {
description:
"Register exact stdio launcher arguments in selected client settings, with backups and verified publication.",
options: mcpRegistrationOptionsSchema,
alias: { command: "c" },
hint: "No package acquisition or server execution. --no-global never writes global settings.",
})}\n`,
);
return true;
}
const discovery = classifyMcpAddDiscovery(flags);
if (discovery === "version") {
presentation.write(`${PRODUCT_IDENTITY.packageVersion}\n`);
return true;
}
if (discovery !== "schema") return false;
presentation.document({
inputSchema: z.toJSONSchema(mcpRegistrationOptionsSchema),
outputSchema: z.toJSONSchema(mcpRegistrationReportSchema),
});
return true;
};
const readRegistrationRequest = (
arguments_: readonly string[],
defaultCommand: Result<
ClientRegistrationCommand,
ClientRegistrationCommandIssue
>,
): Result<RegistrationRequest, RegistrationInputIssue> => {
const request = readMcpAddArguments(arguments_);
if (!request.validation.ok) {
const issue = request.validation.error;
return err({
code: "invalid_value",
message:
issue.kind === "missing_value"
? `Missing value for flag: ${issue.flag}`
: `Unexpected argument: ${issue.token}`,
field: issue.kind === "missing_value" ? issue.field : "arguments",
});
}
const launcher =
request.commandText === undefined
? defaultCommand
: parseClientRegistrationCommand(request.commandText);
return launcher.ok
? ok({
agents: request.agents,
global: request.global,
launcher: launcher.value,
})
: err({
code:
launcher.error.kind === "unsupported_transport"
? "unsupported_transport"
: "invalid_value",
message: launcher.error.message,
field: "command",
});
};
const executeRegistration = async (
request: RegistrationRequest,
environment: Readonly<NodeJS.ProcessEnv>,
signal: AbortSignal,
presentation: RegistrationPresentation,
): Promise<void> => {
const resources = (retainedResources ??= new ArtifactResourceScope());
const outcomes = await resources
.run(async () => {
const targets = await resolveMcpRegistrationClients(
{
agents: request.agents,
global: request.global,
cwd: process.cwd(),
home: homeDirectoryFromEnvironment(environment, process.platform),
platform: process.platform,
environment,
signal,
},
resources,
);
if (!targets.ok) {
presentation.validation(targets.error.message, "agent", targets.error);
return undefined;
}
return registerMcpClients(
targets.value,
request.launcher,
signal,
resources,
);
})
.catch((cause: unknown) => {
if (!(cause instanceof ArtifactReaderFailure)) throw cause;
const error = new ConfigurationError(
"Registration cannot continue until retained resource cleanup completes.",
{
cause,
cleanup: ArtifactReaderFailure.cleanupObservation(
cause,
resources.cleanupResources,
),
},
);
presentation.validation(error.message, "registration", error);
return undefined;
});
if (outcomes !== undefined)
presentRegistration(outcomes, request.launcher, presentation);
};
const clientReport = (
outcome: McpClientRegistrationOutcome,
): McpRegistrationReport["clients"][number] => {
const labels = {
client: outcome.client,
config_path: outcome.configPath,
scope: outcome.scope,
};
if (outcome.status === "not_started")
return { ...labels, status: "not_started" };
if (!outcome.result.ok)
return {
...labels,
status: "failed",
error: {
...projectAnalysisError(outcome.result.error),
reason: outcome.result.error.message,
},
};
const receipt = outcome.result.value;
const permissions =
receipt.permissions === undefined
? {}
: { permissions: receipt.permissions };
return receipt.status === "unchanged"
? { ...labels, ...permissions, status: "unchanged" }
: {
...labels,
...permissions,
status: "configured",
...(receipt.backupPath === undefined
? {}
: { backup_path: receipt.backupPath }),
};
};
const presentRegistration = (
outcomes: readonly McpClientRegistrationOutcome[],
launcher: ClientRegistrationCommand,
presentation: RegistrationPresentation,
): void => {
const clients = outcomes.map(clientReport);
const report: McpRegistrationReport = {
name: "rea",
command: launcher.command,
args: [...launcher.args],
ok: clients.every(
(client) =>
client.status === "configured" || client.status === "unchanged",
),
clients,
};
presentation.document(report);
for (const outcome of outcomes)
if (
outcome.status === "completed" &&
outcome.result.ok &&
outcome.result.value.backupPath !== undefined
)
process.stderr.write(
`${outcome.client === "amp" ? "Amp" : outcome.client} MCP registration backup retained at ${outcome.result.value.backupPath}\n`,
);
if (!report.ok) presentation.fail();
};
-101
View File
@@ -1,101 +0,0 @@
import { spawnSync } from "node:child_process";
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../tests/fixtures/temporaryDirectory.js";
it("admits exact Amp command facts and rejects invalid commands before publication", async () => {
const home = await createTestTempDirectory("rea-amp-source-command-");
try {
await mkdir(join(home, "config"));
const script = `
import { homedir } from 'node:os';
import { resolve, sep, join } from 'node:path';
import { mkdir, readFile, writeFile, rm, realpath } from 'node:fs/promises';
const home = process.env.HOME;
if (!home || await realpath(homedir()) !== await realpath(home))
throw Error('Fixture HOME was not selected before loading registration writers.');
for (const key of ['USERPROFILE','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','APPDATA','LOCALAPPDATA']) {
const value = resolve(process.env[key] ?? '');
if (value !== home && !value.startsWith(home + sep))
throw Error('Fixture config location escapes selected HOME: ' + key);
}
const { createCli, closeCliResources } = await import(${JSON.stringify(new URL("./cli.ts", import.meta.url).href)});
const original = { 'amp.mcpServers': { rea: { command: 'stale-executable', args: ['stale-argument'], env: { KEEP: '1' }, disabled: false }, peer: { command: 'peer' } }, theme: 'dark' };
const directory = join(home, '.config', 'amp');
const path = join(directory, 'settings.json');
const cases = ['""', "'unclosed", 'fixture-server "unclosed', '"/fixture/server path" "" --mcp', String.raw\`'C:\\Program Files\\server.exe' 'C:\\data\\file'\`, 'fixture-server --mcp'];
const results = [];
try {
for (const command of cases) {
await rm(directory, { recursive: true, force: true });
await mkdir(directory, { recursive: true });
const before = JSON.stringify(original) + '\\n';
await writeFile(path, before);
let output = '', exitCode = 0;
await createCli(process.env).serve(['mcp','add','--agent','amp','--command',command,'--json'], { stdout(text) { output += text; }, exit(code) { exitCode = code; } });
const backup = await readFile(path + '.rea.backup', 'utf8').catch(error => { if (error.code === 'ENOENT') return null; throw error; });
results.push({ command, exitCode, output, before, after: await readFile(path, 'utf8'), backup });
}
} finally { await closeCliResources(); }
console.log(JSON.stringify(results));
`;
const child = spawnSync(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", script],
{
encoding: "utf8",
timeout: 20_000,
env: {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
WINDIR: process.env.WINDIR,
TMPDIR: process.env.TMPDIR,
TMP: process.env.TMP,
TEMP: process.env.TEMP,
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, "config"),
XDG_DATA_HOME: join(home, "data"),
XDG_CACHE_HOME: join(home, "cache"),
XDG_STATE_HOME: join(home, "state"),
APPDATA: join(home, "appdata"),
LOCALAPPDATA: join(home, "localappdata"),
REA_LOG_LEVEL: "silent",
},
},
);
expect(child.error).toBeUndefined();
expect(child.status, child.stderr).toBe(0);
const results = JSON.parse(child.stdout);
expect(results).toHaveLength(6);
for (const result of results.slice(0, 3)) {
expect(result.exitCode, result.command).toBe(1);
expect(result.output).toContain("VALIDATION_ERROR");
expect(result.after).toBe(result.before);
expect(result.backup).toBeNull();
}
const expected = [
{ command: "/fixture/server path", args: ["", "--mcp"] },
{
command: String.raw`C:\Program Files\server.exe`,
args: [String.raw`C:\data\file`],
},
{ command: "fixture-server", args: ["--mcp"] },
];
for (const [index, result] of results.slice(3).entries()) {
expect(result.exitCode, result.output).toBe(0);
const original = JSON.parse(result.before);
expect(JSON.parse(result.after)).toEqual({
...original,
"amp.mcpServers": {
...original["amp.mcpServers"],
rea: { ...original["amp.mcpServers"].rea, ...expected[index] },
},
});
expect(result.backup).toBe(result.before);
}
} finally {
await rm(home, { recursive: true, force: true });
}
}, 30_000);
+50 -15
View File
@@ -205,6 +205,29 @@ export const adaptCliOutputArgumentsForIncur = (
return adapted;
};
/**
* Expand equals-form `mcp add` valued options into spaced forms. Single
* owner: the project-scope guard and the `mcp add` dispatch in `cli.ts`
* both consume this, so `--agent=amp` and `--agent amp` select the same
* agent in both places. An empty `--flag=` form is left intact for the
* caller's missing-value handling.
*/
export const expandMcpAddEqualsArguments = (
arguments_: readonly string[],
): string[] =>
arguments_.flatMap((token) => {
if (
token.startsWith("--agent=") ||
token.startsWith("--command=") ||
token.startsWith("-c=")
) {
const separator = token.indexOf("=");
const value = token.slice(separator + 1);
return value === "" ? [token] : [token.slice(0, separator), value];
}
return [token];
});
type McpAddArgumentError =
| {
readonly kind: "missing_value";
@@ -214,7 +237,7 @@ type McpAddArgumentError =
| { readonly kind: "unexpected_argument"; readonly token: string };
/**
* Own token consumption for the REA registration operation. Agent selection
* Own token consumption for the pinned registration builtin. Agent selection
* remains observable even for invalid input so the launcher's scope refusal
* and dispatch cannot interpret a command value as an agent selector.
*/
@@ -222,13 +245,9 @@ export const readMcpAddArguments = (
command: IncurBuiltinArgumentSplit["command"],
): {
readonly agents: readonly string[];
readonly commandText: string | undefined;
readonly global: boolean;
readonly validation: Result<void, McpAddArgumentError>;
} => {
const agents: string[] = [];
let commandText: string | undefined;
let global = true;
let missing: McpAddArgumentError | undefined;
let unexpected: McpAddArgumentError | undefined;
for (let index = 0; index < command.length; index += 1) {
@@ -238,7 +257,7 @@ export const readMcpAddArguments = (
const equals = token.indexOf("=");
const value = equals < 0 ? command[++index] : token.slice(equals + 1);
// Spaced flags cannot satisfy another option's missing value. Equals
// values remain caller-selected data, including a leading dash.
// values retain Incur's existing expansion/discovery behavior.
if (
value === undefined ||
value === "" ||
@@ -250,19 +269,33 @@ export const readMcpAddArguments = (
field: flag === "--agent" ? "agent" : "command",
};
if (flag === "--agent" && value) agents.push(value);
if (flag === "--command" || flag === "-c") commandText = value;
} else if (token === "--no-global") global = false;
else unexpected ??= { kind: "unexpected_argument", token };
} else if (token !== "--no-global")
unexpected ??= { kind: "unexpected_argument", token };
}
const failure = missing ?? unexpected;
return {
agents,
commandText,
global,
validation: failure === undefined ? ok(undefined) : err(failure),
};
};
/**
* Detect a project-scoped Amp registration before Incur dispatches it.
* Incur's `mcp add` parses `--no-global` into `global: false` and honors it
* for its package-runner agents, but its Amp writer always writes the global
* settings file and ignores the flag. The guard reads the agent selection
* dispatch reads: equals forms expand to spaced forms first through
* `expandMcpAddEqualsArguments`, `--command`/`-c` consumes its value, and
* Amp registers by default when no agent is given, so a bare project-scoped
* request would still write global Amp settings. Discovery requests are not
* registrations: `classifyMcpAddDiscovery` below is the single owner of the
* discovery classification, so the guard cannot drift from the routing it
* mirrors.
*/
// ponytail: reject instead of implementing project-scoped Amp registration.
// Ceiling: `mcp add --no-global` with Amp fails until Incur supports Amp
// project scope. Upgrade: drop this guard and its rea.mjs wiring once the
// dependency writes project Amp settings, and cover that path instead.
/**
* How `mcp add` answers a request instead of registering it. Single owner
* for the discovery flags: the Amp-scope guard and the `mcp add` routing in
@@ -291,16 +324,18 @@ export const classifyMcpAddDiscovery = (
return undefined;
};
/** The lightweight launcher and full dispatcher share the same parsed scope fact. */
export const isProjectScopedAmpRegistration = (
arguments_: readonly string[],
): boolean => {
const { command, flags } = splitIncurBuiltinArguments(arguments_);
const { command, flags } = splitIncurBuiltinArguments(
expandMcpAddEqualsArguments(arguments_),
);
if (command[0] !== "mcp" || command[1] !== "add") return false;
if (classifyMcpAddDiscovery(flags) !== undefined) return false;
const rest = command.slice(2);
const { agents, global } = readMcpAddArguments(rest);
return !global && (agents.length === 0 || agents.includes("amp"));
if (!rest.includes("--no-global")) return false;
const { agents } = readMcpAddArguments(rest);
return agents.length === 0 || agents.includes("amp");
};
/** Select production doctor using the same normalized builtin argument owner as CLI dispatch. */
-383
View File
@@ -1,383 +0,0 @@
import { createRequire } from "node:module";
import { mkdir, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { pathToFileURL } from "node:url";
import { Ajv2020 } from "ajv/dist/2020.js";
import { beforeAll, expect, it } from "vitest";
import { z } from "zod";
import { mcpRegistrationReportSchema } from "./contracts/mcpRegistration.js";
import {
createTestWorkspace,
removeTestWorkspace,
} from "../tests/support/workspace/workspaceFixture.js";
import { createTestProcessRegistry } from "../tests/support/process/processFixture.js";
const require = createRequire(import.meta.url);
const CHILD_PROGRAM = `
import { homedir } from 'node:os';
import { join, relative, isAbsolute, delimiter } from 'node:path';
import { mkdir, writeFile, readFile, readdir, realpath, stat } from 'node:fs/promises';
const ambient = process.env.HOME;
if (!ambient || await realpath(ambient) !== await realpath(homedir())) throw Error('Actual temporary HOME was not selected before importing writers.');
const root = process.env.REA_REGISTRATION_FIXTURE_ROOT;
const inside = (parent, path) => { const part = relative(parent, path); if (part.startsWith('..') || isAbsolute(part)) throw Error('Fixture path escaped owned root.'); };
inside(root, ambient);
for (const key of ['USERPROFILE','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','APPDATA','LOCALAPPDATA']) inside(ambient, process.env[key]);
const project = join(root, 'project');
await mkdir(project); process.chdir(project);
const bin = join(root, 'bin'); await mkdir(bin);
const helperLog = join(root, 'helper-invocations');
const witness = join(bin, 'acquisition.cjs');
await writeFile(witness, "require('node:fs').appendFileSync(process.env.REA_REGISTRATION_HELPER_LOG, 'unexpected acquisition\\n'); process.exit(79);\\n");
for (const runner of ['npx','pnpx','bunx']) {
if (process.platform === 'win32') await writeFile(join(bin, runner + '.cmd'), '@"' + process.execPath + '" "' + witness + '"\\r\\n');
else await writeFile(join(bin, runner), '#!' + process.execPath + '\\nrequire(' + JSON.stringify(witness) + ');\\n', { mode: 0o700 });
}
const { createCli, closeCliResources } = await import(${JSON.stringify(new URL("./cli.ts", import.meta.url).href)});
const { resolveMcpRegistrationClients } = await import(${JSON.stringify(new URL("./application/McpRegistrationCatalog.ts", import.meta.url).href)});
const { ArtifactResourceScope } = await import(${JSON.stringify(new URL("./artifacts/ArtifactResourceScope.ts", import.meta.url).href)});
const preflightResources = new ArtifactResourceScope();
const { parse: parseToml } = await import(${JSON.stringify(pathToFileURL(require.resolve("smol-toml")).href)});
const { parse: parseYaml } = await import(${JSON.stringify(pathToFileURL(require.resolve("yaml")).href)});
const environmentFor = async (name) => {
const home = join(root, name); await mkdir(home);
const environment = { ...process.env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home,'config'), XDG_DATA_HOME: join(home,'data'), XDG_CACHE_HOME: join(home,'cache'), XDG_STATE_HOME: join(home,'state'), APPDATA: join(home,'appdata'), LOCALAPPDATA: join(home,'localappdata'), PATH: bin + delimiter + process.env.PATH, REA_REGISTRATION_HELPER_LOG: helperLog, REA_TEST_CALLER_VISIBLE: 'fixture-only' };
for (const key of ['HOME','USERPROFILE','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','APPDATA','LOCALAPPDATA']) inside(home, environment[key]);
return environment;
};
const serve = async (environment, arguments_) => {
let output = '', exitCode = 0;
await createCli(environment).serve(arguments_, { stdout(text) { output += text; }, exit(code) { exitCode = code; } });
const document = JSON.parse(output);
if (Array.isArray(document.clients)) for (const client of document.clients) { inside(environment.HOME, client.config_path); if (client.backup_path) inside(environment.HOME, client.backup_path); }
return { exitCode, document };
};
const invoke = async (environment, agents, command, global = true) => {
// Check the producing paths before granting the public handler any write authority.
const targets = await resolveMcpRegistrationClients({ agents, global, cwd: project, home: environment.HOME, platform: process.platform, environment }, preflightResources);
if (targets.ok) for (const target of targets.value) inside(global ? environment.HOME : project, target.configPath);
return serve(environment, ['mcp','add', ...agents.flatMap(agent => ['--agent', agent]), ...(command === undefined ? [] : ['--command',command]), ...(global ? [] : ['--no-global']), '--json']);
};
const exists = async (path) => readFile(path,'utf8').then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; });
const tree = async (path) => (await readdir(path,{recursive:true})).sort();
const command = '"/fixture/server path" "" --mcp';
let result;
try {
const defaultsEnvironment = await environmentFor('defaults');
const schema = await serve(defaultsEnvironment, ['mcp','add','--schema','--json']);
const all = await invoke(defaultsEnvironment, [], undefined);
const native = [];
for (const client of all.document.clients ?? []) {
const text = await readFile(client.config_path, 'utf8');
const parsed = ['codex','grok-build'].includes(client.client) ? parseToml(text) : client.client === 'hermes' ? parseYaml(text,{version:'1.1'}) : client.client === 'goose' ? parseYaml(text,{version:'1.2'}) : JSON.parse(text);
const key = client.client === 'amp' ? 'amp.mcpServers' : ['codex','grok-build','hermes'].includes(client.client) ? 'mcp_servers' : ['kilo-code','opencode'].includes(client.client) ? 'mcp' : client.client === 'goose' ? 'extensions' : client.client === 'zed' ? 'context_servers' : client.client === 'vscode' ? 'servers' : 'mcpServers';
const entry = parsed[key]?.rea;
const words = client.client === 'goose' ? [entry?.cmd,...entry?.args ?? []] : Array.isArray(entry?.command) ? entry.command : [entry?.command,...entry?.args ?? []];
native.push({ client: client.client, command: words[0], args: words.slice(1), ambientPersisted: Object.values(entry ?? {}).some(value => value && typeof value === 'object' && value.REA_TEST_CALLER_VISIBLE !== undefined) });
}
const repeatedAll = await invoke(defaultsEnvironment, [], undefined);
const mixedEnvironment = await environmentFor('mixed');
const cursorPath = join(mixedEnvironment.HOME,'.cursor','mcp.json');
const ampPath = join(mixedEnvironment.HOME,'.config','amp','settings.json');
const cursorOriginal = JSON.stringify({mcpServers:{rea:{command:'old',args:['old'],env:{KEEP:'fixture'},disabled:true,custom:{policy:'retained'}},peer:{command:'peer'}},theme:'dark'}) + '\\n';
const ampOriginal = JSON.stringify({'amp.mcpServers':{rea:{command:'old',args:['old'],env:{KEEP:'amp-fixture'},disabled:false},peer:{command:'peer'}},theme:'dark'}) + '\\n';
await mkdir(join(mixedEnvironment.HOME,'.cursor'),{recursive:true}); await writeFile(cursorPath,cursorOriginal);
await mkdir(join(mixedEnvironment.HOME,'.config','amp'),{recursive:true}); await writeFile(ampPath,ampOriginal);
const mixed = await invoke(mixedEnvironment,['cursor','amp'],command);
const mixedBytes = [await readFile(cursorPath,'utf8'),await readFile(ampPath,'utf8')];
const inodeBefore = [(await stat(cursorPath)).ino,(await stat(ampPath)).ino];
const mixedRepeat = await invoke(mixedEnvironment,['cursor','amp'],command);
const inodeAfter = [(await stat(cursorPath)).ino,(await stat(ampPath)).ino];
const mixedChecks = { cursor:JSON.parse(mixedBytes[0]),amp:JSON.parse(mixedBytes[1]),backupsMatch:(await readFile(cursorPath+'.rea.backup','utf8'))===cursorOriginal && (await readFile(ampPath+'.rea.backup','utf8'))===ampOriginal, inodePreserved:JSON.stringify(inodeBefore)===JSON.stringify(inodeAfter), backupFiles:(await tree(mixedEnvironment.HOME)).filter(path=>path.includes('.rea.backup')) };
const failureEnvironment = await environmentFor('partial');
const broken = join(failureEnvironment.HOME,'.cursor','mcp.json');
await mkdir(join(failureEnvironment.HOME,'.cursor')); await writeFile(broken,'{"mcpServers":[]}');
const failedThenSuccess = await invoke(failureEnvironment,['cursor','amp'],command);
const successThenFailed = await invoke(failureEnvironment,['amp','cursor'],'"/fixture/server path" "" --changed');
const malformedRetained = await readFile(broken,'utf8');
const runnerEnvironment = await environmentFor('runner-launchers');
const runners = [];
for (const runner of ['npx','pnpx','bunx']) {
const invocation = await invoke(runnerEnvironment,['cursor'],runner + ' "" package --mcp');
const entry = JSON.parse(await readFile(join(runnerEnvironment.HOME,'.cursor','mcp.json'),'utf8')).mcpServers.rea;
runners.push({runner,invocation,entry});
}
const refusedEnvironment = await environmentFor('refused');
const refusedBefore = await tree(refusedEnvironment.HOME);
const refused = [];
for (const agents of [['cursor','goose'],['cursor','amp'],[]]) refused.push(await invoke(refusedEnvironment,agents,command,false));
const refusedAfter = await tree(refusedEnvironment.HOME);
const remoteEnvironment = await environmentFor('remote-refused');
const remoteBefore = await tree(remoteEnvironment.HOME);
const remote = [];
for (const source of ['https://fixture.invalid/mcp','https://fixture-user:[email protected]/mcp']) remote.push(await invoke(remoteEnvironment,['cursor'],source));
const remoteDidNotWrite = JSON.stringify(remoteBefore) === JSON.stringify(await tree(remoteEnvironment.HOME));
result = { remote,remoteDidNotWrite, schema,all,native,repeatedAll,mixed,mixedRepeat,mixedChecks,failedThenSuccess,successThenFailed,malformedRetained,runners,refused,refusalDidNotWrite:JSON.stringify(refusedBefore)===JSON.stringify(refusedAfter),ambientUnchanged:(await tree(ambient)).length===0,helperAcquisition:await exists(helperLog),expectedDefault:{command:process.execPath,args:[process.argv[1],'mcp']} };
} finally { await closeCliResources(); await preflightResources.close(); }
console.log(JSON.stringify(result));
`;
const invocationSchema = z.object({
exitCode: z.number(),
document: z.unknown(),
});
const observationsSchema = z.object({
schema: invocationSchema,
all: invocationSchema,
repeatedAll: invocationSchema,
mixed: invocationSchema,
mixedRepeat: invocationSchema,
failedThenSuccess: invocationSchema,
successThenFailed: invocationSchema,
native: z.array(
z.object({
client: z.string(),
command: z.string(),
args: z.array(z.string()),
ambientPersisted: z.boolean(),
}),
),
expectedDefault: z.object({ command: z.string(), args: z.array(z.string()) }),
mixedChecks: z.object({
cursor: z.unknown(),
amp: z.unknown(),
backupsMatch: z.boolean(),
inodePreserved: z.boolean(),
backupFiles: z.array(z.string()),
}),
runners: z.array(
z.object({
runner: z.enum(["npx", "pnpx", "bunx"]),
invocation: invocationSchema,
entry: z.unknown(),
}),
),
malformedRetained: z.string(),
refused: z.array(invocationSchema),
remote: z.array(invocationSchema),
remoteDidNotWrite: z.boolean(),
refusalDidNotWrite: z.boolean(),
ambientUnchanged: z.boolean(),
helperAcquisition: z.boolean(),
});
const captureRegistration = async () => {
const { root } = await createTestWorkspace("rea-cli-registration-matrix-");
const processes = createTestProcessRegistry();
try {
const home = join(root, "ambient");
await mkdir(home);
const entry = join(root, "entry.mjs");
await writeFile(entry, CHILD_PROGRAM);
const child = await processes.run(
process.execPath,
["--import", "tsx", entry],
{
timeoutMs: 20_000,
env: {
PATH: join(root, "bin") + delimiter + process.env.PATH,
SystemRoot: process.env.SystemRoot,
WINDIR: process.env.WINDIR,
TMPDIR: process.env.TMPDIR,
TMP: process.env.TMP,
TEMP: process.env.TEMP,
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, "config"),
XDG_DATA_HOME: join(home, "data"),
XDG_CACHE_HOME: join(home, "cache"),
XDG_STATE_HOME: join(home, "state"),
APPDATA: join(home, "appdata"),
LOCALAPPDATA: join(home, "localappdata"),
REA_REGISTRATION_FIXTURE_ROOT: root,
REA_LOG_LEVEL: "silent",
},
},
);
expect(child.exitCode, child.stderr).toBe(0);
return observationsSchema.parse(JSON.parse(child.stdout));
} finally {
await processes.shutdown();
await removeTestWorkspace(root);
}
};
let observed: z.infer<typeof observationsSchema>;
beforeAll(async () => {
observed = await captureRegistration();
}, 30_000);
it("validates named input/output schemas against every registration report", () => {
const schemas = z
.object({
inputSchema: z.record(z.string(), z.unknown()),
outputSchema: z.record(z.string(), z.unknown()),
})
.parse(observed.schema.document);
expect(observed.schema.exitCode).toBe(0);
const ajv = new Ajv2020({ strict: true, allErrors: true });
const input = ajv.compile(schemas.inputSchema);
const output = ajv.compile(schemas.outputSchema);
expect(
input({
agent: ["cursor", "amp"],
command: '"/fixture/server path" "" --mcp',
}),
JSON.stringify(input.errors),
).toBe(true);
expect(input({ agent: "amp" })).toBe(false);
for (const invocation of [
observed.all,
observed.repeatedAll,
observed.mixed,
observed.mixedRepeat,
observed.failedThenSuccess,
observed.successThenFailed,
...observed.runners.map(({ invocation }) => invocation),
])
expect(output(invocation.document), JSON.stringify(output.errors)).toBe(
true,
);
});
it("preserves default and mixed launcher facts, metadata, backups and no-op identity", () => {
const all = mcpRegistrationReportSchema.parse(observed.all.document);
expect(observed.all.exitCode).toBe(0);
expect(all.ok).toBe(true);
expect(all.clients).toHaveLength(26);
expect(all.clients.every((client) => client.status === "configured")).toBe(
true,
);
expect({ command: all.command, args: all.args }).toEqual(
observed.expectedDefault,
);
for (const native of observed.native) {
expect(
{ command: native.command, args: native.args },
native.client,
).toEqual(observed.expectedDefault);
expect(native.ambientPersisted, native.client).toBe(false);
}
expect(observed.native).toHaveLength(26);
const repeated = mcpRegistrationReportSchema.parse(
observed.repeatedAll.document,
);
expect(observed.repeatedAll.exitCode).toBe(0);
expect(
repeated.clients.every((client) => client.status === "unchanged"),
).toBe(true);
const mixed = mcpRegistrationReportSchema.parse(observed.mixed.document);
expect(observed.mixed.exitCode).toBe(0);
expect({ command: mixed.command, args: mixed.args }).toEqual({
command: "/fixture/server path",
args: ["", "--mcp"],
});
expect(
mixed.clients.map((client) => ({
client: client.client,
status: client.status,
})),
).toEqual([
{ client: "cursor", status: "configured" },
{ client: "amp", status: "configured" },
]);
expect(observed.mixedChecks.cursor).toMatchObject({
mcpServers: {
rea: {
command: "/fixture/server path",
args: ["", "--mcp"],
env: { KEEP: "fixture" },
disabled: true,
custom: { policy: "retained" },
},
peer: { command: "peer" },
},
theme: "dark",
});
expect(observed.mixedChecks.amp).toMatchObject({
"amp.mcpServers": {
rea: {
command: "/fixture/server path",
args: ["", "--mcp"],
env: { KEEP: "amp-fixture" },
disabled: false,
},
peer: { command: "peer" },
},
theme: "dark",
});
expect(observed.mixedChecks.backupsMatch).toBe(true);
expect(observed.mixedChecks.inodePreserved).toBe(true);
expect(observed.mixedChecks.backupFiles).toHaveLength(2);
expect(
mcpRegistrationReportSchema
.parse(observed.mixedRepeat.document)
.clients.every((client) => client.status === "unchanged"),
).toBe(true);
});
it("retains actionable failures and completed siblings in either order", () => {
const firstFailure = mcpRegistrationReportSchema.parse(
observed.failedThenSuccess.document,
);
const lastFailure = mcpRegistrationReportSchema.parse(
observed.successThenFailed.document,
);
expect(observed.failedThenSuccess.exitCode).toBe(1);
expect(firstFailure.ok).toBe(false);
expect(firstFailure.clients.map((client) => client.status)).toEqual([
"failed",
"configured",
]);
expect(observed.successThenFailed.exitCode).toBe(1);
expect(lastFailure.ok).toBe(false);
expect(lastFailure.clients.map((client) => client.status)).toEqual([
"configured",
"failed",
]);
expect(observed.malformedRetained).toBe('{"mcpServers":[]}');
for (const client of [...firstFailure.clients, ...lastFailure.clients])
if (client.status === "failed")
expect(client.error.reason).toContain(".cursor");
});
it("refuses remote transport and project-scope escalation before writes", () => {
for (const invocation of observed.remote) {
expect(invocation.exitCode).toBe(1);
expect(invocation.document).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: [
expect.objectContaining({
code: "unsupported_transport",
path: "command",
}),
],
});
expect(JSON.stringify(invocation.document)).not.toContain("fixture-secret");
expect(JSON.stringify(invocation.document)).not.toContain("fixture-user");
}
expect(observed.remoteDidNotWrite).toBe(true);
for (const refused of observed.refused) {
expect(refused.exitCode).toBe(1);
expect(refused.document).toMatchObject({ code: "VALIDATION_ERROR" });
}
expect(observed.refusalDidNotWrite).toBe(true);
});
it("preserves runner launcher data without acquisition or ambient writes", () => {
for (const { runner, invocation, entry } of observed.runners) {
expect(invocation.exitCode).toBe(0);
const report = mcpRegistrationReportSchema.parse(invocation.document);
expect({ command: report.command, args: report.args }).toEqual({
command: runner,
args: ["", "package", "--mcp"],
});
expect(entry).toMatchObject({
command: runner,
args: ["", "package", "--mcp"],
});
}
expect(observed.ambientUnchanged).toBe(true);
expect(observed.helperAcquisition).toBe(false);
});
+1 -1
View File
@@ -46,7 +46,7 @@ export const createNativeDispatchBinarySession = (
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
): BinarySession =>
composeDeepBinarySession(
config,
runtimeAnalysisConfig(config),
logger,
selectedEnvironment,
"inspect_native_dispatch_metadata",
+2 -2
View File
@@ -569,8 +569,8 @@ const modules = {
},
}),
),
cutter: service(({ providerEnvironment }) =>
createCutterBridgeService(providerEnvironment),
cutter: service(({ providerEnvironment, settings }) =>
createCutterBridgeService(providerEnvironment, { read: settings }),
),
browserObservation: capability(
({ options }) => options.browserObservation,
+46 -24
View File
@@ -3,6 +3,7 @@ import { parseJebMcpTimeoutMs } from "../config/environment.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js";
import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { err } from "../domain/result.js";
import type { JebMcpConnectionFactory } from "../jeb/JebMcpConnection.js";
import { JebProvider } from "../jeb/JebProvider.js";
@@ -10,7 +11,8 @@ import { JebProvider } from "../jeb/JebProvider.js";
/**
* Construct one JEB provider bound to the caller-selected MCP endpoint. An
* unusable endpoint makes only JEB unavailable; every other CLI command and
* MCP tool is composed alongside it.
* MCP tool is composed alongside it. An invalid launch timeout stays
* unavailable until the accepted operation supplies jeb.mcp_timeout_ms.
*/
export const createJebAnalysisProvider = (
environment: Readonly<Record<string, string | undefined>> = process.env,
@@ -19,29 +21,49 @@ export const createJebAnalysisProvider = (
const requestTimeout = parseJebMcpTimeoutMs(
environment.REA_JEB_MCP_TIMEOUT_MS,
);
if (!requestTimeout.ok) return unconfiguredJebProvider(requestTimeout.error);
try {
return new JebProvider({
environment,
...(requestTimeout.value === undefined
? {}
: {
requestTimeoutMs: requestTimeout.value,
discoveryTimeoutMs: requestTimeout.value,
}),
...(connectionFactory === undefined ? {} : { connectionFactory }),
});
} catch (cause: unknown) {
if (!(cause instanceof AnalysisInputError)) throw cause;
const constraint =
typeof cause.cause === "string" ? cause.cause : cause.message;
return unconfiguredJebProvider(
new ConfigurationError("Invalid REA environment configuration", {
cause,
settings: [{ setting: "REA_JEB_MCP_URL", constraint }],
}),
);
}
const build = (): JebAnalysisPort => {
try {
return new JebProvider({
environment,
...(requestTimeout.ok && requestTimeout.value !== undefined
? {
requestTimeoutMs: requestTimeout.value,
discoveryTimeoutMs: requestTimeout.value,
}
: {}),
...(connectionFactory === undefined ? {} : { connectionFactory }),
});
} catch (cause: unknown) {
if (!(cause instanceof AnalysisInputError)) throw cause;
const constraint =
typeof cause.cause === "string" ? cause.cause : cause.message;
return unconfiguredJebProvider(
new ConfigurationError("Invalid REA environment configuration", {
cause,
settings: [{ setting: "REA_JEB_MCP_URL", constraint }],
}),
);
}
};
if (requestTimeout.ok) return build();
const rejected = unconfiguredJebProvider(requestTimeout.error);
let provider: JebAnalysisPort | undefined;
const live = (): JebAnalysisPort => (provider ??= build());
const overrideActive = (): boolean =>
currentAnalysisOperationSnapshot()?.overrides["jeb.mcp_timeout_ms"] !==
undefined;
return {
inspectAvailability: (signal) =>
overrideActive()
? live().inspectAvailability(signal)
: rejected.inspectAvailability(signal),
close: () =>
provider === undefined ? Promise.resolve() : provider.close(),
execute: (request, options) =>
overrideActive()
? live().execute(request, options)
: rejected.execute(request, options),
};
};
const unconfiguredJebProvider = (
+112 -54
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { launchAnalysisSetting } from "./config/analysisSettings.js";
import { runtimeAnalysisConfig } from "./config/runtimeAnalysisSettings.js";
import { runWithAnalysisOperationSnapshot } from "./domain/analysisOperationSnapshot.js";
import { ConfigurationError } from "./domain/configurationErrors.js";
import {
defaultHopperLauncherPath,
inspectNativeAnalysisConfiguration,
@@ -21,19 +25,46 @@ it("still rejects a NUL in an unresolved expression", () => {
});
it.each([
["REA_ASAR_MAX_LISTING_BYTES", "9007199254740992"],
["REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS", "0"],
["REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION", "2147483648"],
["REA_GHIDRA_VALUE_FLOW_MAX_EDGES", "1.5"],
["REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB", "2048"],
])(
"rejects invalid %s at launch with its exact setting identity",
(setting, value) => {
["asar.max_listing_bytes", "REA_ASAR_MAX_LISTING_BYTES", "9007199254740992"],
[
"ghidra.value_flow_max_operations",
"REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS",
"0",
],
[
"ghidra.value_flow_max_inputs_per_operation",
"REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION",
"2147483648",
],
["ghidra.value_flow_max_edges", "REA_GHIDRA_VALUE_FLOW_MAX_EDGES", "1.5"],
[
"ghidra.decompile_max_payload_mib",
"REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB",
"2048",
],
] as const)(
"defers invalid %s until the operation reads it",
(id, setting, value) => {
const parsed = parseConfig({ [setting]: value });
if (parsed.ok) throw new Error("Invalid resource policy accepted");
expect(parsed.error.settings).toContainEqual(
if (!parsed.ok) throw new Error("runtime seed rejected startup");
const boundary = launchAnalysisSetting(id, { [setting]: value });
if (boundary.ok) throw new Error("invalid runtime seed was admitted");
expect(boundary.error.settings).toContainEqual(
expect.objectContaining({ setting }),
);
const live = runtimeAnalysisConfig(parsed.value);
const read = (): unknown => {
if (id === "asar.max_listing_bytes") return live;
if (id === "ghidra.value_flow_max_operations")
return live.ghidraValueFlowMaxOperations;
if (id === "ghidra.value_flow_max_inputs_per_operation")
return live.ghidraValueFlowMaxInputsPerOperation;
if (id === "ghidra.value_flow_max_edges")
return live.ghidraValueFlowMaxEdges;
return live.ghidraDecompileMaxPayloadMiB;
};
if (id !== "asar.max_listing_bytes")
expect(read).toThrow(ConfigurationError);
},
);
@@ -141,7 +172,18 @@ describe("runtime configuration", () => {
expect(empty.value.hopperTargetPath).toBeUndefined();
expect(empty.value.analysisProvider).toBe("auto");
expect(empty.value.hopperStartupTimeoutMs).toBe(120_000);
expect(empty.value.hopperStartupTimeoutSource).toBe("default");
}
const hopperTimeout = parseConfig({
REA_HOPPER_STARTUP_TIMEOUT_MS: "45000",
});
expect(hopperTimeout).toMatchObject({
ok: true,
value: {
hopperStartupTimeoutMs: 45_000,
hopperStartupTimeoutSource: "setting",
},
});
const result = parseConfig({ HOPPER_TARGET_PATH: "/usr/bin/true" });
expect(result.ok).toBe(true);
if (result.ok) {
@@ -189,7 +231,6 @@ describe("runtime configuration", () => {
});
expect(rejected.issues.map(({ setting }) => setting)).toEqual([
"REA_ANALYSIS_PROVIDER",
"REA_GHIDRA_STARTUP_TIMEOUT_MS",
]);
expect(
parseConfig({
@@ -349,6 +390,20 @@ describe("runtime collection configuration", () => {
);
});
const expectInvalidStartupSeed = (
setting: "REA_HOPPER_STARTUP_TIMEOUT_MS" | "REA_GHIDRA_STARTUP_TIMEOUT_MS",
id: "hopper.startup_timeout_ms" | "ghidra.startup_timeout_ms",
value: string,
read: (config: ReturnType<typeof runtimeAnalysisConfig>) => number,
): void => {
const result = parseConfig({ [setting]: value });
if (!result.ok) throw new Error("runtime seed rejected startup");
const live = runtimeAnalysisConfig(result.value);
expect(() => read(live)).toThrow(ConfigurationError);
const boundary = launchAnalysisSetting(id, { [setting]: value });
if (boundary.ok) throw new Error("invalid startup seed was admitted");
};
describe("configuration failure diagnostics", () => {
it.each(["1", "900000", "2147483647"])(
"accepts Hopper startup deadline %s without changing Ghidra's deadline",
@@ -366,22 +421,26 @@ describe("configuration failure diagnostics", () => {
);
it.each(["", "0", "-1", "1.5", "1e3", "invalid", "2147483648"])(
"rejects malformed Hopper startup deadline %j with its setting",
"defers a malformed Hopper startup deadline %j until the operation reads it",
(value) => {
const result = parseConfig({ REA_HOPPER_STARTUP_TIMEOUT_MS: value });
if (result.ok) throw new Error("Expected invalid startup deadline");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "configuration_invalid",
category: "invalid_input",
details: {
settings: [
{
setting: "REA_HOPPER_STARTUP_TIMEOUT_MS",
constraint: expect.any(String),
},
],
},
});
expectInvalidStartupSeed(
"REA_HOPPER_STARTUP_TIMEOUT_MS",
"hopper.startup_timeout_ms",
value,
(live) => live.hopperStartupTimeoutMs,
);
const parsed = parseConfig({ REA_HOPPER_STARTUP_TIMEOUT_MS: value });
if (!parsed.ok) throw parsed.error;
const live = runtimeAnalysisConfig(parsed.value);
expect(
runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "hopper.startup_timeout_ms": 8000 },
},
() => live.hopperStartupTimeoutMs,
),
).toBe(8000);
},
);
@@ -399,20 +458,12 @@ describe("configuration failure diagnostics", () => {
])(
"rejects malformed Ghidra startup deadline %j with its setting and constraint",
(value) => {
const result = parseConfig({ REA_GHIDRA_STARTUP_TIMEOUT_MS: value });
if (result.ok) throw new Error("Expected invalid startup deadline");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "configuration_invalid",
category: "invalid_input",
details: {
settings: [
{
setting: "REA_GHIDRA_STARTUP_TIMEOUT_MS",
constraint: expect.any(String),
},
],
},
});
expectInvalidStartupSeed(
"REA_GHIDRA_STARTUP_TIMEOUT_MS",
"ghidra.startup_timeout_ms",
value,
(live) => live.ghidraStartupTimeoutMs,
);
},
);
@@ -513,8 +564,8 @@ describe("JEB MCP timeout configuration", () => {
value: 900_000,
});
expect(
parseEnvironmentValues({ REA_JEB_MCP_TIMEOUT_MS: "900000" }),
).toMatchObject({ ok: true, value: { REA_JEB_MCP_TIMEOUT_MS: 900_000 } });
parseEnvironmentValues({ REA_JEB_MCP_TIMEOUT_MS: "900000" }).ok,
).toBe(true);
});
it.each(["", "0", "-1", "1.5", "1e3", "invalid", "2147483648"])(
@@ -630,18 +681,25 @@ describe("Ghidra decompile limit configuration", () => {
const result = parseConfig({
REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: value,
});
if (result.ok) throw new Error("Expected invalid decompile limit");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "configuration_invalid",
details: {
settings: [
{
setting: "REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS",
constraint: expect.any(String),
},
],
},
});
if (!result.ok) throw new Error("runtime seed rejected startup");
const live = runtimeAnalysisConfig(result.value);
expect(() => live.ghidraDecompileTimeoutSeconds).toThrow(
ConfigurationError,
);
expect(
runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "ghidra.decompile_timeout_seconds": null },
},
() => live.ghidraDecompileTimeoutSeconds,
),
).toBeUndefined();
const boundary = launchAnalysisSetting(
"ghidra.decompile_timeout_seconds",
{ REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: value },
);
if (boundary.ok) throw new Error("invalid decompile limit was admitted");
},
);
});
+23
View File
@@ -17,6 +17,17 @@ import { regexMatchingDeadlineMsSchema } from "./regexMatchingDeadline.js";
import { regexWorkerHeapRequestMiBSchema } from "./regexWorkerHeapRequest.js";
import { captureDecoderOldGenerationMiBSchema } from "./captureDecoderHeap.js";
import { parseNativeMatchingAllowance } from "./nativeMatchingAllowance.js";
import { bridgeMaxRequestBytesSchema } from "./bridgeMaxRequestBytes.js";
import {
debuggerCommandDeadlineMsSchema,
debuggerMaxCommandOutputBytesSchema,
} from "./debuggerSessionLimits.js";
import {
windowsChangeTimeDriftMsSchema,
windowsChangeTimeSettleMaxPollsSchema,
windowsChangeTimeSettlePollMsSchema,
} from "./windowsChangeTimeSettle.js";
import { pwntoolsInputBytesSchema } from "../native/pwntools/PwntoolsInputLimit.js";
const environmentSchemas: Readonly<Record<string, z.ZodType<number, string>>> =
{
REA_ASAR_MAX_HEADER_BYTES: asarMaxHeaderBytesSchema,
@@ -33,6 +44,18 @@ const environmentSchemas: Readonly<Record<string, z.ZodType<number, string>>> =
REA_REGEX_WORKER_HEAP_REQUEST_MIB: regexWorkerHeapRequestMiBSchema,
REA_CAPTURE_DECODER_OLD_GENERATION_MIB:
captureDecoderOldGenerationMiBSchema,
REA_BRIDGE_MAX_REQUEST_BYTES: bridgeMaxRequestBytesSchema,
REA_DEBUGGER_COMMAND_DEADLINE_MS: debuggerCommandDeadlineMsSchema,
REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: debuggerMaxCommandOutputBytesSchema,
REA_HOPPER_CALLER_SCAN_BUDGET_MS: providerStartupTimeoutSchema,
REA_JEB_MCP_TIMEOUT_MS: providerStartupTimeoutSchema,
REA_KUNA_PROJECT_TIMEOUT_MS: providerStartupTimeoutSchema,
REA_KUNA_TIMEOUT_MS: providerStartupTimeoutSchema,
REA_PWNTOOLS_MAX_INPUT_BYTES: pwntoolsInputBytesSchema,
REA_WINDOWS_CHANGE_TIME_DRIFT_MS: windowsChangeTimeDriftMsSchema,
REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: windowsChangeTimeSettlePollMsSchema,
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS:
windowsChangeTimeSettleMaxPollsSchema,
};
export const launchAnalysisSetting = (
id: AnalysisSettingId,
+64 -14
View File
@@ -1,8 +1,14 @@
import { expect, it } from "vitest";
import { analysisSettingDefinition } from "../domain/analysisSettingDefinitions.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { GHIDRA_OPERATIONS } from "../ghidra/GhidraProviderMetadata.js";
import { HOPPER_OPERATIONS } from "../hopper/HopperProviderMetadata.js";
import { parseEnvironment } from "./environment.js";
import { parseConfig } from "./parseConfig.js";
import { runtimeAnalysisConfig } from "./runtimeAnalysisSettings.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
BRIDGE_MAX_REQUEST_BYTES_SETTING,
DEFAULT_BRIDGE_MAX_REQUEST,
@@ -19,6 +25,21 @@ const symbolName = "\u0001".repeat(1024);
const session = "00000000-0000-4000-8000-000000000001";
const token = "a".repeat(64);
it("advertises the bridge ceiling for every bridge-backed operation", () => {
const advertised = new Set(
analysisSettingDefinition("bridge.max_request_bytes").operations,
);
const required = [
...HOPPER_OPERATIONS,
...GHIDRA_OPERATIONS,
"list_cutter_sessions",
"cutter_command",
];
expect(required.filter((operation) => !advertised.has(operation))).toEqual(
[],
);
});
it("keeps the 16 MiB ceiling when the setting is unset", () => {
expect(parseBridgeMaxRequestBytes(undefined)).toEqual({
ok: true,
@@ -31,7 +52,7 @@ it("keeps the 16 MiB ceiling when the setting is unset", () => {
);
const environment = parseEnvironment({});
if (!environment.ok) throw new Error("doctor rejected an unset ceiling");
expect(environment.value.REA_BRIDGE_MAX_REQUEST_BYTES).toBeUndefined();
expect(environment.ok).toBe(true);
});
it("accepts a caller-selected ceiling inside the floor and maximum", () => {
@@ -44,7 +65,7 @@ it("accepts a caller-selected ceiling inside the floor and maximum", () => {
[BRIDGE_MAX_REQUEST_BYTES_SETTING]: "8192",
});
if (!environment.ok) throw new Error("doctor rejected a valid ceiling");
expect(environment.value.REA_BRIDGE_MAX_REQUEST_BYTES).toBe(8192);
expect(environment.ok).toBe(true);
});
it("sets the maximum to the serialized string bound minus the framing LF", () => {
@@ -135,16 +156,45 @@ it.each([
const doctor = parseEnvironment({
[BRIDGE_MAX_REQUEST_BYTES_SETTING]: value,
});
if (startup.ok) throw new Error(`Startup accepted ${JSON.stringify(value)}`);
expect(startup.error._tag).toBe("ConfigurationError");
expect(startup.error.settings).toContainEqual({
setting: BRIDGE_MAX_REQUEST_BYTES_SETTING,
constraint: parsed.error.message,
});
if (doctor.ok) throw new Error(`Doctor accepted ${JSON.stringify(value)}`);
expect(doctor.error._tag).toBe("ConfigurationError");
expect(doctor.error.settings).toContainEqual({
setting: BRIDGE_MAX_REQUEST_BYTES_SETTING,
constraint: parsed.error.message,
});
if (!startup.ok)
throw new Error(`Startup rejected a runtime seed ${JSON.stringify(value)}`);
expect(
startup.value.invalidRuntimeSeeds?.["bridge.max_request_bytes"]?.settings,
).toEqual([
expect.objectContaining({ setting: BRIDGE_MAX_REQUEST_BYTES_SETTING }),
]);
const live = runtimeAnalysisConfig(startup.value);
expect(() => live.bridgeMaxRequestBytes.maximumBytes).toThrow(
ConfigurationError,
);
expect(
runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "bridge.max_request_bytes": 8192 },
},
() => ({
maximumBytes: live.bridgeMaxRequestBytes.maximumBytes,
source: live.bridgeMaxRequestBytes.source,
}),
),
).toEqual({ maximumBytes: 8192, source: "runtime" });
try {
void live.bridgeMaxRequestBytes.maximumBytes;
throw new Error("reset override activated the built-in default");
} catch (cause) {
expect(cause).toBeInstanceOf(ConfigurationError);
if (cause instanceof ConfigurationError)
expect(projectAnalysisError(cause)).toMatchObject({
code: "configuration_invalid",
details: {
settings: [{ setting: BRIDGE_MAX_REQUEST_BYTES_SETTING }],
},
});
}
if (!doctor.ok)
throw new Error(`Doctor rejected a runtime seed ${JSON.stringify(value)}`);
expect(() => parseBridgeMaxRequestBytes(value)).not.toThrow();
if (parseBridgeMaxRequestBytes(value).ok)
throw new Error(`Bridge parser accepted ${JSON.stringify(value)}`);
});
+43 -18
View File
@@ -1,13 +1,24 @@
import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js";
import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
MAXIMUM_BRIDGE_MAX_REQUEST_BYTES,
MINIMUM_BRIDGE_MAX_REQUEST_BYTES,
} from "../domain/analysisResourceLimits.js";
export const BRIDGE_MAX_REQUEST_BYTES_SETTING = "REA_BRIDGE_MAX_REQUEST_BYTES";
/** Request payload ceiling used when the setting is unset. Today's fixed constant. */
export const DEFAULT_BRIDGE_MAX_REQUEST_BYTES = 16 * 1024 * 1024;
export {
DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
MAXIMUM_BRIDGE_MAX_REQUEST_BYTES,
MINIMUM_BRIDGE_MAX_REQUEST_BYTES,
};
/**
* Smallest ceiling that still admits the largest bounded bridge request.
@@ -22,10 +33,7 @@ export const DEFAULT_BRIDGE_MAX_REQUEST_BYTES = 16 * 1024 * 1024;
* Comments, search text, and Cutter command text are unbounded; this ceiling
* is what limits them. The floor stays below the 16 MiB default so a caller
* can lower the ceiling.
*/
export const MINIMUM_BRIDGE_MAX_REQUEST_BYTES = 7464;
/**
*
* Largest configurable request, in encoded payload bytes.
*
* The ceiling is the final JSON payload, not the raw command. Callers measure
@@ -39,15 +47,11 @@ export const MINIMUM_BRIDGE_MAX_REQUEST_BYTES = 7464;
* then parses that string. A fitting response does not keep the paged wire
* frame, a second concatenated copy, and an outer JSON string.
*/
const NODE_MAX_STRING_LENGTH = 536_870_888;
const FRAMING_LF_CODE_UNITS = 1;
export const MAXIMUM_BRIDGE_MAX_REQUEST_BYTES =
NODE_MAX_STRING_LENGTH - FRAMING_LF_CODE_UNITS;
/** Effective bridge request ceiling and where its value came from. */
export interface BridgeMaxRequestBytes {
readonly maximumBytes: number;
readonly source: "default" | "setting";
readonly source: "default" | "setting" | "runtime";
}
export const DEFAULT_BRIDGE_MAX_REQUEST: BridgeMaxRequestBytes = {
@@ -99,6 +103,9 @@ export const parseBridgeMaxRequestBytes = (
export const resolveBridgeMaxRequestBytes = (
environment: Readonly<Record<string, string | undefined>>,
): BridgeMaxRequestBytes => {
const override = currentAnalysisSettingOverride("bridge.max_request_bytes");
if (override !== undefined)
return { maximumBytes: override, source: "runtime" };
const parsed = parseBridgeMaxRequestBytes(
environment[BRIDGE_MAX_REQUEST_BYTES_SETTING],
);
@@ -107,16 +114,22 @@ export const resolveBridgeMaxRequestBytes = (
};
/** How to read and change the effective bridge request ceiling. */
export const bridgeRequestLimit = (limit: BridgeMaxRequestBytes) =>
analysisSettingLimit(
"bridge.max_request_bytes",
limit.maximumBytes,
limit.source,
);
export const bridgeMaxRequestBytesRecovery = (
limit: BridgeMaxRequestBytes,
peer: BridgePeer = "Hopper",
): string =>
[
`The effective ${BRIDGE_MAX_REQUEST_BYTES_SETTING} value is ${String(limit.maximumBytes)} bytes (source ${limit.source}; default ${String(DEFAULT_BRIDGE_MAX_REQUEST_BYTES)}, floor ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)}, maximum ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}).`,
`Set ${BRIDGE_MAX_REQUEST_BYTES_SETTING} in the environment that starts REA to a decimal integer from ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)} to ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}, then restart REA.`,
`The effective bridge.max_request_bytes value is ${String(limit.maximumBytes)} bytes (source ${limit.source}; default ${String(DEFAULT_BRIDGE_MAX_REQUEST_BYTES)}, floor ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)}, maximum ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}). It can be changed at runtime with update_analysis_settings. The launch seed is ${BRIDGE_MAX_REQUEST_BYTES_SETTING}.`,
peer === "Ghidra"
? `The Ghidra bridge enforces no request ceiling of its own, so only the REA client applies ${BRIDGE_MAX_REQUEST_BYTES_SETTING} to Ghidra requests.`
: "Restart Hopper or Cutter from that same environment so the bridge uses this ceiling.",
? `The Ghidra bridge enforces no request ceiling of its own, so only the REA client applies bridge.max_request_bytes to Ghidra requests.`
: "The Hopper or Cutter bridge keeps the ceiling it started with until that bridge starts again.",
].join(" ");
const limitFacts = (
@@ -142,7 +155,11 @@ export const bridgeRequestOversizeError = (
"transport",
`${refusal} ${recovery}`,
{ ...limitFacts(limit), payload_bytes: payloadBytes },
{ remediationAction: recovery, retryable: false },
{
remediationAction: recovery,
retryable: false,
limit: bridgeRequestLimit(limit),
},
);
};
@@ -163,7 +180,11 @@ export const bridgeRequestLimitMismatchError = (
bridge_max_request_bytes: bridgeBytes,
client_max_request_bytes: limit.maximumBytes,
},
{ remediationAction: recovery, retryable: false },
{
remediationAction: recovery,
retryable: false,
limit: bridgeRequestLimit(limit),
},
);
};
@@ -177,6 +198,10 @@ export const bridgeResponseOversizeError = (
"transport",
`Cutter bridge response exceeded its framed transport limit of ${String(limit.maximumBytes)} bytes. ${recovery}`,
limitFacts(limit),
{ remediationAction: recovery, retryable: false },
{
remediationAction: recovery,
retryable: false,
limit: bridgeRequestLimit(limit),
},
);
};
+15 -14
View File
@@ -21,6 +21,7 @@ import {
CAPTURE_DECODER_OLD_GENERATION_SETTING,
DEFAULT_CAPTURE_DECODER_HEAP,
parseCaptureDecoderHeap,
resolveCaptureDecoderHeap,
} from "./captureDecoderHeap.js";
import { MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB } from "../domain/analysisResourceLimits.js";
@@ -103,11 +104,10 @@ it("uses a valid REA_CAPTURE_DECODER_OLD_GENERATION_MIB override", async () => {
/floor 8 MiB.*maximum 16384 MiB/iu,
);
expect(projected.remediation.action).toContain("update_analysis_settings");
expect(projected.remediation.action).toMatch(/restart REA/iu);
expect(projected.remediation.action).toContain("launch seed");
expect(projected.remediation.action).toContain(
"capture.decoder_old_generation_mib",
);
expect(projected.remediation.action).toMatch(/without a REA restart/iu);
expect(projected.remediation.action).toMatch(
/removes inherited NODE_OPTIONS/iu,
);
@@ -125,7 +125,8 @@ it("uses a valid REA_CAPTURE_DECODER_OLD_GENERATION_MIB override", async () => {
unit: "MiB",
applies_at: "next_worker",
agent_editable: true,
rea_restart_required: false,
update_mode: "runtime",
pending_restart: false,
},
reported_limits: {
old_generation_heap_mib: 256,
@@ -258,20 +259,20 @@ it.each([
const startup = parseEnvironment({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: value,
});
if (startup.ok) throw new Error(`Startup accepted ${JSON.stringify(value)}`);
expect(startup.error.settings).toContainEqual({
setting: CAPTURE_DECODER_OLD_GENERATION_SETTING,
constraint: parsed.error.message,
});
if (!startup.ok)
throw new Error(`Startup rejected a runtime seed ${JSON.stringify(value)}`);
const config = parseConfig({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: value,
});
if (config.ok)
throw new Error(`parseConfig accepted ${JSON.stringify(value)}`);
expect(projectAnalysisError(config.error)).toMatchObject({
code: "configuration_invalid",
message: expect.stringContaining("REA_CAPTURE_DECODER_OLD_GENERATION_MIB"),
});
if (!config.ok)
throw new Error(
`parseConfig rejected a runtime seed ${JSON.stringify(value)}`,
);
expect(() =>
resolveCaptureDecoderHeap({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: value,
}),
).toThrow(ConfigurationError);
expect(
() =>
new NodeCaptureAdapter("har", {
+23 -3
View File
@@ -6,6 +6,7 @@ import {
import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import { err, ok, type Result } from "../domain/result.js";
export const CAPTURE_DECODER_OLD_GENERATION_SETTING =
@@ -60,6 +61,11 @@ export const parseCaptureDecoderHeap = (
export const resolveCaptureDecoderHeap = (
environment: Readonly<Record<string, string | undefined>>,
): CaptureDecoderHeap => {
const override = currentAnalysisSettingOverride(
"capture.decoder_old_generation_mib",
);
if (override !== undefined)
return { oldGenerationMiB: override, source: "runtime" };
const parsed = parseCaptureDecoderHeap(
environment[CAPTURE_DECODER_OLD_GENERATION_SETTING],
);
@@ -67,11 +73,25 @@ export const resolveCaptureDecoderHeap = (
return parsed.value;
};
/** How to read and change the effective HAR/SAZ/qlog old-generation heap. */
export const captureDecoderHeapRecovery = (heap: CaptureDecoderHeap): string =>
const captureDecoderHeapFacts = (heap: CaptureDecoderHeap): string =>
[
`The effective ${CAPTURE_DECODER_OLD_GENERATION_SETTING} value is ${String(heap.oldGenerationMiB)} MiB (source ${heap.source}; default ${String(DEFAULT_CAPTURE_DECODER_OLD_GENERATION_MIB)} MiB, floor ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} MiB, maximum ${String(MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB)} MiB).`,
`Set ${CAPTURE_DECODER_OLD_GENERATION_SETTING} in the environment that starts REA to a decimal integer from ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} to ${String(MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB)}, then restart REA. In a running MCP server, use update_analysis_settings with setting capture.decoder_old_generation_mib and action set instead; the new value applies to new work without a REA restart.`,
`On Node.js the decoder passes --max-old-space-size=${String(heap.oldGenerationMiB)} and removes inherited NODE_OPTIONS, so additional host memory and NODE_OPTIONS do not raise this heap. The ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} MiB semi-space stays fixed.`,
"On Bun the V8 flags are ignored. Windows limits the decoder's committed memory to this value plus a 512 MiB reserve. Other Bun hosts enforce no decoder heap limit.",
].join(" ");
/** Recovery after this REA process has exited. */
export const captureDecoderHeapRecovery = (heap: CaptureDecoderHeap): string =>
[
captureDecoderHeapFacts(heap),
`Set ${CAPTURE_DECODER_OLD_GENERATION_SETTING} in the environment that starts the next REA invocation. It must be a decimal integer from ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} to ${String(MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB)}.`,
].join(" ");
/** Recovery while this session can still call update_analysis_settings. */
export const captureDecoderHeapContinuingRecovery = (
heap: CaptureDecoderHeap,
): string =>
[
captureDecoderHeapFacts(heap),
`Call update_analysis_settings with setting capture.decoder_old_generation_mib. It applies to the next decoder in this session. The launch seed remains ${CAPTURE_DECODER_OLD_GENERATION_SETTING}.`,
].join(" ");
+52 -21
View File
@@ -15,6 +15,7 @@ import {
} from "../domain/analysisResourceLimits.js";
import { GdbSessionManager } from "../gdb/GdbSessionManager.js";
import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { parseEnvironment } from "./environment.js";
import {
DEBUGGER_COMMAND_DEADLINE_SETTING,
@@ -138,14 +139,52 @@ it.each([
);
expect(detail.constraint).toContain(`default is ${defaultValue} ${unit}`);
const startup = parseEnvironment({ [setting]: value });
if (startup.ok) throw new Error(`${setting} was accepted at startup`);
expect(projectAnalysisError(startup.error)).toMatchObject({
code: "configuration_invalid",
details: { settings: [detail] },
});
const runtimeSeed =
setting === DEBUGGER_COMMAND_DEADLINE_SETTING ||
setting === DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING;
if (runtimeSeed) {
if (!startup.ok) throw new Error(`${setting} was rejected at startup`);
} else {
if (startup.ok) throw new Error(`${setting} was accepted at startup`);
expect(projectAnalysisError(startup.error)).toMatchObject({
code: "configuration_invalid",
details: { settings: [detail] },
});
}
},
);
it("applies runtime debugger overrides before rejecting invalid launch values", () => {
const parsed = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: {
"debugger.command_deadline_ms": 25,
"debugger.max_command_output_bytes": 4096,
},
},
() =>
parseDebuggerSessionLimits({
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "nope",
[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING]: "0",
}),
);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.commandDeadline.source).toBe("default");
expect(parsed.value.maxCommandOutputBytes.source).toBe("default");
const sessions = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "debugger.command_deadline_ms": 25 },
},
() =>
parseDebuggerSessionLimits({
[DEBUGGER_MAX_SESSIONS_SETTING]: "nope",
}),
);
expect(sessions.ok).toBe(false);
});
it("names the session setting on a Rizin capacity refusal", async () => {
const manager = new RizinDebugSessionManager(
{ [DEBUGGER_MAX_SESSIONS_SETTING]: "1" },
@@ -171,7 +210,8 @@ it("names the session setting on a Rizin capacity refusal", async () => {
minimum: MINIMUM_DEBUGGER_MAX_SESSIONS,
maximum: MAXIMUM_DEBUGGER_MAX_SESSIONS,
unit: "sessions",
restart_required: true,
update_mode: "restart",
pending_restart: false,
max_sessions: 1,
active_or_reserved_sessions: 1,
},
@@ -247,30 +287,21 @@ it("does not let one Rizin manager consume another manager's session cap", async
}
});
it("refuses an invalid GDB deadline before a session starts", () => {
it("defers an invalid GDB deadline until a command reads it", () => {
expect(
() =>
new GdbSessionManager({
environment: { [DEBUGGER_COMMAND_DEADLINE_SETTING]: "0" },
}),
).toThrow(ConfigurationError);
).not.toThrow();
const parsed = parseDebuggerSessionLimits({
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "0",
});
if (parsed.ok) throw new Error("invalid deadline was accepted");
try {
new GdbSessionManager({
environment: { [DEBUGGER_COMMAND_DEADLINE_SETTING]: "0" },
});
} catch (error) {
if (!(error instanceof ConfigurationError)) throw error;
expect(projectAnalysisError(error)).toMatchObject({
code: "configuration_invalid",
details: {
settings: parsed.error.settings,
},
});
}
expect(projectAnalysisError(parsed.error)).toMatchObject({
code: "configuration_invalid",
details: { settings: parsed.error.settings },
});
expect(
resolveDebuggerSessionLimits({
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "1500",
+73 -22
View File
@@ -1,3 +1,5 @@
import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import type { AnalysisSettingValues } from "../domain/analysisSettings.js";
import {
DEBUGGER_CONTROL_REPLY_BYTES,
DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS,
@@ -34,7 +36,7 @@ export const DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING =
export interface DebuggerAdmissionLimit {
readonly setting: string;
readonly value: number;
readonly source: "default" | "setting";
readonly source: "default" | "setting" | "runtime";
readonly defaultValue: number;
readonly minimum: number;
readonly maximum: number;
@@ -131,14 +133,11 @@ export const debuggerMaxCommandOutputBytesSchema = positiveDecimalIntegerSchema(
);
/** Schemas startup validation spreads. The setting names are not restated. */
/** Restart-only debugger caps. Runtime deadline and output are parsed per call. */
export const debuggerSessionLimitEnvironmentShape = {
[DEBUGGER_COMMAND_DEADLINE_SETTING]:
debuggerCommandDeadlineMsSchema.optional(),
[DEBUGGER_MAX_SESSIONS_SETTING]: debuggerMaxSessionsSchema.optional(),
[DEBUGGER_MAX_PENDING_COMMANDS_SETTING]:
debuggerMaxPendingCommandsSchema.optional(),
[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING]:
debuggerMaxCommandOutputBytesSchema.optional(),
};
export interface DebuggerSessionLimits {
@@ -148,6 +147,12 @@ export interface DebuggerSessionLimits {
readonly maxCommandOutputBytes: DebuggerAdmissionLimit;
}
const RUNTIME_DEBUGGER_OVERRIDES = {
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "debugger.command_deadline_ms",
[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING]:
"debugger.max_command_output_bytes",
} as const satisfies Partial<Record<string, keyof AnalysisSettingValues>>;
const parseOne = (
admission: DebuggerAdmissionLimit,
schema: ReturnType<typeof positiveDecimalIntegerSchema>,
@@ -156,25 +161,50 @@ const parseOne = (
): Result<DebuggerAdmissionLimit, ConfigurationError> => {
if (value === undefined) return ok(admission);
const parsed = schema.safeParse(value);
return parsed.success
? ok({ ...admission, value: parsed.data, source: "setting" })
: err(
new ConfigurationError(constraint, {
settings: [{ setting: admission.setting, constraint }],
}),
);
if (parsed.success)
return ok({ ...admission, value: parsed.data, source: "setting" });
const override =
RUNTIME_DEBUGGER_OVERRIDES[
admission.setting as keyof typeof RUNTIME_DEBUGGER_OVERRIDES
];
if (
override !== undefined &&
currentAnalysisOperationSnapshot()?.overrides[override] !== undefined
)
return ok(admission);
return err(
new ConfigurationError(constraint, {
settings: [{ setting: admission.setting, constraint }],
}),
);
};
/** Resolve every shared debugger cap from one environment. Invalid values are refused. */
export const parseDebuggerSessionLimits = (
/** Parse the selected runtime cap without coupling it to another setting. */
export const parseDebuggerCommandDeadline = (
environment: Readonly<Record<string, string | undefined>>,
): Result<DebuggerSessionLimits, ConfigurationError> => {
const commandDeadline = parseOne(
) =>
parseOne(
DEFAULT_DEBUGGER_COMMAND_DEADLINE,
debuggerCommandDeadlineMsSchema,
debuggerCommandDeadlineConstraint,
environment[DEBUGGER_COMMAND_DEADLINE_SETTING],
);
export const parseDebuggerCommandOutput = (
environment: Readonly<Record<string, string | undefined>>,
) =>
parseOne(
DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_LIMIT,
debuggerMaxCommandOutputBytesSchema,
debuggerMaxCommandOutputBytesConstraint,
environment[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING],
);
/** Resolve every shared debugger cap from one environment. Invalid values are refused. */
export const parseDebuggerSessionLimits = (
environment: Readonly<Record<string, string | undefined>>,
): Result<DebuggerSessionLimits, ConfigurationError> => {
const commandDeadline = parseDebuggerCommandDeadline(environment);
const maxSessions = parseOne(
DEFAULT_DEBUGGER_MAX_SESSIONS_LIMIT,
debuggerMaxSessionsSchema,
@@ -187,12 +217,7 @@ export const parseDebuggerSessionLimits = (
debuggerMaxPendingCommandsConstraint,
environment[DEBUGGER_MAX_PENDING_COMMANDS_SETTING],
);
const maxCommandOutputBytes = parseOne(
DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_LIMIT,
debuggerMaxCommandOutputBytesSchema,
debuggerMaxCommandOutputBytesConstraint,
environment[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING],
);
const maxCommandOutputBytes = parseDebuggerCommandOutput(environment);
const settings = [
commandDeadline,
maxSessions,
@@ -219,6 +244,32 @@ export const parseDebuggerSessionLimits = (
});
};
const RUNTIME_DEBUGGER_SETTINGS = new Set<string>([
DEBUGGER_COMMAND_DEADLINE_SETTING,
DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING,
]);
/**
* Session and pending caps still fail construction. An invalid runtime deadline
* or output cap does not: the next command reads the snapshot or the launch seed.
*/
export const debuggerConstructionLimits = (
environment: Readonly<Record<string, string | undefined>>,
): DebuggerSessionLimits => {
const parsed = parseDebuggerSessionLimits(environment);
if (parsed.ok) return parsed.value;
const blockers = parsed.error.settings.filter(
(setting) => !RUNTIME_DEBUGGER_SETTINGS.has(setting.setting),
);
if (blockers.length > 0) throw parsed.error;
const startup = { ...environment };
delete startup[DEBUGGER_COMMAND_DEADLINE_SETTING];
delete startup[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING];
const again = parseDebuggerSessionLimits(startup);
if (!again.ok) throw again.error;
return again.value;
};
/** Refuse an invalid launch cap instead of falling back to the default. */
export const resolveDebuggerSessionLimits = (
environment: Readonly<Record<string, string | undefined>>,
+33 -55
View File
@@ -6,7 +6,10 @@ import { z } from "zod";
import { mcpInputBudgetSchema } from "./mcpInputBudget.js";
import { isAbsolute } from "node:path";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
ConfigurationError,
type ConfigurationSettingIssue,
} from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import { analysisProviderSelectorSchema } from "../domain/providerSelection.js";
import {
@@ -16,34 +19,13 @@ import {
import { ghidraDisabledAnalyzersSchema } from "./ghidraDisabledAnalyzers.js";
import {
DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS,
ghidraDecompileTimeoutSchema,
providerStartupTimeoutSchema,
} from "./providerStartupTimeout.js";
import {
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
} from "../domain/analysisResourceLimits.js";
import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js";
import { mcpOutputSchemaProfileSchema } from "./mcpOutputSchemaProfile.js";
import { mcpToolCatalogProfileSchema } from "./mcpToolCatalogProfile.js";
import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js";
import { asarMaxHeaderBytesSchema } from "./asarHeaderAdmission.js";
import { asarMaxListingBytesSchema } from "./asarListingAdmission.js";
import {
ghidraValueFlowCountSchema,
ghidraDecompilePayloadMiBSchema,
} from "./ghidraValueFlowLimits.js";
import { bridgeMaxRequestBytesSchema } from "./bridgeMaxRequestBytes.js";
import { captureDecoderOldGenerationMiBSchema } from "./captureDecoderHeap.js";
import { debuggerSessionLimitEnvironmentShape } from "./debuggerSessionLimits.js";
import { regexMatchingDeadlineMsSchema } from "./regexMatchingDeadline.js";
import { regexWorkerHeapRequestMiBSchema } from "./regexWorkerHeapRequest.js";
import {
windowsChangeTimeDriftMsSchema,
windowsChangeTimeSettleMaxPollsSchema,
windowsChangeTimeSettlePollMsSchema,
} from "./windowsChangeTimeSettle.js";
import {
mcpDoctorDeadlineMsSchema,
mcpFirstCatalogBudgetMsSchema,
@@ -58,26 +40,8 @@ import { parseJebMcpEndpoint } from "./jebMcpEndpoint.js";
const environmentSchema = z.object({
...debuggerSessionLimitEnvironmentShape,
REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"),
REA_BRIDGE_MAX_REQUEST_BYTES: bridgeMaxRequestBytesSchema.optional(),
REA_MCP_MAX_RESPONSE_BYTES: mcpResponseBudgetSchema.optional(),
REA_MCP_MAX_INPUT_ELEMENTS: mcpInputBudgetSchema.optional(),
REA_ASAR_MAX_HEADER_BYTES: asarMaxHeaderBytesSchema.optional(),
REA_ASAR_MAX_LISTING_BYTES: asarMaxListingBytesSchema.optional(),
REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS: ghidraValueFlowCountSchema.optional(),
REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION:
ghidraValueFlowCountSchema.optional(),
REA_GHIDRA_VALUE_FLOW_MAX_EDGES: ghidraValueFlowCountSchema.optional(),
REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB:
ghidraDecompilePayloadMiBSchema.optional(),
REA_CAPTURE_DECODER_OLD_GENERATION_MIB:
captureDecoderOldGenerationMiBSchema.optional(),
REA_REGEX_MATCHING_DEADLINE_MS: regexMatchingDeadlineMsSchema.optional(),
REA_REGEX_WORKER_HEAP_REQUEST_MIB: regexWorkerHeapRequestMiBSchema.optional(),
REA_WINDOWS_CHANGE_TIME_DRIFT_MS: windowsChangeTimeDriftMsSchema.optional(),
REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS:
windowsChangeTimeSettlePollMsSchema.optional(),
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS:
windowsChangeTimeSettleMaxPollsSchema.optional(),
REA_MCP_INITIALIZE_BUDGET_MS: mcpInitializeBudgetMsSchema.optional(),
REA_MCP_TOOLS_LIST_BUDGET_MS: mcpToolsListBudgetMsSchema.optional(),
REA_MCP_FIRST_CATALOG_BUDGET_MS: mcpFirstCatalogBudgetMsSchema.optional(),
@@ -91,7 +55,6 @@ const environmentSchema = z.object({
.min(1)
.refine(isAbsolute, "REA_IDA_MCP_CONFIG must be absolute")
.optional(),
REA_JEB_MCP_TIMEOUT_MS: providerStartupTimeoutSchema.optional(),
REA_JEB_MCP_URL: z
.string()
.superRefine((value, context) => {
@@ -121,19 +84,9 @@ const environmentSchema = z.object({
.min(1)
.refine(isAbsolute, "REA_GHIDRA_NATIVEAOT_JAR must be absolute")
.optional(),
REA_GHIDRA_STARTUP_TIMEOUT_MS: providerStartupTimeoutSchema.default(
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
),
REA_HOPPER_STARTUP_TIMEOUT_MS: providerStartupTimeoutSchema.default(
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
),
REA_HOPPER_CALLER_SCAN_BUDGET_MS: providerStartupTimeoutSchema.default(
DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
),
REA_SWIFTC_COMPILE_TIMEOUT_MS: providerStartupTimeoutSchema.default(
DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS,
),
REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: ghidraDecompileTimeoutSchema.optional(),
REA_GHIDRA_PROJECT_PATH: ghidraProjectPathSchema.optional(),
REA_GHIDRA_CHECKPOINT_DIR: z
.string()
@@ -165,6 +118,31 @@ const environmentSchema = z.object({
REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"),
});
/** Copy producer-owned update facts from a Zod issue without inventing them. */
const configurationSettingFromIssue = (issue: {
readonly path: readonly PropertyKey[];
readonly message: string;
readonly code: string;
readonly params?: Readonly<Record<string, unknown>> | undefined;
}): ConfigurationSettingIssue => {
const setting = String(issue.path[0] ?? "environment");
const constraint = issue.message;
if (issue.code !== "custom" || issue.params === undefined)
return { setting, constraint };
const updateMode = issue.params.update_mode;
const pendingRestart = issue.params.pending_restart;
return {
setting,
constraint,
...(updateMode === "runtime" || updateMode === "restart"
? { update_mode: updateMode }
: {}),
...(typeof pendingRestart === "boolean"
? { pending_restart: pendingRestart }
: {}),
};
};
/** Parse the JEB-only timeout at its isolated provider-composition boundary. */
export const parseJebMcpTimeoutMs = (
value: string | undefined,
@@ -231,10 +209,10 @@ export const parseEnvironmentValues = (
const parsed = environmentSchema.safeParse(literals);
if (!parsed.success) {
// Zod messages name the constraint, never the rejected value.
const settings = parsed.error.issues.map((issue) => ({
setting: String(issue.path[0] ?? "environment"),
constraint: issue.message,
}));
// A producer may attach update_mode and pending_restart on a custom issue.
const settings = parsed.error.issues.map((issue) =>
configurationSettingFromIssue(issue),
);
return err(
new ConfigurationError("Invalid REA environment configuration", {
cause: parsed.error,
+33 -18
View File
@@ -11,8 +11,12 @@ import {
type HopperCallerScanFacts,
} from "../domain/native/hopperCallerReconciliation.js";
import { parseConfig } from "./parseConfig.js";
import { runtimeAnalysisConfig } from "./runtimeAnalysisSettings.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
HOPPER_CALLER_SCAN_BUDGET_SETTING,
hopperCallerScanLimit,
hopperCallerScanRecovery,
} from "./hopperCallerScanBudget.js";
@@ -53,25 +57,38 @@ it("records the default caller-scan budget and forwards an explicit setting", ()
).toEqual({ [HOPPER_CALLER_SCAN_BUDGET_SETTING]: "250" });
});
it("rejects a malformed caller-scan seed unless an override is active", () => {
const parsed = parseConfig({ [HOPPER_CALLER_SCAN_BUDGET_SETTING]: "0" });
if (!parsed.ok) throw new Error("runtime seed rejected startup");
const live = runtimeAnalysisConfig(parsed.value);
expect(() => live.hopperCallerScanBudget.milliseconds).toThrow(
ConfigurationError,
);
expect(
runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "hopper.caller_scan_budget_ms": 250 },
},
() => live.hopperCallerScanBudget.milliseconds,
),
).toBe(250);
});
it("explains the time budget and that fixed byte allowances have no override", () => {
const parsed = parseConfig({ [HOPPER_CALLER_SCAN_BUDGET_SETTING]: "250" });
if (!parsed.ok) throw parsed.error;
const budget = parsed.value.hopperCallerScanBudget;
const time = hopperCallerScanRecovery(
facts({ stop: { code: "work_budget", budget_ms: 250 } }),
budget,
);
expect(time).toContain(`${HOPPER_CALLER_SCAN_BUDGET_SETTING} is 250 ms`);
expect(time).toContain(
`The default is ${String(DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS)} ms`,
);
expect(time).toContain(
`from ${String(MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS)} to ${String(MAX_NODE_TIMER_DELAY_MS)} ms`,
);
expect(time).toContain("no runtime override");
expect(time).toContain(
`${HOPPER_CALLER_SCAN_BUDGET_SETTING} does not raise them`,
);
expect(hopperCallerScanLimit(budget)).toMatchObject({
setting: "hopper.caller_scan_budget_ms",
effective_value: 250,
source: "setting",
minimum: MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS,
maximum: MAX_NODE_TIMER_DELAY_MS,
update_mode: "runtime",
pending_restart: false,
});
expect(DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS).toBe(2000);
const page = hopperCallerScanRecovery(
facts({
stop: {
@@ -82,9 +99,7 @@ it("explains the time budget and that fixed byte allowances have no override", (
budget,
);
expect(page).toContain("no runtime override");
expect(page).toContain(
`${HOPPER_CALLER_SCAN_BUDGET_SETTING} does not raise them`,
);
expect(page).toContain("hopper.caller_scan_budget_ms does not raise them");
expect(page).not.toContain("is 250 ms");
expect(
hopperCallerScanRecovery(facts({ stop: { code: "complete" } }), budget),
+15 -4
View File
@@ -1,3 +1,4 @@
import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js";
import {
DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
MAX_NODE_TIMER_DELAY_MS,
@@ -16,7 +17,7 @@ export const HOPPER_CALLER_SCAN_BUDGET_SETTING =
export interface HopperCallerScanBudget {
readonly setting: typeof HOPPER_CALLER_SCAN_BUDGET_SETTING;
readonly milliseconds: number;
readonly source: "default" | "setting";
readonly source: "default" | "setting" | "runtime";
readonly defaultMilliseconds: number;
readonly floorMilliseconds: number;
readonly maximumMilliseconds: number;
@@ -52,7 +53,15 @@ export const hopperCallerScanBudgetForMilliseconds = (
milliseconds,
);
const fixedAllowanceRecovery = `This release has no runtime override for the ${String(HOPPER_CALLER_PAGE_ALLOWANCE_BYTES)} byte instruction, native-caller, and page allowances or the ${String(HOPPER_CALLER_SCAN_RETENTION_BYTES)} byte retention budget. ${HOPPER_CALLER_SCAN_BUDGET_SETTING} does not raise them.`;
const fixedAllowanceRecovery = `This release has no runtime override for the ${String(HOPPER_CALLER_PAGE_ALLOWANCE_BYTES)} byte instruction, native-caller, and page allowances or the ${String(HOPPER_CALLER_SCAN_RETENTION_BYTES)} byte retention budget. hopper.caller_scan_budget_ms does not raise them.`;
/** Structured facts for the scan time budget. The sentence is display text. */
export const hopperCallerScanLimit = (budget: HopperCallerScanBudget) =>
analysisSettingLimit(
"hopper.caller_scan_budget_ms",
budget.milliseconds,
budget.source,
);
/** Environment recovery for a caller-scan limit. Domain facts stay separate. */
export const hopperCallerScanRecovery = (
@@ -64,7 +73,9 @@ export const hopperCallerScanRecovery = (
facts.stop.code === "retention_budget" ||
!facts.instructions_complete ||
facts.native_callers_complete === false;
if (facts.stop.code === "work_budget")
return `${budget.setting} is ${String(budget.milliseconds)} ms (source: ${budget.source}). The default is ${String(budget.defaultMilliseconds)} ms. Allowed values are integers from ${String(budget.floorMilliseconds)} to ${String(budget.maximumMilliseconds)} ms. Set it in the environment that starts REA and restart REA. ${fixedAllowanceRecovery}`;
if (facts.stop.code === "work_budget") {
const limit = hopperCallerScanLimit(budget);
return `hopper.caller_scan_budget_ms is ${String(limit.effective_value)} ms (source ${limit.source ?? "unknown"}; default ${String(budget.defaultMilliseconds)} ms, floor ${String(limit.minimum)} ms, maximum ${String(limit.maximum)} ms). It can be changed at runtime with update_analysis_settings. The launch seed is ${budget.setting}. ${fixedAllowanceRecovery}`;
}
return fixedLimit ? fixedAllowanceRecovery : "";
};
+43
View File
@@ -0,0 +1,43 @@
import { expect, it } from "vitest";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
kunaProjectDeadline,
readKunaConfiguration,
} from "../kuna/KunaConfiguration.js";
import { acceptedExportDeadline } from "../kuna/KunaProjectExport.js";
it("keeps the deadline a failed export accepted after the override is reset", () => {
const environment = {
REA_KUNA_COMMAND: "/usr/bin/kuna",
REA_KUNA_PROJECT_TIMEOUT_MS: "not-a-timeout",
};
const configuration = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "kuna.project_timeout_ms": 5000 },
},
() => {
const parsed = readKunaConfiguration(environment, "linux");
if (!parsed.ok) throw new Error(parsed.error.reason);
return parsed.value;
},
);
expect(configuration.projectTimeoutLaunchValid).toBe(false);
const accepted = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "kuna.project_timeout_ms": 5000 },
},
() => kunaProjectDeadline(configuration),
);
expect(accepted).toEqual({
setting: "REA_KUNA_PROJECT_TIMEOUT_MS",
ms: 5000,
source: "runtime",
});
expect(() => kunaProjectDeadline(configuration)).toThrow(
/no runtime override is active/,
);
expect(acceptedExportDeadline(configuration, accepted)).toEqual(accepted);
expect(accepted).toMatchObject({ ms: 5000, source: "runtime" });
});
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff. Show more