Compare commits

...
Author SHA1 Message Date
morluto e9c31c4981 Merge main into perf/tests-cli-session-default
Keep this branch's parity test (fresh process versus the session, with
the absent-stdin case). Route the DMG recovery test's CLI calls to fresh
processes, since it checks each command releases its own attachment, and
move a new Rizin caller from sharedCliRun to cli.run.
2026-11-13 22:49:43 -05:00
morluto bddbd80afb refactor(cli): run one-shot commands through one shared owner (#10405)
* refactor(cli): run one-shot commands through one shared owner

scripts/rea.mjs and the test CLI session worker each implemented the
one-shot command sequence: argument normalization, output admission,
configuration errors, version documents, the Amp scope refusal, and
result delivery. The worker copy had drifted: it never rendered
formatted version reports or the Amp refusal.

Move the sequence into src/cliOneShot.ts. Both callers supply a host
for their streams, exit and resource ownership. The launcher keeps
closed-pipe status, worker shutdown and process exit; the session
worker captures output per command.

The cliOutput bundle entry is superseded and removed. The dispatcher
test now replaces only the command runtime, so output admission runs
production code. A new parity test compares the file session with a
fresh process.

* test(cli): bundle the one-shot entry in the runtime bundle fixture
2026-11-14 11:48:19 +08:00
morluto f1e250ee76 test(dmg): stop hard-coding host speed in attachment recovery (#10431)
The test bounded each hdiutil and CLI step with its own 10-30 s process
deadline. On hosted macOS runners the test takes 32-45 s (about three
times a local run), and the busy-image CLI call, which verifies the whole
image before its attach is refused, exceeded 30 s and was killed in two
recent main runs. Remove those per-step deadlines; the project deadline
still bounds a hang.
2026-11-14 11:47:59 +08:00
morluto 71712f27f7 test(mcp): open stdio clients through the shared local-tools fixture (#10411)
* test(mcp): open stdio clients through the shared local-tools fixture

Twelve files built their own StdioClientTransport and Client for
`rea mcp`. Those clients never listed tools, so the SDK did not
validate structured results against advertised output schemas, and each
file repeated transport setup, stderr draining and close ordering.

Use connectLocalToolsMcp, which lists tools, validates replies,
silences logging, leaves deep providers unconfigured and owns cleanup.
Per-test clients register test-scoped cleanup; module-level clients
collect cleanup for afterAll.

* test(mcp): keep failed file-scoped MCP cleanup owned

The three module-level clients removed every cleanup callback before
running them, so a failed cleanup lost its only handle. Add
fileLocalToolsMcp, which connects before the file's tests and removes
each cleanup only after it succeeds, and use it in those files.
2026-11-14 11:47:55 +08:00
morluto fcc0a8c454 perf(mcp): build advertised tool schemas once per contract (#10421)
* perf(mcp): build advertised tool schemas once per contract

The MCP SDK reprojects every registered tool schema on each tools/list.
REA memoized that projection, but per wrapper, and each server built new
wrappers for every contract, so each new server reconverted the whole
catalog: about 280 ms per tools/list for 255 tools with the full input
profile. Each wrapper also registered a new schema in zod's global
registry, so heap grew with every server.

Contracts are immutable module values, so build each advertised input
and output wrapper once per contract. tools/list on later servers drops
to about 26 ms (full profile) or 48 ms (compact).

* test(mcp): load only the contract family under test
2026-11-14 11:47:51 +08:00
morluto 243e4015cb docs(ci): remove dated shard measurements from the testing guides (#10414)
* docs(ci): move dated shard measurements into a verification record

The testing guide and testing-lanes guide mixed the October 2026 shard
budget and stale-history measurements into current rules. Move them to
docs/verification/ci-shard-scheduling-2026-10-20.md and link it from
the rules they justify.

* docs(ci): drop dated shard measurements instead of keeping a record
2026-11-14 11:47:46 +08:00
RRFZ 4a4e959513 fix(javascript): retain global escapes across repeated calls 2026-11-14 04:46:27 +01:00
RRFZ 4e1e9b62a7 fix(javascript): preserve global escapes across callback boundaries 2026-11-14 04:46:27 +01:00
RRFZ 54677ec1d6 fix(javascript): preserve timer delays after global escapes 2026-11-14 04:46:27 +01:00
RRFZ 1abd327913 fix(javascript): retain timer delays after opaque global effects 2026-11-14 04:46:27 +01:00
RRFZ 186b2c352c fix(javascript): omit Node timers with rejected delay values 2026-11-14 04:46:27 +01:00
RRFZ 827b31c128 fix(javascript): honor child-process callback overloads 2026-11-14 04:46:27 +01:00
gensokyo-devandClaude Opus 5.5 ee339e5db5 fix(debuggers): keep stopped Rizin and GDB sessions explainable
After REA stopped a Rizin debug session (frame or output limit, deadline,
cancellation, protocol failure) or the debugger exited, later commands
returned a generic invalid_request and status gave no reason. GDB
collapsed the same states the same way.

Both managers now keep a published session addressable until
close_*_session releases it, record the first reason it stopped, report
it as closed_reason in session status, and refuse later commands with
capability_unavailable naming that reason and the close-then-restart
recovery. An ID the server never returned, or that close released, still
fails as invalid_request with its own recovery. Retained stopped
sessions keep their slot, which the capacity recovery now says. A GDB
command deadline now records its reason like cancellation already did.

Fixes #8024

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 04:42:35 +01:00
gensokyo-devandClaude Opus 5.5 6ce708cfde fix(rizin): report shell refusals instead of complete empty results
Rizin exits 0 when its shell refuses a statement, so execute_rizin_command
reported completion_status complete with exit code 0 and an empty stdout
that read as a genuine empty result.

Rizin 0.6 through 0.9.1 log exactly four refusal lines from
librz/core/cmd/cmd.c: an unknown command, a wrong argument count, an
invalid statement (RZ_CMD_STATUS_INVALID, which also stops the remaining
statements) and a parse error. The Rizin command profile now classifies a
completed process's stderr against those lines; the shared one-shot
service reports completion_status "rejected" and a limitation that names
the refusal and says stdout holds only earlier statements' output. Other
ERROR lines come from commands that ran and stay diagnostics.

Fixes #10364

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 04:42:35 +01:00
gensokyo-devandClaude Opus 5.5 884b683a94 fix(cli): advertise the decimal token grammar on integer arguments
`cliIntegerArgument` piped a regex-guarded string into a number but never
published Zod's input view, so `--schema` advertised a bare integer for 15
fields while the parser accepted only decimal argv text. `environment-serve
--port` had the same gap through the transforming MCP_PORT owner.

Remove `cliIntegerArgument` and route every call site through
`cliDecimalField`, binding each field to its domain or contract owner where
one exists so bounds, defaults and optionality come from that owner. Export
the input-view publisher as `publishArgvInput` and apply it to `--port`,
which keeps the MCP_PORT grammar shared with `rea mcp --http`.

The decimal catalog test now requires every numeric field's advertisement
to admit its decimal argv token, covers repeated options and
string-advertised token grammars, and checks `--schema` output directly.

The string branch's missing numeric range (#9348) is unchanged.

Fixes #9344

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 04:27:54 +01:00
morluto 5e169583f6 Merge remote-tracking branch 'origin/main' into perf/tests-cli-session-default
# Conflicts:
#	tests/boundary/cli/reconstructionObligationLedgerCli.test.ts
2026-11-13 18:00:07 -05:00
5815178a8d ci: give the Apple artifact lane a passing-run budget
Passing runs of the macOS artifact lane took 10.2-14.9 minutes (median
13.6) across recent runs, and 4 of 12 runs that reached their steps were
cancelled at the 15-minute limit without a failing test. Allow 25
minutes so runner speed alone does not fail the lane.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:54:30 +08:00
87743d67f5 test: check the deadlines Vitest applies to collected tests
Probes in a source and a built project read each collected test's
deadline, including test, suite, and module long-running tags, so a
change in project inheritance or tag resolution fails directly. Both
the policy and the probes read one set of base deadlines.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:27:07 +08:00
d3efeb0c36 test: assert elapsed time where a deadline guarded complexity
Four wide-input regressions used a short per-test deadline as their only
check against a slow but terminating implementation. Assert the same
budgets explicitly so the project deadline only detects hangs. The
composed-body test keeps its exact read count instead.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:27:07 +08:00
46728ca41d test: remove per-test deadlines owned by project policy
Explicit deadlines at or below the project deadline only shortened it:
a literal 30-second value also disabled the coverage scaling, which timed
out wide-capacity regressions on main. Workloads above the built
deadline use the long-running tag. Deadlines derived from an operation
budget remain.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:27:07 +08:00
62798fa8b0 test: share the wide fingerprint analysis across projection modes
Both projection modes read the same 130,000-callable analysis without
changing it, so analyze the source once.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:27:07 +08:00
5bad48e122 test: let Vitest projects own test deadlines
Source projects allow 30 seconds and built projects 120 seconds, both
doubled under coverage, and hooks share their project's deadline instead
of Vitest's 10-second default. A long-running tag covers bounded
workloads that need more.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:27:07 +08:00
morluto 497fd2acd9 test(cli): give each shared-session command an empty stdin
The shared CLI process reads session frames from its own stdin. A
command that reads stdin without supplied input, such as
`compare-wasm-interfaces - --json`, read that pipe and hung, where a
fresh process receives EOF. The worker now presents an ended, empty
stdin to each command and restores its own afterwards.

The parity test compared the routed path with itself after cli.run
began choosing the session. It now compares a fresh process with the
session directly, including the `mcp add` refusal and a command
reading absent stdin.
2026-11-13 15:24:05 -05:00
morluto cc7d73d23a test(ci): double test deadlines on hosted runners (#10417)
* test(javascript): give the wide fingerprint regression a capacity deadline

Each case runs about 22 s of CPU-bound projection on a typical hosted
runner. Its explicit 30 s deadline failed on slower runners at both two
and four Vitest workers, and it also overrode the suite's 60 s
coverage-mode allowance.

* test(ci): double test deadlines on hosted runners

Hosted CI jobs run four Vitest workers on a four-core runner. CPU-bound
capacity cases then share every core with other workers and their
subprocesses; three such cases exceeded the 30 s deadline at four
workers (16.8-22.7 s at two). The deadline only needs to bound a hang,
so hosted CI doubles it: 60 s, or 120 s with coverage. Local runs keep
30 s and 60 s.

* test(ci): check worker and deadline policy for local and hosted runners

The policy test loaded vitest.config.ts in a child that inherited the
parent's GITHUB_ACTIONS, so on CI it observed the hosted deadlines and
failed. Select the runner explicitly and check both: two workers and the
base deadline locally, four workers and twice the deadline on hosted CI.
2026-11-13 15:17:24 -05:00
morluto 73b207ac64 style(tests): format the managed member summary call site 2026-11-13 15:08:43 -05:00
morluto 5b7f5d2298 perf(tests): run plain CLI commands on the file's shared process
`cli.run` in `cliTest` files started a fresh compiled CLI for every
command, about 0.45s and 260 MB each before any work. The file-scoped
session already existed, but callers had to opt in through
`runFileCli` or `sharedCliRun`, so 1,300 call sites still spawned.

Make the fixture choose: `cli.run` uses the shared process when it
reports the command exactly as a fresh process would, and starts one
otherwise (stdin, explicit deadlines, MCP and MCP doctor routing,
NODE_OPTIONS, or enabled logging). `freshProcess: true` keeps
explicit process-level checks. The session worker now presents the
launcher's entrypoint, so registration identity matches a fresh
process.

`runFileCli` and `sharedCliRun` are superseded and removed; their
call sites use `cli.run`, and paired fresh/shared checks use
`freshProcess`.
2026-11-13 14:44:08 -05:00
morluto 940d7156f6 test(cli): bundle the one-shot entry in the runtime bundle fixture 2026-11-13 14:44:06 -05:00
morluto da91bbd4eb test(ci): retire loader-ownership history seeding (#10404)
* test(ci): retire loader-ownership history seeding

Both timing tiers now publish javascriptLiteralLoaderOwnership.test.ts's
own duration (fast 23.7s, full 26.6s), so the seeding returned its input
unchanged. Remove it and its tests.

* docs(ci): describe shard weighting without retired loader seeding
2026-11-13 14:20:11 -05:00
morluto 06d2413c41 refactor(cli): run one-shot commands through one shared owner
scripts/rea.mjs and the test CLI session worker each implemented the
one-shot command sequence: argument normalization, output admission,
configuration errors, version documents, the Amp scope refusal, and
result delivery. The worker copy had drifted: it never rendered
formatted version reports or the Amp refusal.

Move the sequence into src/cliOneShot.ts. Both callers supply a host
for their streams, exit and resource ownership. The launcher keeps
closed-pipe status, worker shutdown and process exit; the session
worker captures output per command.

The cliOutput bundle entry is superseded and removed. The dispatcher
test now replaces only the command runtime, so output admission runs
production code. A new parity test compares the file session with a
fresh process.
2026-11-13 14:13:07 -05:00
morlutoandClaude Fable 5.2 3403338ea0 perf(ci): use four Vitest workers on hosted runners (#10399)
Hosted CI jobs own a four-core runner but the suite ran on two workers.
Other hosts keep the two-worker bound because they are shared.

Co-authored-by: Claude Fable 5.2 <[email protected]>
2026-11-13 13:51:39 -05:00
Kaoru0822 4566e483ab fix(process): retain wide descendant inventories (#10386) 2026-11-13 10:49:10 -08:00
Kaoru0822 bf63760cb4 fix(setup): normalize Gemini trust rules before selection (#10378) 2026-11-13 10:48:50 -08:00
Kaoru0822 3b006841e2 fix(rizin): qualify process completion in command evidence (#10377)
* fix(rizin): expose command validity limits in observations

* test(rizin): compare guidance with its owning contract
2026-11-13 10:48:35 -08:00
gensokyo-devandClaude Opus 5.5 3cb75d46bf test(mcp): guard portable pattern escapes in every schema profile
The advertised-pattern guard only inspected the default profile and only
rejected the `\0` and `\u0000` NUL spellings, so a `\uD800` range shipped
in 6.9.0 unnoticed. Reject any unescaped `\u` or `\0` escape, and run that
check for each input and output schema profile.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-13 18:57:31 +01:00
gensokyo-devandClaude Opus 5.5 b67c1e8ce6 fix(contracts): enforce well-formed Unicode paths without advertising surrogate patterns
rea-agents 6.9.0 advertised `^[^\uD800-\uDFFF]*$` on every local path
input. RE2 has no `\u` escape and Rust's `regex`/`jsonschema` crates refuse
non-scalar code points, so strict MCP clients and gateways (DeepSeek, RE2
gateways, Open Design) rejected the complete tools request and no REA tool
could run.

Move the rule into one runtime check, `rejectUnpairedSurrogates`, which
advertises nothing and still raises the `invalid_format` regex issue, so
CLI and MCP corrections keep reason, expected value, and message. The
objdump disassembly symbol already enforced the same rule with its own
refinement; it now shares the owner and the correction shape.

Dropping the pattern also brings inspect_android_jni_bindings within the
compact profile's per-schema budget, so compact delivery now keeps its
DEX dimension pattern. The descriptor admission test follows the actual
presentation instead of assuming compact always omits it.

Fixes #10238
Fixes #10348
Fixes #10198

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-13 18:57:31 +01:00
morluto cdc4541ef7 refactor: remove redundant seams and preserve cleanup ownership (#10375)
* refactor: remove redundant seams and preserve cleanup ownership

* fix: validate PE resource extents before budget checks
2026-11-13 10:11:06 -05:00
Kaoru0822 4b26f38d6f fix(javascript): preserve wide async chunk collection (#10362)
* fix(javascript): collect async chunks without call spreads

* test(javascript): bound the public checkpoint control
2026-11-13 06:17:43 -08:00
Kaoru0822 e841fb16e4 fix(browser): validate wide scenario reference lists (#10359) 2026-11-13 06:17:22 -08:00
Kaoru0822 26b70c8ea4 fix(mcp): complete wide retained occurrence inventories (#10356) 2026-11-13 06:16:57 -08:00
Kaoru0822 5d046b9ad7 fix(javascript): retain wide function fingerprint inventories (#10355) 2026-11-13 06:16:36 -08:00
Kaoru0822 c4cec6373d fix(javascript): retain wide runtime load-state inventories (#10354) 2026-11-13 06:15:03 -08:00
Kaoru0822 02a33a8f58 fix(javascript): reject invalid fs open argument roles (#10346) 2026-11-13 06:10:25 -08:00
Kaoru0822 ea101dbe30 fix(browser): interpret agent link relations once per element (#10345) 2026-11-13 06:07:58 -08:00
Kaoru0822 fd3e613d23 fix(captures): retain wide native numeric exclusions (#10343)
fix(captures): append admitted numeric exclusions without call spreads
2026-11-13 06:07:41 -08:00
Kaoru0822 adf8bdf833 fix(javascript): preserve GraphQL Source ownership across spreads (#10338)
* fix(javascript): preserve GraphQL Source ownership across spreads

* fix(javascript): keep inline Sources owned by their enclosing Source
2026-11-13 06:07:24 -08:00
Kaoru0822 cd96c5e9eb fix(desktop): preserve own JSON keys in task grading (#10331)
* fix(desktop): preserve own state keys in task witnesses

* test(desktop): preserve witness parse failure details

* fix(desktop): retain own column keys in task conditions
2026-11-13 06:07:06 -08:00
Kaoru0822 617e4cbfb3 fix(windows): preserve pre-epoch shortcut timestamp milliseconds (#10305)
fix(windows): keep pre-epoch shortcut times in their millisecond
2026-11-13 06:06:47 -08:00
morluto bbe2bcc979 refactor: remove legacy seams and consolidate resource ownership (#10349) 2026-11-13 04:45:16 -05:00
Kaoru0822 5cddc69c51 fix(verify): keep adopted resources live until command owner closes (#10314) 2026-11-12 23:58:18 -08:00
Kaoru0822 b2c4635a36 fix(verify): retain Go fixtures until owned cleanup completes (#10309) 2026-11-12 23:58:00 -08:00
Kaoru0822 15a2de737c fix(navigation): offer available instruction observations (#10307) 2026-11-12 23:57:46 -08:00
Kaoru0822 c2ca3819e5 fix(analysis): iterate admitted inventories without call spreads (#10304) 2026-11-12 23:57:04 -08:00
Kaoru0822 54906fdb1f fix(verify): check Android readiness repair guidance (#10242) 2026-11-12 20:40:42 -08:00
Kaoru0822 0084810ef7 fix(browser): bound cached responses with zero-padded lengths (#10237) 2026-11-12 20:40:26 -08:00
Kaoru0822 feca30c72c fix(go): validate string extents before budget admission (#10231) 2026-11-12 20:40:05 -08:00
Kaoru0822 913b029c96 fix(desktop): retain dconf failures without stderr (#10230) 2026-11-12 20:39:50 -08:00
Kaoru0822 bfecbb1d48 test(native): cover deadlines throughout signature inspection (#10217) 2026-11-12 20:39:31 -08:00
Kaoru0822 070cfce443 fix(ci): settle cancelled test-group descendants (#10229) 2026-11-12 20:38:32 -08:00
Kaoru0822 0c01517cc0 fix(pcap): disclose truncated DNS responses as partial (#10214)
* fix(pcap): disclose truncated DNS responses as partial

* fix(pcap): explain DNS truncation in coverage limitations

* fix(pcap): preserve mDNS TC semantics
2026-11-12 20:34:34 -08:00
Kaoru0822 7c9d65fe97 fix(test): resolve filesystem aliases before planning prerequisites (#10213) 2026-11-12 20:29:42 -08:00
ShuaixinHuangandShuaixinHuang 9c86465d47 fix(captures): retain preparation cleanup owners (#10288)
Co-authored-by: ShuaixinHuang <[email protected]>
2026-11-12 22:08:37 -05:00
morluto c836f57b7d refactor: consolidate shared analysis boundaries (#10293) 2026-11-12 22:05:32 -05:00
morluto 6878a84796 fix(tests): restore worker fixture and allow setup completion 2026-11-12 20:57:43 -05:00
Kaoru0822 937d10df1c fix(pe): enforce DER extension critical encoding (#10212) 2026-11-12 19:33:31 -05:00
Kaoru0822 c19d319929 fix(desktop): preserve literal directories in ELF origin expansion (#10215)
fix(desktop): expand ELF origin paths as literal directory names
2026-11-12 19:33:16 -05:00
Kaoru0822 966bde3780 fix(android): enforce DEX array dimension limit (#10227) 2026-11-12 19:29:53 -05:00
Kaoru0822 4d15b7f509 fix(android): preserve SDK-gated manifest permissions (#10236) 2026-11-12 19:29:40 -05:00
Kaoru0822 50d4f7bea4 fix(desktop): validate guest file replies at their shared boundary (#10224)
* fix(desktop): reject malformed guest file base64

* fix(desktop): scope guest file metadata validation to its namespace
2026-11-12 19:29:18 -05:00
Kaoru0822 594d73985c fix(desktop): preserve Unicode separators in entry values (#10210) 2026-11-12 19:29:05 -05:00
Kaoru0822 418e3f5e91 fix(release): honor selected npm in update verification (#10240) 2026-11-12 19:28:29 -05:00
Kaoru0822 0f5ac9d5f3 fix(desktop): preserve multiline apt-config locations (#10241) 2026-11-12 19:28:11 -05:00
2SinceJulyand2SinceJuly 7559e06740 fix(contracts): drop nested defaults from a root union sibling (#10254)
* fix(contracts): drop nested defaults from a root union sibling

Flattening a union of strict object groups lifts each group's properties
into one object. A default the scenario group carried inside
normalization.rules survived that lift even though the passive group
declares no normalization, so a client that fills nested defaults sent
{normalization: {rules: []}} and the runtime parser refused the call
with "Unrecognized key: normalization".

Remove defaults below a property that any group omits, not just the one
on the property itself. A property every group owns keeps its own
nested guidance.

* fix(contracts): keep a nested default a reference still targets

Dropping the nested default of a property a sibling group omits also removed defaults that references point into. A resource whose validation $ref targets its own default was advertised with that reference intact but its target gone, so the advertised schema no longer compiled in AJV or client projection.

Collect the paths into a default that references inside the branch target and keep those entries while stripping the rest. Other nested defaults are still removed, so the strict sibling group keeps refusing the object.

* test(contracts): type the root union default regression fixture

The fixture spread the canonical branch through a widened cast, so the repository typecheck rejected the spread and indexed access. Narrow the branch shape and guard the optional schema before collecting its referenced defaults.

---------

Co-authored-by: 2SinceJuly <[email protected]>
2026-11-12 19:27:58 -05:00
ShuaixinHuangandShuaixinHuang 9d32836d37 fix(apple): expand loaded siblings along the first dyld chain (#10248)
* fix(apple): expand loaded siblings along the first dyld chain

* fix(apple): retain conditional incoming dyld expansion edges

* fix(apple): qualify findings by their expansion chain

---------

Co-authored-by: ShuaixinHuang <[email protected]>
2026-11-12 19:27:40 -05:00
morluto 02ebd3e02d refactor: prune test-only seams and obsolete helpers (#10275) 2026-11-12 19:24:54 -05:00
1383 changed files with 20559 additions and 13385 deletions

No files matched your search

+3 -1
View File
@@ -755,7 +755,9 @@ jobs:
needs: [changes, static, build]
if: needs.changes.outputs.apple == 'true'
runs-on: macos-15
timeout-minutes: 15
# Passing runs take 10-15 minutes on macOS runners of varying speed, so a
# 15-minute budget cancelled about a third of runs. Keep a hang bounded.
timeout-minutes: 25
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+3 -1
View File
@@ -464,7 +464,9 @@ class ReaJadxBridge {
if (element.getTagName().equals("uses-sdk")) {
minSdk = element.getAttributeNS(android, "minSdkVersion");
targetSdk = element.getAttributeNS(android, "targetSdkVersion");
} else if (element.getTagName().equals("uses-permission")) {
} else if (element.getTagName().equals("uses-permission")
|| element.getTagName().equals("uses-permission-sdk-23")
|| element.getTagName().equals("uses-permission-sdk-m")) {
String permission = element.getAttributeNS(android, "name");
if (!permission.isEmpty()) permissions.add(permission);
}
-9
View File
@@ -958,15 +958,6 @@ class _BridgeServer(threading.Thread):
proof.update(fragment.encode("utf-8"))
return proof.hexdigest()
def _encode_authenticated_response(
self, value: dict[str, Any], client_nonce: str, server_nonce: str
) -> bytes | None:
"""Return one exact frame. The socket path writes the same pieces."""
proof = self._authenticated_proof(value, client_nonce, server_nonce)
if proof is None:
return None
return b"".join(self._iter_authenticated_frame(value, proof))
def _iter_authenticated_frame(self, value: dict[str, Any], proof: str):
yield _AUTHENTICATED_PREFIX
for fragment in _response_fragments(value):
-7
View File
@@ -154,13 +154,6 @@ class HopperApiFacade:
)
def _configure_hopper_api(document_provider):
"""Inject Hopper's document provider or a standalone test facade."""
global _hopper_api, _rea_session_document
_hopper_api = HopperApiFacade(document_provider)
_rea_session_document = None
def _api():
"""Resolve Hopper globals lazily so importing this module needs no Hopper."""
global _hopper_api
+32 -32
View File
@@ -238,7 +238,9 @@ broad changes or diagnosing CI, rather than after every edit. Package/install
changes additionally need package verification; provider changes need actual
provider evidence.
Vitest projects use up to two workers, bounded by host parallelism.
Vitest projects use up to two workers, bounded by host parallelism. GitHub
Actions jobs own their runner and use up to four; see
[test deadlines](#developer-commands) for how deadlines scale there.
`process-boundary` runs later with serial files because process-tree sampling
shares host resources. Acceptance and process-global cases use isolated forks
without serial scheduling. Pure domain/service, composition, conformance,
@@ -283,7 +285,7 @@ coverage. Ordinary CI also exhausted the former 384 MiB budget after earlier
files loaded the shared source/contract graph, while later command registration
and schema/help rendering still needed transient allocation space. This is a
measured capacity control, not a suite-wide speedup claim. The worker count
remains bounded at two, and spawned REA processes retain their own heap settings.
remains bounded, and spawned REA processes retain their own heap settings.
Coverage enablement still controls instrumentation, test deadlines and coverage
thresholds; explicitly disabling it or selecting only a provider does not enable
instrumentation.
@@ -299,6 +301,18 @@ work from fixture generation, schema compilation, subprocess startup and cleanup
Choose elapsed, CPU and process-family memory limits before the run; a worker
count or test timeout alone does not enforce those limits.
A test deadline turns a hang into a named failure; it is not a performance
assertion. `vitest.config.ts` applies the deadlines in
`tests/support/testDeadlines.ts` per project: 30 seconds for source projects
and 120 seconds for built projects, with hooks sharing their project's
deadline. Coverage doubles every deadline, and so does hosted CI, where
CPU-bound cases share every core with other workers and their subprocesses.
Do not pass a per-test timeout to fit a slow host. Tag a bounded workload that needs longer with
`{ tags: ["long-running"] }`, or a whole file with
`// @module-tag long-running`. Assert elapsed time explicitly when speed is the
behavior under test. Derive an explicit deadline only when a test must outlive
an operation deadline whose diagnostics it checks.
Keep capacity fixtures intact when diagnosing a test failure. For byte equality
of large buffers, use `Buffer.equals` or `Buffer.compare`; a generic structural
diff can allocate far more than the production operation.
@@ -343,11 +357,17 @@ The process restarts when the environment changes, after 32 commands, or when
the worker reports a resident set of at least 1 GiB. A fatal worker failure
or a broken session frame retires that process and waits for it to exit before
the next command starts.
`cli.run` still starts a fresh process, including stdin and explicit deadlines.
`runFileCli` uses the file session for commands that have neither. Go binary,
IL2CPP metadata, and dylib-resolution commands use that session. File teardown
stops the shared CLI process, including startup still in flight, and does not
open another after close.
In a `cliTest` file, `cli.run` uses that shared process when it reports the
command exactly as a fresh `rea` process would. Both run the same one-shot
owner (`src/cliOneShot.ts`), and the shared process presents the launcher's
entrypoint. A command starts a fresh process instead when it supplies stdin or
an explicit deadline, enters MCP or the MCP doctor (which the launcher routes
first), sets `NODE_OPTIONS`, or enables logging, which writes outside the
per-command capture. Pass `freshProcess: true` for process-level facts such as
startup, signals, or exit, and keep one fresh check beside reused cases where
dispatch itself is under test. `createTestCli` drivers always start a fresh
process. File teardown stops the shared CLI process, including startup still in
flight, and does not open another after close.
`exportedFunctionBindingsCli.test.ts` uses the shared compiled CLI session for
its file and compares its JSON command results with the MCP session. It selects
20-second startup and command deadlines; other callers retain their existing
@@ -1077,32 +1097,12 @@ existing four-minute timing collection plus the bounded diagnostic stages.
Fast built shards target under ten minutes of uncontended shard-job execution.
That figure is the `test-shard` job. The job waits for classification and the
build, and the portable-test merge runs after the slowest shard. On 2026-10-20,
run `39451548945` measured those surrounding jobs with runner queue excluded:
classification 15 seconds, build 49 seconds, and the portable-test merge 29
seconds. Shard 1/5 of that run spent 22 seconds before built Vitest and 3
seconds after it, including `npm ci` at 9 seconds. `node_modules` is about 674
MiB, so publishing it as a shared artifact would transfer more data than that
install saves. The npm download cache stays the setup cache.
File timings from that run put the built suite at 7944 seconds of module time.
The sequencer keeps each file's full duration on one worker. Extra workers
overlap other files in the same sequence group, and `process-boundary` runs
after that group. Scheduling the unsplit inventory onto five shards peaks at
820 seconds of tests. That run's slowest built shard job lasted 925 seconds
(shard 4/5), and shard 1/5 spent 874 seconds in built Vitest, so hosted job
time sits above the file schedule. Six shards of the same inventory peak at
684 seconds of tests. Eight shards, after the 578-second loader file is split
by case count (54 cases stay in that file and 46 move into each of three
files), peak at 513 seconds of tests. With the measured 25-second shard setup,
that shard job is about 538 seconds. Adding the measured classification, build,
and merge makes an uncontended portable critical path of about 631 seconds,
still before the gap between this file schedule and hosted Vitest. Runner queue
is separate and grows when more shard jobs compete for runners.
build, and the portable-test merge runs after the slowest shard. Runner queue is
separate and grows when more shard jobs compete for runners. The npm download
cache stays the setup cache rather than a shared `node_modules` artifact.
`process-boundary` stays serial because process-tree sampling shares the host.
Raising the worker cap from two to four on this four-core host cut a six-file
acceptance sample from 96–101 seconds to 71 seconds and did not remove the
single-file floor, so the cap stays two. Full qualification keeps six covered
Hosted CI jobs use four workers; other hosts stay at two
because they are shared with other checks. Full qualification keeps six covered
shards; its longer instrumentation is outside this fast budget.
The separate `CI timings` workflow batches recent completed CI and Release runs
@@ -215,7 +215,10 @@ Resolution follows dyld:
image that loaded it, up to the process root.
- Dependencies load dependents-first in load-command order, and each image
loads once per process. A later @rpath request whose install name matches an
already loaded image reuses it.
already loaded image reuses it. Loading an image and expanding its dependencies
are separate: an already loaded sibling can first be expanded through another
dependency, inheriting that traversal's rpath stack. Cycles do not expand an
image twice, and lazy edges do not establish a launch-time traversal chain.
- Symlinks such as `Versions/Current` are followed inside the analyzed root.
An escape through `..` or an absolute link is `escapes-target`.
- Dyld path components are POSIX names. On Windows hosts, link text uses `\`
@@ -252,8 +255,12 @@ one of:
A `conditional` image is still traced, because it is an in-root candidate
that may load. Every edge below it has `loader_conditional: true`, and a
request that reuses it by install name is `conditional` too. An in-root dylib
with no platform load command remains unknown unless the root is an executable
request that reuses it by install name is `conditional` too. A definitely loaded
image can also first expand through a conditional chain: its dependency edges
then have `loader_conditional: true`, while later reuse of the image retains its
definite first-load result.
An in-root dylib with no platform load command remains unknown unless the root is an executable
with observed macOS platform and the candidate's CPU type is x86_64, i386, or arm64. In that macOS context,
[Apple's dyld](https://github.com/apple-oss-distributions/dyld/blob/fd8d0c4d52320ebf64db34f3cb280310d905c5ae/mach_o/UnsafeHeader.cpp#L1137-L1145) supplies an implicit macOS platform to legacy dylibs on those CPU
types. The candidate records `platform_basis: "macos-dyld-implicit"`; the
+14 -8
View File
@@ -222,7 +222,7 @@ cannot answer, such as an address without one exact file offset, omits this
hint; follow its `remediation.action`. `recovery.kind=inspect_timeout_state` names
the operation and the REA server/provider deadline. Inspect partial Evidence
and reported cleanup state, then follow the returned remediation. Change a
request field or REA_* setting only when the returned remediation explicitly
request field or REA\_\* setting only when the returned remediation explicitly
recommends that change. Restart REA when that text says the value is read at
startup. A mention that a control does not raise the deadline is not a
recommendation to change it. When it names no setting, reduce supported work;
@@ -1065,6 +1065,17 @@ from an older request does not override a newer outcome. Request deadlines and
cancellations neither mark nor clear availability. A new provider client starts
without these outcomes.
A `provider_missing` group may also list `alternatives`: advertised tools the
current provider makes available for part of that operation's question. When
the provider does not declare `next_address` or `prev_address`, they list
whichever of `inspect_native_instruction`, `read_function_instructions` and
`procedure_assembly` qualify. These answer instruction-level questions only:
none navigates arbitrary analyzed objects such as data or addresses outside a
procedure, and neither listing order nor address plus length proves executed
control flow. Calling the missing operation returns `capability_unavailable`
with the same tools in `details.alternatives` and a remediation stating each
one's scope. Without a qualifying alternative, both keep their generic advice.
Standalone command operations (such as `inspect_with_objdump` and
`execute_rizin_command`) similarly reflect observed executable launch failures:
an unprobed executable is reported available; once a launch failure is observed,
@@ -1911,13 +1922,10 @@ support for omitted historical values.
known and differ. If only one value is known, storage comparison is unknown;
when both are omitted, storage is not compared.
<!-- BEGIN:compare-artifacts:compared-format-metadata-kinds -->
`compare_artifacts` also compares the declarations in the `format_metadata`
of UnityFS members (`unityfs-entry`), PyInstaller entries
`compare_artifacts` also compares the declarations in the `format_metadata` of <!-- BEGIN:compare-artifacts:compared-format-metadata-kinds -->UnityFS members (`unityfs-entry`), PyInstaller entries
(`pyinstaller-entry`), Node SEA members (`node-sea-entry`),
ESP image segments (`esp-image-segment`), DfuSe elements (`dfuse-element`),
ESP flash partitions (`esp-flash-partition`) and FIT images (`fit-image`);
ESP flash partitions (`esp-flash-partition`) and FIT images (`fit-image`)<!-- END:compare-artifacts:compared-format-metadata-kinds -->;
each format's guide names the compared fields. A changed declaration is a
metadata change, including when content is unavailable. A declaration that
only one side records, or that the other side records as another kind, is
@@ -1928,8 +1936,6 @@ container or from caller options that identify the target, or repeat
occurrence fields, except Godot PCK source paths, which
[Godot PCK archives](../godot-pck.md) explains.
<!-- END:compare-artifacts:compared-format-metadata-kinds -->
Every occurrence records `entry_kind`, `executable` and `encrypted`. A
different entry kind or `encrypted` value is a `metadata` difference, and a
different `executable` value is an `executable` difference. Occurrence and node
+6
View File
@@ -96,6 +96,10 @@ Persistent command Evidence retains the caller-selected and resolved startup pat
Session status retains up to 64 recent protocol frames. Each history entry keeps a complete-character UTF-8 prefix of at most 16 KiB, plus a truncation marker when needed; `recent_output_truncated` reports omitted history. This history budget also accounts for replacement characters from invalid UTF-8. It does not shorten command Evidence, whose output budget defaults to 16 MiB. Rizin debug startup waits 30000 ms with no runtime override. Each command frame defaults to 120000 ms; its deadline, command-output budget, session capacity and pending-command queue use the [shared debugger launch settings](#debugger-launch-settings). The 16777216-byte frame cap and 4096-frame command cap remain fixed. Evidence names the cap that stopped the command. The one-shot `command_timeout_ms` setting does not change a debug session.
### Stopped debugger sessions
A Rizin or GDB session stops accepting commands when REA stops it, for example after an output or frame limit, a command deadline, cancellation or a protocol failure, or when the debugger process exits. REA keeps the stopped session and the first reason it observed until `close_rizin_debug_session` or `close_gdb_session` releases it, including after a retried cleanup succeeds. `rizin_debug_session_status` and `gdb_session_status` then report `state: "closed"` with that reason in `closed_reason`. A later `rizin_debug_command` or `gdb_console` on the session fails with `capability_unavailable`; `details.reason` carries the same reason, and the remediation is to close the session and start a new one. A `session_id` that this server never returned, or that close already released, fails with `invalid_request` instead.
### Debugger launch settings
GDB and Rizin share these settings in the environment that launches REA:
@@ -136,6 +140,8 @@ rea debug-with-rizin --inferior-tty /dev/pts/7 ./program dr
rea execute-rizin-command ./program iI
```
Rizin exits with status 0 even when its shell refuses a statement, so the exit code does not show whether the command ran. Rizin 0.6 through 0.9.1 log one of four stderr lines for such a refusal: `ERROR: Command '…' does not exist.`, ``ERROR: Wrong number of arguments passed to `…`…``, `ERROR: core: Error while executing command: …` (an invalid statement), or `ERROR: core: Error while parsing command: …`. Rizin then skips the refused statement and every later one in the command. When the completed process's stderr contains one of these lines, REA reports `completion_status: "rejected"` and adds a limitation naming the refusal. stdout then holds only the output of earlier statements, so empty stdout is not an empty result. Other `ERROR:` lines, such as `ERROR: Cannot find function at …`, come from commands that ran; REA keeps them in stderr and reports `complete`. A command that lowers `log.level` or redirects stderr hides refusals from REA.
Captured stdout and stderr use the same `max_output_bytes` (CLI: `--max-output-bytes`) admission and recovery described above, default 16 MiB. One-shot commands independently use `command_timeout_ms`. Raising output admission cannot recover an earlier capture; command effects and cleanup must be checked before another invocation.
### Decoder and map inputs
@@ -10,8 +10,8 @@
4. Verifier: `appTaskPython.ts` renders `READER_RUNNER`, `READERS`,
`_reader_views` and `evaluate_with_readers` only for a task with readers,
so a task without renders as before.
5. Application: `readTaskReaderViews` and `readerViewsOfBytes` in
`DesktopTaskReaders.ts`; `runReaderHere` in `src/desktop/TaskReaderProcess.ts`;
5. Application: `readerViewsOfBytes` in `DesktopTaskReaders.ts`;
`runReaderHere` in `src/desktop/TaskReaderProcess.ts`;
the witness runs readers through `AgentEnvComputer.exec`; the export runs
them on `shippedSeedBytes` and the end's retained content.
6. Docs: the tasks reference, the witness page, the build skill's recipes for
+8 -22
View File
@@ -842,28 +842,14 @@ branches are not producers. A tier with no qualifying artifact leaves history
unset, and Vitest uses its own assignment. A later schedule that reuses
qualification without uploading timings leaves the previous artifact in place.
On 2026-10-21 the newest full history was run
`39545047294`, recorded before compiled-CLI session reuse. Fast run
`39552964873` packed its eight built shards from that history, and those shard
jobs executed for 319–591 seconds. Replaying that run's file durations through
the sequencer's two-worker schedule balances every shard at about 404 seconds
of file time. The same replay with the stale history weights leaves the slowest
shard near 547 seconds and the fastest near 293. That spread is file-schedule
time, not runner queue, which is why each tier keeps its own history.
CI assigns discovered files by historical duration, with a deterministic
fallback weight for new files, rather than dividing only by file count.
Each file keeps its full duration on one worker. Extra workers overlap other
files in the same sequence group, and `process-boundary` runs after that
group. History published before the competing-loader split still names only
`javascriptLiteralPercentPaths.test.ts`. Until an artifact records
`javascriptLiteralLoaderOwnership.test.ts`, the sequencer assigns that
recorded duration by the pre-split case counts: 54/192 stays with the
percent-path file and 138/192 goes to the consolidated loader file. History
that names the retired Early, Middle, and Late shards sums those durations
onto the consolidated file instead, and scales a partial set as three equal
shares. That weight is the old dual-surface cost. The demoted file runs every
loader once through the shared stdio MCP session, so the first schedule
overestimates it. Every discovered file still belongs to exactly one shard;
Each tier keeps its own history because stale weights from another tier, or
from before a large test change, unbalance the shards.
CI assigns discovered files by historical duration rather than dividing only
by file count. A file with no recorded duration, such as a newly added or
renamed test, is weighted at the median recorded duration until a published
artifact records it. Each file keeps its full duration on one worker. Extra
workers overlap other files in the same sequence group, and `process-boundary`
runs after that group. Every discovered file still belongs to exactly one shard;
worker limits and project ordering remain unchanged. Fast qualification keeps
eight built shards without coverage. The merge job rejects successful reports
with missing, unexpected, or duplicate portable files. Coverage instrumentation
+1
View File
@@ -3,6 +3,7 @@
"entry": [
"src/main.ts",
"src/cli.ts",
"src/cliOneShot.ts",
"src/cliOutput.ts",
"src/cliBuiltinArguments.ts",
"src/cli/cliResultOutput.ts",
+1
View File
@@ -3,6 +3,7 @@
"entry": [
"src/main.ts!",
"src/cli.ts!",
"src/cliOneShot.ts!",
"src/cliOutput.ts!",
"src/cliBuiltinArguments.ts!",
"src/cli/cliResultOutput.ts!",
+3 -2
View File
@@ -5,7 +5,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
/**
* Reduce filesystem resolution during CLI and MCP startup. Keep dependencies
* external, dynamic imports lazy, and original compiled worker entrypoints.
* external and original compiled worker entrypoints. Dynamic imports can split
* into lazy chunks unless the same module is also statically reachable.
*/
export const bundleRuntime = async (root) => {
const compiledRoot = resolve(root, "dist");
@@ -23,7 +24,7 @@ export const bundleRuntime = async (root) => {
"cli-runtime",
{
cli: "cli.js",
cliOutput: "cliOutput.js",
cliOneShot: "cliOneShot.js",
cliBuiltinArguments: "cliBuiltinArguments.js",
cliResultOutput: "cli/cliResultOutput.js",
mcpDoctor: "mcpDoctor.js",
+1 -1
View File
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
+5
View File
@@ -19,6 +19,11 @@ if (
const child = spawn(
process.execPath,
[
// Reuse the development runner's POSIX process-group owner. Windows
// source tests must remain usable without the built native Job Object.
...(process.platform === "win32"
? []
: [resolve("scripts/run-exclusive.mjs"), "--unlocked", process.execPath]),
resolve("node_modules/vitest/vitest.mjs"),
"run",
...projects.flatMap(({ name }) => ["--project", name]),
+11
View File
@@ -2,6 +2,17 @@ import type {
Client,
CallToolRequestOptions,
} from "@modelcontextprotocol/client";
import type { VerifierCommands } from "./verify-commands.mjs";
/** Keep the opened target and its owned stdio server alive through verification. */
export function withArtifactMcp(
target: string,
verify: (client: Client) => Promise<void>,
options?: {
readonly commands?: VerifierCommands;
readonly signal?: AbortSignal;
},
): Promise<void>;
/** Call with the server's advertised schema; callers cannot replace it. */
export function artifactMcpCall(
+1
View File
@@ -15,6 +15,7 @@ export function parseDevelopmentTestRequest(
export function developmentTestPlan(
request: DevelopmentTestRequest,
baseCommit?: string,
resolvedPaths?: readonly string[],
): {
readonly needsBuild: boolean;
readonly artifactTasks: readonly string[];
+7 -3
View File
@@ -58,7 +58,11 @@ const artifactConsumers = {
};
/** Plan source feedback or explicit tests with their runtime/artifact prerequisites. */
export const developmentTestPlan = (request, baseCommit) => {
export const developmentTestPlan = (
request,
baseCommit,
resolvedPaths = request.paths,
) => {
const explicit = request.paths.length > 0;
if (
request.mode === "changed" &&
@@ -70,12 +74,12 @@ export const developmentTestPlan = (request, baseCommit) => {
? []
: SOURCE_TEST_PROJECTS.map(({ name }) => name);
return {
needsBuild: request.paths.some(
needsBuild: resolvedPaths.some(
(path) => projectForTest(path).group === "built",
),
artifactTasks: Object.entries(artifactConsumers).flatMap(
([task, consumers]) =>
request.paths.some((path) => consumers.includes(path)) ? [task] : [],
resolvedPaths.some((path) => consumers.includes(path)) ? [task] : [],
),
vitestArguments: [
"run",
+5 -2
View File
@@ -10,7 +10,6 @@ import { access, readFile, readdir, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { GHIDRA_ANNOTATIONS_RETAINED } from "../../dist/ghidra/GhidraProviderClient.js";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { OFFICIAL_TOOL_CONTRACTS } from "../../dist/contracts/officialToolContracts.js";
@@ -571,7 +570,11 @@ export async function verifyGhidraBoundaries(
// The producer reason describes the failure; continuing-session guidance
// is projected separately and the following live calls verify it.
assert.match(regexError.details.reason, /stack while matching pattern/u);
assert.ok(regexError.message.includes(GHIDRA_ANNOTATIONS_RETAINED));
assert.ok(
regexError.message.includes(
"The active analysis session and annotations remain available.",
),
);
rejectedCalls++;
const nestedPattern = "(".repeat(12000) + "a" + ")".repeat(12000);
const compileFailure = await client.callTool(
+35 -13
View File
@@ -2,10 +2,7 @@ import { randomUUID } from "node:crypto";
import type { RetainedResourceScope } from "../../src/application/AnalysisProvider.js";
import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js";
import {
ArtifactResourceScope,
type ArtifactResourceOwner,
} from "../../src/artifacts/ArtifactResourceScope.js";
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
import {
prepareOwnedCommand,
runOwnedCommand,
@@ -61,6 +58,7 @@ export interface VerifierCommands {
arguments_: readonly string[],
options?: VerifierCommandOptions,
): Promise<VerifierCommandOutput>;
/** Retain a live resource until close, independently of ordinary command execution. */
adopt(owner: VerifierCleanupOwner, resource: string): void;
readonly cleanupResources: readonly string[];
close(): Promise<void>;
@@ -152,13 +150,17 @@ const commandLimits = (options: VerifierCommandOptions) => {
};
};
type AdoptedVerifierOwner = {
readonly kind: "lease";
readonly resource: string;
readonly lease: AnalysisCleanupLease;
};
class VerifierCommandOwner implements VerifierCommands {
readonly #resources = new ArtifactResourceScope();
readonly #controller = new AbortController();
readonly #adopted = new WeakMap<
VerifierCleanupOwner,
ArtifactResourceOwner
>();
readonly #adopted = new WeakMap<VerifierCleanupOwner, AdoptedVerifierOwner>();
readonly #liveAdopted = new Set<AdoptedVerifierOwner>();
readonly #resourceOwner: RetainedResourceScope;
#closed = false;
#released = false;
@@ -170,13 +172,21 @@ class VerifierCommandOwner implements VerifierCommands {
) => Promise<SpawnedOwnedProviderProcess>,
) {
this.#resourceOwner = {
retain: (owner, resource) => this.adopt(owner, resource),
retain: (owner, resource) =>
this.#resources.retain(this.#adoptedOwner(owner, resource)),
run: (work) => this.#resources.run(work),
};
}
get cleanupResources(): readonly string[] {
return this.#resources.cleanupResources;
return [
...new Set([
...this.#resources.cleanupResources,
...[...this.#liveAdopted].flatMap(({ lease, resource }) =>
lease.released ? [] : (lease.cleanupResources ?? [resource]),
),
]),
];
}
exec(
@@ -233,6 +243,15 @@ class VerifierCommandOwner implements VerifierCommands {
}
adopt(owner: VerifierCleanupOwner, resource: string): void {
const retained = this.#adoptedOwner(owner, resource);
if (this.#closed) this.#resources.retain(retained);
else this.#liveAdopted.add(retained);
}
#adoptedOwner(
owner: VerifierCleanupOwner,
resource: string,
): AdoptedVerifierOwner {
if (this.#released)
throw new Error("Verifier command resource release has completed");
let retained = this.#adopted.get(owner);
@@ -244,15 +263,18 @@ class VerifierCommandOwner implements VerifierCommands {
};
this.#adopted.set(owner, retained);
}
// An admitted command can transfer a late preparation/cleanup capability
// while close is joining it. Acquisition must precede this scope's release.
this.#resources.retain(retained);
return retained;
}
close(): Promise<void> {
this.#closed = true;
this.#controller.abort(new Error("Verifier command owner closed"));
if (this.#closing !== undefined) return this.#closing;
// Live adoption owns a lifetime, not a failed cleanup. Only closure moves
// these leases into the retry scope; command failures enter it immediately.
// Late transfers during admitted work still join that same close below.
for (const owner of this.#liveAdopted) this.#resources.retain(owner);
this.#liveAdopted.clear();
let attempt: Promise<void>;
attempt = this.#resources
.close()
+1 -1
View File
@@ -54,7 +54,7 @@ if (await pathExists(join(root, "src"))) {
"dist/main.js",
"dist/mcp/main.js",
"dist/cli-runtime/cli.js",
"dist/cli-runtime/cliOutput.js",
"dist/cli-runtime/cliOneShot.js",
"dist/cli-runtime/cliBuiltinArguments.js",
"dist/cli-runtime/cliResultOutput.js",
"dist/cli-runtime/mcpDoctor.js",
+54 -142
View File
@@ -64,7 +64,7 @@ const runtimeFiles = [
? ["dist/mcp/main.js", "dist/cli-runtime/mcpDoctor.js"]
: [
"dist/cli-runtime/cli.js",
"dist/cli-runtime/cliOutput.js",
"dist/cli-runtime/cliOneShot.js",
"dist/cli-runtime/cliResultOutput.js",
]),
];
@@ -104,154 +104,66 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
);
}
} else {
const {
cliConfigurationError,
renderCliOutputArgumentError,
renderEmptyFilteredCliOutput,
renderVersionReport,
parseCliOutputArguments,
} = await import("../dist/cli-runtime/cliOutput.js");
const { runOneShotCli } = await import("../dist/cli-runtime/cliOneShot.js");
guardClosedStdout();
const cliArgs = isShellCompletion
? args
: builtinArguments.normalizeCliArguments(args);
// Completion words are transport data, including partial output flags.
const outputOptions = parseCliOutputArguments(
isShellCompletion ? [] : cliArgs,
);
const outputArguments = outputOptions.validation;
// `--version` with an explicit format reports the runtime as a document;
// plain `--version` stays Incur's bare version string.
const versionOutput = renderVersionReport(
outputOptions,
{
packageName: packageJson.name,
packageVersion: packageJson.version,
versions: process.versions,
platform: process.platform,
architecture: process.arch,
},
process.stdout.isTTY === true,
);
const configurationError = cliConfigurationError(process.env);
let dispatchFailure;
let closeCliResources;
// Incur can exit from the callback below before this function returns.
// That callback owns the status, including a closed-pipe exit of 141.
let exiting = false;
try {
// Doctor is the readiness report for this failure. Other commands still stop
// here so they do not run with an unusable log level.
if (
configurationError !== undefined &&
(isShellCompletion ||
builtinArguments.splitIncurBuiltinArguments(cliArgs).command[0] !==
"doctor")
) {
process.stderr.write(`${configurationError}\n`);
process.exitCode = 1;
} else if (!outputArguments.ok) {
writeStdout(renderCliOutputArgumentError(outputArguments));
process.exitCode = 1;
} else if (versionOutput !== undefined) {
writeStdout(versionOutput);
} else if (
!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.
//
// 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.";
const refusalFormat = outputOptions.format;
if (
refusalFormat === "json" ||
refusalFormat === "jsonl" ||
refusalFormat === "yaml"
) {
writeStdout(
renderCliOutputArgumentError({
ok: false,
format: refusalFormat,
code: "AMP_PROJECT_SCOPE_UNSUPPORTED",
message: refusalMessage,
}),
);
} else {
process.stderr.write(`${refusalMessage}\n`);
}
process.exitCode = 1;
} else {
const cliRuntime = await import("../dist/cli-runtime/cli.js");
closeCliResources = cliRuntime.closeCliResources;
const { createCli } = cliRuntime;
const { createCliResultOutput } =
await import("../dist/cli-runtime/cliResultOutput.js");
let wroteOutput = false;
const resultOutput = createCliResultOutput(outputOptions, process.stdout);
try {
await createCli(process.env, resultOutput).serve(cliArgs, {
stdout: (output) => {
if (resultOutput?.handled) {
if (resultOutput.failed && !stdoutClosed)
process.stderr.write(output);
return;
}
if (output.length > 0) wroteOutput = true;
if (resultOutput?.captureSdkOutput(output)) return;
writeStdout(output);
},
// A command failure caused by the closed pipe keeps the pipe's status.
// Worker shutdown is shared with the dispatch finally below. A failed
// shutdown is reported here because this callback is what exits.
exit: (code) => {
exiting = true;
void (async () => {
let status = typeof code === "number" ? code : 0;
try {
await closeCliResources();
} catch (cleanup) {
process.stderr.write(
`Failed to close CLI resources: ${cleanupFailureText(cleanup)}\n`,
);
if (status === 0) status = 1;
}
try {
await resultOutput?.finishSdkOutput(status);
} catch (cause) {
if (!stdoutClosed)
process.stderr.write(`${cleanupFailureText(cause)}\n`);
if (status === 0) status = 1;
}
process.exit(stdoutClosed ? SIGPIPE_EXIT_STATUS : status);
})();
},
});
} catch (cause) {
if (wroteOutput) throw cause;
const recovered = await resultOutput
?.recoverFormattingFailure(cause)
.catch((writeFailure) => {
// Recovery writes outside Incur's command boundary. A reader that
// declined this document keeps the same quiet SIGPIPE outcome.
if (!stdoutClosed) throw writeFailure;
return true;
});
if (!recovered) throw cause;
process.exitCode = stdoutClosed ? SIGPIPE_EXIT_STATUS : 1;
}
if (!wroteOutput && !resultOutput?.handled)
writeStdout(renderEmptyFilteredCliOutput(outputOptions) ?? "");
// An SDK failure exits through the callback, which owns its pending write.
if (!exiting)
await resultOutput?.finishSdkOutput(
typeof process.exitCode === "number" ? process.exitCode : 0,
);
}
await runOneShotCli(args, {
environment: process.env,
runtime: {
packageName: packageJson.name,
packageVersion: packageJson.version,
versions: process.versions,
platform: process.platform,
architecture: process.arch,
},
stdoutIsTerminal: process.stdout.isTTY === true,
resultDestination: process.stdout,
closedStdoutStatus: SIGPIPE_EXIT_STATUS,
writeStdout,
writeStderr: (text) => {
process.stderr.write(text);
},
stdoutClosed: () => stdoutClosed,
exitCode: () =>
typeof process.exitCode === "number" ? process.exitCode : 0,
setExitCode: (code) => {
process.exitCode = code;
},
ownCliResources: (close) => {
closeCliResources = close;
},
exitRequested: () => exiting,
// A command failure caused by the closed pipe keeps the pipe's status.
// Worker shutdown is shared with the dispatch finally below. A failed
// shutdown is reported here because this callback is what exits.
exit: (code, finishOutput) => {
exiting = true;
void (async () => {
let status = code;
try {
await closeCliResources();
} catch (cleanup) {
process.stderr.write(
`Failed to close CLI resources: ${cleanupFailureText(cleanup)}\n`,
);
if (status === 0) status = 1;
}
try {
await finishOutput(status);
} catch (cause) {
if (!stdoutClosed)
process.stderr.write(`${cleanupFailureText(cause)}\n`);
if (status === 0) status = 1;
}
process.exit(stdoutClosed ? SIGPIPE_EXIT_STATUS : status);
})();
},
});
} catch (cause) {
if (cause?.code === "EPIPE") {
stdoutClosed = true;
+17 -3
View File
@@ -1,12 +1,13 @@
import { spawn } from "node:child_process";
import { stat } from "node:fs/promises";
import { resolve } from "node:path";
import { realpath, stat } from "node:fs/promises";
import { relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import {
developmentTestPlan,
parseDevelopmentTestRequest,
} from "./lib/development-tests.mjs";
import { projectForTest } from "./lib/test-projects.mjs";
let child;
let forceTimer;
@@ -29,9 +30,22 @@ for (const signal of Object.keys(signalExitCodes))
try {
const [mode, ...arguments_] = process.argv.slice(2);
const request = parseDevelopmentTestRequest(mode, arguments_);
const resolvedPaths = [];
const repository = await realpath(".");
for (const path of request.paths) {
if (!(await stat(path)).isFile())
throw new Error(`Test path is not a file: ${path}`);
// Ownership follows the filesystem's canonical spelling. Execution keeps
// the caller path so exact discovery still rejects excluded symlink aliases.
const resolvedPath = relative(repository, await realpath(path))
.split(sep)
.join("/");
if (
request.mode !== "focused" &&
projectForTest(resolvedPath).group !== "source"
)
throw new Error(`Use test:focused for built test projects: ${path}`);
resolvedPaths.push(resolvedPath);
}
let baseCommit;
if (request.mode === "changed") {
@@ -42,7 +56,7 @@ try {
);
baseCommit = base.output.trim();
}
const plan = developmentTestPlan(request, baseCommit);
const plan = developmentTestPlan(request, baseCommit, resolvedPaths);
if (request.dryRun) {
console.log(JSON.stringify({ ...request, baseCommit, ...plan }, null, 2));
} else {
+44 -30
View File
@@ -1,22 +1,23 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { isUtf8 } from "node:buffer";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../dist/domain/evidence.js";
import { goBinarySchema } from "../dist/domain/go/goBinary.js";
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js";
import { artifactMcpCall } from "./lib/artifact-e2e.mjs";
import { requireMcpOutput } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { createOwnedMcpStdioTransport } from "./lib/owned-mcp-stdio.mjs";
import { closeVerifierResources } from "./lib/verifier-cleanup.mjs";
const execute = promisify(execFile);
const commands = await createVerifierCommands();
const compiler = process.env.REA_VERIFY_GO_COMMAND ?? "go";
if (process.env.REA_VERIFY_GO_COMMAND !== undefined && !isAbsolute(compiler))
throw new Error(
@@ -51,13 +52,16 @@ const goEnvironment = {
// Go's own verifier seam isolates telemetry; no global configuration is changed.
TEST_TELEMETRY_DIR: join(root.path, "telemetry"),
};
const client = new Client({ name: "go-binary-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: environment,
stderr: "pipe",
});
let client;
let transport;
const cleanup = new AnalysisCleanupLease(async () => {
await closeVerifierResources([
() => client?.close(),
() => transport?.close(),
() => commands.close(),
]);
await root.close();
}, [root.path]);
const targets = [
["linux", "amd64", "elf", 64, "little"],
["linux", "386", "elf", 32, "little"],
@@ -73,10 +77,19 @@ const verified = [];
const omittedCoverage = [];
const failures = [];
try {
client = new Client({ name: "go-binary-verifier", version: "1" });
transport = await createOwnedMcpStdioTransport({
command: process.execPath,
arguments: [entrypoint, "mcp"],
hostEnvironment: environment,
cwd: process.cwd(),
expectedCommand: null,
sweepTokenOwnedProcesses: true,
});
await prepareFixture();
try {
compilerVersion = (
await execute(compiler, ["version"], {
await commands.exec(compiler, ["version"], {
cwd: root.path,
env: goEnvironment,
timeout: 10_000,
@@ -185,7 +198,7 @@ try {
const path = join(root.path, "linux-amd64-byte-strings");
const expected = JSON.parse(
(
await execute(oracle, ["--build-byte-fixture", compiler, path], {
await commands.exec(oracle, ["--build-byte-fixture", compiler, path], {
cwd: sourceRoot,
env: { ...goEnvironment, GOOS: "linux", GOARCH: "amd64" },
timeout: 120_000,
@@ -198,21 +211,16 @@ try {
} catch (cause) {
failures.push(cause);
} finally {
for (const close of [
() => client.close(),
() => transport.close(),
() => root.close(),
]) {
try {
await close();
} catch (cause) {
failures.push(cause);
}
try {
await cleanup.release();
} catch (cause) {
failures.push(cause);
}
}
const verifier = await completeVerifierRun(run);
try {
await assert.rejects(lstat(root.path), { code: "ENOENT" });
if (cleanup.released)
await assert.rejects(lstat(root.path), { code: "ENOENT" });
if (verifier.process_lineage.status === "verified")
assert.deepEqual(verifier.process_lineage.descendants, []);
} catch (cause) {
@@ -226,15 +234,21 @@ if (failures.length > 0) {
compiler: compilerVersion,
verified,
omitted_coverage: omittedCoverage,
retained_root: cleanup.released ? null : root.path,
verifier,
},
null,
2,
),
);
throw new AggregateError(
failures,
"Real Go metadata verification failed; cleanup failures are retained.",
throw Object.assign(
new AggregateError(
failures,
cleanup.released
? "Real Go metadata verification failed; cleanup failures are retained."
: `Real Go metadata verification failed; cleanup is incomplete at ${root.path}.`,
),
{ cleanupLeases: cleanup.released ? [] : [cleanup] },
);
}
console.log(
@@ -256,7 +270,7 @@ console.log(
);
async function go(args, overrides = {}) {
return execute(compiler, args, {
return commands.exec(compiler, args, {
cwd: sourceRoot,
env: { ...goEnvironment, ...overrides },
timeout: 120_000,
@@ -356,7 +370,7 @@ async function inspect(mode, path, includeStringLiterals = false) {
...(includeStringLiterals ? ["--include-string-literals"] : []),
"--json",
];
const { stdout } = await execute(process.execPath, args, {
const { stdout } = await commands.exec(process.execPath, args, {
env: environment,
timeout: 30_000,
maxBuffer: 16 * 1024 * 1024,
@@ -424,7 +438,7 @@ async function verify(
byteExpected ??
JSON.parse(
(
await execute(oracle, [oraclePath], {
await commands.exec(oracle, [oraclePath], {
timeout: 10_000,
maxBuffer: 1024 * 1024,
})
-2
View File
@@ -1,4 +1,3 @@
import { deepStrictEqual } from "node:assert";
import {
lstat,
mkdir,
@@ -13,7 +12,6 @@ import { dirname, isAbsolute, join } from "node:path";
import { parse as parseToml } from "smol-toml";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import { PRODUCT_IDENTITY } from "../dist/identity.js";
import { pathExists } from "./lib/verify-package-core.mjs";
import { parse as parseJsonc } from "jsonc-parser";
import { skillReferenceIssues } from "./lib/docs-facts.mjs";
+3 -5
View File
@@ -99,8 +99,7 @@ async function verifyPublishedUpdate({
environment,
);
await commands.exec(
"npm",
await commands.execNpm(
[
"install",
"--global",
@@ -118,7 +117,7 @@ async function verifyPublishedUpdate({
);
const npmRoot = (
await commands.exec("npm", ["root", "--global"], {
await commands.execNpm(["root", "--global"], {
cwd: canaryRoot,
env: environment,
timeout: 30000,
@@ -186,8 +185,7 @@ if (result.status === "failed") process.exitCode = 1;
}
async function findPreviousPublishedVersion(targetVersion, cwd, environment) {
const { stdout } = await commands.exec(
"npm",
const { stdout } = await commands.execNpm(
["view", "rea-agents", "versions", "--json"],
{ cwd, env: environment, timeout: 30000, maxBuffer: 16 * 1024 * 1024 },
);
-4
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { readFile, mkdtemp, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -13,7 +12,6 @@ import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import { readGhidraLanguageCatalog } from "../dist/ghidra/GhidraLanguageCatalog.js";
import { parseEvidence } from "../dist/domain/evidence.js";
import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
import { functionDossierSchema } from "../dist/domain/native/functionDossier.js";
import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs";
import { buildGameCubeDolFixture } from "../tests/conformance/ghidra/dol-fixture.mjs";
@@ -96,8 +94,6 @@ async function languageInstalled(languageId) {
}
}
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
/** One MCP session plus CLI check. `languageOverride` is set only for the Gekko pass. */
async function runPass({ fixture, languageOverride, label, layout }) {
const env = { ...baseEnv };
+2 -7
View File
@@ -151,7 +151,7 @@ export async function verifyAndroidFailureRecovery({
failure_code: "runtime_unavailable",
},
availabilityReason: "provider_unavailable",
availabilityRemediation: /selected Java runtime/u,
availabilityRemediation: /selected Java launcher/u,
message: /selected Java runtime/u,
remediation: /selected launcher/u,
});
@@ -194,12 +194,7 @@ export async function verifyAndroidFailureRecovery({
repository,
available: false,
unavailableReason: scenario.availabilityReason ?? "provider_missing",
remediation:
scenario.availabilityRemediation ??
new RegExp(
scenario.observation.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"),
"u",
),
remediation: scenario.availabilityRemediation ?? scenario.remediation,
});
console.log(`PASS CLI applicable recovery: ${scenario.label}`);
}
+40 -11
View File
@@ -94,29 +94,36 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
ts.forEachChild(node, collect);
};
collect(sourceFile);
const potential = (node) => {
const potential = (node, matchesCandidate) => {
const expression = unwrapExpression(node);
if (exactLimit(literalText(expression)) !== undefined) return true;
if (ts.isIdentifier(expression)) return candidates.has(expression.text);
if (ts.isIdentifier(expression)) return matchesCandidate(expression);
if (ts.isPropertyAccessExpression(expression))
return (
exactLimit(expression.name.text) !== undefined ||
potential(expression.expression)
potential(expression.expression, matchesCandidate)
);
if (ts.isElementAccessExpression(expression))
return (
expression.argumentExpression !== undefined &&
potential(expression.argumentExpression)
potential(expression.argumentExpression, matchesCandidate)
);
if (ts.isObjectLiteralExpression(expression))
return expression.properties.some(
(property) =>
ts.isPropertyAssignment(property) && potential(property.initializer),
ts.isPropertyAssignment(property) &&
potential(property.initializer, matchesCandidate),
);
if (ts.isConditionalExpression(expression))
return potential(expression.whenTrue) || potential(expression.whenFalse);
return (
potential(expression.whenTrue, matchesCandidate) ||
potential(expression.whenFalse, matchesCandidate)
);
if (ts.isBinaryExpression(expression))
return potential(expression.left) || potential(expression.right);
return (
potential(expression.left, matchesCandidate) ||
potential(expression.right, matchesCandidate)
);
return false;
};
let changed = true;
@@ -125,13 +132,34 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
for (const declaration of declarations) {
if (
!candidates.has(declaration.name.text) &&
potential(declaration.initializer)
potential(declaration.initializer, (identifier) =>
candidates.has(identifier.text),
)
) {
candidates.add(declaration.name.text);
changed = true;
}
}
}
// Names narrow the search cheaply, but only the lexical declaration can
// establish that a particular use is an alias of a limit.
const lexicalCandidates = new Set();
changed = true;
while (changed) {
changed = false;
for (const declaration of declarations) {
if (
candidates.has(declaration.name.text) &&
!lexicalCandidates.has(declaration) &&
potential(declaration.initializer, (identifier) =>
lexicalCandidates.has(symbols(identifier)?.valueDeclaration),
)
) {
lexicalCandidates.add(declaration);
changed = true;
}
}
}
const unknown = (node, reason) => {
const path = sourceFile.fileName;
const offset = node.getStart();
@@ -152,11 +180,12 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
declaration === undefined ||
!ts.isVariableDeclaration(declaration) ||
!ts.isVariableDeclarationList(declaration.parent) ||
declaration.initializer === undefined
declaration.initializer === undefined ||
!lexicalCandidates.has(declaration)
)
return undefined;
if ((declaration.parent.flags & ts.NodeFlags.Const) === 0) {
unknown(identifier, "mutable setting-key or value alias");
unknown(declaration.name, "mutable setting-key or value alias");
return undefined;
}
if (declaration.pos >= identifier.pos) {
@@ -890,7 +919,7 @@ if (direct) {
const entries = parseNumericLimitRegistry(registry);
const listed = new Map();
for (const entry of entries) {
const { name, policy, read, reason } = entry;
const { name, read, reason } = entry;
if (listed.has(name)) failures.push(`${name} is listed twice`);
listed.set(name, entry);
if (reason.trim().length < 40)
+31 -6
View File
@@ -20,6 +20,7 @@ it.each([
source:
'process.stdout.write("partial-雪"); process.stderr.write("waiting\\n"); setInterval(() => {}, 1000)',
code: "provider_timeout",
timeoutMs: 500,
stdout: "partial-雪",
stderr: "waiting\n",
},
@@ -28,6 +29,7 @@ it.each([
source:
'process.stdout.write("partial-雪"); process.stderr.write("adb: error: device offline\\n"); process.exitCode = 1',
code: "unsupported_target",
expectedPath: "caller-device",
stdout: "partial-雪",
stderr: "adb: error: device offline\n",
},
@@ -36,6 +38,7 @@ it.each([
source:
'process.stdout.write("partial-雪"); process.stderr.write("adb: error: cannot stat \'/host/token file\': Permission denied\\n"); process.exitCode = 1',
code: "unsupported_target",
expectedPath: "/host/token file",
stdout: "partial-雪",
stderr: "adb: error: cannot stat '/host/token file': Permission denied\n",
},
@@ -54,7 +57,7 @@ it.each([
},
["-e", testCase.source],
{
timeoutMs: testCase.name === "timeout" ? 500 : 5_000,
timeoutMs: testCase.timeoutMs ?? 5_000,
maxOutputBytes: testCase.maxOutputBytes ?? 1024,
},
undefined,
@@ -75,12 +78,34 @@ it.each([
},
});
if (testCase.code === "unsupported_target")
expect(projected.details?.path).toBe(
testCase.name === "local path refusal"
? "/host/token file"
: "caller-device",
);
expect(projected.details?.path).toBe(testCase.expectedPath);
if (testCase.code === "provider_timeout") {
expect(projected.details?.timeout_ms).toBe(testCase.timeoutMs);
expect(projected.details?.limit).toBeUndefined();
}
expect(JSON.stringify(projected)).not.toContain(
"ambient-value-must-not-be-persisted",
);
});
it.each([true, false])(
"projects a truncated adb capture as retryable only for volatile output (%s)",
async (volatileOutput) => {
const result = await runAdbCapture(
volatileOutput ? "read_adb_logcat" : "install_adb_package",
{ device: "device" },
{ binary: process.execPath, pathSource: "environment", environment: {} },
["-e", 'process.stdout.write("X".repeat(4096))'],
{ timeoutMs: 5_000, maxOutputBytes: 1024, volatileOutput },
undefined,
);
if (result.ok) throw new Error("Expected a capture allowance refusal");
const projected = projectAnalysisError(result.error);
expect(projected.retryable).toBe(volatileOutput);
expect(projected).toMatchObject({
code: "resource_constraint",
details: { reported_limits: { max_output_bytes: 1024 } },
});
expect(projected.details?.limit).toBeUndefined();
},
);
-60
View File
@@ -1,5 +1,4 @@
import { expect, it, vi } from "vitest";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
ExecFileOutputCancelledError,
execFileBytes,
@@ -212,65 +211,6 @@ it("does not infer cancellation from a foreign ABORT_ERR code", async () => {
});
});
it.each([true, false])(
"projects a truncated adb capture as retryable only for volatile output (%s)",
async (volatileOutput) => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("too much"), {
code: 1,
stdout: "",
stderr: "",
signal: null,
killed: false,
outputTruncated: true,
}),
);
const result = await runAdbCapture(
volatileOutput ? "read_adb_logcat" : "install_adb_package",
{ device: "device" },
{ binary: "/host/adb", pathSource: "environment", environment: {} },
volatileOutput ? ["logcat", "-d"] : ["install", "app.apk"],
{ timeoutMs: 100, maxOutputBytes: 1024, volatileOutput },
undefined,
);
if (result.ok) throw new Error("Expected a capture allowance refusal");
const projected = projectAnalysisError(result.error);
expect(projected.retryable).toBe(volatileOutput);
expect(projected).toMatchObject({
code: "resource_constraint",
details: { reported_limits: { max_output_bytes: 1024 } },
});
expect(projected.details?.limit).toBeUndefined();
},
);
it("reports the effective fixed adb deadline", async () => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("deadline"), {
code: "ETIMEDOUT",
stdout: "",
stderr: "",
signal: null,
killed: true,
}),
);
const result = await runAdbCapture(
"pull_adb_file",
{ device: "device" },
{ binary: "/host/adb", pathSource: "environment", environment: {} },
["pull", "/remote"],
{ timeoutMs: 600_000, maxOutputBytes: 1024 },
undefined,
);
if (result.ok) throw new Error("Expected an adb deadline");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "provider_timeout",
details: { timeout_ms: 600_000 },
});
expect(projected.details?.limit).toBeUndefined();
});
it("keeps a physical failure even when the later abort reason is that same error", async () => {
const controller = new AbortController();
const failure = new Error("physical command failure");
+1 -1
View File
@@ -191,7 +191,7 @@ fi`,
await Promise.allSettled([inspection, closing]);
await provider.close();
}
}, 15_000);
});
it("fails closed when adb exits successfully without a recognized version", async () => {
const stdout = `unexpected adb output ${"x".repeat(700)}\n`;
+5 -7
View File
@@ -84,6 +84,7 @@ import {
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { createEvidence } from "../domain/evidence.js";
import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js";
import {
PrivateRuntimeRoot,
type PrivateRuntimeRootOptions,
@@ -993,15 +994,12 @@ export class AdbProvider {
const partialObservation =
execution === undefined
? undefined
: createEvidence(execution.subject ?? undefined, execution.provider, {
: createAnalysisExecutionEvidence(
operation,
parameters,
result: execution.result,
rawResult: execution.rawResult,
limitations: execution.limitations,
locations: execution.locations,
confidence: "observed",
});
execution,
"observed",
);
return new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
resources,
+48 -32
View File
@@ -47,19 +47,32 @@ it("reports the fixed adb version-probe deadline", async () => {
});
it("reports the fixed adb version-output cap", async () => {
vi.mocked(execFileOutput).mockRejectedValueOnce(
Object.assign(new Error("too much"), {
code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
stdout: "",
stderr: "",
signal: null,
killed: false,
outputTruncated: true,
}),
);
const failure = await inspectAdbClient(selection).catch(
(cause: unknown) => cause,
);
let selected: string = selection.binary;
if (process.platform === "win32") {
vi.mocked(execFileOutput).mockRejectedValueOnce(
Object.assign(new Error("too much"), {
code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
stdout: "",
stderr: "",
signal: null,
killed: false,
outputTruncated: true,
}),
);
} else {
const root = await mkdtemp(join(tmpdir(), "rea-adb-version-cap-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
selected = join(root, "adb");
await writeFile(
selected,
`#!${process.execPath}\nprocess.stdout.write("x".repeat(100_000));\n`,
{ mode: 0o755 },
);
}
const failure = await inspectAdbClient({
...selection,
binary: selected,
}).catch((cause: unknown) => cause);
const projected = projectAnalysisError(
failure as Parameters<typeof projectAnalysisError>[0],
);
@@ -74,25 +87,28 @@ it("reports the fixed adb version-output cap", async () => {
expect(projected.details?.limit).toBeUndefined();
});
it("distinguishes execute denial from missing or unreadable version output", async () => {
vi.mocked(execFileOutput).mockRejectedValueOnce(
Object.assign(new Error("denied"), {
code: "EACCES",
spawnFailed: true,
stdout: "",
stderr: "",
signal: null,
killed: false,
}),
);
await expect(inspectAdbClient(selection)).rejects.toMatchObject({
code: "binary_not_executable",
diagnostics: {
selected_binary: selection.binary,
filesystem_error: "EACCES",
},
});
});
it.skipIf(process.platform !== "win32")(
"classifies an EACCES execute refusal on Windows",
async () => {
vi.mocked(execFileOutput).mockRejectedValueOnce(
Object.assign(new Error("denied"), {
code: "EACCES",
spawnFailed: true,
stdout: "",
stderr: "",
signal: null,
killed: false,
}),
);
await expect(inspectAdbClient(selection)).rejects.toMatchObject({
code: "binary_not_executable",
diagnostics: {
selected_binary: selection.binary,
filesystem_error: "EACCES",
},
});
},
);
it("names an EPERM execute refusal separately from missing permission", async () => {
vi.mocked(execFileOutput).mockRejectedValueOnce(
+12 -16
View File
@@ -58,7 +58,7 @@ export const androidSnapshotCopy = (
: undefined;
/** Adopt a snapshot descriptor whose close rejected, keyed by its exact path. */
export type AndroidSnapshotCloseRetention = (
type AndroidSnapshotCloseRetention = (
path: string,
owner: OwnedFileHandle,
) => void;
@@ -69,8 +69,8 @@ export const snapshotAndroidTarget = async (
sha256: string,
root: string,
operation: AndroidOperation,
signal?: AbortSignal,
retainFailedClose?: AndroidSnapshotCloseRetention,
signal: AbortSignal | undefined,
retainFailedClose: AndroidSnapshotCloseRetention,
): Promise<string> => {
const snapshot = join(root, "target.apk");
try {
@@ -105,8 +105,8 @@ export const snapshotAndroidEngine = async (
sha256: string,
root: string,
operation: AndroidOperation,
signal?: AbortSignal,
retainFailedClose?: AndroidSnapshotCloseRetention,
signal: AbortSignal | undefined,
retainFailedClose: AndroidSnapshotCloseRetention,
): Promise<{ path: string; sha256: string }> => {
const path = join(root, "engine.jar");
try {
@@ -159,17 +159,14 @@ const copyAndroidSnapshot = async (
snapshotPath: string,
expectedSha256: string,
signal: AbortSignal | undefined,
retainFailedClose: AndroidSnapshotCloseRetention | undefined,
retainFailedClose: AndroidSnapshotCloseRetention,
): Promise<AndroidSnapshotCopy> => {
assertRegularFileActive(signal);
let removeSnapshot = false;
const retain = (path: string) =>
retainFailedClose === undefined
? {}
: {
retainFailedClose: (owner: OwnedFileHandle) =>
retainFailedClose(path, owner),
};
const retain = (path: string) => ({
retainFailedClose: (owner: OwnedFileHandle) =>
retainFailedClose(path, owner),
});
try {
// Close each descriptor through its owner. A rejected close keeps the
// copy outcome and stays retained until that owner is retried; the
@@ -287,7 +284,7 @@ const openSnapshotSource = async (
path: string,
signal: AbortSignal | undefined,
retention: {
readonly retainFailedClose?: (owner: OwnedFileHandle) => void;
readonly retainFailedClose: (owner: OwnedFileHandle) => void;
},
): Promise<StableRegularFileDescriptor> => {
try {
@@ -305,10 +302,9 @@ const openSnapshotSource = async (
};
const retainAdmissionOwner = (
retain: ((owner: OwnedFileHandle) => void) | undefined,
retain: (owner: OwnedFileHandle) => void,
owner: OwnedFileHandle,
): RegularFileCleanupFailure["retention"] => {
if (retain === undefined) return { kind: "not_requested" };
try {
retain(owner);
return { kind: "callback_returned" };
+4 -4
View File
@@ -313,10 +313,10 @@ export class JadxProvider implements AndroidAnalysisPort {
: AbortSignal.any([signal, this.#shutdown.signal]),
});
this.#inspections.add(pending);
void pending.then(
() => this.#inspections.delete(pending),
() => this.#inspections.delete(pending),
);
const remove = (): void => {
this.#inspections.delete(pending);
};
void pending.then(remove, remove);
return pending;
}
+3 -3
View File
@@ -415,11 +415,11 @@ export class ApkSignatureProvider implements ApkSignaturePort {
const { v1, decoded: v1Decoded, duplicates } = v1Read.value;
const v2 = decodeScheme(block.value, APK_BLOCK_ID.signatureV2, "v2");
if (!v2.ok) return err(v2.error);
if (!v2.ok) return v2;
const v3 = decodeScheme(block.value, APK_BLOCK_ID.signatureV3, "v3");
if (!v3.ok) return err(v3.error);
if (!v3.ok) return v3;
const v31 = decodeScheme(block.value, APK_BLOCK_ID.signatureV31, "v3.1");
if (!v31.ok) return err(v31.error);
if (!v31.ok) return v31;
// Account for representation expansion before allocating the report.
const projected = projectedOutputBytes(
+16 -8
View File
@@ -1,5 +1,6 @@
import * as commandOwner from "../process/OwnedCommand.js";
import { failedInspectionDescriptor } from "../../tests/fixtures/processInspectionCleanup.js";
import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js";
import {
AnalysisError,
primaryAnalysisError,
@@ -486,6 +487,7 @@ it("cancels a pending filesystem lookup and owns its eventual rejection", async
const pendingStat = new Promise((_resolve, reject) => {
rejectLookup = reject;
});
void pendingStat.catch(() => undefined);
const observed = new Promise<void>((resolve) => {
started = resolve;
});
@@ -502,14 +504,20 @@ it("cancels a pending filesystem lookup and owns its eventual rejection", async
"win32",
controller.signal,
);
await observed;
controller.abort();
await expect(pending).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
});
rejectLookup(new Error("filesystem completed after cancellation"));
await Promise.resolve();
expect(lookup.stat).toHaveBeenCalledTimes(1);
try {
await awaitAcknowledgement(observed, pending, "apktool filesystem lookup");
controller.abort();
await expect(pending).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
});
rejectLookup(new Error("filesystem completed after cancellation"));
await Promise.resolve();
expect(lookup.stat).toHaveBeenCalledTimes(1);
} finally {
controller.abort();
rejectLookup(new Error("fixture cleanup"));
await pending.catch(() => undefined);
}
});
it("bounds the entire pending PATH lookup by the selection deadline", async () => {
@@ -58,7 +58,6 @@ it.skipIf(process.platform === "win32")(
await fixture.cleanup([probe]);
}
},
30_000,
);
let commands = new ApktoolCommandCleanup();
+23 -4
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, onTestFinished } from "vitest";
import { createDeferred } from "../../tests/fixtures/binarySession.js";
import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js";
import {
getNavigationContext,
@@ -500,7 +501,16 @@ it("waits for late facet cleanup after cancellation and retains its owner", asyn
settled = true;
return result;
});
await started.promise;
onTestFinished(async () => {
controller.abort();
late.resolve(err(failure));
try {
await pending;
} finally {
await lease.release();
}
});
await awaitAcknowledgement(started.promise, pending, "facet execution");
controller.abort();
await Promise.resolve();
expect(settled).toBe(false);
@@ -530,7 +540,12 @@ it("reports cancellation after a late successful facet that ignored its signal",
{ address: "0x401000", document: "fixture" },
controller.signal,
);
await started.promise;
onTestFinished(async () => {
controller.abort();
late.resolve(ok(createAnalysisExecution("finished", provider)));
await pending;
});
await awaitAcknowledgement(started.promise, pending, "facet execution");
controller.abort();
late.resolve(ok(createAnalysisExecution("finished", provider)));
const result = await pending;
@@ -586,7 +601,11 @@ it("preserves a provider's cancellation for the same signal", async () => {
{ address: "0x401000", document: "fixture" },
controller.signal,
);
await started.promise;
onTestFinished(async () => {
controller.abort(reason);
await pending;
});
await awaitAcknowledgement(started.promise, pending, "provider execution");
controller.abort(reason);
const result = await pending;
if (result.ok) throw new Error("expected cancellation");
+5
View File
@@ -53,6 +53,11 @@ export interface ExecutionOptions {
* supplied it.
*/
readonly selectorOrigin?: "provider";
/**
* Whether the caller's catalog advertises an operation; refusal recovery
* names only advertised operations. Absent means every operation is.
*/
readonly isOperationAdvertised?: (operation: AnalysisOperation) => boolean;
}
/** Closed operations accepted by provider-backed analysis sessions. */
-9
View File
@@ -6,7 +6,6 @@ import {
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";
@@ -366,7 +365,6 @@ it("reads an accepted override before an invalid launch seed", () => {
{
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,
@@ -375,9 +373,6 @@ it("reads an accepted override before an invalid launch seed", () => {
},
},
() => ({
bridge: resolveBridgeMaxRequestBytes({
REA_BRIDGE_MAX_REQUEST_BYTES: "nope",
}),
capture: resolveCaptureDecoderHeap({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "0",
}),
@@ -393,10 +388,6 @@ it("reads an accepted override before an invalid launch seed", () => {
}),
}),
);
expect(resolved.bridge).toMatchObject({
maximumBytes: 8192,
source: "runtime",
});
expect(resolved.capture).toMatchObject({
oldGenerationMiB: 64,
source: "runtime",
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
import { resolveAppleBundleExecutable } from "./AppBundleExecutable.js";
it.each([
["no comment", ""],
@@ -22,7 +22,7 @@ it.each([
await writeFile(infoPlist, source);
await writeFile(executable, "fixture - never executed");
expect(await resolveAppBundleExecutable(bundle)).toEqual({
expect(await resolveAppleBundleExecutable(bundle)).toEqual({
ok: true,
value: { executable, infoPlist },
});
+9 -13
View File
@@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { binaryPlistObjectTable } from "../../tests/fixtures/binaryPlistObjectTable.js";
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
import { resolveAppleBundleExecutable } from "./AppBundleExecutable.js";
const binaryPlistExecutableWithThreeByteUid = (): Buffer => {
const ascii = (value: string): Buffer => {
@@ -53,7 +53,7 @@ it.each([
);
await writeFile(executable, "inert fixture; never executed");
const result = await resolveAppBundleExecutable(bundle);
const result = await resolveAppleBundleExecutable(bundle);
expect(result).toEqual({
ok: true,
value: { executable, infoPlist: plistPath },
@@ -71,7 +71,7 @@ describe("app bundle Info.plist magic", () => {
await writeFile(plistPath, binaryPlistExecutableWithThreeByteUid());
await writeFile(executable, "inert fixture; never executed");
expect(await resolveAppBundleExecutable(bundle)).toEqual({
expect(await resolveAppleBundleExecutable(bundle)).toEqual({
ok: true,
value: { executable, infoPlist: plistPath },
});
@@ -86,17 +86,13 @@ describe("app bundle Info.plist magic", () => {
const plist = Buffer.from("bplist00");
plist[0] = (plist[0] ?? 0) | 0x80;
await writeFile(plistPath, plist);
let decoded = false;
const result = await resolveAppBundleExecutable(bundle, {
readFile: async () => plist,
realpath: async (path) => path,
decodeBinaryPlist: async () => {
decoded = true;
return "Fixture";
const result = await resolveAppleBundleExecutable(bundle);
expect(result).toMatchObject({
ok: false,
error: {
cause: { name: "XmlPlistTextDecodeError", reason: "invalid-bytes" },
},
});
expect(decoded).toBe(false);
expect(result.ok).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -118,7 +114,7 @@ describe("app bundle program directory containment", () => {
await writeFile(join(bundle, "Contents", "Info.plist"), plist);
let childResolved = false;
const result = await resolveAppBundleExecutable(bundle, {
const result = await resolveAppleBundleExecutable(bundle, {
readFile: async () => Buffer.from(plist),
realpath: async (path) => {
if (path === bundle) return canonicalBundle;
+4 -22
View File
@@ -88,10 +88,9 @@ export interface ResolvedAppBundleResourceRoot {
}
/** Filesystem operations used by the resolver, injectable for boundary tests. */
export interface AppBundleFileSystem {
interface AppBundleFileSystem {
readonly readFile: (path: string) => Promise<Buffer>;
readonly realpath: (path: string) => Promise<string>;
readonly decodeBinaryPlist?: (path: string) => Promise<string>;
}
const defaultFileSystem: AppBundleFileSystem = { readFile, realpath };
@@ -102,7 +101,7 @@ const defaultFileSystem: AppBundleFileSystem = { readFile, realpath };
* a wrapper whose `Wrapper` directory holds the iOS bundle; that bundle is
* found by listing `Wrapper`, never by following the `WrappedBundle` link.
*/
export const resolveAppBundleExecutable = async (
const resolveAppBundleExecutable = async (
path: string,
fileSystem: AppBundleFileSystem = defaultFileSystem,
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
@@ -396,15 +395,6 @@ const codeDescription = (cause: unknown): string => {
: "";
};
const isSystemErrorCode = (cause: unknown): boolean => {
const code = errorCode(cause);
return (
typeof code === "string" &&
/^[A-Z][A-Z0-9_]+$/u.test(code) &&
!code.startsWith("ERR_")
);
};
const isPermissionDenied = (cause: unknown): boolean =>
errorCode(cause) === "EACCES" || errorCode(cause) === "EPERM";
@@ -461,20 +451,12 @@ const resolveLayoutExecutable = async (
let name: string;
try {
name =
hasBinaryPlistSignature(plist) &&
fileSystem.decodeBinaryPlist !== undefined
? await fileSystem.decodeBinaryPlist(plistPath)
: executableName(decodeInfoPlistValue(plist));
name = executableName(decodeInfoPlistValue(plist));
} catch (cause: unknown) {
return err(
new BinaryTargetError(
plistPath,
isPermissionDenied(cause)
? `permission denied decoding ${noun} Info.plist ${plistPath}${codeDescription(cause)}`
: isSystemErrorCode(cause)
? `could not decode ${noun} Info.plist ${plistPath}${codeDescription(cause)}`
: `${noun} Info.plist is malformed, unsupported, or lacks CFBundleExecutable: ${plistPath}${cause instanceof Error ? ` (${cause.message})` : ""}`,
`${noun} Info.plist is malformed, unsupported, or lacks CFBundleExecutable: ${plistPath}${cause instanceof Error ? ` (${cause.message})` : ""}`,
{
cause,
// A bundle without code, such as a resource bundle, declares no
@@ -3,7 +3,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
import { resolveAppleBundleExecutable } from "./AppBundleExecutable.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
describe("OpenStep Info.plist nesting during app target resolution", () => {
@@ -20,7 +20,7 @@ describe("OpenStep Info.plist nesting during app target resolution", () => {
`{ CFBundleExecutable = App; payload = ${"(".repeat(depth)}x${")".repeat(depth)}; }`,
);
const result = await resolveAppBundleExecutable(app);
const result = await resolveAppleBundleExecutable(app);
if (depth === 127) {
expect(result).toMatchObject({
@@ -52,7 +52,7 @@ describe("OpenStep Info.plist nesting during app target resolution", () => {
`{ CFBundleExecutable = App; }${"(".repeat(3000)}x${")".repeat(3000)}`,
);
expect(await resolveAppBundleExecutable(app)).toMatchObject({
expect(await resolveAppleBundleExecutable(app)).toMatchObject({
ok: true,
value: { executable: join(programs, "App") },
});
+3 -3
View File
@@ -208,7 +208,7 @@ export const parseBinaryTarget = async (
throwIfTargetResolutionCancelled(signal);
const resolved = await resolveAppBundle(canonical);
throwIfTargetResolutionCancelled(signal);
if (!resolved.ok) return err(resolved.error);
if (!resolved.ok) return resolved;
const { executable: path, infoPlist } = resolved.value;
const bundle =
infoPlist === undefined ? {} : { bundleInfoPlist: infoPlist };
@@ -747,8 +747,8 @@ const throwIfTargetResolutionCancelled = (signal?: AbortSignal): void => {
const detectArtifactFormat = async (
path: string,
source: StableRegularFileDescriptor,
signal?: AbortSignal,
sourcePath = path,
signal: AbortSignal | undefined,
sourcePath: string,
): Promise<
| Exclude<
BinaryTarget["format"],
+30 -3
View File
@@ -56,6 +56,7 @@ import {
type AnalysisCleanupRetry,
} from "../domain/analysisErrorRecovery.js";
import { isBundleResourceOperation } from "./binary/BundleResourceSession.js";
import { offeredOperationAlternatives } from "./OperationAlternatives.js";
const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
FRIDA_TOOL_CONTRACTS.map(({ name }) => name),
@@ -126,6 +127,8 @@ export type AvailabilityPolicy = {
type AvailabilityFacts = {
readonly defaultModeAvailable?: boolean;
readonly modes?: readonly ToolAvailabilityMode[];
/** Advertised operations the current provider offers for part of the question. */
readonly alternatives?: readonly AnalysisOperation[];
};
type Availability = AvailabilityFacts &
(
@@ -162,6 +165,7 @@ type AvailabilityContext = {
readonly targetFormat: string | undefined;
readonly descriptors: ReadonlyMap<string, ProviderDescriptor>;
readonly policy: AvailabilityPolicy;
readonly isToolAdvertised: (name: string) => boolean;
};
type EnhancedContractName = Extract<
@@ -322,10 +326,15 @@ const ADDRESS_CONTEXT_EFFECT_COMPOSITION: ModeComposition = {
],
};
/** Build stable per-operation availability for discovery and tool visibility. */
/**
* Build stable per-operation availability for the caller's advertised tools.
* Each tool's availability still derives from the complete provider facts;
* advertisement narrows only the reported tools and the alternatives named.
*/
export const buildCapabilityInventory = (
sessionStatus: JsonValue,
policy: AvailabilityPolicy,
isToolAdvertised: (name: string) => boolean = () => true,
): readonly ToolAvailability[] => {
const status = statusSchema.parse(sessionStatus);
const descriptors = new Map<string, ProviderDescriptor>(
@@ -345,6 +354,7 @@ export const buildCapabilityInventory = (
targetFormat: status.format,
descriptors,
policy,
isToolAdvertised,
});
const effects = composedEffects(
contract.name,
@@ -398,8 +408,13 @@ export const buildCapabilityInventory = (
available: false,
reason: availability.reason,
remediation: availability.remediation,
...(availability.alternatives === undefined
? {}
: { alternatives: [...availability.alternatives] }),
};
}).sort((left, right) => compareUnicodeCodePoints(left.name, right.name));
})
.filter(({ name }) => isToolAdvertised(name))
.sort((left, right) => compareUnicodeCodePoints(left.name, right.name));
};
type ClientRequirements = ToolAvailability["client_requirements"];
@@ -445,6 +460,9 @@ export function compactToolAvailability(
? {}
: { default_mode_available: entry.default_mode_available }),
...(entry.modes === undefined ? {} : { modes: [...entry.modes] }),
...(entry.available || entry.alternatives === undefined
? {}
: { alternatives: [...entry.alternatives] }),
...(JSON.stringify(entry.client_requirements) ===
JSON.stringify(clientRequirements)
? {}
@@ -959,6 +977,7 @@ const providerAvailability = ({
kind,
name,
policy,
isToolAdvertised,
}: AvailabilityContext): Availability => {
if (kind === "enhanced") return composedAvailability(name, descriptors);
const launchFailure =
@@ -976,12 +995,20 @@ const providerAvailability = ({
kind === "native-provider")
)
return { reason: "available", remediation: null };
if (descriptor === undefined)
if (descriptor === undefined) {
const offered = offeredOperationAlternatives(
name,
(alternative) =>
descriptors.get(alternative)?.available === true &&
isToolAdvertised(alternative),
);
return {
reason: "provider_missing",
remediation:
"Install or configure a provider that declares this operation.",
...(offered === undefined ? {} : { alternatives: offered.operations }),
};
}
return descriptor.available
? { reason: "available", remediation: null }
: {
+6 -1
View File
@@ -93,7 +93,12 @@ it("joins concurrent shutdowns and seals ownership before asynchronous cleanup s
try {
await awaitAcknowledgement(entered, first, "capability cleanup");
second = scope.close();
expect(second).toBe(first);
let secondSettled = false;
void second.then(() => {
secondSettled = true;
});
await Promise.resolve();
expect(secondSettled).toBe(false);
expect(close).toHaveBeenCalledTimes(1);
expect(() => scope.own("later-owner", {}, async () => undefined)).toThrow(
"after host shutdown",
@@ -1,15 +1,14 @@
import { CLAUDE_DESKTOP_PACKAGE_FAMILY } from "../../tests/fixtures/claudeDesktop.js";
import { basename, dirname, join, sep } from "node:path";
import { describe, expect, it } from "vitest";
import {
CLAUDE_DESKTOP_PACKAGE_FAMILY,
resolveWindowsClaudeDesktopLocation,
type ClaudeDesktopLocationFileSystem,
} from "./ClaudeDesktopConfigLocation.js";
import {
packagePrivateLauncherBlocker,
packagePrivateStorageFamily,
redirectedWriteBlocker,
type MsixPackageView,
windowsAppDataRoots,
@@ -642,15 +641,19 @@ describe("package-private REA launchers", () => {
"rea.mjs",
);
it("names the package that privately stores a path", () => {
expect(packagePrivateStorageFamily(stored, packages)).toBe(
CLAUDE_DESKTOP_PACKAGE_FAMILY,
);
expect(packagePrivateStorageFamily(launcher, packages)).toBeUndefined();
it("blocks private cache paths without treating other package paths as private", () => {
const identity = (path: string) => path;
expect(
packagePrivateStorageFamily(
join(packages, CLAUDE_DESKTOP_PACKAGE_FAMILY, "LocalState", "x"),
packagePrivateLauncherBlocker([stored], packages, identity),
).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY);
expect(
packagePrivateLauncherBlocker([launcher], packages, identity),
).toBeUndefined();
expect(
packagePrivateLauncherBlocker(
[join(packages, CLAUDE_DESKTOP_PACKAGE_FAMILY, "LocalState", "x")],
packages,
identity,
),
).toBeUndefined();
});
@@ -29,7 +29,7 @@ const PROBES_PER_DIRECTORY = 2;
* publisher ID Windows derives from `CN="Anthropic, PBC", O="Anthropic, PBC",
* L=San Francisco, S=California, C=US, …`.
*/
export const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc";
const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc";
/** Observed Claude package names plus Windows' 13-character publisher ID. */
const CLAUDE_PACKAGE_FAMILY =
+3 -3
View File
@@ -23,14 +23,14 @@ export interface ClientConfigurationSource extends ClientConfigurationDocument {
const invalidEnvironment = (
path: string,
error: ConfigurationError,
overrides: Readonly<Record<string, string>> = {},
platform: NodeJS.Platform = process.platform,
overrides: Readonly<Record<string, string>> | undefined,
platform: NodeJS.Platform,
): ClientConfigurationFileError => {
const constraints = error.settings
.map(({ setting, constraint }) => `${setting}: ${constraint}`)
.join("; ");
const overrideNames = new Set(
Object.keys(overrides).map((name) =>
Object.keys(overrides ?? {}).map((name) =>
platform === "win32" ? name.toUpperCase() : name,
),
);
@@ -1,15 +1,9 @@
import { validateRegistrationEnvironment } from "./RegistrationEnvironmentValidation.js";
import { clientRegistrationSource } from "./ClientRegistrationSource.js";
import type { SetupClient } from "./SupportedClients.js";
import {
inspectAnalysisSettingSeeds,
launchAnalysisSetting,
} from "../config/analysisSettings.js";
import {
parseEnvironment,
parseEnvironmentValues,
type Environment,
} from "../config/environment.js";
import { launchAnalysisSetting } from "../config/analysisSettings.js";
import type { Environment } from "../config/environment.js";
import {
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
@@ -314,34 +308,24 @@ export const clientRegistrationEnvironmentDetailsWithBudget = async (
const validatedDetails = (
environment: Readonly<Record<string, string>>,
validation: "fragment" | "effective",
platform: NodeJS.Platform = process.platform,
expressionSettings: ReadonlySet<string> = new Set(),
platform: NodeJS.Platform,
expressionSettings: ReadonlySet<string>,
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
const runtimeEnvironment = snapshotEnvironment(environment, platform);
const unresolved = new Set(
Object.keys(
snapshotEnvironment(
Object.fromEntries([...expressionSettings].map((name) => [name, ""])),
platform,
),
),
const validated = validateRegistrationEnvironment(
environment,
expressionSettings,
validation,
platform,
);
const validated =
validation === "fragment"
? parseEnvironmentValues(runtimeEnvironment, unresolved)
: parseEnvironment(runtimeEnvironment, unresolved);
if (!validated.ok) return validated;
const seedIssues = inspectAnalysisSettingSeeds(
runtimeEnvironment,
unresolved,
);
if (seedIssues.length > 0)
return err(
new ConfigurationError("Invalid REA environment configuration", {
settings: seedIssues,
}),
);
return ok(environmentDetails(environment, validated.value, unresolved));
return validated.ok
? ok(
environmentDetails(
environment,
validated.value.configuration,
validated.value.unresolvedSettings,
),
)
: validated;
};
const admittedTimeout = (
@@ -397,7 +381,7 @@ const registrationTimeoutInputs = (
const environmentDetails = (
environment: Readonly<Record<string, string>>,
configuration: Environment,
unresolvedSettings: ReadonlySet<string> = new Set(),
unresolvedSettings: ReadonlySet<string>,
): ClientRegistrationEnvironmentDetails => ({
values: Object.fromEntries(
Object.entries(environment).sort(([left], [right]) =>
+1 -1
View File
@@ -649,7 +649,7 @@ const packageVersionRemediation = (
const unavailableStatus = (
client: SetupClient,
state: "missing" | "invalid",
configPath: string = client.configPath,
configPath: string,
): ClientRegistrationStatus => ({
client: client.name,
config_path: configPath,
+8 -11
View File
@@ -67,10 +67,7 @@ import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import { profilelessNativeDispatchProfile } from "./binary/ProfilelessNativeWorkflowSnapshot.js";
import { err, ok, type Result } from "../domain/result.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import type {
AnalysisExecution,
ProviderIdentity,
} from "./AnalysisProvider.js";
import type { AnalysisExecution } from "./AnalysisProvider.js";
import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js";
import { REA_WORKFLOW_PROVIDER } from "../domain/investigationProviders.js";
import { workflowAnalysisProfile } from "./WorkflowAnalysisProfile.js";
@@ -296,7 +293,7 @@ export const runProviderAnalysis = async (
delivery.detail,
operationSignal,
);
return result.ok ? ok(result.value) : err(cliError(result.error, path));
return result.ok ? result : err(cliError(result.error, path));
}
const admitted = admitOneShotOperation(tool, arguments_, operationSignal);
if (!admitted.ok) return err(cliError(admitted.error, path));
@@ -445,14 +442,14 @@ const runAnalysis = async (
let isDirectory = false;
if (BUNDLE_RESOURCE_OPERATIONS.has(tool)) {
const inspected = await inspectBundleResourceInput(path);
if (!inspected.ok) return err(inspected.error);
if (!inspected.ok) return inspected;
isDirectory = inspected.value;
}
if (BUNDLE_RESOURCE_OPERATIONS.has(tool) && isDirectory) {
const opened = await session.openBundleResources(path, { signal });
if (!opened.ok) return err(opened.error);
if (!opened.ok) return opened;
const execution = await session.execute(tool, arguments_, { signal });
if (!execution.ok) return err(execution.error);
if (!execution.ok) return execution;
return ok(
withEvidenceCallerInputPath(
createAnalysisExecutionEvidence(
@@ -472,7 +469,7 @@ const runAnalysis = async (
signal,
...targetSelection(options),
});
if (!prepared.ok) return err(prepared.error);
if (!prepared.ok) return prepared;
const { snapshot } = prepared.value;
let resolvedTarget: ResolvedSessionOpen | undefined;
if (snapshot !== undefined && prepared.value.target !== undefined) {
@@ -484,7 +481,7 @@ const runAnalysis = async (
? {}
: { providerId: options.providerId }),
});
if (!preview.ok) return err(preview.error);
if (!preview.ok) return preview;
resolvedTarget = preview.value;
const route = preview.value.route;
const bindingProfile =
@@ -548,7 +545,7 @@ const runAnalysis = async (
resolvedTarget === undefined
? await session.open(path, openOptions)
: await session.openResolvedTarget(resolvedTarget, openOptions);
if (!opened.ok) return err(opened.error);
if (!opened.ok) return opened;
const evidenceProfile = analysisProfileForEvidence(session, tool);
const executed = await executeAnalysisTool({
session,
+11 -38
View File
@@ -1,18 +1,11 @@
import { inspectAnalysisSettingSeeds } from "../config/analysisSettings.js";
import { validateRegistrationEnvironment } from "./RegistrationEnvironmentValidation.js";
import { isMap, isSeq, type Node, type YAMLMap } from "yaml";
import {
parseEnvironment,
parseEnvironmentValues,
type Environment,
} from "../config/environment.js";
import type { Environment } from "../config/environment.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import type { ClientConfigurationDocument } from "./ClientConfigurationDocument.js";
import { CordisExpression, DshCordisYaml } from "./DshCordisYaml.js";
import {
mergeEnvironmentOverrides,
snapshotEnvironment,
} from "../process/snapshotEnvironment.js";
import { mergeEnvironmentOverrides } from "../process/snapshotEnvironment.js";
export const DSH_ENTRY_ID = "rea-mcp";
export const DSH_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client";
@@ -255,35 +248,15 @@ export class DshCordisDocument {
);
literals[key] = value;
}
const runtimeEnvironment = snapshotEnvironment(literals, platform);
const unresolved = new Set(
Object.keys(
snapshotEnvironment(
Object.fromEntries([...unresolvedSettings].map((key) => [key, ""])),
platform,
),
),
const validated = validateRegistrationEnvironment(
literals,
unresolvedSettings,
validation,
platform,
);
const validated =
validation === "fragment"
? parseEnvironmentValues(runtimeEnvironment, unresolved)
: parseEnvironment(runtimeEnvironment, unresolved);
if (!validated.ok) return validated;
const seedIssues = inspectAnalysisSettingSeeds(
runtimeEnvironment,
unresolved,
);
if (seedIssues.length > 0)
return err(
new ConfigurationError("Invalid REA environment configuration", {
settings: seedIssues,
}),
);
return ok({
values: literals,
configuration: validated.value,
unresolvedSettings: unresolved,
});
return validated.ok
? ok({ values: literals, ...validated.value })
: validated;
}
desired(
+1 -3
View File
@@ -307,9 +307,7 @@ const projectWrite = (
bundle: EvidenceBundle,
written: Result<PublishedEvidenceFile, AnalysisError>,
): Result<JsonValue, AnalysisError> =>
written.ok
? ok(evidenceBundleExportReceipt(bundle, written.value))
: err(written.error);
written.ok ? ok(evidenceBundleExportReceipt(bundle, written.value)) : written;
/** Standalone bundle discovery shares the same read owner as import and comparison. */
export const inspectEvidenceBundleCommand = async (
+6 -11
View File
@@ -31,16 +31,11 @@ import {
import { readJsonFile, writeTextParts } from "./JsonFiles.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
type EvidenceReadFailure =
type EvidenceBundleFileFailure =
| EvidenceFileError
| EvidenceIntegrityError
| AnalysisResourceConstraintError
| AnalysisCancelledError;
type EvidenceWriteFailure =
| EvidenceFileError
| EvidenceIntegrityError
| AnalysisCancelledError
| AnalysisResourceConstraintError;
/** Project a published bundle with record and residual-unknown revision counts. */
export const evidenceBundleExportReceipt = (
@@ -59,7 +54,7 @@ export const readEvidenceBundle = (
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
): Promise<Result<EvidenceBundle, EvidenceBundleFileFailure>> =>
readBundle(path, false, signal, resources, operation);
/** Read an authenticated immutable snapshot for a subsequent session import. */
@@ -68,7 +63,7 @@ export const readImmutableEvidenceBundle = (
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
): Promise<Result<EvidenceBundle, EvidenceBundleFileFailure>> =>
readBundle(path, true, signal, resources, operation);
/** Join both comparison reads and preserve cleanup owned by either failed input. */
@@ -109,7 +104,7 @@ const readBundle = async (
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
): Promise<Result<EvidenceBundle, EvidenceBundleFileFailure>> => {
if (signal?.aborted)
return err(
new AnalysisCancelledError(operation, {
@@ -135,7 +130,7 @@ export const authenticateEvidenceBundleValue = (
value: unknown,
path: string,
options: { readonly operation: string; readonly immutable?: boolean },
): Result<EvidenceBundle, EvidenceReadFailure> => {
): Result<EvidenceBundle, EvidenceBundleFileFailure> => {
const { operation } = options;
const headroom = requireAuthenticationHeadroom(value, {
operation,
@@ -226,7 +221,7 @@ export const writeEvidenceBundle = async (
): Promise<
Result<
{ readonly path: string; readonly bytes: number },
EvidenceWriteFailure
EvidenceBundleFileFailure
>
> => {
if (signal?.aborted === true)
+24 -16
View File
@@ -2,6 +2,7 @@ import { realpath, stat } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { stripComments } from "jsonc-parser";
import { z } from "zod";
import { jsonRecordSchema } from "../domain/jsonRecord.js";
import { err, ok, type Result } from "../domain/result.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
@@ -34,8 +35,7 @@ const folderTrustSchema = z
.optional(),
})
.passthrough();
const trustRulesSchema = z.record(
z.string(),
const trustRulesSchema = jsonRecordSchema(
z.enum(["TRUST_FOLDER", "TRUST_PARENT", "DO_NOT_TRUST"]),
);
const sourceFailure = (
@@ -47,18 +47,18 @@ const sourceFailure = (
detail: `Gemini settings in ${path} are malformed: ${cause instanceof Error ? cause.message : String(cause)}`,
});
// Gemini resolves symlinks when possible and compares macOS/Windows paths without case.
const normalizedPath = async (
path: string,
platform: NodeJS.Platform,
): Promise<string> => {
const physical = await realpath(path).catch(() => path);
const absolute = resolve(physical).replaceAll("\\", "/");
// Gemini normalizes trust-file keys before replacement and rule precedence.
const normalizedPath = (path: string, platform: NodeJS.Platform): string => {
const absolute = resolve(path).replaceAll("\\", "/");
return platform === "win32" || platform === "darwin"
? absolute.toLowerCase()
: absolute;
};
// Applicability additionally resolves symlinks, without changing rule identity.
const physicalPath = async (path: string, platform: NodeJS.Platform) =>
normalizedPath(await realpath(path).catch(() => path), platform);
const workspaceTrusted = async (
settings: NonNullable<SetupClient["geminiSettings"]>,
sources: readonly SettingsSource[],
@@ -78,21 +78,29 @@ const workspaceTrusted = async (
);
if (!text.ok) return text;
if (text.value === undefined) return ok(false);
let rules: z.output<typeof trustRulesSchema>;
let rules: ReadonlyMap<string, z.output<typeof trustRulesSchema>[string]>;
try {
// Gemini strips comments, but rejects trailing commas, BOMs and invalid trust levels.
rules = trustRulesSchema.parse(JSON.parse(stripComments(text.value)));
const parsed = trustRulesSchema.parse(
JSON.parse(stripComments(text.value)),
);
rules = new Map(
Object.entries(parsed).map(([path, level]) => [
normalizedPath(path, settings.platform),
level,
]),
);
} catch (cause: unknown) {
return err(sourceFailure(settings.trustedFoldersPath, cause));
}
const workspace = await normalizedPath(
const workspace = await physicalPath(
settings.workspaceDirectory,
settings.platform,
);
let longest = -1;
let trusted = false;
for (const [path, level] of Object.entries(rules)) {
const effective = await normalizedPath(
for (const [path, level] of rules) {
const effective = await physicalPath(
level === "TRUST_PARENT" ? dirname(path) : path,
settings.platform,
);
@@ -138,11 +146,11 @@ export const readClientPolicyBlock = async (
if (parsed.value !== undefined)
sources.push({ path, document: parsed.value });
}
const workspace = await normalizedPath(
const workspace = await physicalPath(
settings.workspaceDirectory,
settings.platform,
);
const home = await normalizedPath(
const home = await physicalPath(
dirname(dirname(client.configPath)),
settings.platform,
);
@@ -4,7 +4,6 @@ import { parseDocument } from "yaml";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
editGooseConfigurationYaml,
GooseConfigurationYamlError,
parseGooseConfigurationYaml,
stringifyGooseConfigurationYaml,
} from "./GooseConfigurationYaml.js";
@@ -111,11 +110,15 @@ describe("Goose YAML 1.2 source facts", () => {
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(GooseConfigurationYamlError);
expect(failure).toBeInstanceOf(ConfigurationError);
expect(failure).toMatchObject({ kind: "shadowed_member", path });
expect(failure).toHaveProperty(
"message",
`Goose YAML contains a duplicate mapping member at ${path.join(".")}`,
);
expect(() =>
editGooseConfigurationYaml(source, ["extensions", "rea"], extension),
).toThrow(GooseConfigurationYamlError);
).toThrow(`duplicate mapping member at ${path.join(".")}`);
},
);
+1 -1
View File
@@ -143,7 +143,7 @@ const gooseYamlOptions = {
} as const;
/** A duplicate YAML member cannot establish one selected configuration value. */
export class GooseConfigurationYamlError extends ConfigurationError {
class GooseConfigurationYamlError extends ConfigurationError {
readonly kind = "shadowed_member";
constructor(
readonly path: readonly string[],
-1
View File
@@ -1,7 +1,6 @@
import { expect, it } from "vitest";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { MANAGED_SKILL_FILES } from "./SetupSkill.js";
import { searchGuidanceOutputSchema } from "../contracts/guidanceContracts.js";
import { RetainedRegularFileCleanupFailure } from "./RegularFileRead.js";
+2 -2
View File
@@ -49,13 +49,13 @@ export type HopperRequestFailure = HopperDownloadFailure & {
export const HOPPER_DOWNLOAD_BYTES = 100_000_000;
/** Longest wait for the response or its next bytes before a download stalls. */
export const HOPPER_DOWNLOAD_STALL_MS = 30_000;
const HOPPER_DOWNLOAD_STALL_MS = 30_000;
/**
* Overall bound for one download. A slow connection that keeps delivering
* bytes is bounded here rather than by a deadline sized for fast networks.
*/
export const HOPPER_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
const HOPPER_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
/** Small release feed and key requests must not use the package bounds. */
export const HOPPER_METADATA_TIMEOUT_MS = 10_000;
+19 -46
View File
@@ -4,9 +4,8 @@ import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
formatCommandForDisplay,
installLinuxHopper,
linuxPackageManagerCommands,
linuxHopperInstallDisclosure,
linuxSharedLibrariesAvailable,
parseLinuxDistribution,
supportsReaHost,
@@ -31,19 +30,6 @@ import * as nativeCommands from "../process/ExecFileOutput.js";
import * as pythonRuntime from "./LinuxHopperPython.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
describe("Linux package-manager command display", () => {
it("preserves argument boundaries for paths with spaces and quotes", () => {
expect(
formatCommandForDisplay("pkexec", [
"pacman",
"-U",
"/tmp/rea test/hopper.arch",
"apostrophe'path",
]),
).toBe('pkexec pacman -U "/tmp/rea test/hopper.arch" "apostrophe\'path"');
});
});
class RecordingLinuxHost implements LinuxHopperInstallHost {
distributionValue: LinuxDistribution | undefined = {
id: "ubuntu",
@@ -263,47 +249,34 @@ describe("Linux Hopper installation", () => {
] as const)(
"selects the %s native package manager and dependencies",
(family, executable, packages) => {
expect(
linuxPackageManagerCommands(family, "/tmp/Hopper package", true),
).toContainEqual(
expect.objectContaining({
executable,
args: expect.arrayContaining([...packages]),
}),
const root = linuxHopperInstallDisclosure(family, true).commands;
const nonRoot = linuxHopperInstallDisclosure(family, false).commands;
expect(root.some((command) => command.startsWith(`${executable} `))).toBe(
true,
);
for (const dependency of packages)
expect(
root.some((command) => command.split(" ").includes(dependency)),
).toBe(true);
expect(
linuxPackageManagerCommands(family, "/tmp/Hopper package", false),
).toContainEqual(
expect.objectContaining({
executable: "pkexec",
args: expect.arrayContaining([executable, "/tmp/Hopper package"]),
}),
);
nonRoot.some(
(command) =>
command.startsWith(`pkexec ${executable} `) &&
command.includes(`"<verified-hopper.${family}>"`),
),
).toBe(true);
},
);
it.each([true, false])(
"keeps installed apt dependencies at their versions while Hopper may upgrade (root: %s)",
(isRoot) => {
const prefix = isRoot ? [] : ["pkexec"];
const prefix = isRoot ? "" : "pkexec ";
expect(
linuxPackageManagerCommands("deb", "/tmp/Hopper package", isRoot).map(
({ executable, args }) => [executable, ...args],
),
linuxHopperInstallDisclosure("deb", isRoot).commands.slice(0, 2),
).toEqual([
[
...prefix,
"apt-get",
"install",
"-y",
"--no-upgrade",
"xvfb",
"xauth",
"python3",
"libx11-6",
"libxtst6",
],
[...prefix, "apt-get", "install", "-y", "/tmp/Hopper package"],
`${prefix}apt-get install -y --no-upgrade xvfb xauth python3 libx11-6 libxtst6`,
`${prefix}apt-get install -y "<verified-hopper.deb>"`,
]);
},
);
+3 -3
View File
@@ -511,7 +511,7 @@ export const installLinuxHopper = async (
};
/** Canonical launcher path for a legacy user-local Linux Hopper installation. */
export const linuxHopperLauncherPath = (home: string): string =>
const linuxHopperLauncherPath = (home: string): string =>
appendCallerPath(home, ".local/share/rea/hopper/bin/Hopper", "linux");
/** REA's managed Linux install path, preferred when no explicit launcher is set. */
@@ -796,7 +796,7 @@ const runPackageManagerCommand = async (
};
/** Preserve argument boundaries in a command shown to the user. */
export const formatCommandForDisplay = (
const formatCommandForDisplay = (
executable: string,
args: readonly string[],
): string =>
@@ -807,7 +807,7 @@ export const formatCommandForDisplay = (
.join(" ");
/** Select the native package manager and authorization boundary without shell evaluation. */
export const linuxPackageManagerCommands = (
const linuxPackageManagerCommands = (
family: LinuxPackageFamily,
archive: string,
isRoot: boolean,
@@ -6,16 +6,11 @@ import { err, ok } from "../domain/result.js";
import * as pythonRuntime from "./LinuxHopperPython.js";
import {
linuxHopperInstallDisclosure,
linuxPackageManagerCommands,
systemLinuxHopperInstallHost,
} from "./LinuxHopper.js";
/** Package transactions that install Hopper before any runtime library repair. */
const debCommands = linuxPackageManagerCommands(
"deb",
"/tmp/verified-hopper.deb",
false,
).length;
const debCommands = 2;
const missingPython = {
interpreter: "/usr/bin/python3",
@@ -41,6 +36,34 @@ const commandSuccess = {
};
describe("Linux Hopper package runtime", () => {
it("preserves package path boundaries in the reported failed command", async () => {
const directory = await createTestTempDirectory("rea-hopper-command-");
const host = systemLinuxHopperInstallHost(
{ XDG_CACHE_HOME: directory },
async () => undefined,
);
const archive = "/tmp/rea test/hopper's package.rpm";
const command = vi
.spyOn(boundedCommands, "execFileBoundedOutput")
.mockResolvedValue({
exitCode: 1,
stdout: "",
stderr: "fixture failure",
outputTruncated: false,
});
try {
const result = await host.installPackage("rpm", archive);
expect(command.mock.calls[0]?.[1]).toContain(archive);
expect(result).toMatchObject({
status: "failed",
failure: { command: expect.stringContaining(`"${archive}"`) },
});
} finally {
command.mockRestore();
await host.close?.();
}
});
it.each([
{
name: "installs and verifies the matching future Python library",
+7 -20
View File
@@ -11,7 +11,7 @@ import {
import { tmpdir } from "node:os";
import { join, posix, relative } from "node:path";
import { installMacHopperBundle } from "./MacHopper.js";
import { linuxHopperLauncherPath } from "./LinuxHopper.js";
import { linuxHopperLauncherCandidates } from "./LinuxHopper.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { describe, expect, it } from "vitest";
@@ -19,34 +19,17 @@ import {
localMacHopperRenamedLauncherCandidates,
macHopperCanonicalLauncherCandidates,
macHopperUserApplicationPath,
macHopperRenamedLauncherCandidates,
readMacHopperRenamedLauncherCandidates,
} from "./MacHopperPaths.js";
describe("macOS Hopper launcher paths", () => {
const home = "/fixture/home";
it("keeps canonical system and user candidates ahead of renamed bundles", () => {
it("keeps canonical system and user candidates in order", () => {
expect(macHopperCanonicalLauncherCandidates(home)).toEqual([
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
]);
expect(
macHopperRenamedLauncherCandidates(
home,
new Map([
[
"/Applications",
["Hopper z.app", "Hopper a.app", "Hopper Disassembler.app"],
],
["/fixture/home/Applications", ["Hopper User.app"]],
]),
),
).toEqual([
"/Applications/Hopper a.app/Contents/MacOS/hopper",
"/Applications/Hopper z.app/Contents/MacOS/hopper",
"/fixture/home/Applications/Hopper User.app/Contents/MacOS/hopper",
]);
});
it.skipIf(process.platform === "win32")(
@@ -84,7 +67,7 @@ describe("macOS Hopper launcher paths", () => {
expect(macHopperCanonicalLauncherCandidates(home)[1]).toBe(
`${home}/Applications/Hopper Disassembler.app/Contents/MacOS/hopper`,
);
expect(linuxHopperLauncherPath(home)).toBe(
expect(linuxHopperLauncherCandidates(home)[1]).toBe(
`${home}/.local/share/rea/hopper/bin/Hopper`,
);
const source = join(temporary, "source.app");
@@ -154,13 +137,17 @@ describe("macOS Hopper launcher paths", () => {
recursive: true,
});
await mkdir(join(applications, "Hopper Disassembler.app"));
await mkdir(join(applications, "Hopper Alpha.app"));
await mkdir(join(applications, "Hopper Zeta.app"));
await writeFile(join(applications, "Hopper File.app"), "");
const runtime = [...localMacHopperRenamedLauncherCandidates(home)];
expect(runtime.filter((path) => path.startsWith(home))).toEqual([
posix.join(home, "Applications/Hopper Alpha.app/Contents/MacOS/hopper"),
posix.join(
home,
"Applications/Hopper Preview.app/Contents/MacOS/hopper",
),
posix.join(home, "Applications/Hopper Zeta.app/Contents/MacOS/hopper"),
]);
expect(await readMacHopperRenamedLauncherCandidates(home)).toEqual(
runtime,
+1 -1
View File
@@ -43,7 +43,7 @@ export const macHopperCanonicalLauncherCandidates = (
];
/** Build deterministic candidates from the discovered app bundle names. */
export const macHopperRenamedLauncherCandidates = (
const macHopperRenamedLauncherCandidates = (
homeDirectory: string,
bundleNamesByRoot: ReadonlyMap<string, readonly string[]>,
): readonly string[] =>
+17 -11
View File
@@ -12,6 +12,7 @@ import { join, sep } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js";
import { ok } from "../domain/result.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
let resources: ArtifactResourceScope;
@@ -612,17 +613,22 @@ describe("Registration cancellation and sibling outcomes", () => {
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("{}");
try {
await awaitAcknowledgement(admitted, first, "registration transaction");
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("{}");
} finally {
unblock();
await first;
}
});
it("keeps successful siblings after ordinary failures and marks work not started after cancellation", async () => {
+165 -133
View File
@@ -5,7 +5,28 @@ import { z } from "zod";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { createTestProcessRegistry } from "../../tests/support/process/processFixture.js";
import { resolveMcpRegistrationClientId } from "./McpRegistrationCatalog.js";
import { resolveMcpRegistrationClients } from "./McpRegistrationCatalog.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
const selectAgent = async (agent: string) => {
const root = await createTestTempDirectory("rea-mcp-alias-");
const resources = new ArtifactResourceScope();
try {
return await resolveMcpRegistrationClients(
{
agents: [agent],
global: true,
cwd: root,
home: root,
platform: process.platform,
environment: {},
},
resources,
);
} finally {
await resources.close();
}
};
it.each([
["cline-vscode", "cline"],
@@ -23,15 +44,22 @@ it.each([
["kiro", "kiro-cli"],
["mastra", "mastracode"],
["pi-agent", "pi"],
])("retains helper alias %s as %s", (alias, expected) => {
expect(resolveMcpRegistrationClientId(alias.toUpperCase())).toBe(expected);
])("resolves registration alias %s as %s", async (alias, expected) => {
const result = await selectAgent(alias.toUpperCase());
if (!result.ok) throw result.error;
expect(result.value.map(({ id }) => id)).toEqual([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();
});
it.each(["__proto__", "constructor", "unknown-client"])(
"rejects unknown registration identity %s",
async (agent) => {
const result = await selectAgent(agent);
expect(result).toMatchObject({
ok: false,
error: { _tag: "ConfigurationError" },
});
},
);
const clientSchema = z.object({
id: z.string(),
@@ -256,133 +284,137 @@ const REVIEWED_CLIENT_IDS = [
"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"),
it(
"resolves selected-environment paths and scopes without external catalog readers",
{ tags: ["long-running"] },
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(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 });
}
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>,
+1 -1
View File
@@ -356,7 +356,7 @@ const ALIASES = {
const isClientId = (value: string): value is McpRegistrationClientId =>
Object.hasOwn(MCP_REGISTRATION_CLIENTS, value);
export const resolveMcpRegistrationClientId = (
const resolveMcpRegistrationClientId = (
input: string,
): McpRegistrationClientId | undefined => {
const lower = input.toLowerCase();
+4 -18
View File
@@ -1,3 +1,4 @@
import { readElfAttributeUleb } from "../domain/elfAttributeUleb.js";
import type { MipsGnuAttributes } from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -22,21 +23,6 @@ export const parseMipsGnuAttributes = (
const readU32 = (offset: number): number =>
littleEndian ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const readUleb = (
offset: number,
end: number,
): { readonly value: number; readonly next: number } | undefined => {
let value = 0;
for (let index = 0; index < 5 && offset + index < end; index += 1) {
const byte = bytes[offset + index]!;
value += (byte & 0x7f) * 2 ** (7 * index);
if ((byte & 0x80) === 0)
return value <= 0xffffffff
? { value, next: offset + index + 1 }
: undefined;
}
return undefined;
};
let fpAbi: number | null = null;
let msa: number | null = null;
@@ -65,7 +51,7 @@ export const parseMipsGnuAttributes = (
let cursor = vendorEnd + 1;
while (cursor < end) {
const scopeStart = cursor;
const scope = readUleb(cursor, end);
const scope = readElfAttributeUleb(bytes, cursor, end);
if (scope === undefined)
return err("malformed MIPS GNU attribute scope tag");
if (scope.value !== TAG_FILE)
@@ -85,7 +71,7 @@ export const parseMipsGnuAttributes = (
const scopeEnd = scopeStart + scopeLength;
cursor = scope.next + 4;
while (cursor < scopeEnd) {
const tag = readUleb(cursor, scopeEnd);
const tag = readElfAttributeUleb(bytes, cursor, scopeEnd);
if (tag === undefined) return err("malformed MIPS GNU attribute tag");
if (
tag.value !== TAG_GNU_MIPS_ABI_FP &&
@@ -94,7 +80,7 @@ export const parseMipsGnuAttributes = (
return err(
`MIPS ELF declares GNU attribute tag ${tag.value}, which REA does not inspect`,
);
const value = readUleb(tag.next, scopeEnd);
const value = readElfAttributeUleb(bytes, tag.next, scopeEnd);
if (value === undefined)
return err("malformed MIPS GNU attribute value");
if (tag.value === TAG_GNU_MIPS_ABI_FP) {
+1 -1
View File
@@ -54,7 +54,7 @@ const pathWithin = (path: string, root: string): boolean => {
* processes see those files at virtual AppData locations; other processes do
* not, and Windows removes them with the package.
*/
export const packagePrivateStorageFamily = (
const packagePrivateStorageFamily = (
canonicalPath: string,
packagesDirectory: string,
): string | undefined => {
+84
View File
@@ -0,0 +1,84 @@
import type { AnalysisOperation } from "./AnalysisProvider.js";
/** One operation that observes part of what a missing operation answers. */
interface OperationAlternative {
readonly operation: AnalysisOperation;
/** What it returns, as a noun phrase. */
readonly observes: string;
}
/**
* Follow-ups for one question shape that an undeclared operation would have
* answered. They are conditional rather than substitutes: `limitation`
* states what none of them establishes.
*/
interface OperationAlternativeRelation {
readonly question: string;
readonly alternatives: readonly OperationAlternative[];
readonly limitation: string;
}
const INSTRUCTION_OBSERVATIONS: OperationAlternativeRelation = {
question: "instruction-level questions",
alternatives: [
{
operation: "inspect_native_instruction",
observes:
"the decoded instruction or data at one exact address, with its length and static flow",
},
{
operation: "read_function_instructions",
observes: "one analyzed procedure's instruction listing in address order",
},
{
operation: "procedure_assembly",
observes: "one analyzed procedure's assembly text",
},
],
limitation:
"None navigates arbitrary analyzed objects such as data or addresses outside a procedure, and neither listing order nor address plus length proves executed control flow.",
};
/** Operations absent from this relation keep their consumer's generic advice. */
const OPERATION_ALTERNATIVES: ReadonlyMap<
string,
OperationAlternativeRelation
> = new Map<AnalysisOperation, OperationAlternativeRelation>([
["next_address", INSTRUCTION_OBSERVATIONS],
["prev_address", INSTRUCTION_OBSERVATIONS],
]);
/** Declared alternatives the caller can run now, in declaration order. */
export interface OfferedOperationAlternatives {
readonly operations: readonly AnalysisOperation[];
/** Each offer's scope, what none establishes, and the provider choice. */
readonly remediation: string;
}
/**
* Select the declared alternatives for an operation the current provider does
* not declare. `isOffered` holds only for operations that provider makes
* available and the caller advertises. Undefined means none qualifies.
*/
export const offeredOperationAlternatives = (
operation: string,
isOffered: (alternative: AnalysisOperation) => boolean,
): OfferedOperationAlternatives | undefined => {
const relation = OPERATION_ALTERNATIVES.get(operation);
if (relation === undefined) return undefined;
const offered = relation.alternatives.filter((alternative) =>
isOffered(alternative.operation),
);
if (offered.length === 0) return undefined;
const described = offered.map(
({ operation: alternative, observes }) => `${alternative} (${observes})`,
);
const listed =
described.length === 1
? described[0]
: `${described.slice(0, -1).join(", ")} and ${described.at(-1)}`;
return {
operations: offered.map((alternative) => alternative.operation),
remediation: `For ${relation.question}, the current provider offers ${listed}. ${relation.limitation} Running ${operation} itself requires a provider that declares it.`,
};
};
@@ -34,7 +34,7 @@ export interface ParsedReferenceSourceEntries {
}
/** Preserve the observed reader failure and add entry-specific recovery guidance. */
export const projectReferenceSourceEntryFailure = (
const projectReferenceSourceEntryFailure = (
entry: Extract<ReferenceSourceEntry, { status: "failed" }>,
): string => `${entry.message} ${entryRecoveryGuidance(entry)}`;
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
projectReferenceSourceImportError,
referenceSourceImportAnalysisError,
type ReferenceSourceImportError,
} from "./ReferenceSourceImportTypes.js";
@@ -20,11 +19,13 @@ describe("reference-source import error projection", () => {
parse: "execution_failure",
} as const;
for (const [code, category] of Object.entries(expectedCategories)) {
const projected = projectReferenceSourceImportError({
tag: "reference-source-import",
code: code as ReferenceSourceImportError["code"],
message: "EIO while reading /selected/source/src/main.ts",
});
const projected = projectAnalysisError(
referenceSourceImportAnalysisError({
tag: "reference-source-import",
code: code as ReferenceSourceImportError["code"],
message: "EIO while reading /selected/source/src/main.ts",
}),
);
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
true,
);
@@ -5,10 +5,6 @@ import {
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
import {
projectAnalysisError,
type AnalysisErrorProjection,
} from "../domain/analysisErrorProjection.js";
import type {
AnalysisError,
AnalysisErrorOptions,
@@ -125,12 +121,6 @@ export const referenceSourceImportAnalysisError = (
);
};
/** Canonical CLI/MCP projection, retaining source facts under diagnostic details. */
export const projectReferenceSourceImportError = (
error: ReferenceSourceImportError,
): AnalysisErrorProjection =>
projectAnalysisError(referenceSourceImportAnalysisError(error));
/** Import a caller-selected local source tree as historical reference. */
export interface ReferenceSourceImportOptions {
readonly root: string;
@@ -0,0 +1,49 @@
import { inspectAnalysisSettingSeeds } from "../config/analysisSettings.js";
import {
parseEnvironment,
parseEnvironmentValues,
type Environment,
} from "../config/environment.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
/** Validate literal registration settings while retaining expression-owned unknowns. */
export const validateRegistrationEnvironment = (
values: Readonly<Record<string, string>>,
expressionSettings: ReadonlySet<string>,
validation: "fragment" | "effective",
platform: NodeJS.Platform,
): Result<
{
readonly configuration: Environment;
readonly unresolvedSettings: ReadonlySet<string>;
},
ConfigurationError
> => {
const runtimeEnvironment = snapshotEnvironment(values, platform);
const unresolvedSettings = new Set(
Object.keys(
snapshotEnvironment(
Object.fromEntries([...expressionSettings].map((name) => [name, ""])),
platform,
),
),
);
const validated =
validation === "fragment"
? parseEnvironmentValues(runtimeEnvironment, unresolvedSettings)
: parseEnvironment(runtimeEnvironment, unresolvedSettings);
if (!validated.ok) return validated;
const seedIssues = inspectAnalysisSettingSeeds(
runtimeEnvironment,
unresolvedSettings,
);
if (seedIssues.length > 0)
return err(
new ConfigurationError("Invalid REA environment configuration", {
settings: seedIssues,
}),
);
return ok({ configuration: validated.value, unresolvedSettings });
};
-1
View File
@@ -23,7 +23,6 @@ import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
import { DEFAULT_CATALOG_IDENTITY } from "../catalogIdentity.js";
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
/** Recording setup host for service-level planning and recovery tests. */
export class FakeSetupHost implements SetupHost {
+8 -1
View File
@@ -101,9 +101,16 @@ it("starts child finalization synchronously and shares concurrent closes", async
]);
const first = owner.close();
expect(calls).toBe(1);
expect(owner.close()).toBe(first);
const second = owner.close();
let secondSettled = false;
void second.then(() => {
secondSettled = true;
});
await Promise.resolve();
expect(secondSettled).toBe(false);
complete?.();
expect(await first).toBeUndefined();
expect(await second).toBeUndefined();
expect(await owner.close()).toBeUndefined();
expect(calls).toBe(1);
});
+2 -2
View File
@@ -420,8 +420,8 @@ export const validateCanonicalSkillDirectories = async (
const canonicalSkillFiles = async (
home: string,
clientIds: readonly string[],
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
resources?: ArtifactResourceScope,
): Promise<readonly CanonicalSkillFile[]> => {
const destinations = skillDestinations(
+1 -1
View File
@@ -872,7 +872,7 @@ const readClientConfiguration = async (
client: UninstallClient,
fileSystem: UninstallFileSystem,
resources: ArtifactResourceScope,
ownershipEstablished = false,
ownershipEstablished: boolean,
): Promise<ClientConfigurationRead> => {
const applicabilityUnverified =
client.applicability === "unverified" && !ownershipEstablished;
+1 -1
View File
@@ -18,7 +18,7 @@ import { UpdateCommandProgress } from "./UpdateCommandProgress.js";
import type { UpdateOutput } from "./Update.js";
import { withoutPackageRunnerIdentity } from "./ClientRegistrationIdentity.js";
export const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024;
const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024;
const UPDATE_PROBE_TIMEOUT_MS = 10_000;
/** The runner owns whether the process started; callers need not infer it from text. */
+2 -2
View File
@@ -144,7 +144,7 @@ const INVALID_NPM_RELEASE_METADATA =
"Invalid npm release metadata: expected a version string or a single-element version array.";
/** bun info arguments for dist-tags.latest; Bun resolves its configured registry. */
export const BUN_LATEST_VERSION_COMMAND: readonly string[] = [
const BUN_LATEST_VERSION_COMMAND: readonly string[] = [
"bun",
"info",
PRODUCT_IDENTITY.packageName,
@@ -173,7 +173,7 @@ const npmLatestVersionCommand = (
*/
const parseNpmLatestVersion = (metadata: string): Result<string, string> => {
const decoded = safeParseJson(metadata);
if (!decoded.ok) return err(decoded.error);
if (!decoded.ok) return decoded;
const version = readNpmLatestVersion(decoded.value);
return version === undefined
? err(INVALID_NPM_RELEASE_METADATA)
+1 -1
View File
@@ -217,7 +217,7 @@ export const executeComposedWorkflow = async (
};
/** Scope of the exact-address reference lookups composed by trace_feature. */
export const TRACE_FEATURE_REFERENCE_LIMITATION =
const TRACE_FEATURE_REFERENCE_LIMITATION =
"Reference queries cover only each matched start address, not interior bytes of strings and procedures. Empty references and truncated: false do not establish complete reference coverage.";
/** Build the shared Evidence record for a successful composed binary workflow. */
@@ -17,7 +17,7 @@ import {
AnalysisCapabilityUnavailableError,
} from "../../domain/analysisErrorCore.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { validateAnalysisViewSubject } from "../../domain/analysisView/analysisViewParent.js";
import { safeParseEvidence } from "../../domain/evidenceValidation.js";
import {
createEvidence,
@@ -249,15 +249,11 @@ const projectResolvedAnalysisView = (
},
});
if (!projected.ok) return projected;
if (
parent.subject !== null &&
parent.subject.digest.sha256 !== projected.value.artifact?.sha256
)
return err(
new EvidenceIntegrityError(
`Parent subject SHA-256 ${parent.subject.digest.sha256} differs from analysis artifact SHA-256 ${projected.value.artifact?.sha256 ?? "unknown"}.`,
),
);
const identity = validateAnalysisViewSubject(
parent.subject,
projected.value.artifact?.sha256,
);
if (!identity.ok) return identity;
return ok(createAnalysisViewEvidence(input, presentation, projected.value));
};
@@ -11,7 +11,7 @@ import {
inspectAnalysisView,
projectRetainedAnalysis,
} from "./AnalysisViewService.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisView.js";
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisViewRequest.js";
import type { Evidence } from "../../domain/evidence.js";
const trace = async (
@@ -31,7 +31,6 @@ import type {
} from "../AnalysisProvider.js";
import {
AnalysisSnapshotCache,
SNAPSHOT_CACHE_ENTRY_CEILING,
isSnapshotCacheable,
isSourceEvidenceRetainable,
} from "./AnalysisSnapshotCache.js";
@@ -595,7 +594,7 @@ describe("analysis snapshot cache capacity", () => {
parameters: { query: "existing" },
execution,
};
for (let index = 0; index < SNAPSHOT_CACHE_ENTRY_CEILING - 1; index += 1)
for (let index = 0; index < 9_999; index += 1)
cache.recordWorkflow({
...workflowInput,
parameters: { query: String(index) },
@@ -620,9 +619,7 @@ describe("analysis snapshot cache capacity", () => {
});
cache.recordWorkflow(workflowInput);
expect(cache.entries()).toHaveLength(1);
expect(cache.workflowEntries()).toHaveLength(
SNAPSHOT_CACHE_ENTRY_CEILING - 1,
);
expect(cache.workflowEntries()).toHaveLength(9_999);
cache.record({
...directInput,
@@ -669,9 +666,7 @@ describe("analysis snapshot cache capacity", () => {
),
"a capacity-refused workflow binding is not counted",
).toBe(0);
expect(cache.workflowEntries()).toHaveLength(
SNAPSHOT_CACHE_ENTRY_CEILING - 1,
);
expect(cache.workflowEntries()).toHaveLength(9_999);
const otherTarget = { ...target, sha256: "b".repeat(64) };
cache.recordWorkflow({
@@ -87,7 +87,7 @@ const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet<string> = new Set(
);
/** Maximum duplicated result bindings retained across direct and workflow queries. */
export const SNAPSHOT_CACHE_ENTRY_CEILING = 10_000;
const SNAPSHOT_CACHE_ENTRY_CEILING = 10_000;
const replayDocumentSelected = (
operation: AnalysisOperation,
@@ -173,7 +173,7 @@ export const requiresNativeSourceAdmissionBeforeReplay = (
): boolean => process.platform === "win32" && providerId === "ghidra";
/** Limitation that marks every observation replayed from a snapshot. */
export const SNAPSHOT_REPLAY_LIMITATION =
const SNAPSHOT_REPLAY_LIMITATION =
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.";
/** Bounded in-memory cache for one immutable binary identity. */
+39 -44
View File
@@ -54,7 +54,10 @@ import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
import { recordEvidenceSources } from "../investigation/recordEvidenceSources.js";
import { recoverProviderSourceFailure } from "../investigation/sourcePublicationRecovery.js";
import { BinarySessionRecords } from "./BinarySessionRecords.js";
import { binarySessionStatus } from "./BinarySessionStatus.js";
import {
binarySessionStatus,
currentRouteCapability,
} from "./BinarySessionStatus.js";
import { SessionRuntimeAvailability } from "./SessionRuntimeAvailability.js";
import { isSourceEvidenceRetainable } from "./AnalysisSnapshotCache.js";
import type { BinarySessionDetail } from "../../contracts/sessionStatusContract.js";
@@ -1396,11 +1399,6 @@ export class BinarySession
: structuredClone(this.#active.target);
}
activeBundleResourceRoot(): ResolvedAppBundleResourceRoot | undefined {
const root = this.#activeBundleResources?.root;
return root === undefined ? undefined : structuredClone(root);
}
override exportAnalysisSnapshot() {
if (this.#activeBundleResources !== undefined)
return err(new ResourceSnapshotMismatchError("export"));
@@ -1567,6 +1565,8 @@ export class BinarySession
parameters: arguments_,
snapshotContext: options?.snapshotContext,
nativeSourceAdmitted: this.#active?.nativeSourceAdmitted === true,
isOperationAvailable: (operation) => this.#currentlyAvailable(operation),
isOperationAdvertised: options?.isOperationAdvertised,
unboundOperationError: (operation, route) =>
this.#providerRouter.unboundOperationError(operation, route),
lookupSnapshot: (target, profile, operation, parameters) =>
@@ -1705,6 +1705,20 @@ export class BinarySession
return execution;
}
/** Whether the active route can run an operation now, as status reports it. */
#currentlyAvailable(operation: string): boolean {
const active = this.#active;
const descriptor = active?.route.capabilities.get(operation);
return (
descriptor !== undefined &&
currentRouteCapability(
descriptor,
active?.target,
this.#runtimeAvailability.entries,
).available
);
}
#clearRuntimeAvailability(): void {
if (this.#runtimeAvailability.clear()) this.#emitAvailabilityChanged();
}
@@ -1740,22 +1754,16 @@ export class BinarySession
this.#transitionPending += 1;
// Later calls wait for this transition and must not be included in its drain.
const admittedThrough = this.#transitionGeneration++;
const result = this.#transition.then(
() => operation(admittedThrough),
() => operation(admittedThrough),
);
const perform = (): Promise<T> => operation(admittedThrough);
const result = this.#transition.then(perform, perform);
this.#transition = result.then(
() => undefined,
() => undefined,
);
void result.then(
() => {
this.#transitionPending -= 1;
},
() => {
this.#transitionPending -= 1;
},
);
const finish = (): void => {
this.#transitionPending -= 1;
};
void result.then(finish, finish);
return result;
}
@@ -1850,32 +1858,19 @@ export class BinarySession
);
};
signal.addEventListener("abort", onAbort, { once: true });
transition.then(
() => {
signal.removeEventListener("abort", onAbort);
resolve(
signal.aborted
? err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
}),
)
: ok(undefined),
);
},
() => {
signal.removeEventListener("abort", onAbort);
resolve(
signal.aborted
? err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
}),
)
: ok(undefined),
);
},
);
const finish = (): void => {
signal.removeEventListener("abort", onAbort);
resolve(
signal.aborted
? err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
}),
)
: ok(undefined),
);
};
transition.then(finish, finish);
});
}
}
@@ -145,9 +145,11 @@ it("opens a resource-only bundle without executable identity and keeps snapshot
path: opened.value.canonicalPath,
caller_path: opened.value.callerPath,
});
expect(session.activeBundleResourceRoot()?.canonicalPath).toBe(
opened.value.canonicalPath,
);
expect(await session.status()).toMatchObject({
open: true,
session_kind: "bundle_resources",
resource_root: { canonical_path: opened.value.canonicalPath },
});
expect(
bundleResourceRootSchema.parse({
caller_path: opened.value.callerPath,
@@ -17,6 +17,7 @@ import type {
CapabilityDescriptor,
ExecutionOptions,
} from "../AnalysisProvider.js";
import { offeredOperationAlternatives } from "../OperationAlternatives.js";
import {
isSnapshotCacheable,
requestsFreshIndependentDecompile,
@@ -45,6 +46,11 @@ interface PrepareSessionExecutionInput {
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly snapshotContext?: ExecutionOptions["snapshotContext"];
readonly nativeSourceAdmitted: boolean;
/** Current availability as binary_session reports it, not the declaration. */
readonly isOperationAvailable: (operation: AnalysisOperation) => boolean;
readonly isOperationAdvertised:
| ((operation: AnalysisOperation) => boolean)
| undefined;
readonly unboundOperationError: (
operation: AnalysisOperation,
route: SessionProviderRoute,
@@ -58,9 +64,9 @@ interface PrepareSessionExecutionInput {
}
/** Recovery for an operation absent from the selected binary route. */
export const undeclaredOperationGuidance = (
const undeclaredOperationGuidance = (
operation: string,
entryCommentAvailable = false,
entryCommentAvailable: boolean,
): string => {
const entryComment =
entryCommentAvailable &&
@@ -79,6 +85,8 @@ export const prepareSessionExecution = (
operation,
parameters,
nativeSourceAdmitted,
isOperationAvailable,
isOperationAdvertised,
unboundOperationError,
lookupSnapshot,
} = input;
@@ -97,6 +105,15 @@ export const prepareSessionExecution = (
capability === undefined
? undeclaredOperationGuidance(operation, entryCommentAvailable)
: undefined;
const offered =
capability === undefined
? offeredOperationAlternatives(
operation,
(alternative) =>
isOperationAvailable(alternative) &&
(isOperationAdvertised?.(alternative) ?? true),
)
: undefined;
return err(
new AnalysisCapabilityUnavailableError(
active.route.binding?.identity.id ?? active.route.identity.id,
@@ -114,6 +131,12 @@ export const prepareSessionExecution = (
: {
userMessage: `The selected provider does not declare ${operation}.`,
fallbackRemediationAction: undeclaredGuidance,
...(offered === undefined
? {}
: {
alternatives: offered.operations,
remediationAction: offered.remediation,
}),
},
),
);
@@ -0,0 +1,200 @@
import { describe, expect, it, onTestFinished } from "vitest";
import { observed } from "../../../tests/fixtures/analysisExecution.js";
import {
createBinarySessionTargets,
createTestBinarySession,
} from "../../../tests/fixtures/binarySession.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { ok } from "../../domain/result.js";
import type {
AnalysisOperation,
AnalysisProvider,
CapabilityDescriptor,
ExecutionOptions,
} from "../AnalysisProvider.js";
import { buildCapabilityInventory } from "../CapabilityInventory.js";
import { offeredOperationAlternatives } from "../OperationAlternatives.js";
const IDENTITY = {
id: "fixture",
name: "Fixture analysis provider",
version: "1",
} as const;
const GENERIC =
"This analysis is unavailable for the current target. Choose another analysis or target.";
/** The owning formatter's remediation for exactly these offered tools. */
const remediationFor = (operation: string, offered: readonly string[]) =>
offeredOperationAlternatives(operation, (alternative) =>
offered.includes(alternative),
)?.remediation;
const capability = (
operation: Exclude<AnalysisOperation, "health">,
available = true,
): CapabilityDescriptor => ({
provider: IDENTITY,
operation,
...(available
? ({ available: true, reason: null } as const)
: ({
available: false,
reason: `${operation} needs a loaded listing`,
} as const)),
cachePolicy: "live",
effects: {
mutatesArtifact: false,
launchesProcess: false,
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem: false,
changesPermissions: false,
requiresRoot: false,
},
limitations: [],
});
/** A deep provider whose declared operations alone decide the offers. */
const openSession = async (capabilities: readonly CapabilityDescriptor[]) => {
const provider: AnalysisProvider = {
identity: () => IDENTITY,
resolveAnalysisProfile: () =>
Promise.resolve(
ok({ profile: createAnalysisProfile(IDENTITY, { fixture: true }) }),
),
capabilities: () => capabilities,
createClient: () => ({
execute: (operation) => Promise.resolve(observed(operation)),
close: () => Promise.resolve(ok(null)),
}),
};
const session = createTestBinarySession(provider);
onTestFinished(async () => {
expect(await session.close()).toEqual({ ok: true, value: null });
});
const [target] = await createBinarySessionTargets();
expect((await session.open(target)).ok).toBe(true);
return session;
};
const refusal = async (
session: Awaited<ReturnType<typeof openSession>>,
operation: "next_address" | "prev_address",
options?: ExecutionOptions,
) => {
const result = await session.execute(operation, {}, options);
if (result.ok) throw new Error(`${operation} unexpectedly succeeded`);
return projectAnalysisError(result.error);
};
const offeredInDiscovery = async (
session: Awaited<ReturnType<typeof openSession>>,
isToolAdvertised?: (name: string) => boolean,
) =>
buildCapabilityInventory(
await session.status(),
{ processCaptureEnabled: false },
isToolAdvertised,
)
.filter(({ name }) => name === "next_address" || name === "prev_address")
.map((tool) => (tool.available ? undefined : tool.alternatives));
describe("undeclared navigation refusals", () => {
it("describe only offered tools, what none establishes and the provider choice", () => {
const offered = offeredOperationAlternatives(
"prev_address",
(alternative) => alternative === "procedure_assembly",
);
expect(offered?.operations).toEqual(["procedure_assembly"]);
expect(offered?.remediation).toContain("procedure_assembly (");
expect(offered?.remediation).not.toMatch(
/inspect_native_instruction|read_function_instructions/u,
);
expect(offered?.remediation).toContain("executed control flow");
expect(offered?.remediation).toContain("prev_address itself");
expect(
offeredOperationAlternatives("list_procedures", () => true),
).toBeUndefined();
});
it("offer only alternatives the provider makes available", async () => {
const session = await openSession([
capability("inspect_native_instruction", false),
capability("read_function_instructions"),
capability("procedure_assembly"),
]);
for (const operation of ["next_address", "prev_address"] as const)
expect(await refusal(session, operation)).toMatchObject({
code: "capability_unavailable",
remediation: {
action: remediationFor(operation, [
"read_function_instructions",
"procedure_assembly",
]),
},
details: {
provider_id: "fixture",
operation,
alternatives: ["read_function_instructions", "procedure_assembly"],
},
});
expect(await offeredInDiscovery(session)).toEqual([
["read_function_instructions", "procedure_assembly"],
["read_function_instructions", "procedure_assembly"],
]);
});
it("offer only alternatives the caller advertises", async () => {
const session = await openSession([
capability("read_function_instructions"),
capability("procedure_assembly"),
]);
const withoutListing = (operation: string) =>
operation !== "read_function_instructions";
const narrowed = await refusal(session, "next_address", {
isOperationAdvertised: withoutListing,
});
expect(narrowed.details?.alternatives).toEqual(["procedure_assembly"]);
expect(narrowed.remediation.action).toBe(
remediationFor("next_address", ["procedure_assembly"]),
);
expect(await offeredInDiscovery(session, withoutListing)).toEqual([
["procedure_assembly"],
["procedure_assembly"],
]);
const hidden = (operation: string) =>
operation !== "read_function_instructions" &&
operation !== "procedure_assembly";
const generic = await refusal(session, "prev_address", {
isOperationAdvertised: hidden,
});
expect(generic.remediation.action).toEqual(
expect.stringContaining("binary_session"),
);
expect(generic.details).not.toHaveProperty("alternatives");
expect(await offeredInDiscovery(session, hidden)).toEqual([
undefined,
undefined,
]);
});
it("keep a declared operation's own unavailability reason", async () => {
const session = await openSession([
capability("next_address", false),
capability("read_function_instructions"),
]);
const declared = await refusal(session, "next_address");
expect(declared).toMatchObject({
code: "capability_unavailable",
remediation: { action: GENERIC },
details: { reason: "next_address needs a loaded listing" },
});
expect(declared.details).not.toHaveProperty("alternatives");
const [next, previous] = await offeredInDiscovery(session);
expect(next).toBeUndefined();
expect(previous).toEqual(["read_function_instructions"]);
});
});
+31 -22
View File
@@ -51,6 +51,34 @@ interface BinarySessionStatusInput {
readonly refreshCandidates?: boolean;
}
/**
* One route capability as the session currently reports it: target
* applicability and observed runtime unavailability override the declaration.
*/
export const currentRouteCapability = (
descriptor: CapabilityDescriptor,
target: BinaryTarget | undefined,
runtimeUnavailability: ReadonlyMap<string, { readonly reason: string }>,
) => {
const targetRefusal =
target === undefined
? undefined
: descriptor.targetApplicability === undefined
? artifactTargetRefusal(descriptor.operation, target)
: targetApplicabilityRefusal(descriptor.targetApplicability, target);
if (targetRefusal !== undefined && descriptor.available)
return {
...descriptor,
available: false as const,
availabilityCode: targetRefusal.code,
reason: targetRefusal.reason,
};
const unavailable = runtimeUnavailability.get(descriptor.operation);
return unavailable === undefined
? descriptor
: runtimeUnavailable(descriptor, unavailable.reason);
};
/** Project internal provider routing state into the caller-visible session status. */
export const binarySessionStatus = async ({
investigation,
@@ -79,28 +107,9 @@ export const binarySessionStatus = async ({
.sort((left, right) =>
compareUnicodeCodePoints(left.operation, right.operation),
)
.map((descriptor) => {
const targetRefusal =
target === undefined
? undefined
: descriptor.targetApplicability === undefined
? artifactTargetRefusal(descriptor.operation, target)
: targetApplicabilityRefusal(
descriptor.targetApplicability,
target,
);
if (targetRefusal !== undefined && descriptor.available)
return {
...descriptor,
available: false as const,
availabilityCode: targetRefusal.code,
reason: targetRefusal.reason,
};
const unavailable = runtimeUnavailability.get(descriptor.operation);
return unavailable === undefined
? descriptor
: runtimeUnavailable(descriptor, unavailable.reason);
});
.map((descriptor) =>
currentRouteCapability(descriptor, target, runtimeUnavailability),
);
const capabilities = providerCapabilitySets(routeCapabilities);
const analysisProviderBinding =
bundleResourceRoot !== undefined
@@ -31,9 +31,7 @@ type RoutedOperation = Pick<
>;
/** Reason recorded for an operation's own refusal or provider-wide failure. */
export const runtimeFailureReason = (
failure: AnalysisError,
): string | undefined => {
const runtimeFailureReason = (failure: AnalysisError): string | undefined => {
if (failure._tag === "AnalysisCapabilityUnavailableError")
return failure.userMessage ?? failure.message;
return PROVIDER_WIDE_FAILURES.has(failure._tag)
@@ -42,7 +40,7 @@ export const runtimeFailureReason = (
};
/** Explain unavailability inferred for an operation no request failed for. */
export const inferredUnavailabilityReason = (
const inferredUnavailabilityReason = (
providerId: string,
failure: Pick<ProviderFailure, "operation" | "reason">,
): string =>
@@ -1,3 +1,6 @@
import { createHash } from "node:crypto";
import { parseAppleIps } from "../../native/apple/AppleIpsParser.js";
import { inspectAppleIpsInputSchema } from "../../domain/native/appleIps.js";
import { parseEvidence } from "../../domain/evidence.js";
import { expect, it } from "vitest";
import { AppleIpsService } from "./AppleIpsService.js";
@@ -10,52 +13,19 @@ import {
} from "../../domain/analysisErrorCore.js";
const identity = APPLE_IPS_PROVIDER;
const report: AppleIps = {
artifact: {
path: "/artifacts/report.ips",
sha256: "a".repeat(64),
bytes: 128,
const bytes = Buffer.from(
'{"bug_type":"309"}\n{"procName":"rea-crash-fixture","threads":[],"usedImages":[]}',
);
const path = "/artifacts/report.ips";
const report = parseAppleIps(
bytes,
{
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
},
metadata: {
bug_type: "309",
timestamp: null,
os_version: null,
bundle_id: null,
incident_id: null,
name: null,
build_version: null,
platform: null,
unlisted_keys: [],
},
process: {
name: "rea-crash-fixture",
path: null,
pid: null,
parent: null,
parent_pid: null,
cpu_type: null,
translated: null,
os_version: null,
},
exception: null,
termination: null,
faulting_thread: null,
threads: [],
images: [],
classification: { status: "unclassified", matches: [] },
coverage: {
status: "complete",
reason: null,
threads: 0,
frames: 0,
images: 0,
registers: 0,
type_mismatches: [],
},
unlisted_body_keys: [],
join: null,
limitations: ["Opened Mach-O and dSYM UUID joins remain unobserved."],
};
inspectAppleIpsInputSchema.parse({ path }),
);
it.each([0, 5])(
"rejects an unresolved faulting thread %s without its mismatch",
@@ -1,11 +1,13 @@
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type {
ExecutionOptions,
ProviderIdentity,
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type {
BinaryLayout,
InspectBinaryLayoutInput,
} from "../../domain/native/binaryLayout.js";
import type { Result } from "../../domain/result.js";
import type { ProviderIdentity } from "../AnalysisProvider.js";
/** Replaceable offline binary-layout provider; owns its parser and process protocol. */
export interface BinaryLayoutPort {
@@ -5,10 +5,7 @@ import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { byteEntropySchema } from "../../domain/native/byteEntropy.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import {
ByteEntropyService,
MAX_BYTE_ENTROPY_BLOCKS,
} from "./ByteEntropyService.js";
import { ByteEntropyService } from "./ByteEntropyService.js";
it("keeps whole-file identity and entropy while selecting unaligned blocks and a partial tail", async () => {
const root = await createTestTempDirectory("rea-entropy-");
@@ -421,7 +418,7 @@ it("reports a changed artifact when its parent directory moves during streaming"
it("refuses a block series above the output-row limit before scanning", async () => {
const root = await createTestTempDirectory("rea-entropy-block-limit-");
const path = join(root, "bytes");
await writeFile(path, Buffer.alloc(MAX_BYTE_ENTROPY_BLOCKS * 256 + 1));
await writeFile(path, Buffer.alloc(32_768 * 256 + 1));
let reads = 0;
const service = new ByteEntropyService({
stat,
@@ -443,8 +440,8 @@ it("refuses a block series above the output-row limit before scanning", async ()
_tag: "AnalysisResourceConstraintError",
resource: "capacity",
reportedLimits: {
max_blocks: MAX_BYTE_ENTROPY_BLOCKS,
requested_blocks: MAX_BYTE_ENTROPY_BLOCKS + 1,
max_blocks: 32_768,
requested_blocks: 32_769,
},
},
});
@@ -44,7 +44,7 @@ import { completeProviderEvidence } from "../ProviderOperationExecution.js";
import { parseAnalysisInput } from "../AnalysisInput.js";
/** Bound retained result rows before constructing the Evidence representation. */
export const MAX_BYTE_ENTROPY_BLOCKS = 32_768;
const MAX_BYTE_ENTROPY_BLOCKS = 32_768;
/** Generated files such as Linux /proc entries report size 0 but still hold bytes. */
const ZERO_SIZE_LIMITATION =
@@ -2,7 +2,10 @@ import { parseEvidence } from "../../domain/evidence.js";
import { expect, it } from "vitest";
import { ElfNotesService } from "./ElfNotesService.js";
import { ELF_NOTES_PROVIDER } from "../../domain/investigationProviders.js";
import type { ElfNotes } from "../../domain/native/elfNotes.js";
import { createHash } from "node:crypto";
import { inspectElfNotesInputSchema } from "../../domain/native/elfNotes.js";
import { elfNoteFixture } from "../../native/elf/ElfNotes.fixture.js";
import { parseElfNotes } from "../../native/elf/ElfNoteParser.js";
import { ok } from "../../domain/result.js";
import {
AnalysisCancelledError,
@@ -11,22 +14,17 @@ import {
} from "../../domain/analysisErrorCore.js";
const identity = ELF_NOTES_PROVIDER;
const report: ElfNotes = {
artifact: {
path: "/artifacts/app",
sha256: "a".repeat(64),
bytes: 512,
const path = "/artifacts/app";
const bytes = elfNoteFixture({ ownership: "none" });
const report = parseElfNotes(
bytes,
{
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
},
format: "elf64",
byte_order: "little",
machine: 62,
type: 3,
type_name: "shared",
tables: [],
entries: [],
coverage: { status: "complete", examined_tables: 0, examined_entries: 0 },
limitations: ["No PT_NOTE program header or SHT_NOTE section was declared."],
};
inspectElfNotesInputSchema.parse({ path }),
);
it("rejects a relative path and extra arguments before reading", async () => {
let calls = 0;
Loaded 100 of 1383 files, more files were not shown because too many files have changed in this diff. Show more