mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 18:49:25 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11b9760830 | ||
|
|
9fd5eca98a | ||
|
|
ed307b8d52 | ||
|
|
17e8deebfd | ||
|
|
454d7121f5 | ||
|
|
02243e21f3 | ||
|
|
4c1f2537e1 | ||
|
|
229ebd49d3 | ||
|
|
41493cf39b | ||
|
|
9304152a95 | ||
|
|
f1622b32d5 |
No files matched your search
@@ -755,9 +755,7 @@ jobs:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.apple == 'true'
|
||||
runs-on: macos-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
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
|
||||
@@ -464,9 +464,7 @@ 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")
|
||||
|| element.getTagName().equals("uses-permission-sdk-23")
|
||||
|| element.getTagName().equals("uses-permission-sdk-m")) {
|
||||
} else if (element.getTagName().equals("uses-permission")) {
|
||||
String permission = element.getAttributeNS(android, "name");
|
||||
if (!permission.isEmpty()) permissions.add(permission);
|
||||
}
|
||||
|
||||
@@ -958,6 +958,15 @@ 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):
|
||||
|
||||
@@ -154,6 +154,13 @@ 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
@@ -238,9 +238,7 @@ 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. GitHub
|
||||
Actions jobs own their runner and use up to four; see
|
||||
[test deadlines](#developer-commands) for how deadlines scale there.
|
||||
Vitest projects use up to two workers, bounded by host parallelism.
|
||||
`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,
|
||||
@@ -285,7 +283,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, and spawned REA processes retain their own heap settings.
|
||||
remains bounded at two, 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.
|
||||
@@ -301,18 +299,6 @@ 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.
|
||||
@@ -357,17 +343,11 @@ 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.
|
||||
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.
|
||||
`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.
|
||||
`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
|
||||
@@ -1097,12 +1077,32 @@ 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. 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.
|
||||
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.
|
||||
`process-boundary` stays serial because process-tree sampling shares the host.
|
||||
Hosted CI jobs use four workers; other hosts stay at two
|
||||
because they are shared with other checks. Full qualification keeps six covered
|
||||
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
|
||||
shards; its longer instrumentation is outside this fast budget.
|
||||
|
||||
The separate `CI timings` workflow batches recent completed CI and Release runs
|
||||
|
||||
@@ -215,10 +215,7 @@ 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. 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.
|
||||
already loaded image reuses it.
|
||||
- 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 `\`
|
||||
@@ -255,12 +252,8 @@ 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. 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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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,17 +1065,6 @@ 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,
|
||||
@@ -1922,10 +1911,13 @@ 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.
|
||||
|
||||
`compare_artifacts` also compares the declarations in the `format_metadata` of <!-- BEGIN:compare-artifacts:compared-format-metadata-kinds -->UnityFS members (`unityfs-entry`), PyInstaller entries
|
||||
<!-- BEGIN:compare-artifacts:compared-format-metadata-kinds -->
|
||||
|
||||
`compare_artifacts` also compares the declarations in the `format_metadata`
|
||||
of 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`)<!-- END:compare-artifacts:compared-format-metadata-kinds -->;
|
||||
ESP flash partitions (`esp-flash-partition`) and FIT images (`fit-image`);
|
||||
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
|
||||
@@ -1936,6 +1928,8 @@ 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
|
||||
@@ -2138,6 +2132,26 @@ check full-profile local-path rules. Compact MCP workflow tests verify canonical
|
||||
rejection of invalid inputs before provider work, independent of how much detail
|
||||
the selected advertisement retains.
|
||||
|
||||
### Retained-view guidance
|
||||
|
||||
`inspect_analysis_view` advertises the shared source, Evidence and paging rules.
|
||||
Its format-specific selectors are available on demand through the existing
|
||||
guidance tools: search with `{"query":"inspect_analysis_view <format or view kind>"}`,
|
||||
then pass a returned locator to `read_guidance_section`. For example, search for
|
||||
`inspect_analysis_view OpenAPI` before selecting retained API operations.
|
||||
These compiled tool sections ship in the runtime alongside the installed workflow
|
||||
guides; they use the same bundle digest, section selection and stale-locator checks.
|
||||
They are documentation, not target observations or Evidence. CLI callers use
|
||||
`rea guidance --action search --query "inspect_analysis_view OpenAPI"`, then
|
||||
`rea guidance --action read --locator <locator>` through the same owner.
|
||||
|
||||
In hot/cold catalogs, first discover `search_guidance` and `read_guidance_section`
|
||||
by their exact names. Keyword tool discovery also searches the compiled tool
|
||||
guidance, so moving a selector out of the advertised description does not remove
|
||||
its tool-discovery keywords. Full input/output schemas and canonical validation
|
||||
remain unchanged; a full-catalog client does not need catalog refresh to use the
|
||||
guidance tools.
|
||||
|
||||
<a id="hotcold-catalog-discovery"></a>
|
||||
|
||||
### Hot/cold catalog discovery
|
||||
@@ -2147,6 +2161,13 @@ clients that refresh `tools/list` after `notifications/tools/list_changed`.
|
||||
The default, `full`, retains the complete upfront advertisement. Restart the
|
||||
server to change profiles. See [client configuration](../getting-started/client-configuration.md#hotcold-catalogs).
|
||||
|
||||
Legacy connections receive list-change notifications unsolicited. On protocol
|
||||
revision `2026-07-28`, subscribe to tool-list changes with `subscriptions/listen`;
|
||||
the pinned JS SDK exposes `client.listen({toolsListChanged: true})` or the
|
||||
`ClientOptions.listChanged.tools` convenience handler. Registering a notification
|
||||
handler alone does not open a modern subscription. Refresh `tools/list` after
|
||||
the subscribed notification before calling newly loaded tools.
|
||||
|
||||
Hot/cold initially advertises `binary_session`, `open_binary`, `close_binary`,
|
||||
`get_evidence_bundle`, and the adapter's `discover_tools`.
|
||||
`inspect_analysis_view` is loaded on demand like other analysis tools. When a
|
||||
@@ -2156,7 +2177,7 @@ that accept `view` can answer a selected first question without loading the
|
||||
cross-domain view contract. Complete output schemas and canonical result
|
||||
validation are preserved when each tool loads.
|
||||
An explicit tool-family scope can remove any of those domain tools.
|
||||
`discover_tools` searches canonical names and descriptions using case-insensitive
|
||||
`discover_tools` searches canonical names, descriptions and compiled tool guidance using case-insensitive
|
||||
words, or accepts exact `names`. It loads the matching page and returns names
|
||||
and abbreviated descriptions with their configured input schemas, so the next
|
||||
call can use the returned arguments contract. Output schemas remain in
|
||||
|
||||
@@ -96,10 +96,6 @@ 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:
|
||||
@@ -140,8 +136,6 @@ 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: `readerViewsOfBytes` in `DesktopTaskReaders.ts`;
|
||||
`runReaderHere` in `src/desktop/TaskReaderProcess.ts`;
|
||||
5. Application: `readTaskReaderViews` and `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
|
||||
|
||||
+22
-8
@@ -842,14 +842,28 @@ 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.
|
||||
|
||||
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;
|
||||
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;
|
||||
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
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"entry": [
|
||||
"src/main.ts",
|
||||
"src/cli.ts",
|
||||
"src/cliOneShot.ts",
|
||||
"src/cliOutput.ts",
|
||||
"src/cliBuiltinArguments.ts",
|
||||
"src/cli/cliResultOutput.ts",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"entry": [
|
||||
"src/main.ts!",
|
||||
"src/cli.ts!",
|
||||
"src/cliOneShot.ts!",
|
||||
"src/cliOutput.ts!",
|
||||
"src/cliBuiltinArguments.ts!",
|
||||
"src/cli/cliResultOutput.ts!",
|
||||
|
||||
@@ -5,8 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
/**
|
||||
* Reduce filesystem resolution during CLI and MCP startup. Keep dependencies
|
||||
* external and original compiled worker entrypoints. Dynamic imports can split
|
||||
* into lazy chunks unless the same module is also statically reachable.
|
||||
* external, dynamic imports lazy, and original compiled worker entrypoints.
|
||||
*/
|
||||
export const bundleRuntime = async (root) => {
|
||||
const compiledRoot = resolve(root, "dist");
|
||||
@@ -24,7 +23,7 @@ export const bundleRuntime = async (root) => {
|
||||
"cli-runtime",
|
||||
{
|
||||
cli: "cli.js",
|
||||
cliOneShot: "cliOneShot.js",
|
||||
cliOutput: "cliOutput.js",
|
||||
cliBuiltinArguments: "cliBuiltinArguments.js",
|
||||
cliResultOutput: "cli/cliResultOutput.js",
|
||||
mcpDoctor: "mcpDoctor.js",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -19,11 +19,6 @@ 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]),
|
||||
|
||||
@@ -2,17 +2,6 @@ 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(
|
||||
|
||||
@@ -15,7 +15,6 @@ export function parseDevelopmentTestRequest(
|
||||
export function developmentTestPlan(
|
||||
request: DevelopmentTestRequest,
|
||||
baseCommit?: string,
|
||||
resolvedPaths?: readonly string[],
|
||||
): {
|
||||
readonly needsBuild: boolean;
|
||||
readonly artifactTasks: readonly string[];
|
||||
|
||||
@@ -58,11 +58,7 @@ const artifactConsumers = {
|
||||
};
|
||||
|
||||
/** Plan source feedback or explicit tests with their runtime/artifact prerequisites. */
|
||||
export const developmentTestPlan = (
|
||||
request,
|
||||
baseCommit,
|
||||
resolvedPaths = request.paths,
|
||||
) => {
|
||||
export const developmentTestPlan = (request, baseCommit) => {
|
||||
const explicit = request.paths.length > 0;
|
||||
if (
|
||||
request.mode === "changed" &&
|
||||
@@ -74,12 +70,12 @@ export const developmentTestPlan = (
|
||||
? []
|
||||
: SOURCE_TEST_PROJECTS.map(({ name }) => name);
|
||||
return {
|
||||
needsBuild: resolvedPaths.some(
|
||||
needsBuild: request.paths.some(
|
||||
(path) => projectForTest(path).group === "built",
|
||||
),
|
||||
artifactTasks: Object.entries(artifactConsumers).flatMap(
|
||||
([task, consumers]) =>
|
||||
resolvedPaths.some((path) => consumers.includes(path)) ? [task] : [],
|
||||
request.paths.some((path) => consumers.includes(path)) ? [task] : [],
|
||||
),
|
||||
vitestArguments: [
|
||||
"run",
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -570,11 +571,7 @@ 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(
|
||||
"The active analysis session and annotations remain available.",
|
||||
),
|
||||
);
|
||||
assert.ok(regexError.message.includes(GHIDRA_ANNOTATIONS_RETAINED));
|
||||
rejectedCalls++;
|
||||
const nestedPattern = "(".repeat(12000) + "a" + ")".repeat(12000);
|
||||
const compileFailure = await client.callTool(
|
||||
|
||||
@@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { RetainedResourceScope } from "../../src/application/AnalysisProvider.js";
|
||||
import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js";
|
||||
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
ArtifactResourceScope,
|
||||
type ArtifactResourceOwner,
|
||||
} from "../../src/artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
prepareOwnedCommand,
|
||||
runOwnedCommand,
|
||||
@@ -58,7 +61,6 @@ 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>;
|
||||
@@ -150,17 +152,13 @@ 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, AdoptedVerifierOwner>();
|
||||
readonly #liveAdopted = new Set<AdoptedVerifierOwner>();
|
||||
readonly #adopted = new WeakMap<
|
||||
VerifierCleanupOwner,
|
||||
ArtifactResourceOwner
|
||||
>();
|
||||
readonly #resourceOwner: RetainedResourceScope;
|
||||
#closed = false;
|
||||
#released = false;
|
||||
@@ -172,21 +170,13 @@ class VerifierCommandOwner implements VerifierCommands {
|
||||
) => Promise<SpawnedOwnedProviderProcess>,
|
||||
) {
|
||||
this.#resourceOwner = {
|
||||
retain: (owner, resource) =>
|
||||
this.#resources.retain(this.#adoptedOwner(owner, resource)),
|
||||
retain: (owner, resource) => this.adopt(owner, resource),
|
||||
run: (work) => this.#resources.run(work),
|
||||
};
|
||||
}
|
||||
|
||||
get cleanupResources(): readonly string[] {
|
||||
return [
|
||||
...new Set([
|
||||
...this.#resources.cleanupResources,
|
||||
...[...this.#liveAdopted].flatMap(({ lease, resource }) =>
|
||||
lease.released ? [] : (lease.cleanupResources ?? [resource]),
|
||||
),
|
||||
]),
|
||||
];
|
||||
return this.#resources.cleanupResources;
|
||||
}
|
||||
|
||||
exec(
|
||||
@@ -243,15 +233,6 @@ 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);
|
||||
@@ -263,18 +244,15 @@ class VerifierCommandOwner implements VerifierCommands {
|
||||
};
|
||||
this.#adopted.set(owner, retained);
|
||||
}
|
||||
return 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);
|
||||
}
|
||||
|
||||
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
@@ -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/cliOneShot.js",
|
||||
"dist/cli-runtime/cliOutput.js",
|
||||
"dist/cli-runtime/cliBuiltinArguments.js",
|
||||
"dist/cli-runtime/cliResultOutput.js",
|
||||
"dist/cli-runtime/mcpDoctor.js",
|
||||
|
||||
+142
-54
@@ -64,7 +64,7 @@ const runtimeFiles = [
|
||||
? ["dist/mcp/main.js", "dist/cli-runtime/mcpDoctor.js"]
|
||||
: [
|
||||
"dist/cli-runtime/cli.js",
|
||||
"dist/cli-runtime/cliOneShot.js",
|
||||
"dist/cli-runtime/cliOutput.js",
|
||||
"dist/cli-runtime/cliResultOutput.js",
|
||||
]),
|
||||
];
|
||||
@@ -104,66 +104,154 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const { runOneShotCli } = await import("../dist/cli-runtime/cliOneShot.js");
|
||||
const {
|
||||
cliConfigurationError,
|
||||
renderCliOutputArgumentError,
|
||||
renderEmptyFilteredCliOutput,
|
||||
renderVersionReport,
|
||||
parseCliOutputArguments,
|
||||
} = await import("../dist/cli-runtime/cliOutput.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 {
|
||||
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);
|
||||
})();
|
||||
},
|
||||
});
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (cause?.code === "EPIPE") {
|
||||
stdoutClosed = true;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { realpath, stat } from "node:fs/promises";
|
||||
import { relative, resolve, sep } from "node:path";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { resolve } 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;
|
||||
@@ -30,22 +29,9 @@ 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") {
|
||||
@@ -56,7 +42,7 @@ try {
|
||||
);
|
||||
baseCommit = base.output.trim();
|
||||
}
|
||||
const plan = developmentTestPlan(request, baseCommit, resolvedPaths);
|
||||
const plan = developmentTestPlan(request, baseCommit);
|
||||
if (request.dryRun) {
|
||||
console.log(JSON.stringify({ ...request, baseCommit, ...plan }, null, 2));
|
||||
} else {
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
#!/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 commands = await createVerifierCommands();
|
||||
const execute = promisify(execFile);
|
||||
const compiler = process.env.REA_VERIFY_GO_COMMAND ?? "go";
|
||||
if (process.env.REA_VERIFY_GO_COMMAND !== undefined && !isAbsolute(compiler))
|
||||
throw new Error(
|
||||
@@ -52,16 +51,13 @@ const goEnvironment = {
|
||||
// Go's own verifier seam isolates telemetry; no global configuration is changed.
|
||||
TEST_TELEMETRY_DIR: join(root.path, "telemetry"),
|
||||
};
|
||||
let client;
|
||||
let transport;
|
||||
const cleanup = new AnalysisCleanupLease(async () => {
|
||||
await closeVerifierResources([
|
||||
() => client?.close(),
|
||||
() => transport?.close(),
|
||||
() => commands.close(),
|
||||
]);
|
||||
await root.close();
|
||||
}, [root.path]);
|
||||
const client = new Client({ name: "go-binary-verifier", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const targets = [
|
||||
["linux", "amd64", "elf", 64, "little"],
|
||||
["linux", "386", "elf", 32, "little"],
|
||||
@@ -77,19 +73,10 @@ 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 commands.exec(compiler, ["version"], {
|
||||
await execute(compiler, ["version"], {
|
||||
cwd: root.path,
|
||||
env: goEnvironment,
|
||||
timeout: 10_000,
|
||||
@@ -198,7 +185,7 @@ try {
|
||||
const path = join(root.path, "linux-amd64-byte-strings");
|
||||
const expected = JSON.parse(
|
||||
(
|
||||
await commands.exec(oracle, ["--build-byte-fixture", compiler, path], {
|
||||
await execute(oracle, ["--build-byte-fixture", compiler, path], {
|
||||
cwd: sourceRoot,
|
||||
env: { ...goEnvironment, GOOS: "linux", GOARCH: "amd64" },
|
||||
timeout: 120_000,
|
||||
@@ -211,16 +198,21 @@ try {
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
} finally {
|
||||
try {
|
||||
await cleanup.release();
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
for (const close of [
|
||||
() => client.close(),
|
||||
() => transport.close(),
|
||||
() => root.close(),
|
||||
]) {
|
||||
try {
|
||||
await close();
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
const verifier = await completeVerifierRun(run);
|
||||
try {
|
||||
if (cleanup.released)
|
||||
await assert.rejects(lstat(root.path), { code: "ENOENT" });
|
||||
await assert.rejects(lstat(root.path), { code: "ENOENT" });
|
||||
if (verifier.process_lineage.status === "verified")
|
||||
assert.deepEqual(verifier.process_lineage.descendants, []);
|
||||
} catch (cause) {
|
||||
@@ -234,21 +226,15 @@ if (failures.length > 0) {
|
||||
compiler: compilerVersion,
|
||||
verified,
|
||||
omitted_coverage: omittedCoverage,
|
||||
retained_root: cleanup.released ? null : root.path,
|
||||
verifier,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
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] },
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
"Real Go metadata verification failed; cleanup failures are retained.",
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
@@ -270,7 +256,7 @@ console.log(
|
||||
);
|
||||
|
||||
async function go(args, overrides = {}) {
|
||||
return commands.exec(compiler, args, {
|
||||
return execute(compiler, args, {
|
||||
cwd: sourceRoot,
|
||||
env: { ...goEnvironment, ...overrides },
|
||||
timeout: 120_000,
|
||||
@@ -370,7 +356,7 @@ async function inspect(mode, path, includeStringLiterals = false) {
|
||||
...(includeStringLiterals ? ["--include-string-literals"] : []),
|
||||
"--json",
|
||||
];
|
||||
const { stdout } = await commands.exec(process.execPath, args, {
|
||||
const { stdout } = await execute(process.execPath, args, {
|
||||
env: environment,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
@@ -438,7 +424,7 @@ async function verify(
|
||||
byteExpected ??
|
||||
JSON.parse(
|
||||
(
|
||||
await commands.exec(oracle, [oraclePath], {
|
||||
await execute(oracle, [oraclePath], {
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { deepStrictEqual } from "node:assert";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
@@ -12,6 +13,7 @@ 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";
|
||||
|
||||
@@ -99,7 +99,8 @@ async function verifyPublishedUpdate({
|
||||
environment,
|
||||
);
|
||||
|
||||
await commands.execNpm(
|
||||
await commands.exec(
|
||||
"npm",
|
||||
[
|
||||
"install",
|
||||
"--global",
|
||||
@@ -117,7 +118,7 @@ async function verifyPublishedUpdate({
|
||||
);
|
||||
|
||||
const npmRoot = (
|
||||
await commands.execNpm(["root", "--global"], {
|
||||
await commands.exec("npm", ["root", "--global"], {
|
||||
cwd: canaryRoot,
|
||||
env: environment,
|
||||
timeout: 30000,
|
||||
@@ -185,7 +186,8 @@ if (result.status === "failed") process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function findPreviousPublishedVersion(targetVersion, cwd, environment) {
|
||||
const { stdout } = await commands.execNpm(
|
||||
const { stdout } = await commands.exec(
|
||||
"npm",
|
||||
["view", "rea-agents", "versions", "--json"],
|
||||
{ cwd, env: environment, timeout: 30000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/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";
|
||||
@@ -12,6 +13,7 @@ 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";
|
||||
@@ -94,6 +96,8 @@ 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 };
|
||||
|
||||
@@ -151,7 +151,7 @@ export async function verifyAndroidFailureRecovery({
|
||||
failure_code: "runtime_unavailable",
|
||||
},
|
||||
availabilityReason: "provider_unavailable",
|
||||
availabilityRemediation: /selected Java launcher/u,
|
||||
availabilityRemediation: /selected Java runtime/u,
|
||||
message: /selected Java runtime/u,
|
||||
remediation: /selected launcher/u,
|
||||
});
|
||||
@@ -194,7 +194,12 @@ export async function verifyAndroidFailureRecovery({
|
||||
repository,
|
||||
available: false,
|
||||
unavailableReason: scenario.availabilityReason ?? "provider_missing",
|
||||
remediation: scenario.availabilityRemediation ?? scenario.remediation,
|
||||
remediation:
|
||||
scenario.availabilityRemediation ??
|
||||
new RegExp(
|
||||
scenario.observation.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"),
|
||||
"u",
|
||||
),
|
||||
});
|
||||
console.log(`PASS CLI applicable recovery: ${scenario.label}`);
|
||||
}
|
||||
|
||||
@@ -94,36 +94,29 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
|
||||
ts.forEachChild(node, collect);
|
||||
};
|
||||
collect(sourceFile);
|
||||
const potential = (node, matchesCandidate) => {
|
||||
const potential = (node) => {
|
||||
const expression = unwrapExpression(node);
|
||||
if (exactLimit(literalText(expression)) !== undefined) return true;
|
||||
if (ts.isIdentifier(expression)) return matchesCandidate(expression);
|
||||
if (ts.isIdentifier(expression)) return candidates.has(expression.text);
|
||||
if (ts.isPropertyAccessExpression(expression))
|
||||
return (
|
||||
exactLimit(expression.name.text) !== undefined ||
|
||||
potential(expression.expression, matchesCandidate)
|
||||
potential(expression.expression)
|
||||
);
|
||||
if (ts.isElementAccessExpression(expression))
|
||||
return (
|
||||
expression.argumentExpression !== undefined &&
|
||||
potential(expression.argumentExpression, matchesCandidate)
|
||||
potential(expression.argumentExpression)
|
||||
);
|
||||
if (ts.isObjectLiteralExpression(expression))
|
||||
return expression.properties.some(
|
||||
(property) =>
|
||||
ts.isPropertyAssignment(property) &&
|
||||
potential(property.initializer, matchesCandidate),
|
||||
ts.isPropertyAssignment(property) && potential(property.initializer),
|
||||
);
|
||||
if (ts.isConditionalExpression(expression))
|
||||
return (
|
||||
potential(expression.whenTrue, matchesCandidate) ||
|
||||
potential(expression.whenFalse, matchesCandidate)
|
||||
);
|
||||
return potential(expression.whenTrue) || potential(expression.whenFalse);
|
||||
if (ts.isBinaryExpression(expression))
|
||||
return (
|
||||
potential(expression.left, matchesCandidate) ||
|
||||
potential(expression.right, matchesCandidate)
|
||||
);
|
||||
return potential(expression.left) || potential(expression.right);
|
||||
return false;
|
||||
};
|
||||
let changed = true;
|
||||
@@ -132,34 +125,13 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
|
||||
for (const declaration of declarations) {
|
||||
if (
|
||||
!candidates.has(declaration.name.text) &&
|
||||
potential(declaration.initializer, (identifier) =>
|
||||
candidates.has(identifier.text),
|
||||
)
|
||||
potential(declaration.initializer)
|
||||
) {
|
||||
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();
|
||||
@@ -180,12 +152,11 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
|
||||
declaration === undefined ||
|
||||
!ts.isVariableDeclaration(declaration) ||
|
||||
!ts.isVariableDeclarationList(declaration.parent) ||
|
||||
declaration.initializer === undefined ||
|
||||
!lexicalCandidates.has(declaration)
|
||||
declaration.initializer === undefined
|
||||
)
|
||||
return undefined;
|
||||
if ((declaration.parent.flags & ts.NodeFlags.Const) === 0) {
|
||||
unknown(declaration.name, "mutable setting-key or value alias");
|
||||
unknown(identifier, "mutable setting-key or value alias");
|
||||
return undefined;
|
||||
}
|
||||
if (declaration.pos >= identifier.pos) {
|
||||
@@ -919,7 +890,7 @@ if (direct) {
|
||||
const entries = parseNumericLimitRegistry(registry);
|
||||
const listed = new Map();
|
||||
for (const entry of entries) {
|
||||
const { name, read, reason } = entry;
|
||||
const { name, policy, read, reason } = entry;
|
||||
if (listed.has(name)) failures.push(`${name} is listed twice`);
|
||||
listed.set(name, entry);
|
||||
if (reason.trim().length < 40)
|
||||
|
||||
@@ -20,7 +20,6 @@ it.each([
|
||||
source:
|
||||
'process.stdout.write("partial-雪"); process.stderr.write("waiting\\n"); setInterval(() => {}, 1000)',
|
||||
code: "provider_timeout",
|
||||
timeoutMs: 500,
|
||||
stdout: "partial-雪",
|
||||
stderr: "waiting\n",
|
||||
},
|
||||
@@ -29,7 +28,6 @@ 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",
|
||||
},
|
||||
@@ -38,7 +36,6 @@ 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",
|
||||
},
|
||||
@@ -57,7 +54,7 @@ it.each([
|
||||
},
|
||||
["-e", testCase.source],
|
||||
{
|
||||
timeoutMs: testCase.timeoutMs ?? 5_000,
|
||||
timeoutMs: testCase.name === "timeout" ? 500 : 5_000,
|
||||
maxOutputBytes: testCase.maxOutputBytes ?? 1024,
|
||||
},
|
||||
undefined,
|
||||
@@ -78,34 +75,12 @@ it.each([
|
||||
},
|
||||
});
|
||||
if (testCase.code === "unsupported_target")
|
||||
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(projected.details?.path).toBe(
|
||||
testCase.name === "local path refusal"
|
||||
? "/host/token file"
|
||||
: "caller-device",
|
||||
);
|
||||
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();
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
ExecFileOutputCancelledError,
|
||||
execFileBytes,
|
||||
@@ -211,6 +212,65 @@ 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");
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -84,7 +84,6 @@ 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,
|
||||
@@ -994,12 +993,15 @@ export class AdbProvider {
|
||||
const partialObservation =
|
||||
execution === undefined
|
||||
? undefined
|
||||
: createAnalysisExecutionEvidence(
|
||||
: createEvidence(execution.subject ?? undefined, execution.provider, {
|
||||
operation,
|
||||
parameters,
|
||||
execution,
|
||||
"observed",
|
||||
);
|
||||
result: execution.result,
|
||||
rawResult: execution.rawResult,
|
||||
limitations: execution.limitations,
|
||||
locations: execution.locations,
|
||||
confidence: "observed",
|
||||
});
|
||||
return new ProviderCleanupError(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
resources,
|
||||
|
||||
@@ -47,32 +47,19 @@ it("reports the fixed adb version-probe deadline", async () => {
|
||||
});
|
||||
|
||||
it("reports the fixed adb version-output cap", async () => {
|
||||
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);
|
||||
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,
|
||||
);
|
||||
const projected = projectAnalysisError(
|
||||
failure as Parameters<typeof projectAnalysisError>[0],
|
||||
);
|
||||
@@ -87,28 +74,25 @@ it("reports the fixed adb version-output cap", async () => {
|
||||
expect(projected.details?.limit).toBeUndefined();
|
||||
});
|
||||
|
||||
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("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("names an EPERM execute refusal separately from missing permission", async () => {
|
||||
vi.mocked(execFileOutput).mockRejectedValueOnce(
|
||||
|
||||
@@ -58,7 +58,7 @@ export const androidSnapshotCopy = (
|
||||
: undefined;
|
||||
|
||||
/** Adopt a snapshot descriptor whose close rejected, keyed by its exact path. */
|
||||
type AndroidSnapshotCloseRetention = (
|
||||
export type AndroidSnapshotCloseRetention = (
|
||||
path: string,
|
||||
owner: OwnedFileHandle,
|
||||
) => void;
|
||||
@@ -69,8 +69,8 @@ export const snapshotAndroidTarget = async (
|
||||
sha256: string,
|
||||
root: string,
|
||||
operation: AndroidOperation,
|
||||
signal: AbortSignal | undefined,
|
||||
retainFailedClose: AndroidSnapshotCloseRetention,
|
||||
signal?: AbortSignal,
|
||||
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 | undefined,
|
||||
retainFailedClose: AndroidSnapshotCloseRetention,
|
||||
signal?: AbortSignal,
|
||||
retainFailedClose?: AndroidSnapshotCloseRetention,
|
||||
): Promise<{ path: string; sha256: string }> => {
|
||||
const path = join(root, "engine.jar");
|
||||
try {
|
||||
@@ -159,14 +159,17 @@ const copyAndroidSnapshot = async (
|
||||
snapshotPath: string,
|
||||
expectedSha256: string,
|
||||
signal: AbortSignal | undefined,
|
||||
retainFailedClose: AndroidSnapshotCloseRetention,
|
||||
retainFailedClose: AndroidSnapshotCloseRetention | undefined,
|
||||
): Promise<AndroidSnapshotCopy> => {
|
||||
assertRegularFileActive(signal);
|
||||
let removeSnapshot = false;
|
||||
const retain = (path: string) => ({
|
||||
retainFailedClose: (owner: OwnedFileHandle) =>
|
||||
retainFailedClose(path, owner),
|
||||
});
|
||||
const retain = (path: string) =>
|
||||
retainFailedClose === undefined
|
||||
? {}
|
||||
: {
|
||||
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
|
||||
@@ -284,7 +287,7 @@ const openSnapshotSource = async (
|
||||
path: string,
|
||||
signal: AbortSignal | undefined,
|
||||
retention: {
|
||||
readonly retainFailedClose: (owner: OwnedFileHandle) => void;
|
||||
readonly retainFailedClose?: (owner: OwnedFileHandle) => void;
|
||||
},
|
||||
): Promise<StableRegularFileDescriptor> => {
|
||||
try {
|
||||
@@ -302,9 +305,10 @@ const openSnapshotSource = async (
|
||||
};
|
||||
|
||||
const retainAdmissionOwner = (
|
||||
retain: (owner: OwnedFileHandle) => void,
|
||||
retain: ((owner: OwnedFileHandle) => void) | undefined,
|
||||
owner: OwnedFileHandle,
|
||||
): RegularFileCleanupFailure["retention"] => {
|
||||
if (retain === undefined) return { kind: "not_requested" };
|
||||
try {
|
||||
retain(owner);
|
||||
return { kind: "callback_returned" };
|
||||
|
||||
@@ -313,10 +313,10 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
: AbortSignal.any([signal, this.#shutdown.signal]),
|
||||
});
|
||||
this.#inspections.add(pending);
|
||||
const remove = (): void => {
|
||||
this.#inspections.delete(pending);
|
||||
};
|
||||
void pending.then(remove, remove);
|
||||
void pending.then(
|
||||
() => this.#inspections.delete(pending),
|
||||
() => this.#inspections.delete(pending),
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 v2;
|
||||
if (!v2.ok) return err(v2.error);
|
||||
const v3 = decodeScheme(block.value, APK_BLOCK_ID.signatureV3, "v3");
|
||||
if (!v3.ok) return v3;
|
||||
if (!v3.ok) return err(v3.error);
|
||||
const v31 = decodeScheme(block.value, APK_BLOCK_ID.signatureV31, "v3.1");
|
||||
if (!v31.ok) return v31;
|
||||
if (!v31.ok) return err(v31.error);
|
||||
|
||||
// Account for representation expansion before allocating the report.
|
||||
const projected = projectedOutputBytes(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as commandOwner from "../process/OwnedCommand.js";
|
||||
import { failedInspectionDescriptor } from "../../tests/fixtures/processInspectionCleanup.js";
|
||||
import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
primaryAnalysisError,
|
||||
@@ -487,7 +486,6 @@ 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;
|
||||
});
|
||||
@@ -504,20 +502,14 @@ it("cancels a pending filesystem lookup and owns its eventual rejection", async
|
||||
"win32",
|
||||
controller.signal,
|
||||
);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
it("bounds the entire pending PATH lookup by the selection deadline", async () => {
|
||||
|
||||
@@ -58,6 +58,7 @@ it.skipIf(process.platform === "win32")(
|
||||
await fixture.cleanup([probe]);
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
let commands = new ApktoolCommandCleanup();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDeferred } from "../../tests/fixtures/binarySession.js";
|
||||
import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js";
|
||||
|
||||
import {
|
||||
getNavigationContext,
|
||||
@@ -501,16 +500,7 @@ it("waits for late facet cleanup after cancellation and retains its owner", asyn
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
controller.abort();
|
||||
late.resolve(err(failure));
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
await lease.release();
|
||||
}
|
||||
});
|
||||
await awaitAcknowledgement(started.promise, pending, "facet execution");
|
||||
await started.promise;
|
||||
controller.abort();
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
@@ -540,12 +530,7 @@ it("reports cancellation after a late successful facet that ignored its signal",
|
||||
{ address: "0x401000", document: "fixture" },
|
||||
controller.signal,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
controller.abort();
|
||||
late.resolve(ok(createAnalysisExecution("finished", provider)));
|
||||
await pending;
|
||||
});
|
||||
await awaitAcknowledgement(started.promise, pending, "facet execution");
|
||||
await started.promise;
|
||||
controller.abort();
|
||||
late.resolve(ok(createAnalysisExecution("finished", provider)));
|
||||
const result = await pending;
|
||||
@@ -601,11 +586,7 @@ it("preserves a provider's cancellation for the same signal", async () => {
|
||||
{ address: "0x401000", document: "fixture" },
|
||||
controller.signal,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
controller.abort(reason);
|
||||
await pending;
|
||||
});
|
||||
await awaitAcknowledgement(started.promise, pending, "provider execution");
|
||||
await started.promise;
|
||||
controller.abort(reason);
|
||||
const result = await pending;
|
||||
if (result.ok) throw new Error("expected cancellation");
|
||||
|
||||
@@ -53,11 +53,6 @@ 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. */
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -365,6 +366,7 @@ 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,
|
||||
@@ -373,6 +375,9 @@ 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",
|
||||
}),
|
||||
@@ -388,6 +393,10 @@ 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 { resolveAppleBundleExecutable } from "./AppBundleExecutable.js";
|
||||
import { resolveAppBundleExecutable } 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 resolveAppleBundleExecutable(bundle)).toEqual({
|
||||
expect(await resolveAppBundleExecutable(bundle)).toEqual({
|
||||
ok: true,
|
||||
value: { executable, infoPlist },
|
||||
});
|
||||
|
||||
@@ -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 { resolveAppleBundleExecutable } from "./AppBundleExecutable.js";
|
||||
import { resolveAppBundleExecutable } 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 resolveAppleBundleExecutable(bundle);
|
||||
const result = await resolveAppBundleExecutable(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 resolveAppleBundleExecutable(bundle)).toEqual({
|
||||
expect(await resolveAppBundleExecutable(bundle)).toEqual({
|
||||
ok: true,
|
||||
value: { executable, infoPlist: plistPath },
|
||||
});
|
||||
@@ -86,13 +86,17 @@ describe("app bundle Info.plist magic", () => {
|
||||
const plist = Buffer.from("bplist00");
|
||||
plist[0] = (plist[0] ?? 0) | 0x80;
|
||||
await writeFile(plistPath, plist);
|
||||
const result = await resolveAppleBundleExecutable(bundle);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
cause: { name: "XmlPlistTextDecodeError", reason: "invalid-bytes" },
|
||||
let decoded = false;
|
||||
const result = await resolveAppBundleExecutable(bundle, {
|
||||
readFile: async () => plist,
|
||||
realpath: async (path) => path,
|
||||
decodeBinaryPlist: async () => {
|
||||
decoded = true;
|
||||
return "Fixture";
|
||||
},
|
||||
});
|
||||
expect(decoded).toBe(false);
|
||||
expect(result.ok).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -114,7 +118,7 @@ describe("app bundle program directory containment", () => {
|
||||
await writeFile(join(bundle, "Contents", "Info.plist"), plist);
|
||||
let childResolved = false;
|
||||
|
||||
const result = await resolveAppleBundleExecutable(bundle, {
|
||||
const result = await resolveAppBundleExecutable(bundle, {
|
||||
readFile: async () => Buffer.from(plist),
|
||||
realpath: async (path) => {
|
||||
if (path === bundle) return canonicalBundle;
|
||||
|
||||
@@ -88,9 +88,10 @@ export interface ResolvedAppBundleResourceRoot {
|
||||
}
|
||||
|
||||
/** Filesystem operations used by the resolver, injectable for boundary tests. */
|
||||
interface AppBundleFileSystem {
|
||||
export 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 };
|
||||
@@ -101,7 +102,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.
|
||||
*/
|
||||
const resolveAppBundleExecutable = async (
|
||||
export const resolveAppBundleExecutable = async (
|
||||
path: string,
|
||||
fileSystem: AppBundleFileSystem = defaultFileSystem,
|
||||
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
|
||||
@@ -395,6 +396,15 @@ 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";
|
||||
|
||||
@@ -451,12 +461,20 @@ const resolveLayoutExecutable = async (
|
||||
|
||||
let name: string;
|
||||
try {
|
||||
name = executableName(decodeInfoPlistValue(plist));
|
||||
name =
|
||||
hasBinaryPlistSignature(plist) &&
|
||||
fileSystem.decodeBinaryPlist !== undefined
|
||||
? await fileSystem.decodeBinaryPlist(plistPath)
|
||||
: executableName(decodeInfoPlistValue(plist));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new BinaryTargetError(
|
||||
plistPath,
|
||||
`${noun} Info.plist is malformed, unsupported, or lacks CFBundleExecutable: ${plistPath}${cause instanceof Error ? ` (${cause.message})` : ""}`,
|
||||
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})` : ""}`,
|
||||
{
|
||||
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 { resolveAppleBundleExecutable } from "./AppBundleExecutable.js";
|
||||
import { resolveAppBundleExecutable } 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 resolveAppleBundleExecutable(app);
|
||||
const result = await resolveAppBundleExecutable(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 resolveAppleBundleExecutable(app)).toMatchObject({
|
||||
expect(await resolveAppBundleExecutable(app)).toMatchObject({
|
||||
ok: true,
|
||||
value: { executable: join(programs, "App") },
|
||||
});
|
||||
|
||||
@@ -208,7 +208,7 @@ export const parseBinaryTarget = async (
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const resolved = await resolveAppBundle(canonical);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (!resolved.ok) return resolved;
|
||||
if (!resolved.ok) return err(resolved.error);
|
||||
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 | undefined,
|
||||
sourcePath: string,
|
||||
signal?: AbortSignal,
|
||||
sourcePath = path,
|
||||
): Promise<
|
||||
| Exclude<
|
||||
BinaryTarget["format"],
|
||||
|
||||
@@ -56,7 +56,6 @@ 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),
|
||||
@@ -127,8 +126,6 @@ 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 &
|
||||
(
|
||||
@@ -165,7 +162,6 @@ type AvailabilityContext = {
|
||||
readonly targetFormat: string | undefined;
|
||||
readonly descriptors: ReadonlyMap<string, ProviderDescriptor>;
|
||||
readonly policy: AvailabilityPolicy;
|
||||
readonly isToolAdvertised: (name: string) => boolean;
|
||||
};
|
||||
|
||||
type EnhancedContractName = Extract<
|
||||
@@ -326,15 +322,10 @@ const ADDRESS_CONTEXT_EFFECT_COMPOSITION: ModeComposition = {
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Build stable per-operation availability for discovery and tool visibility. */
|
||||
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>(
|
||||
@@ -354,7 +345,6 @@ export const buildCapabilityInventory = (
|
||||
targetFormat: status.format,
|
||||
descriptors,
|
||||
policy,
|
||||
isToolAdvertised,
|
||||
});
|
||||
const effects = composedEffects(
|
||||
contract.name,
|
||||
@@ -408,13 +398,8 @@ export const buildCapabilityInventory = (
|
||||
available: false,
|
||||
reason: availability.reason,
|
||||
remediation: availability.remediation,
|
||||
...(availability.alternatives === undefined
|
||||
? {}
|
||||
: { alternatives: [...availability.alternatives] }),
|
||||
};
|
||||
})
|
||||
.filter(({ name }) => isToolAdvertised(name))
|
||||
.sort((left, right) => compareUnicodeCodePoints(left.name, right.name));
|
||||
}).sort((left, right) => compareUnicodeCodePoints(left.name, right.name));
|
||||
};
|
||||
|
||||
type ClientRequirements = ToolAvailability["client_requirements"];
|
||||
@@ -460,9 +445,6 @@ 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)
|
||||
? {}
|
||||
@@ -977,7 +959,6 @@ const providerAvailability = ({
|
||||
kind,
|
||||
name,
|
||||
policy,
|
||||
isToolAdvertised,
|
||||
}: AvailabilityContext): Availability => {
|
||||
if (kind === "enhanced") return composedAvailability(name, descriptors);
|
||||
const launchFailure =
|
||||
@@ -995,20 +976,12 @@ const providerAvailability = ({
|
||||
kind === "native-provider")
|
||||
)
|
||||
return { reason: "available", remediation: null };
|
||||
if (descriptor === undefined) {
|
||||
const offered = offeredOperationAlternatives(
|
||||
name,
|
||||
(alternative) =>
|
||||
descriptors.get(alternative)?.available === true &&
|
||||
isToolAdvertised(alternative),
|
||||
);
|
||||
if (descriptor === undefined)
|
||||
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 }
|
||||
: {
|
||||
|
||||
@@ -93,12 +93,7 @@ it("joins concurrent shutdowns and seals ownership before asynchronous cleanup s
|
||||
try {
|
||||
await awaitAcknowledgement(entered, first, "capability cleanup");
|
||||
second = scope.close();
|
||||
let secondSettled = false;
|
||||
void second.then(() => {
|
||||
secondSettled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(secondSettled).toBe(false);
|
||||
expect(second).toBe(first);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(() => scope.own("later-owner", {}, async () => undefined)).toThrow(
|
||||
"after host shutdown",
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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,
|
||||
@@ -641,19 +642,15 @@ describe("package-private REA launchers", () => {
|
||||
"rea.mjs",
|
||||
);
|
||||
|
||||
it("blocks private cache paths without treating other package paths as private", () => {
|
||||
const identity = (path: string) => path;
|
||||
it("names the package that privately stores a path", () => {
|
||||
expect(packagePrivateStorageFamily(stored, packages)).toBe(
|
||||
CLAUDE_DESKTOP_PACKAGE_FAMILY,
|
||||
);
|
||||
expect(packagePrivateStorageFamily(launcher, packages)).toBeUndefined();
|
||||
expect(
|
||||
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")],
|
||||
packagePrivateStorageFamily(
|
||||
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, …`.
|
||||
*/
|
||||
const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc";
|
||||
export const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc";
|
||||
|
||||
/** Observed Claude package names plus Windows' 13-character publisher ID. */
|
||||
const CLAUDE_PACKAGE_FAMILY =
|
||||
|
||||
@@ -23,14 +23,14 @@ export interface ClientConfigurationSource extends ClientConfigurationDocument {
|
||||
const invalidEnvironment = (
|
||||
path: string,
|
||||
error: ConfigurationError,
|
||||
overrides: Readonly<Record<string, string>> | undefined,
|
||||
platform: NodeJS.Platform,
|
||||
overrides: Readonly<Record<string, string>> = {},
|
||||
platform: NodeJS.Platform = process.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,9 +1,15 @@
|
||||
import { validateRegistrationEnvironment } from "./RegistrationEnvironmentValidation.js";
|
||||
import { clientRegistrationSource } from "./ClientRegistrationSource.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
|
||||
import { launchAnalysisSetting } from "../config/analysisSettings.js";
|
||||
import type { Environment } from "../config/environment.js";
|
||||
import {
|
||||
inspectAnalysisSettingSeeds,
|
||||
launchAnalysisSetting,
|
||||
} from "../config/analysisSettings.js";
|
||||
import {
|
||||
parseEnvironment,
|
||||
parseEnvironmentValues,
|
||||
type Environment,
|
||||
} from "../config/environment.js";
|
||||
import {
|
||||
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
|
||||
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
|
||||
@@ -308,24 +314,34 @@ export const clientRegistrationEnvironmentDetailsWithBudget = async (
|
||||
const validatedDetails = (
|
||||
environment: Readonly<Record<string, string>>,
|
||||
validation: "fragment" | "effective",
|
||||
platform: NodeJS.Platform,
|
||||
expressionSettings: ReadonlySet<string>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
expressionSettings: ReadonlySet<string> = new Set(),
|
||||
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
|
||||
const validated = validateRegistrationEnvironment(
|
||||
environment,
|
||||
expressionSettings,
|
||||
validation,
|
||||
platform,
|
||||
const runtimeEnvironment = snapshotEnvironment(environment, platform);
|
||||
const unresolved = new Set(
|
||||
Object.keys(
|
||||
snapshotEnvironment(
|
||||
Object.fromEntries([...expressionSettings].map((name) => [name, ""])),
|
||||
platform,
|
||||
),
|
||||
),
|
||||
);
|
||||
return validated.ok
|
||||
? ok(
|
||||
environmentDetails(
|
||||
environment,
|
||||
validated.value.configuration,
|
||||
validated.value.unresolvedSettings,
|
||||
),
|
||||
)
|
||||
: validated;
|
||||
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));
|
||||
};
|
||||
|
||||
const admittedTimeout = (
|
||||
@@ -381,7 +397,7 @@ const registrationTimeoutInputs = (
|
||||
const environmentDetails = (
|
||||
environment: Readonly<Record<string, string>>,
|
||||
configuration: Environment,
|
||||
unresolvedSettings: ReadonlySet<string>,
|
||||
unresolvedSettings: ReadonlySet<string> = new Set(),
|
||||
): ClientRegistrationEnvironmentDetails => ({
|
||||
values: Object.fromEntries(
|
||||
Object.entries(environment).sort(([left], [right]) =>
|
||||
|
||||
@@ -649,7 +649,7 @@ const packageVersionRemediation = (
|
||||
const unavailableStatus = (
|
||||
client: SetupClient,
|
||||
state: "missing" | "invalid",
|
||||
configPath: string,
|
||||
configPath: string = client.configPath,
|
||||
): ClientRegistrationStatus => ({
|
||||
client: client.name,
|
||||
config_path: configPath,
|
||||
|
||||
@@ -67,7 +67,10 @@ 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 } from "./AnalysisProvider.js";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js";
|
||||
import { REA_WORKFLOW_PROVIDER } from "../domain/investigationProviders.js";
|
||||
import { workflowAnalysisProfile } from "./WorkflowAnalysisProfile.js";
|
||||
@@ -293,7 +296,7 @@ export const runProviderAnalysis = async (
|
||||
delivery.detail,
|
||||
operationSignal,
|
||||
);
|
||||
return result.ok ? result : err(cliError(result.error, path));
|
||||
return result.ok ? ok(result.value) : err(cliError(result.error, path));
|
||||
}
|
||||
const admitted = admitOneShotOperation(tool, arguments_, operationSignal);
|
||||
if (!admitted.ok) return err(cliError(admitted.error, path));
|
||||
@@ -442,14 +445,14 @@ const runAnalysis = async (
|
||||
let isDirectory = false;
|
||||
if (BUNDLE_RESOURCE_OPERATIONS.has(tool)) {
|
||||
const inspected = await inspectBundleResourceInput(path);
|
||||
if (!inspected.ok) return inspected;
|
||||
if (!inspected.ok) return err(inspected.error);
|
||||
isDirectory = inspected.value;
|
||||
}
|
||||
if (BUNDLE_RESOURCE_OPERATIONS.has(tool) && isDirectory) {
|
||||
const opened = await session.openBundleResources(path, { signal });
|
||||
if (!opened.ok) return opened;
|
||||
if (!opened.ok) return err(opened.error);
|
||||
const execution = await session.execute(tool, arguments_, { signal });
|
||||
if (!execution.ok) return execution;
|
||||
if (!execution.ok) return err(execution.error);
|
||||
return ok(
|
||||
withEvidenceCallerInputPath(
|
||||
createAnalysisExecutionEvidence(
|
||||
@@ -469,7 +472,7 @@ const runAnalysis = async (
|
||||
signal,
|
||||
...targetSelection(options),
|
||||
});
|
||||
if (!prepared.ok) return prepared;
|
||||
if (!prepared.ok) return err(prepared.error);
|
||||
const { snapshot } = prepared.value;
|
||||
let resolvedTarget: ResolvedSessionOpen | undefined;
|
||||
if (snapshot !== undefined && prepared.value.target !== undefined) {
|
||||
@@ -481,7 +484,7 @@ const runAnalysis = async (
|
||||
? {}
|
||||
: { providerId: options.providerId }),
|
||||
});
|
||||
if (!preview.ok) return preview;
|
||||
if (!preview.ok) return err(preview.error);
|
||||
resolvedTarget = preview.value;
|
||||
const route = preview.value.route;
|
||||
const bindingProfile =
|
||||
@@ -545,7 +548,7 @@ const runAnalysis = async (
|
||||
resolvedTarget === undefined
|
||||
? await session.open(path, openOptions)
|
||||
: await session.openResolvedTarget(resolvedTarget, openOptions);
|
||||
if (!opened.ok) return opened;
|
||||
if (!opened.ok) return err(opened.error);
|
||||
const evidenceProfile = analysisProfileForEvidence(session, tool);
|
||||
const executed = await executeAnalysisTool({
|
||||
session,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { validateRegistrationEnvironment } from "./RegistrationEnvironmentValidation.js";
|
||||
import { inspectAnalysisSettingSeeds } from "../config/analysisSettings.js";
|
||||
import { isMap, isSeq, type Node, type YAMLMap } from "yaml";
|
||||
import type { Environment } from "../config/environment.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 type { ClientConfigurationDocument } from "./ClientConfigurationDocument.js";
|
||||
import { CordisExpression, DshCordisYaml } from "./DshCordisYaml.js";
|
||||
import { mergeEnvironmentOverrides } from "../process/snapshotEnvironment.js";
|
||||
import {
|
||||
mergeEnvironmentOverrides,
|
||||
snapshotEnvironment,
|
||||
} from "../process/snapshotEnvironment.js";
|
||||
|
||||
export const DSH_ENTRY_ID = "rea-mcp";
|
||||
export const DSH_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client";
|
||||
@@ -248,15 +255,35 @@ export class DshCordisDocument {
|
||||
);
|
||||
literals[key] = value;
|
||||
}
|
||||
const validated = validateRegistrationEnvironment(
|
||||
literals,
|
||||
unresolvedSettings,
|
||||
validation,
|
||||
platform,
|
||||
const runtimeEnvironment = snapshotEnvironment(literals, platform);
|
||||
const unresolved = new Set(
|
||||
Object.keys(
|
||||
snapshotEnvironment(
|
||||
Object.fromEntries([...unresolvedSettings].map((key) => [key, ""])),
|
||||
platform,
|
||||
),
|
||||
),
|
||||
);
|
||||
return validated.ok
|
||||
? ok({ values: literals, ...validated.value })
|
||||
: validated;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
desired(
|
||||
|
||||
@@ -307,7 +307,9 @@ const projectWrite = (
|
||||
bundle: EvidenceBundle,
|
||||
written: Result<PublishedEvidenceFile, AnalysisError>,
|
||||
): Result<JsonValue, AnalysisError> =>
|
||||
written.ok ? ok(evidenceBundleExportReceipt(bundle, written.value)) : written;
|
||||
written.ok
|
||||
? ok(evidenceBundleExportReceipt(bundle, written.value))
|
||||
: err(written.error);
|
||||
|
||||
/** Standalone bundle discovery shares the same read owner as import and comparison. */
|
||||
export const inspectEvidenceBundleCommand = async (
|
||||
|
||||
@@ -31,11 +31,16 @@ import {
|
||||
import { readJsonFile, writeTextParts } from "./JsonFiles.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
|
||||
type EvidenceBundleFileFailure =
|
||||
type EvidenceReadFailure =
|
||||
| EvidenceFileError
|
||||
| EvidenceIntegrityError
|
||||
| AnalysisResourceConstraintError
|
||||
| AnalysisCancelledError;
|
||||
type EvidenceWriteFailure =
|
||||
| EvidenceFileError
|
||||
| EvidenceIntegrityError
|
||||
| AnalysisCancelledError
|
||||
| AnalysisResourceConstraintError;
|
||||
|
||||
/** Project a published bundle with record and residual-unknown revision counts. */
|
||||
export const evidenceBundleExportReceipt = (
|
||||
@@ -54,7 +59,7 @@ export const readEvidenceBundle = (
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "import_evidence_bundle",
|
||||
): Promise<Result<EvidenceBundle, EvidenceBundleFileFailure>> =>
|
||||
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
|
||||
readBundle(path, false, signal, resources, operation);
|
||||
|
||||
/** Read an authenticated immutable snapshot for a subsequent session import. */
|
||||
@@ -63,7 +68,7 @@ export const readImmutableEvidenceBundle = (
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "import_evidence_bundle",
|
||||
): Promise<Result<EvidenceBundle, EvidenceBundleFileFailure>> =>
|
||||
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
|
||||
readBundle(path, true, signal, resources, operation);
|
||||
|
||||
/** Join both comparison reads and preserve cleanup owned by either failed input. */
|
||||
@@ -104,7 +109,7 @@ const readBundle = async (
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "import_evidence_bundle",
|
||||
): Promise<Result<EvidenceBundle, EvidenceBundleFileFailure>> => {
|
||||
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
@@ -130,7 +135,7 @@ export const authenticateEvidenceBundleValue = (
|
||||
value: unknown,
|
||||
path: string,
|
||||
options: { readonly operation: string; readonly immutable?: boolean },
|
||||
): Result<EvidenceBundle, EvidenceBundleFileFailure> => {
|
||||
): Result<EvidenceBundle, EvidenceReadFailure> => {
|
||||
const { operation } = options;
|
||||
const headroom = requireAuthenticationHeadroom(value, {
|
||||
operation,
|
||||
@@ -221,7 +226,7 @@ export const writeEvidenceBundle = async (
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly path: string; readonly bytes: number },
|
||||
EvidenceBundleFileFailure
|
||||
EvidenceWriteFailure
|
||||
>
|
||||
> => {
|
||||
if (signal?.aborted === true)
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -35,7 +34,8 @@ const folderTrustSchema = z
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const trustRulesSchema = jsonRecordSchema(
|
||||
const trustRulesSchema = z.record(
|
||||
z.string(),
|
||||
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 normalizes trust-file keys before replacement and rule precedence.
|
||||
const normalizedPath = (path: string, platform: NodeJS.Platform): string => {
|
||||
const absolute = resolve(path).replaceAll("\\", "/");
|
||||
// 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("\\", "/");
|
||||
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,29 +78,21 @@ const workspaceTrusted = async (
|
||||
);
|
||||
if (!text.ok) return text;
|
||||
if (text.value === undefined) return ok(false);
|
||||
let rules: ReadonlyMap<string, z.output<typeof trustRulesSchema>[string]>;
|
||||
let rules: z.output<typeof trustRulesSchema>;
|
||||
try {
|
||||
// Gemini strips comments, but rejects trailing commas, BOMs and invalid trust levels.
|
||||
const parsed = trustRulesSchema.parse(
|
||||
JSON.parse(stripComments(text.value)),
|
||||
);
|
||||
rules = new Map(
|
||||
Object.entries(parsed).map(([path, level]) => [
|
||||
normalizedPath(path, settings.platform),
|
||||
level,
|
||||
]),
|
||||
);
|
||||
rules = trustRulesSchema.parse(JSON.parse(stripComments(text.value)));
|
||||
} catch (cause: unknown) {
|
||||
return err(sourceFailure(settings.trustedFoldersPath, cause));
|
||||
}
|
||||
const workspace = await physicalPath(
|
||||
const workspace = await normalizedPath(
|
||||
settings.workspaceDirectory,
|
||||
settings.platform,
|
||||
);
|
||||
let longest = -1;
|
||||
let trusted = false;
|
||||
for (const [path, level] of rules) {
|
||||
const effective = await physicalPath(
|
||||
for (const [path, level] of Object.entries(rules)) {
|
||||
const effective = await normalizedPath(
|
||||
level === "TRUST_PARENT" ? dirname(path) : path,
|
||||
settings.platform,
|
||||
);
|
||||
@@ -146,11 +138,11 @@ export const readClientPolicyBlock = async (
|
||||
if (parsed.value !== undefined)
|
||||
sources.push({ path, document: parsed.value });
|
||||
}
|
||||
const workspace = await physicalPath(
|
||||
const workspace = await normalizedPath(
|
||||
settings.workspaceDirectory,
|
||||
settings.platform,
|
||||
);
|
||||
const home = await physicalPath(
|
||||
const home = await normalizedPath(
|
||||
dirname(dirname(client.configPath)),
|
||||
settings.platform,
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { parseDocument } from "yaml";
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
editGooseConfigurationYaml,
|
||||
GooseConfigurationYamlError,
|
||||
parseGooseConfigurationYaml,
|
||||
stringifyGooseConfigurationYaml,
|
||||
} from "./GooseConfigurationYaml.js";
|
||||
@@ -110,15 +111,11 @@ describe("Goose YAML 1.2 source facts", () => {
|
||||
} catch (cause: unknown) {
|
||||
failure = cause;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(ConfigurationError);
|
||||
expect(failure).toBeInstanceOf(GooseConfigurationYamlError);
|
||||
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(`duplicate mapping member at ${path.join(".")}`);
|
||||
).toThrow(GooseConfigurationYamlError);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ const gooseYamlOptions = {
|
||||
} as const;
|
||||
|
||||
/** A duplicate YAML member cannot establish one selected configuration value. */
|
||||
class GooseConfigurationYamlError extends ConfigurationError {
|
||||
export class GooseConfigurationYamlError extends ConfigurationError {
|
||||
readonly kind = "shadowed_member";
|
||||
constructor(
|
||||
readonly path: readonly string[],
|
||||
|
||||
@@ -2,6 +2,8 @@ import { expect, it } from "vitest";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { readGuidanceSectionInputSchema } from "../contracts/guidanceContracts.js";
|
||||
import { MANAGED_SKILL_FILES } from "./SetupSkill.js";
|
||||
import { TOOL_GUIDANCE } from "../toolGuidance.js";
|
||||
import { parseGuidanceSections } from "./GuidanceSections.js";
|
||||
import {
|
||||
readGuidanceSectionWithReader,
|
||||
searchGuidanceWithReader,
|
||||
@@ -76,21 +78,33 @@ it("issues readable locators independently of heading length", async () => {
|
||||
await resources.close();
|
||||
});
|
||||
|
||||
it.each([2048, 2049])(
|
||||
"bounds the combined index across guides with %i sections each",
|
||||
it.each([4096, 4097])(
|
||||
"bounds the combined skill and tool index at %i sections",
|
||||
async (count) => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
const compiled = parseGuidanceSections(
|
||||
new Map(
|
||||
Object.values(TOOL_GUIDANCE).map(({ guide, content }) => [
|
||||
guide,
|
||||
content,
|
||||
]),
|
||||
),
|
||||
);
|
||||
if (compiled === undefined) throw new Error("Invalid compiled guidance");
|
||||
const remaining = count - compiled.length;
|
||||
const result = await searchGuidanceWithReader(
|
||||
{ query: "needle", limit: 1 },
|
||||
async (path) =>
|
||||
path === MANAGED_SKILL_FILES[0] || path === MANAGED_SKILL_FILES[1]
|
||||
? "# needle\n".repeat(count)
|
||||
: "",
|
||||
path === MANAGED_SKILL_FILES[0]
|
||||
? "# needle\n".repeat(Math.floor(remaining / 2))
|
||||
: path === MANAGED_SKILL_FILES[1]
|
||||
? "# needle\n".repeat(Math.ceil(remaining / 2))
|
||||
: "",
|
||||
resources,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { status: count === 2048 ? "matches" : "bundle_unavailable" },
|
||||
value: { status: count === 4096 ? "matches" : "bundle_unavailable" },
|
||||
});
|
||||
await resources.close();
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { MANAGED_SKILL_FILES, readSkillFile } from "./SetupSkill.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { TOOL_GUIDANCE } from "../toolGuidance.js";
|
||||
import {
|
||||
MAX_SUBSECTION_POINTERS,
|
||||
readGuidanceSectionInputSchema,
|
||||
@@ -32,6 +33,13 @@ const SKILL_ROOT = `../../skills/${PRODUCT_IDENTITY.skillName}/`;
|
||||
const SOURCE = "installed_rea_guidance" as const;
|
||||
const MAX_GUIDE_FILE_BYTES = 1_048_576;
|
||||
const MAX_GUIDE_BUNDLE_BYTES = 4_194_304;
|
||||
const COMPILED_GUIDES = new Map(
|
||||
Object.values(TOOL_GUIDANCE).map(({ guide, content }) => [guide, content]),
|
||||
);
|
||||
const GUIDANCE_FILES: ReadonlySet<string> = new Set([
|
||||
...MANAGED_SKILL_FILES,
|
||||
...COMPILED_GUIDES.keys(),
|
||||
]);
|
||||
const sha256 = (value: string | Uint8Array): string =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
|
||||
@@ -85,8 +93,11 @@ const readBundle = async (
|
||||
resources: ArtifactResourceScope,
|
||||
operation: string,
|
||||
): Promise<GuidanceBundleRead> => {
|
||||
const files = new Map<string, string>();
|
||||
let bundleBytes = 0;
|
||||
const files = new Map(COMPILED_GUIDES);
|
||||
let bundleBytes = [...files.values()].reduce(
|
||||
(bytes, content) => bytes + Buffer.byteLength(content, "utf8"),
|
||||
0,
|
||||
);
|
||||
try {
|
||||
for (const relativePath of MANAGED_SKILL_FILES) {
|
||||
const content = await readGuide(relativePath);
|
||||
@@ -712,7 +723,7 @@ const decodeLocator = (locator: string): LocatorIdentity | undefined => {
|
||||
typeof item.bundle !== "string" ||
|
||||
!/^[a-f0-9]{64}$/u.test(item.bundle) ||
|
||||
typeof item.guide !== "string" ||
|
||||
!(MANAGED_SKILL_FILES as readonly string[]).includes(item.guide) ||
|
||||
!GUIDANCE_FILES.has(item.guide) ||
|
||||
!Number.isInteger(item.ordinal) ||
|
||||
typeof item.section !== "string" ||
|
||||
!/^[a-f0-9]{64}$/u.test(item.section)
|
||||
|
||||
@@ -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. */
|
||||
const HOPPER_DOWNLOAD_STALL_MS = 30_000;
|
||||
export 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.
|
||||
*/
|
||||
const HOPPER_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
|
||||
export 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;
|
||||
|
||||
@@ -4,8 +4,9 @@ import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
formatCommandForDisplay,
|
||||
installLinuxHopper,
|
||||
linuxHopperInstallDisclosure,
|
||||
linuxPackageManagerCommands,
|
||||
linuxSharedLibrariesAvailable,
|
||||
parseLinuxDistribution,
|
||||
supportsReaHost,
|
||||
@@ -30,6 +31,19 @@ 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",
|
||||
@@ -249,34 +263,47 @@ describe("Linux Hopper installation", () => {
|
||||
] as const)(
|
||||
"selects the %s native package manager and dependencies",
|
||||
(family, executable, 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(
|
||||
nonRoot.some(
|
||||
(command) =>
|
||||
command.startsWith(`pkexec ${executable} `) &&
|
||||
command.includes(`"<verified-hopper.${family}>"`),
|
||||
),
|
||||
).toBe(true);
|
||||
linuxPackageManagerCommands(family, "/tmp/Hopper package", true),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
executable,
|
||||
args: expect.arrayContaining([...packages]),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
linuxPackageManagerCommands(family, "/tmp/Hopper package", false),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
executable: "pkexec",
|
||||
args: expect.arrayContaining([executable, "/tmp/Hopper package"]),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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(
|
||||
linuxHopperInstallDisclosure("deb", isRoot).commands.slice(0, 2),
|
||||
linuxPackageManagerCommands("deb", "/tmp/Hopper package", isRoot).map(
|
||||
({ executable, args }) => [executable, ...args],
|
||||
),
|
||||
).toEqual([
|
||||
`${prefix}apt-get install -y --no-upgrade xvfb xauth python3 libx11-6 libxtst6`,
|
||||
`${prefix}apt-get install -y "<verified-hopper.deb>"`,
|
||||
[
|
||||
...prefix,
|
||||
"apt-get",
|
||||
"install",
|
||||
"-y",
|
||||
"--no-upgrade",
|
||||
"xvfb",
|
||||
"xauth",
|
||||
"python3",
|
||||
"libx11-6",
|
||||
"libxtst6",
|
||||
],
|
||||
[...prefix, "apt-get", "install", "-y", "/tmp/Hopper package"],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -511,7 +511,7 @@ export const installLinuxHopper = async (
|
||||
};
|
||||
|
||||
/** Canonical launcher path for a legacy user-local Linux Hopper installation. */
|
||||
const linuxHopperLauncherPath = (home: string): string =>
|
||||
export 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. */
|
||||
const formatCommandForDisplay = (
|
||||
export const formatCommandForDisplay = (
|
||||
executable: string,
|
||||
args: readonly string[],
|
||||
): string =>
|
||||
@@ -807,7 +807,7 @@ const formatCommandForDisplay = (
|
||||
.join(" ");
|
||||
|
||||
/** Select the native package manager and authorization boundary without shell evaluation. */
|
||||
const linuxPackageManagerCommands = (
|
||||
export const linuxPackageManagerCommands = (
|
||||
family: LinuxPackageFamily,
|
||||
archive: string,
|
||||
isRoot: boolean,
|
||||
|
||||
@@ -6,11 +6,16 @@ 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 = 2;
|
||||
const debCommands = linuxPackageManagerCommands(
|
||||
"deb",
|
||||
"/tmp/verified-hopper.deb",
|
||||
false,
|
||||
).length;
|
||||
|
||||
const missingPython = {
|
||||
interpreter: "/usr/bin/python3",
|
||||
@@ -36,34 +41,6 @@ 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",
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, posix, relative } from "node:path";
|
||||
import { installMacHopperBundle } from "./MacHopper.js";
|
||||
import { linuxHopperLauncherCandidates } from "./LinuxHopper.js";
|
||||
import { linuxHopperLauncherPath } from "./LinuxHopper.js";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -19,17 +19,34 @@ 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 in order", () => {
|
||||
it("keeps canonical system and user candidates ahead of renamed bundles", () => {
|
||||
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")(
|
||||
@@ -67,7 +84,7 @@ describe("macOS Hopper launcher paths", () => {
|
||||
expect(macHopperCanonicalLauncherCandidates(home)[1]).toBe(
|
||||
`${home}/Applications/Hopper Disassembler.app/Contents/MacOS/hopper`,
|
||||
);
|
||||
expect(linuxHopperLauncherCandidates(home)[1]).toBe(
|
||||
expect(linuxHopperLauncherPath(home)).toBe(
|
||||
`${home}/.local/share/rea/hopper/bin/Hopper`,
|
||||
);
|
||||
const source = join(temporary, "source.app");
|
||||
@@ -137,17 +154,13 @@ 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,
|
||||
|
||||
@@ -43,7 +43,7 @@ export const macHopperCanonicalLauncherCandidates = (
|
||||
];
|
||||
|
||||
/** Build deterministic candidates from the discovered app bundle names. */
|
||||
const macHopperRenamedLauncherCandidates = (
|
||||
export const macHopperRenamedLauncherCandidates = (
|
||||
homeDirectory: string,
|
||||
bundleNamesByRoot: ReadonlyMap<string, readonly string[]>,
|
||||
): readonly string[] =>
|
||||
|
||||
@@ -12,7 +12,6 @@ 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;
|
||||
@@ -613,22 +612,17 @@ describe("Registration cancellation and sibling outcomes", () => {
|
||||
entered();
|
||||
await blocked;
|
||||
});
|
||||
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;
|
||||
}
|
||||
await admitted;
|
||||
const controller = new AbortController();
|
||||
const queued = register(path, launcher, controller.signal);
|
||||
controller.abort("queued cancellation");
|
||||
unblock();
|
||||
await first;
|
||||
expect(await queued).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
expect(await readFile(path, "utf8")).toBe("{}");
|
||||
});
|
||||
|
||||
it("keeps successful siblings after ordinary failures and marks work not started after cancellation", async () => {
|
||||
|
||||
@@ -5,28 +5,7 @@ import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { createTestProcessRegistry } from "../../tests/support/process/processFixture.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();
|
||||
}
|
||||
};
|
||||
import { resolveMcpRegistrationClientId } from "./McpRegistrationCatalog.js";
|
||||
|
||||
it.each([
|
||||
["cline-vscode", "cline"],
|
||||
@@ -44,22 +23,15 @@ it.each([
|
||||
["kiro", "kiro-cli"],
|
||||
["mastra", "mastracode"],
|
||||
["pi-agent", "pi"],
|
||||
])("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]);
|
||||
])("retains helper alias %s as %s", (alias, expected) => {
|
||||
expect(resolveMcpRegistrationClientId(alias.toUpperCase())).toBe(expected);
|
||||
});
|
||||
|
||||
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" },
|
||||
});
|
||||
},
|
||||
);
|
||||
it("does not treat prototype names or unknown strings as client identities", () => {
|
||||
expect(resolveMcpRegistrationClientId("__proto__")).toBeUndefined();
|
||||
expect(resolveMcpRegistrationClientId("constructor")).toBeUndefined();
|
||||
expect(resolveMcpRegistrationClientId("unknown-client")).toBeUndefined();
|
||||
});
|
||||
|
||||
const clientSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -284,137 +256,133 @@ const REVIEWED_CLIENT_IDS = [
|
||||
"amp",
|
||||
];
|
||||
|
||||
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"),
|
||||
},
|
||||
it("resolves selected-environment paths and scopes without external catalog readers", async () => {
|
||||
const home = await createTestTempDirectory("rea-registration-catalog-");
|
||||
const processes = createTestProcessRegistry();
|
||||
try {
|
||||
await mkdir(join(home, "config"));
|
||||
const child = await processes.run(
|
||||
process.execPath,
|
||||
["--import", "tsx", "--input-type=module", "--eval", CATALOG_PROGRAM],
|
||||
{
|
||||
timeoutMs: 50_000,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
SystemRoot: process.env.SystemRoot,
|
||||
WINDIR: process.env.WINDIR,
|
||||
TMPDIR: process.env.TMPDIR,
|
||||
TMP: process.env.TMP,
|
||||
TEMP: process.env.TEMP,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
XDG_CONFIG_HOME: join(home, "config"),
|
||||
XDG_DATA_HOME: join(home, "data"),
|
||||
XDG_CACHE_HOME: join(home, "cache"),
|
||||
XDG_STATE_HOME: join(home, "state"),
|
||||
APPDATA: join(home, "appdata"),
|
||||
LOCALAPPDATA: join(home, "localappdata"),
|
||||
},
|
||||
);
|
||||
expect(child.exitCode, child.stderr).toBe(0);
|
||||
const result = catalogObservationsSchema.parse(JSON.parse(child.stdout));
|
||||
assertKiloNativeLayers(result, home);
|
||||
expect(result.empty.ok).toBe(true);
|
||||
if (!result.empty.ok) throw new Error(result.empty.error.message);
|
||||
expect(result.empty.value.map((client) => client.id)).toEqual(
|
||||
REVIEWED_CLIENT_IDS,
|
||||
);
|
||||
expect(result.layeredKilo.ok).toBe(true);
|
||||
if (!result.layeredKilo.ok)
|
||||
throw new Error(result.layeredKilo.error.message);
|
||||
const kiloLayers = [
|
||||
"config.json",
|
||||
"kilo.json",
|
||||
"kilo.jsonc",
|
||||
"opencode.json",
|
||||
"opencode.jsonc",
|
||||
].map((name) => join(home, "layered", "kilo", name));
|
||||
expect(result.layeredKilo.value[0]).toMatchObject({
|
||||
configPath: kiloLayers.at(-1),
|
||||
configPaths: kiloLayers,
|
||||
});
|
||||
expect(result.layeredOpenCode.ok).toBe(true);
|
||||
if (!result.layeredOpenCode.ok)
|
||||
throw new Error(result.layeredOpenCode.error.message);
|
||||
expect(result.layeredOpenCode.value[0]?.configPaths).toEqual(
|
||||
["config.json", "opencode.json", "opencode.jsonc"].map((name) =>
|
||||
join(home, "config", "opencode", name),
|
||||
),
|
||||
);
|
||||
expect(result.explicit.ok).toBe(true);
|
||||
if (!result.explicit.ok) throw new Error(result.explicit.error.message);
|
||||
expect(result.explicit.value.map((client) => client.id)).toEqual([
|
||||
"codex",
|
||||
"amp",
|
||||
"gemini-cli",
|
||||
"fx",
|
||||
"windsurf",
|
||||
]);
|
||||
expect(result.explicit.value[0]?.configPath).toBe(
|
||||
join(home, "selected-codex", "config.toml"),
|
||||
);
|
||||
expect(
|
||||
result.explicit.value.find((client) => client.id === "fx"),
|
||||
).toMatchObject({ format: "fx", privateProfile: true });
|
||||
expect(result.detected.ok).toBe(true);
|
||||
if (!result.detected.ok) throw new Error(result.detected.error.message);
|
||||
expect(result.detected.value.map((client) => client.id)).toEqual([
|
||||
"claude-code",
|
||||
"cursor",
|
||||
"amp",
|
||||
]);
|
||||
expect(result.project.ok, JSON.stringify(result.project)).toBe(true);
|
||||
if (!result.project.ok) throw new Error(result.project.error.message);
|
||||
expect(result.project.value[0]?.format).toBe("copilot_cli_project");
|
||||
expect(
|
||||
result.project.value.map(({ configPath, scope }) => ({
|
||||
configPath,
|
||||
scope,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
configPath: join(home, "project", ".github", "mcp.json"),
|
||||
scope: "project",
|
||||
},
|
||||
...(process.platform === "win32"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
configPath: join(home, "project", ".kilocode", "kilo.jsonc"),
|
||||
scope: "project",
|
||||
},
|
||||
]),
|
||||
{
|
||||
configPath: join(home, "project", "opencode.jsonc"),
|
||||
scope: "project",
|
||||
},
|
||||
]);
|
||||
for (const refused of [
|
||||
result.onlyGlobal,
|
||||
result.defaultProject,
|
||||
result.projectAmp,
|
||||
result.unknown,
|
||||
...(result.blockedCandidate === null ? [] : [result.blockedCandidate]),
|
||||
result.unknownDetection,
|
||||
]) {
|
||||
expect(refused.ok).toBe(false);
|
||||
if (refused.ok) throw new Error("Expected preflight refusal.");
|
||||
expect(refused.error.tag).toBe("ConfigurationError");
|
||||
}
|
||||
expect(result.cancelled).toMatchObject({
|
||||
ok: false,
|
||||
error: { tag: "AnalysisCancelledError" },
|
||||
});
|
||||
} finally {
|
||||
await processes.shutdown();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
},
|
||||
);
|
||||
expect(child.exitCode, child.stderr).toBe(0);
|
||||
const result = catalogObservationsSchema.parse(JSON.parse(child.stdout));
|
||||
assertKiloNativeLayers(result, home);
|
||||
expect(result.empty.ok).toBe(true);
|
||||
if (!result.empty.ok) throw new Error(result.empty.error.message);
|
||||
expect(result.empty.value.map((client) => client.id)).toEqual(
|
||||
REVIEWED_CLIENT_IDS,
|
||||
);
|
||||
expect(result.layeredKilo.ok).toBe(true);
|
||||
if (!result.layeredKilo.ok)
|
||||
throw new Error(result.layeredKilo.error.message);
|
||||
const kiloLayers = [
|
||||
"config.json",
|
||||
"kilo.json",
|
||||
"kilo.jsonc",
|
||||
"opencode.json",
|
||||
"opencode.jsonc",
|
||||
].map((name) => join(home, "layered", "kilo", name));
|
||||
expect(result.layeredKilo.value[0]).toMatchObject({
|
||||
configPath: kiloLayers.at(-1),
|
||||
configPaths: kiloLayers,
|
||||
});
|
||||
expect(result.layeredOpenCode.ok).toBe(true);
|
||||
if (!result.layeredOpenCode.ok)
|
||||
throw new Error(result.layeredOpenCode.error.message);
|
||||
expect(result.layeredOpenCode.value[0]?.configPaths).toEqual(
|
||||
["config.json", "opencode.json", "opencode.jsonc"].map((name) =>
|
||||
join(home, "config", "opencode", name),
|
||||
),
|
||||
);
|
||||
expect(result.explicit.ok).toBe(true);
|
||||
if (!result.explicit.ok) throw new Error(result.explicit.error.message);
|
||||
expect(result.explicit.value.map((client) => client.id)).toEqual([
|
||||
"codex",
|
||||
"amp",
|
||||
"gemini-cli",
|
||||
"fx",
|
||||
"windsurf",
|
||||
]);
|
||||
expect(result.explicit.value[0]?.configPath).toBe(
|
||||
join(home, "selected-codex", "config.toml"),
|
||||
);
|
||||
expect(
|
||||
result.explicit.value.find((client) => client.id === "fx"),
|
||||
).toMatchObject({ format: "fx", privateProfile: true });
|
||||
expect(result.detected.ok).toBe(true);
|
||||
if (!result.detected.ok) throw new Error(result.detected.error.message);
|
||||
expect(result.detected.value.map((client) => client.id)).toEqual([
|
||||
"claude-code",
|
||||
"cursor",
|
||||
"amp",
|
||||
]);
|
||||
expect(result.project.ok, JSON.stringify(result.project)).toBe(true);
|
||||
if (!result.project.ok) throw new Error(result.project.error.message);
|
||||
expect(result.project.value[0]?.format).toBe("copilot_cli_project");
|
||||
expect(
|
||||
result.project.value.map(({ configPath, scope }) => ({
|
||||
configPath,
|
||||
scope,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
configPath: join(home, "project", ".github", "mcp.json"),
|
||||
scope: "project",
|
||||
},
|
||||
...(process.platform === "win32"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
configPath: join(home, "project", ".kilocode", "kilo.jsonc"),
|
||||
scope: "project",
|
||||
},
|
||||
]),
|
||||
{
|
||||
configPath: join(home, "project", "opencode.jsonc"),
|
||||
scope: "project",
|
||||
},
|
||||
]);
|
||||
for (const refused of [
|
||||
result.onlyGlobal,
|
||||
result.defaultProject,
|
||||
result.projectAmp,
|
||||
result.unknown,
|
||||
...(result.blockedCandidate === null ? [] : [result.blockedCandidate]),
|
||||
result.unknownDetection,
|
||||
]) {
|
||||
expect(refused.ok).toBe(false);
|
||||
if (refused.ok) throw new Error("Expected preflight refusal.");
|
||||
expect(refused.error.tag).toBe("ConfigurationError");
|
||||
}
|
||||
},
|
||||
);
|
||||
expect(result.cancelled).toMatchObject({
|
||||
ok: false,
|
||||
error: { tag: "AnalysisCancelledError" },
|
||||
});
|
||||
} finally {
|
||||
await processes.shutdown();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
const assertKiloNativeLayers = (
|
||||
result: z.infer<typeof catalogObservationsSchema>,
|
||||
|
||||
@@ -356,7 +356,7 @@ const ALIASES = {
|
||||
const isClientId = (value: string): value is McpRegistrationClientId =>
|
||||
Object.hasOwn(MCP_REGISTRATION_CLIENTS, value);
|
||||
|
||||
const resolveMcpRegistrationClientId = (
|
||||
export const resolveMcpRegistrationClientId = (
|
||||
input: string,
|
||||
): McpRegistrationClientId | undefined => {
|
||||
const lower = input.toLowerCase();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readElfAttributeUleb } from "../domain/elfAttributeUleb.js";
|
||||
import type { MipsGnuAttributes } from "../domain/binaryTargetTypes.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
@@ -23,6 +22,21 @@ 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;
|
||||
@@ -51,7 +65,7 @@ export const parseMipsGnuAttributes = (
|
||||
let cursor = vendorEnd + 1;
|
||||
while (cursor < end) {
|
||||
const scopeStart = cursor;
|
||||
const scope = readElfAttributeUleb(bytes, cursor, end);
|
||||
const scope = readUleb(cursor, end);
|
||||
if (scope === undefined)
|
||||
return err("malformed MIPS GNU attribute scope tag");
|
||||
if (scope.value !== TAG_FILE)
|
||||
@@ -71,7 +85,7 @@ export const parseMipsGnuAttributes = (
|
||||
const scopeEnd = scopeStart + scopeLength;
|
||||
cursor = scope.next + 4;
|
||||
while (cursor < scopeEnd) {
|
||||
const tag = readElfAttributeUleb(bytes, cursor, scopeEnd);
|
||||
const tag = readUleb(cursor, scopeEnd);
|
||||
if (tag === undefined) return err("malformed MIPS GNU attribute tag");
|
||||
if (
|
||||
tag.value !== TAG_GNU_MIPS_ABI_FP &&
|
||||
@@ -80,7 +94,7 @@ export const parseMipsGnuAttributes = (
|
||||
return err(
|
||||
`MIPS ELF declares GNU attribute tag ${tag.value}, which REA does not inspect`,
|
||||
);
|
||||
const value = readElfAttributeUleb(bytes, tag.next, scopeEnd);
|
||||
const value = readUleb(tag.next, scopeEnd);
|
||||
if (value === undefined)
|
||||
return err("malformed MIPS GNU attribute value");
|
||||
if (tag.value === TAG_GNU_MIPS_ABI_FP) {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
const packagePrivateStorageFamily = (
|
||||
export const packagePrivateStorageFamily = (
|
||||
canonicalPath: string,
|
||||
packagesDirectory: string,
|
||||
): string | undefined => {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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. */
|
||||
const projectReferenceSourceEntryFailure = (
|
||||
export const projectReferenceSourceEntryFailure = (
|
||||
entry: Extract<ReferenceSourceEntry, { status: "failed" }>,
|
||||
): string => `${entry.message} ${entryRecoveryGuidance(entry)}`;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
projectReferenceSourceImportError,
|
||||
referenceSourceImportAnalysisError,
|
||||
type ReferenceSourceImportError,
|
||||
} from "./ReferenceSourceImportTypes.js";
|
||||
@@ -19,13 +20,11 @@ describe("reference-source import error projection", () => {
|
||||
parse: "execution_failure",
|
||||
} as const;
|
||||
for (const [code, category] of Object.entries(expectedCategories)) {
|
||||
const projected = projectAnalysisError(
|
||||
referenceSourceImportAnalysisError({
|
||||
tag: "reference-source-import",
|
||||
code: code as ReferenceSourceImportError["code"],
|
||||
message: "EIO while reading /selected/source/src/main.ts",
|
||||
}),
|
||||
);
|
||||
const projected = projectReferenceSourceImportError({
|
||||
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,6 +5,10 @@ import {
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
|
||||
import {
|
||||
projectAnalysisError,
|
||||
type AnalysisErrorProjection,
|
||||
} from "../domain/analysisErrorProjection.js";
|
||||
import type {
|
||||
AnalysisError,
|
||||
AnalysisErrorOptions,
|
||||
@@ -121,6 +125,12 @@ 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;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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 });
|
||||
};
|
||||
@@ -23,6 +23,7 @@ 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 {
|
||||
|
||||
@@ -101,16 +101,9 @@ it("starts child finalization synchronously and shares concurrent closes", async
|
||||
]);
|
||||
const first = owner.close();
|
||||
expect(calls).toBe(1);
|
||||
const second = owner.close();
|
||||
let secondSettled = false;
|
||||
void second.then(() => {
|
||||
secondSettled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(secondSettled).toBe(false);
|
||||
expect(owner.close()).toBe(first);
|
||||
complete?.();
|
||||
expect(await first).toBeUndefined();
|
||||
expect(await second).toBeUndefined();
|
||||
expect(await owner.close()).toBeUndefined();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
@@ -420,8 +420,8 @@ export const validateCanonicalSkillDirectories = async (
|
||||
const canonicalSkillFiles = async (
|
||||
home: string,
|
||||
clientIds: readonly string[],
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<readonly CanonicalSkillFile[]> => {
|
||||
const destinations = skillDestinations(
|
||||
|
||||
@@ -872,7 +872,7 @@ const readClientConfiguration = async (
|
||||
client: UninstallClient,
|
||||
fileSystem: UninstallFileSystem,
|
||||
resources: ArtifactResourceScope,
|
||||
ownershipEstablished: boolean,
|
||||
ownershipEstablished = false,
|
||||
): Promise<ClientConfigurationRead> => {
|
||||
const applicabilityUnverified =
|
||||
client.applicability === "unverified" && !ownershipEstablished;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { UpdateCommandProgress } from "./UpdateCommandProgress.js";
|
||||
import type { UpdateOutput } from "./Update.js";
|
||||
import { withoutPackageRunnerIdentity } from "./ClientRegistrationIdentity.js";
|
||||
|
||||
const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024;
|
||||
export 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. */
|
||||
|
||||
@@ -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. */
|
||||
const BUN_LATEST_VERSION_COMMAND: readonly string[] = [
|
||||
export 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 decoded;
|
||||
if (!decoded.ok) return err(decoded.error);
|
||||
const version = readNpmLatestVersion(decoded.value);
|
||||
return version === undefined
|
||||
? err(INVALID_NPM_RELEASE_METADATA)
|
||||
|
||||
@@ -217,7 +217,7 @@ export const executeComposedWorkflow = async (
|
||||
};
|
||||
|
||||
/** Scope of the exact-address reference lookups composed by trace_feature. */
|
||||
const TRACE_FEATURE_REFERENCE_LIMITATION =
|
||||
export 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 { validateAnalysisViewSubject } from "../../domain/analysisView/analysisViewParent.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import { safeParseEvidence } from "../../domain/evidenceValidation.js";
|
||||
import {
|
||||
createEvidence,
|
||||
@@ -249,11 +249,15 @@ const projectResolvedAnalysisView = (
|
||||
},
|
||||
});
|
||||
if (!projected.ok) return projected;
|
||||
const identity = validateAnalysisViewSubject(
|
||||
parent.subject,
|
||||
projected.value.artifact?.sha256,
|
||||
);
|
||||
if (!identity.ok) return identity;
|
||||
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"}.`,
|
||||
),
|
||||
);
|
||||
return ok(createAnalysisViewEvidence(input, presentation, projected.value));
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
inspectAnalysisView,
|
||||
projectRetainedAnalysis,
|
||||
} from "./AnalysisViewService.js";
|
||||
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisViewRequest.js";
|
||||
import { analysisViewRequestSchema } from "../../domain/analysisView/analysisView.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
|
||||
const trace = async (
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
} from "../AnalysisProvider.js";
|
||||
import {
|
||||
AnalysisSnapshotCache,
|
||||
SNAPSHOT_CACHE_ENTRY_CEILING,
|
||||
isSnapshotCacheable,
|
||||
isSourceEvidenceRetainable,
|
||||
} from "./AnalysisSnapshotCache.js";
|
||||
@@ -594,7 +595,7 @@ describe("analysis snapshot cache capacity", () => {
|
||||
parameters: { query: "existing" },
|
||||
execution,
|
||||
};
|
||||
for (let index = 0; index < 9_999; index += 1)
|
||||
for (let index = 0; index < SNAPSHOT_CACHE_ENTRY_CEILING - 1; index += 1)
|
||||
cache.recordWorkflow({
|
||||
...workflowInput,
|
||||
parameters: { query: String(index) },
|
||||
@@ -619,7 +620,9 @@ describe("analysis snapshot cache capacity", () => {
|
||||
});
|
||||
cache.recordWorkflow(workflowInput);
|
||||
expect(cache.entries()).toHaveLength(1);
|
||||
expect(cache.workflowEntries()).toHaveLength(9_999);
|
||||
expect(cache.workflowEntries()).toHaveLength(
|
||||
SNAPSHOT_CACHE_ENTRY_CEILING - 1,
|
||||
);
|
||||
|
||||
cache.record({
|
||||
...directInput,
|
||||
@@ -666,7 +669,9 @@ describe("analysis snapshot cache capacity", () => {
|
||||
),
|
||||
"a capacity-refused workflow binding is not counted",
|
||||
).toBe(0);
|
||||
expect(cache.workflowEntries()).toHaveLength(9_999);
|
||||
expect(cache.workflowEntries()).toHaveLength(
|
||||
SNAPSHOT_CACHE_ENTRY_CEILING - 1,
|
||||
);
|
||||
|
||||
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. */
|
||||
const SNAPSHOT_CACHE_ENTRY_CEILING = 10_000;
|
||||
export 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. */
|
||||
const SNAPSHOT_REPLAY_LIMITATION =
|
||||
export 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. */
|
||||
|
||||
@@ -54,10 +54,7 @@ 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,
|
||||
currentRouteCapability,
|
||||
} from "./BinarySessionStatus.js";
|
||||
import { binarySessionStatus } from "./BinarySessionStatus.js";
|
||||
import { SessionRuntimeAvailability } from "./SessionRuntimeAvailability.js";
|
||||
import { isSourceEvidenceRetainable } from "./AnalysisSnapshotCache.js";
|
||||
import type { BinarySessionDetail } from "../../contracts/sessionStatusContract.js";
|
||||
@@ -1399,6 +1396,11 @@ 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"));
|
||||
@@ -1565,8 +1567,6 @@ 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,20 +1705,6 @@ 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();
|
||||
}
|
||||
@@ -1754,16 +1740,22 @@ 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 perform = (): Promise<T> => operation(admittedThrough);
|
||||
const result = this.#transition.then(perform, perform);
|
||||
const result = this.#transition.then(
|
||||
() => operation(admittedThrough),
|
||||
() => operation(admittedThrough),
|
||||
);
|
||||
this.#transition = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const finish = (): void => {
|
||||
this.#transitionPending -= 1;
|
||||
};
|
||||
void result.then(finish, finish);
|
||||
void result.then(
|
||||
() => {
|
||||
this.#transitionPending -= 1;
|
||||
},
|
||||
() => {
|
||||
this.#transitionPending -= 1;
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1858,19 +1850,32 @@ export class BinarySession
|
||||
);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
const finish = (): void => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(
|
||||
signal.aborted
|
||||
? err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
)
|
||||
: ok(undefined),
|
||||
);
|
||||
};
|
||||
transition.then(finish, finish);
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,11 +145,9 @@ it("opens a resource-only bundle without executable identity and keeps snapshot
|
||||
path: opened.value.canonicalPath,
|
||||
caller_path: opened.value.callerPath,
|
||||
});
|
||||
expect(await session.status()).toMatchObject({
|
||||
open: true,
|
||||
session_kind: "bundle_resources",
|
||||
resource_root: { canonical_path: opened.value.canonicalPath },
|
||||
});
|
||||
expect(session.activeBundleResourceRoot()?.canonicalPath).toBe(
|
||||
opened.value.canonicalPath,
|
||||
);
|
||||
expect(
|
||||
bundleResourceRootSchema.parse({
|
||||
caller_path: opened.value.callerPath,
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
CapabilityDescriptor,
|
||||
ExecutionOptions,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { offeredOperationAlternatives } from "../OperationAlternatives.js";
|
||||
import {
|
||||
isSnapshotCacheable,
|
||||
requestsFreshIndependentDecompile,
|
||||
@@ -46,11 +45,6 @@ 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,
|
||||
@@ -64,9 +58,9 @@ interface PrepareSessionExecutionInput {
|
||||
}
|
||||
|
||||
/** Recovery for an operation absent from the selected binary route. */
|
||||
const undeclaredOperationGuidance = (
|
||||
export const undeclaredOperationGuidance = (
|
||||
operation: string,
|
||||
entryCommentAvailable: boolean,
|
||||
entryCommentAvailable = false,
|
||||
): string => {
|
||||
const entryComment =
|
||||
entryCommentAvailable &&
|
||||
@@ -85,8 +79,6 @@ export const prepareSessionExecution = (
|
||||
operation,
|
||||
parameters,
|
||||
nativeSourceAdmitted,
|
||||
isOperationAvailable,
|
||||
isOperationAdvertised,
|
||||
unboundOperationError,
|
||||
lookupSnapshot,
|
||||
} = input;
|
||||
@@ -105,15 +97,6 @@ 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,
|
||||
@@ -131,12 +114,6 @@ export const prepareSessionExecution = (
|
||||
: {
|
||||
userMessage: `The selected provider does not declare ${operation}.`,
|
||||
fallbackRemediationAction: undeclaredGuidance,
|
||||
...(offered === undefined
|
||||
? {}
|
||||
: {
|
||||
alternatives: offered.operations,
|
||||
remediationAction: offered.remediation,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -51,34 +51,6 @@ 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,
|
||||
@@ -107,9 +79,28 @@ export const binarySessionStatus = async ({
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.operation, right.operation),
|
||||
)
|
||||
.map((descriptor) =>
|
||||
currentRouteCapability(descriptor, target, runtimeUnavailability),
|
||||
);
|
||||
.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);
|
||||
});
|
||||
const capabilities = providerCapabilitySets(routeCapabilities);
|
||||
const analysisProviderBinding =
|
||||
bundleResourceRoot !== undefined
|
||||
|
||||
@@ -31,7 +31,9 @@ type RoutedOperation = Pick<
|
||||
>;
|
||||
|
||||
/** Reason recorded for an operation's own refusal or provider-wide failure. */
|
||||
const runtimeFailureReason = (failure: AnalysisError): string | undefined => {
|
||||
export const runtimeFailureReason = (
|
||||
failure: AnalysisError,
|
||||
): string | undefined => {
|
||||
if (failure._tag === "AnalysisCapabilityUnavailableError")
|
||||
return failure.userMessage ?? failure.message;
|
||||
return PROVIDER_WIDE_FAILURES.has(failure._tag)
|
||||
@@ -40,7 +42,7 @@ const runtimeFailureReason = (failure: AnalysisError): string | undefined => {
|
||||
};
|
||||
|
||||
/** Explain unavailability inferred for an operation no request failed for. */
|
||||
const inferredUnavailabilityReason = (
|
||||
export const inferredUnavailabilityReason = (
|
||||
providerId: string,
|
||||
failure: Pick<ProviderFailure, "operation" | "reason">,
|
||||
): string =>
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
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";
|
||||
@@ -13,19 +10,52 @@ import {
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const identity = APPLE_IPS_PROVIDER;
|
||||
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,
|
||||
const report: AppleIps = {
|
||||
artifact: {
|
||||
path: "/artifacts/report.ips",
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 128,
|
||||
},
|
||||
inspectAppleIpsInputSchema.parse({ path }),
|
||||
);
|
||||
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."],
|
||||
};
|
||||
|
||||
it.each([0, 5])(
|
||||
"rejects an unresolved faulting thread %s without its mismatch",
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { ExecutionOptions } 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,7 +5,10 @@ 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 } from "./ByteEntropyService.js";
|
||||
import {
|
||||
ByteEntropyService,
|
||||
MAX_BYTE_ENTROPY_BLOCKS,
|
||||
} 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-");
|
||||
@@ -418,7 +421,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(32_768 * 256 + 1));
|
||||
await writeFile(path, Buffer.alloc(MAX_BYTE_ENTROPY_BLOCKS * 256 + 1));
|
||||
let reads = 0;
|
||||
const service = new ByteEntropyService({
|
||||
stat,
|
||||
@@ -440,8 +443,8 @@ it("refuses a block series above the output-row limit before scanning", async ()
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
resource: "capacity",
|
||||
reportedLimits: {
|
||||
max_blocks: 32_768,
|
||||
requested_blocks: 32_769,
|
||||
max_blocks: MAX_BYTE_ENTROPY_BLOCKS,
|
||||
requested_blocks: MAX_BYTE_ENTROPY_BLOCKS + 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Loaded 100 of 1396 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user