Compare commits

..
1565 changed files with 17901 additions and 22651 deletions

No files matched your search

+1 -3
View File
@@ -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
+1 -24
View File
@@ -63,18 +63,6 @@
"rea/no-shared-module-mock": "error"
},
"overrides": [
{
"files": ["src/domain/**/*.ts"],
"rules": { "rea/no-locale-compare": "error" }
},
{
"files": [
"src/domain/**/*.test.ts",
"src/domain/**/*.spec.ts",
"src/domain/**/*.fixture.ts"
],
"rules": { "rea/no-locale-compare": "off" }
},
{
"files": [
"scripts/electron-active-hook*.cjs",
@@ -88,19 +76,12 @@
{
"files": ["src/**/*.ts"],
"rules": {
"rea/no-babel-visitor-keys": "error",
"typescript/no-misused-promises": "error"
}
},
{
"files": [
"tests/**/*.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.fixture.ts"
],
"files": ["tests/**/*.ts", "src/**/*.test.ts", "src/**/*.fixture.ts"],
"rules": {
"rea/no-babel-visitor-keys": "off",
"max-lines": "off",
"max-lines-per-function": [
"error",
@@ -113,10 +94,6 @@
"typescript/no-misused-promises": "warn"
}
},
{
"files": ["src/domain/javascript/javascriptSemanticTraversal.ts"],
"rules": { "rea/no-babel-visitor-keys": "off" }
},
{
"files": ["src/application/**/*.test.ts"],
"rules": {
+1 -3
View File
@@ -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);
}
+9
View File
@@ -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):
+7
View File
@@ -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
+27 -21
View File
@@ -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.
@@ -1091,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
+1 -3
View File
@@ -174,9 +174,7 @@ Official references: [mitmproxy protocol support](https://github.com/mitmproxy/m
## Verifier regressions
The built process-boundary test suite runs the Node verifier regressions on
POSIX hosts. To run them directly after building REA, and to run the Python
laboratory regressions with its pinned dependencies, use the isolated guest:
After building REA, run these inside the same isolated guest:
```sh
node --test scripts/lib/http3-verifier-lifecycle.test.mjs scripts/lib/http3-capture-assertions.test.mjs
+36 -15
View File
@@ -222,7 +222,7 @@ cannot answer, such as an address without one exact file offset, omits this
hint; follow its `remediation.action`. `recovery.kind=inspect_timeout_state` names
the operation and the REA server/provider deadline. Inspect partial Evidence
and reported cleanup state, then follow the returned remediation. Change a
request field or REA\_\* setting only when the returned remediation explicitly
request field or REA_* setting only when the returned remediation explicitly
recommends that change. Restart REA when that text says the value is read at
startup. A mention that a control does not raise the deadline is not a
recommendation to change it. When it names no setting, reduce supported work;
@@ -1065,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
-6
View File
@@ -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
View File
@@ -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
-1
View File
@@ -3,7 +3,6 @@
"entry": [
"src/main.ts",
"src/cli.ts",
"src/cliOneShot.ts",
"src/cliOutput.ts",
"src/cliBuiltinArguments.ts",
"src/cli/cliResultOutput.ts",
-1
View File
@@ -3,7 +3,6 @@
"entry": [
"src/main.ts!",
"src/cli.ts!",
"src/cliOneShot.ts!",
"src/cliOutput.ts!",
"src/cliBuiltinArguments.ts!",
"src/cli/cliResultOutput.ts!",
+2 -3
View File
@@ -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 -1
View File
@@ -14,7 +14,7 @@ const directDependencies = (rootPackage) => ({
const installedPackageKey = (name) => `node_modules/${name}`;
/** Compare root direct dependencies with npm's hidden installed lockfile. */
const dependencyInstallProblems = (rootLock, installedLock) => {
export const dependencyInstallProblems = (rootLock, installedLock) => {
const rootPackage = rootLock.packages?.[""];
if (rootPackage === undefined)
return ["package-lock.json does not contain a root package entry"];
+2
View File
@@ -26,6 +26,8 @@ export type CacheRequest = (
endpoint: string,
) => Promise<unknown>;
export function assertRetentionPrefix(prefix: string): void;
export function selectPrefixDeletions(
caches: readonly unknown[],
family?: RetentionFamily,
+1 -1
View File
@@ -22,7 +22,7 @@ const PREFIX_PATTERNS = [
];
/** Reject prefixes that could match npm downloads or another platform. */
function assertRetentionPrefix(prefix) {
export function assertRetentionPrefix(prefix) {
if (
typeof prefix !== "string" ||
!PREFIX_PATTERNS.some((pattern) => pattern.test(prefix))
+6
View File
@@ -99,3 +99,9 @@ export function createGitHubApi(options?: {
budget: { requests: number; startedAt: number; limitMs: number },
) => Promise<unknown>;
}): FailureReportApi;
export function writeFailureReport(
report: CiFailureReport,
outputDirectory?: string,
summaryPath?: string,
): Promise<{ jsonPath: string; markdownPath: string }>;
+1 -1
View File
@@ -467,7 +467,7 @@ function errorCode(error) {
return error instanceof Error ? error.message.slice(0, 160) : "unknown_error";
}
async function writeFailureReport(
export async function writeFailureReport(
report,
outputDirectory = OUTPUT_DIRECTORY,
summaryPath,
+1 -1
View File
@@ -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";
+1
View File
@@ -91,4 +91,5 @@ export function writePrCiHealthReport(
outputDirectory?: string,
): Promise<{ jsonPath: string; markdownPath: string }>;
export function renderMarkdownSummary(report: PrCiHealthReport): string;
import type { GitHubApiRequest } from "./github-api.mjs";
+1 -1
View File
@@ -656,7 +656,7 @@ export async function writePrCiHealthReport(
return { jsonPath, markdownPath };
}
function renderMarkdownSummary(report) {
export function renderMarkdownSummary(report) {
const rows = report.pullRequests.map(
(pull) =>
`| #${String(pull.number ?? "?")} | ${escapeCell(pull.head.repository ?? "unknown")} | ${escapeCell(pull.head.ref ?? "unknown")} | \`${pull.head.sha ?? "unknown"}\` | ${pull.ci.category} | ${escapeCell(pull.ci.reason)} |`,
@@ -28,3 +28,6 @@ export interface ProductionReachabilityReport {
export function parseProductionReachabilityReport(
text: string,
): ProductionReachabilityReport;
export function recordProductionReachabilityReport(
root?: string,
): Promise<ProductionReachabilityReport>;
@@ -34,7 +34,9 @@ export const parseProductionReachabilityReport = (text) =>
reportSchema.parse(JSON.parse(text));
/** Run production reachability without turning candidates into failures. */
const recordProductionReachabilityReport = async (root = process.cwd()) => {
export const recordProductionReachabilityReport = async (
root = process.cwd(),
) => {
const reportDirectory = join(root, ".cache", "static-checks");
const reportPath = join(reportDirectory, "production-reachability.json");
-5
View File
@@ -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]),
+1 -1
View File
@@ -6,7 +6,7 @@ import { promisify } from "node:util";
const exec = promisify(execFile);
/** Commands the macOS bundle fixture needs from Command Line Tools. */
const MACOS_BUNDLE_FIXTURE_COMMANDS = [
export const MACOS_BUNDLE_FIXTURE_COMMANDS = [
["/usr/bin/xcrun", ["--find", "clang"]],
["/usr/bin/xcrun", ["--find", "codesign"]],
["/usr/bin/ditto", ["-h"]],
-11
View File
@@ -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(
+1 -1
View File
@@ -1,4 +1,4 @@
function sourceMapSummary(sourceMaps) {
export function sourceMapSummary(sourceMaps) {
return {
status: sourceMaps.status,
requested: sourceMaps.requested,
+13
View File
@@ -1,3 +1,11 @@
export const CURSOR_PLUGIN_MANIFEST: ".cursor-plugin/plugin.json";
export const CURSOR_PLUGIN_MCP: "mcp.json";
export const AUTHORED_SKILL_DIRECTORY: ".agents/skills/reverse-engineer-anything";
export const CURSOR_PLUGIN_SKILLS: ".cursor-plugin/skills";
export const CURSOR_PLUGIN_SKILL: "reverse-engineer-anything";
export const CURSOR_PLUGIN_LOGO: "assets/rea-plugin.png";
export const CURSOR_PLUGIN_REPOSITORY: "https://github.com/morluto/rea";
export interface CursorPluginBundleIssue {
readonly field: string;
}
@@ -48,6 +56,11 @@ export interface CursorPluginInspection {
readonly issues: readonly CursorPluginBundleIssue[];
}
export function cursorPluginSkillPaths(
directory: string,
prefix?: string,
): Promise<readonly string[] | null>;
export function cursorPluginBundleIssues(
input: CursorPluginBundleInput,
): readonly CursorPluginBundleIssue[];
+9 -8
View File
@@ -7,13 +7,14 @@ import { ensureGeneratedFile } from "./generated-file.mjs";
// publication (name, release version, repository, skill, and icon). The
// Marketplace checkout has no dist/, so the server is the pinned npm command
// instead of the bundled launcher.
const CURSOR_PLUGIN_MANIFEST = ".cursor-plugin/plugin.json";
const CURSOR_PLUGIN_MCP = "mcp.json";
const AUTHORED_SKILL_DIRECTORY = ".agents/skills/reverse-engineer-anything";
const CURSOR_PLUGIN_SKILLS = ".cursor-plugin/skills";
const CURSOR_PLUGIN_SKILL = "reverse-engineer-anything";
const CURSOR_PLUGIN_LOGO = "assets/rea-plugin.png";
const CURSOR_PLUGIN_REPOSITORY = "https://github.com/morluto/rea";
export const CURSOR_PLUGIN_MANIFEST = ".cursor-plugin/plugin.json";
export const CURSOR_PLUGIN_MCP = "mcp.json";
export const AUTHORED_SKILL_DIRECTORY =
".agents/skills/reverse-engineer-anything";
export const CURSOR_PLUGIN_SKILLS = ".cursor-plugin/skills";
export const CURSOR_PLUGIN_SKILL = "reverse-engineer-anything";
export const CURSOR_PLUGIN_LOGO = "assets/rea-plugin.png";
export const CURSOR_PLUGIN_REPOSITORY = "https://github.com/morluto/rea";
const GENERATE_COMMAND = "node scripts/generate-cursor-plugin-skill.mjs";
const INSTALL_LINK_MARKERS = ["install-mcp", "deeplink/mcp", "mcp-install"];
@@ -26,7 +27,7 @@ const failure = (code, detail) => {
const cursorServerArgs = (version) => ["-y", `rea-agents@${version}`, "mcp"];
/** Relative file inventory for one skill directory, in stable order. */
const cursorPluginSkillPaths = async (directory, prefix = "") => {
export const cursorPluginSkillPaths = async (directory, prefix = "") => {
const paths = [];
let entries;
try {
-1
View File
@@ -15,7 +15,6 @@ export function parseDevelopmentTestRequest(
export function developmentTestPlan(
request: DevelopmentTestRequest,
baseCommit?: string,
resolvedPaths?: readonly string[],
): {
readonly needsBuild: boolean;
readonly artifactTasks: readonly string[];
+3 -7
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
const WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE = Object.freeze({
export const WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE = Object.freeze({
pattern: "a0 ?? c0",
mask: "f0 ff f0",
startFileOffset: 0x380,
-16
View File
@@ -1,16 +0,0 @@
import type { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
export class HopperDeadlineCleanupFailure extends AggregateError {
readonly _tag: "HopperDeadlineCleanupFailure";
readonly cleanupOwner: HopperDeadlineCleanup;
constructor(owner: HopperDeadlineCleanup, failures: unknown[]);
}
export class HopperDeadlineCleanup {
readonly workspace: string;
constructor(workspace: string, profile: ArtifactResourceScope);
setClient(client: {
close(): Promise<{ ok: true } | { ok: false; error: unknown }>;
}): void;
close(): Promise<void>;
}
-71
View File
@@ -1,71 +0,0 @@
import { rm } from "node:fs/promises";
export class HopperDeadlineCleanupFailure extends AggregateError {
constructor(owner, failures) {
super(
failures,
`Hopper cleanup incomplete; workspace may remain at ${owner.workspace}`,
);
this._tag = "HopperDeadlineCleanupFailure";
this.cleanupOwner = owner;
}
}
/** Own the client, profile scope, and workspace until all cleanup completes. */
export class HopperDeadlineCleanup {
#client;
#profile;
#pending;
#removed = false;
constructor(workspace, profile) {
this.workspace = workspace;
this.#profile = profile;
}
setClient(client) {
this.#client = client;
}
close() {
if (this.#pending !== undefined) return this.#pending;
const attempt = this.#close();
const pending = attempt.finally(() => {
if (this.#pending === pending) this.#pending = undefined;
});
this.#pending = pending;
return pending;
}
async #close() {
if (this.#removed) return;
const failures = [];
if (this.#client !== undefined) {
try {
const result = await this.#client.close();
if (!result.ok) throw result.error;
this.#client = undefined;
} catch (cause) {
failures.push(cause);
}
}
if (this.#profile !== undefined) {
try {
await this.#profile.close();
this.#profile = undefined;
} catch (cause) {
failures.push(cause);
}
}
if (failures.length === 0) {
try {
await rm(this.workspace, { recursive: true, force: true });
this.#removed = true;
return;
} catch (cause) {
failures.push(cause);
}
}
throw new HopperDeadlineCleanupFailure(this, failures);
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ function assertHeaders(record, side, headers) {
}
}
function assertEndpointHeaders(actual, expected) {
export function assertEndpointHeaders(actual, expected) {
// The proxy rebuilds pseudo fields from its HTTP state. Compare their values
// by name while retaining the exact order and duplicates of regular headers.
const partition = (headers) => {
@@ -1,10 +1,28 @@
import { analyzeJavaScriptApplication } from "../../dist/application/javascript/JavaScriptApplicationService.js";
import { withVerifierArtifactScope } from "./verifier-artifact-scope.mjs";
import { ArtifactResourceScope } from "../../dist/artifacts/ArtifactResourceScope.js";
/** Analyze one JavaScript application and close its caller-owned resources. */
export const analyzeJavaScriptApplicationWithOwnedScope = (input) =>
withVerifierArtifactScope(async (scope) => {
export const analyzeJavaScriptApplicationWithOwnedScope = async (input) => {
const scope = new ArtifactResourceScope();
let outcome;
try {
const result = await analyzeJavaScriptApplication(input, scope);
if (!result.ok) throw result.error;
return result.value;
});
outcome = result.ok
? { kind: "completed", value: result.value }
: { kind: "failed", cause: result.error };
} catch (cause) {
outcome = { kind: "failed", cause };
}
try {
await scope.close();
} catch (cleanupCause) {
if (outcome.kind === "failed")
throw new AggregateError(
[outcome.cause, cleanupCause],
"JavaScript analysis and artifact cleanup both failed",
);
throw cleanupCause;
}
if (outcome.kind === "failed") throw outcome.cause;
return outcome.value;
};
+1 -1
View File
@@ -78,7 +78,7 @@ export const expandMcpSessionStatus = (status) => ({
});
/** Expand provider capability groups while retaining candidate identity/state. */
const expandProviderCandidates = (candidates) =>
export const expandProviderCandidates = (candidates) =>
candidates.map((candidate) => ({
...candidate,
capabilities: expandProviderCapabilities(
+176
View File
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { test } from "node:test";
import { inspectModuleBoundaries } from "./module-boundaries.mjs";
const fixture = async () => {
const root = await mkdtemp(join(tmpdir(), "rea-module-boundaries-"));
await mkdir(join(root, "src/domain"), { recursive: true });
await mkdir(join(root, "src/composition"), { recursive: true });
await writeFile(
join(root, "src/composition/worker.ts"),
"export default 1;\n",
);
await writeFile(
join(root, "package.json"),
JSON.stringify({ imports: { "#worker": "./dist/composition/worker.js" } }),
);
return root;
};
await test("reports a pure-layer composition import written relatively", async () => {
const root = await fixture();
try {
const violations = inspectModuleBoundaries(
"src/domain/probe.ts",
'import worker from "../composition/worker.js";\nexport default worker;\n',
root,
);
assert.deepEqual(
violations.map(({ boundary, target }) => ({ boundary, target })),
[{ boundary: "pure-layer", target: "src/composition/worker.js" }],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
await test("reports a pure-layer composition import written through the package map", async () => {
const root = await fixture();
try {
const violations = inspectModuleBoundaries(
"src/domain/probe.ts",
'import worker from "#worker";\nexport default worker;\n',
root,
);
assert.deepEqual(
violations.map(({ boundary, target }) => ({ boundary, target })),
[{ boundary: "pure-layer", target: "src/composition/worker.ts" }],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
for (const [name, specifier, imports] of [
[
"conditional",
"#worker",
{
"#worker": {
import: "./dist/composition/worker.js",
default: "./dist/composition/other.js",
},
},
],
["array", "#worker", { "#worker": ["./dist/composition/worker.js"] }],
["pattern", "#workers/worker", { "#workers/*": "./dist/composition/*.js" }],
]) {
await test(`refuses an unsupported valid ${name} package map rather than skipping its edge`, async () => {
const root = await fixture();
try {
await writeFile(join(root, "package.json"), JSON.stringify({ imports }));
const file = "src/domain/probe.ts";
await writeFile(
join(root, file),
`import ${JSON.stringify(specifier)};\n`,
);
// Resolve only: no worker module or authored target source is executed.
const resolved = spawnSync(
process.execPath,
[
"--experimental-import-meta-resolve",
"--input-type=module",
"-e",
"console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
specifier,
pathToFileURL(join(root, file)).href,
],
{ encoding: "utf8" },
);
if (resolved.error !== undefined) throw resolved.error;
assert.equal(resolved.status, 0, resolved.stderr);
assert.equal(
resolved.stdout.trim(),
pathToFileURL(join(root, "dist/composition/worker.js")).href,
);
const checked = spawnSync(
process.execPath,
[
fileURLToPath(
new URL("../verify-module-boundaries.mjs", import.meta.url),
),
file,
],
{ cwd: root, encoding: "utf8" },
);
if (checked.error !== undefined) throw checked.error;
assert.equal(checked.status, 1, checked.stdout);
assert.match(checked.stderr, /Could not inspect src\/domain\/probe.ts/u);
assert.match(checked.stderr, /Cannot resolve package import/u);
assert.match(checked.stderr, /relative source import/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
for (const target of [
"./dist/composition/worker.js",
"./dist/%63omposition/worker.js",
"./%64ist/composition/worker.js",
]) {
await test(`checks the URL-normalized source owner of ${target}`, async () => {
const root = await fixture();
try {
await writeFile(
join(root, "package.json"),
JSON.stringify({ imports: { "#worker": target } }),
);
await mkdir(join(root, "src/application"), { recursive: true });
const file = "src/application/probe.ts";
await writeFile(join(root, file), 'import "#worker";\n');
const resolved = spawnSync(
process.execPath,
[
"--experimental-import-meta-resolve",
"--input-type=module",
"-e",
"console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
"#worker",
pathToFileURL(join(root, file)).href,
],
{ encoding: "utf8" },
);
if (resolved.error !== undefined) throw resolved.error;
assert.equal(resolved.status, 0, resolved.stderr);
assert.equal(
fileURLToPath(resolved.stdout.trim()),
join(root, "dist/composition/worker.js"),
);
const checked = spawnSync(
process.execPath,
[
fileURLToPath(
new URL("../verify-module-boundaries.mjs", import.meta.url),
),
file,
],
{ cwd: root, encoding: "utf8" },
);
if (checked.error !== undefined) throw checked.error;
assert.equal(checked.status, 1, checked.stdout);
assert.match(
checked.stderr,
/application-composition: #worker resolves to src\/composition\/worker.ts/u,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
@@ -1,5 +1,8 @@
export { runOwnedVerifierTerminal } from "./verifier-terminal-owner.mjs";
export type {
VerifierTerminalOptions,
VerifierTerminalDependencies,
VerifierTerminalResult,
VerifierTerminalFailure,
VerifierTerminalCleanupFailure,
} from "./verifier-terminal-owner.mjs";
+2 -5
View File
@@ -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(
+3 -7
View File
@@ -1,10 +1,6 @@
/** Observe possible Hopper resources, including entries with unresolved ownership. */
/** Observe concrete Hopper runtime resources for a before/after cleanup check. */
export function snapshotHopperRuntime(
parent: string,
targetLeaseDirectory: string,
expectedRunId?: string,
): Promise<{
readonly paths: Set<string>;
readonly confirmedSessions: Set<string>;
readonly unresolvedSessions: Set<string>;
}>;
ownedProcessIds?: ReadonlySet<number>,
): Promise<Set<string>>;
+22 -30
View File
@@ -1,38 +1,35 @@
import { lstat, readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
/** Observe possible sessions and target leases without discarding uncertainty. */
/** Observe session paths and target leases without treating their shared parent as a session. */
export async function snapshotHopperRuntime(
parent,
targetLeaseDirectory,
expectedRunId,
ownedProcessIds,
) {
const paths = new Set();
const confirmedSessions = new Set();
const unresolvedSessions = new Set();
const resources = new Set();
for (const entry of await readdir(parent, { withFileTypes: true })) {
const path = join(parent, entry.name);
if (!entry.name.startsWith("rea-") || path === targetLeaseDirectory)
continue;
const confirmed =
typeof expectedRunId === "string" &&
expectedRunId.length > 0 &&
entry.isDirectory() &&
(await hasRunIdentity(path, expectedRunId));
(confirmed ? confirmedSessions : unresolvedSessions).add(path);
paths.add(path);
if (
ownedProcessIds !== undefined &&
(!entry.isDirectory() ||
!(await belongsToOwnedProcess(path, ownedProcessIds)))
)
continue;
resources.add(path);
}
const leaseRoot = await lstat(targetLeaseDirectory).catch(missingIsAbsent);
if (leaseRoot !== undefined) {
if (!leaseRoot.isDirectory() || leaseRoot.isSymbolicLink())
paths.add(targetLeaseDirectory);
else {
const leases = await readdir(targetLeaseDirectory).catch(missingIsAbsent);
for (const name of leases ?? [])
paths.add(join(targetLeaseDirectory, name));
}
if (leaseRoot === undefined) return resources;
if (!leaseRoot.isDirectory() || leaseRoot.isSymbolicLink()) {
resources.add(targetLeaseDirectory);
return resources;
}
return { paths, confirmedSessions, unresolvedSessions };
const leases = await readdir(targetLeaseDirectory).catch(missingIsAbsent);
for (const name of leases ?? [])
resources.add(join(targetLeaseDirectory, name));
return resources;
}
const missingIsAbsent = (cause) => {
@@ -40,21 +37,16 @@ const missingIsAbsent = (cause) => {
throw cause;
};
const hasRunIdentity = async (path, expectedRunId) => {
const belongsToOwnedProcess = async (path, ownedProcessIds) => {
const encoded = await readFile(join(path, "ownership.json"), "utf8").catch(
missingIsAbsent,
);
if (encoded === undefined) return false;
let ownership;
try {
ownership = JSON.parse(encoded);
} catch (cause) {
if (cause instanceof SyntaxError) return false;
throw cause;
}
const ownership = JSON.parse(encoded);
return (
ownership !== null &&
typeof ownership === "object" &&
ownership.run_id === expectedRunId
Number.isSafeInteger(ownership.parent_pid) &&
ownedProcessIds.has(ownership.parent_pid)
);
};
+8 -1
View File
@@ -1,4 +1,4 @@
interface FixtureProcedure {
export interface FixtureProcedure {
readonly address: string;
readonly name: string;
}
@@ -26,6 +26,13 @@ export function requireCurrentDocument(
normalizedResult: (value: unknown, operation: string) => unknown,
): Promise<string>;
export function analyzeFixtureProcedure(
client: ToolClient,
options: unknown,
procedure: FixtureProcedure,
normalizedResult: (value: unknown, operation: string) => unknown,
): Promise<unknown>;
export function verifyRealHopperFixture(options: {
readonly client: ToolClient;
readonly options: unknown;
+1 -1
View File
@@ -310,7 +310,7 @@ export async function resolveFixtureProcedure(
}
/** Request the assembly evidence required by the fixture oracle. */
const analyzeFixtureProcedure = async (
export const analyzeFixtureProcedure = async (
client,
options,
procedure,
+3
View File
@@ -6,3 +6,6 @@ export function unsupportedRuntimeMessage(
range: string,
version: string,
): string | undefined;
/** Human-readable release families for an `engines.node` range. */
export function supportedReleaseProse(range: string): string;
+1 -1
View File
@@ -21,7 +21,7 @@ export const unsupportedRuntimeMessage = (range, version) => {
};
/** Human-readable release families for an `engines.node` range. */
const supportedReleaseProse = (range) =>
export const supportedReleaseProse = (range) =>
`Node.js ${range
.split("||")
.map((clause) => {
-15
View File
@@ -1,15 +0,0 @@
import type { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
export class VerifierArtifactCleanupFailure extends AggregateError {
readonly _tag: "VerifierArtifactCleanupFailure";
readonly cleanupOwner: ArtifactResourceScope;
constructor(
scope: ArtifactResourceScope,
failures: unknown[],
cause: unknown,
);
}
export function withVerifierArtifactScope<T>(
operation: (scope: ArtifactResourceScope) => Promise<T>,
): Promise<T>;
-32
View File
@@ -1,32 +0,0 @@
import { ArtifactResourceScope } from "../../dist/artifacts/ArtifactResourceScope.js";
/** A failed close retains the scope that can retry all of its owners. */
export class VerifierArtifactCleanupFailure extends AggregateError {
constructor(scope, failures, cause) {
super(failures, "Verifier cleanup failed", { cause });
this._tag = "VerifierArtifactCleanupFailure";
this.cleanupOwner = scope;
}
}
/** Run one verifier operation and retain its exact scope if close fails. */
export async function withVerifierArtifactScope(operation) {
const scope = new ArtifactResourceScope();
let outcome;
try {
outcome = { ok: true, value: await operation(scope) };
} catch (cause) {
outcome = { ok: false, cause };
}
try {
await scope.close();
} catch (cleanupCause) {
throw new VerifierArtifactCleanupFailure(
scope,
outcome.ok ? [cleanupCause] : [outcome.cause, cleanupCause],
outcome.ok ? cleanupCause : outcome.cause,
);
}
if (!outcome.ok) throw outcome.cause;
return outcome.value;
}
+13 -35
View File
@@ -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()
+3 -3
View File
@@ -40,7 +40,7 @@ export interface VerifierTerminalOptions {
readonly signal?: AbortSignal;
}
interface VerifierTerminalDependencies {
export interface VerifierTerminalDependencies {
readonly loadPty?: () => Promise<typeof import("@lydell/node-pty")>;
readonly cleanupProcessGroup?: typeof cleanupOwnedProcessGroup;
}
@@ -59,7 +59,7 @@ type TerminalFailureReason =
| "unsupported_platform";
/** Expected terminal failures retain the decoded output observed at failure. */
class VerifierTerminalFailure extends Error {
export class VerifierTerminalFailure extends Error {
readonly _tag = "VerifierTerminalFailure";
constructor(
@@ -74,7 +74,7 @@ class VerifierTerminalFailure extends Error {
}
/** The adopted owner remains the exact authority for retrying this release. */
class VerifierTerminalCleanupFailure extends Error {
export class VerifierTerminalCleanupFailure extends Error {
readonly _tag = "VerifierTerminalCleanupFailure";
readonly reason = "cleanup_incomplete";
-4
View File
@@ -5,8 +5,6 @@ import noEvidenceJsonPreadmission from "./rules/no-evidence-json-preadmission.mj
import noFixtureRootCleanup from "./rules/no-fixture-root-cleanup.mjs";
import noPositionalToolContract from "./rules/no-positional-tool-contract.mjs";
import noSharedModuleMock from "./rules/no-shared-module-mock.mjs";
import noLocaleCompare from "./rules/no-locale-compare.mjs";
import noBabelVisitorKeys from "./rules/no-babel-visitor-keys.mjs";
export default {
meta: { name: "rea" },
@@ -17,7 +15,5 @@ export default {
"no-fixture-root-cleanup": noFixtureRootCleanup,
"no-positional-tool-contract": noPositionalToolContract,
"no-shared-module-mock": noSharedModuleMock,
"no-locale-compare": noLocaleCompare,
"no-babel-visitor-keys": noBabelVisitorKeys,
},
};
@@ -1,4 +0,0 @@
import type { RuleTester } from "oxlint/plugins-dev";
declare const rule: Parameters<RuleTester["run"]>[1];
export default rule;
@@ -1,73 +0,0 @@
import { findBinding } from "../bindings.mjs";
const nameOf = (node) =>
node.type === "Identifier"
? node.name
: node.type === "Literal"
? node.value
: node.type === "TemplateLiteral" && node.expressions.length === 0
? node.quasis[0]?.value.cooked
: undefined;
export default {
meta: {
type: "problem",
docs: { description: "Keep Babel child expansion in its shared owner." },
schema: [],
messages: {
owner:
"Use childNodes or traverseJavaScriptAst instead of Babel VISITOR_KEYS.",
},
},
create(context) {
const source = context.sourceCode;
const isBabelNamespace = (node) => {
if (node?.type !== "Identifier") return false;
const definition = findBinding(source, node)?.defs[0];
if (
definition?.type !== "ImportBinding" ||
definition.node.type !== "ImportNamespaceSpecifier"
)
return false;
const declaration = definition.parent ?? definition.node.parent;
return declaration?.source?.value === "@babel/types";
};
return {
ImportSpecifier(node) {
if (
node.parent.source.value === "@babel/types" &&
(node.imported.name ?? node.imported.value) === "VISITOR_KEYS"
)
context.report({ node, messageId: "owner" });
},
ExportNamedDeclaration(node) {
if (node.source?.value !== "@babel/types") return;
for (const specifier of node.specifiers)
if (
specifier.type === "ExportSpecifier" &&
nameOf(specifier.local) === "VISITOR_KEYS"
)
context.report({ node: specifier, messageId: "owner" });
},
MemberExpression(node) {
if (
(!node.computed || node.property.type !== "Identifier") &&
nameOf(node.property) === "VISITOR_KEYS" &&
isBabelNamespace(node.object)
)
context.report({ node, messageId: "owner" });
},
VariableDeclarator(node) {
if (node.id.type !== "ObjectPattern" || !isBabelNamespace(node.init))
return;
for (const property of node.id.properties)
if (
property.type === "Property" &&
(!property.computed || property.key.type !== "Identifier") &&
nameOf(property.key) === "VISITOR_KEYS"
)
context.report({ node: property, messageId: "owner" });
},
};
},
};
@@ -1,4 +0,0 @@
import type { RuleTester } from "oxlint/plugins-dev";
declare const rule: Parameters<RuleTester["run"]>[1];
export default rule;
-34
View File
@@ -1,34 +0,0 @@
export default {
meta: {
type: "problem",
docs: {
description: "Keep domain ordering independent of the host locale.",
},
schema: [],
messages: {
locale:
"Use compareUnicodeCodePoints instead of localeCompare for domain ordering.",
},
},
create(context) {
return {
CallExpression(node) {
const member = node.callee;
if (member.type !== "MemberExpression") return;
if (
(!member.computed &&
member.property.type === "Identifier" &&
member.property.name === "localeCompare") ||
(member.computed &&
member.property.type === "Literal" &&
member.property.value === "localeCompare") ||
(member.computed &&
member.property.type === "TemplateLiteral" &&
member.property.expressions.length === 0 &&
member.property.quasis[0]?.value.cooked === "localeCompare")
)
context.report({ node, messageId: "locale" });
},
};
},
};
+1 -1
View File
@@ -54,7 +54,7 @@ if (await pathExists(join(root, "src"))) {
"dist/main.js",
"dist/mcp/main.js",
"dist/cli-runtime/cli.js",
"dist/cli-runtime/cliOneShot.js",
"dist/cli-runtime/cliOutput.js",
"dist/cli-runtime/cliBuiltinArguments.js",
"dist/cli-runtime/cliResultOutput.js",
"dist/cli-runtime/mcpDoctor.js",
+142 -54
View File
@@ -64,7 +64,7 @@ const runtimeFiles = [
? ["dist/mcp/main.js", "dist/cli-runtime/mcpDoctor.js"]
: [
"dist/cli-runtime/cli.js",
"dist/cli-runtime/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;
+3 -17
View File
@@ -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 {
+30 -44
View File
@@ -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,
})
+10
View File
@@ -1,3 +1,13 @@
export interface PackageHopperEnvironment {
readonly HOPPER_LAUNCHER_PATH: string;
readonly HOPPER_LOADER_ARGS_JSON: string;
}
export function packageHopperEnvironment(
root: string,
platform?: string,
): PackageHopperEnvironment;
export function verifyPackageEnvironment(input: {
readonly root: string;
readonly workspace: string;
+1 -1
View File
@@ -117,7 +117,7 @@ export async function verifyPackageEnvironment({
};
}
const packageHopperEnvironment = (root, platform = process.platform) => {
export const packageHopperEnvironment = (root, platform = process.platform) => {
const fakeLauncher = join(root, "tests/fixtures/fakeLauncher.mjs");
if (platform !== "linux")
return {
+2
View File
@@ -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";
+5 -3
View File
@@ -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 -1
View File
@@ -414,7 +414,7 @@ export function matchesSymbol(value, expected) {
return tail === expected || tail === `_${expected}`;
}
function symbolTail(value) {
export function symbolTail(value) {
if (typeof value !== "string") return undefined;
return value.split("::").at(-1);
}
+10 -3
View File
@@ -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";
@@ -9,9 +10,10 @@ import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { withVerifierArtifactScope } from "./lib/verifier-artifact-scope.mjs";
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";
@@ -82,15 +84,20 @@ for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
* installation catalog reader it uses. User extensions are not in that catalog.
*/
async function languageInstalled(languageId) {
return withVerifierArtifactScope(async (scope) => {
const scope = new ArtifactResourceScope();
try {
const catalog = await readGhidraLanguageCatalog(
installation.installDir,
scope,
);
return catalog.some((definition) => definition.id === languageId);
});
} finally {
await scope.close();
}
}
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 };
+26 -40
View File
@@ -11,10 +11,6 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { artifactMcpCall } from "./lib/artifact-e2e.mjs";
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js";
assert.equal(process.platform, "darwin", "requires macOS");
@@ -52,9 +48,11 @@ const digest = async (path) =>
createHash("sha256")
.update(await readFile(path))
.digest("hex");
const sessionsBefore = (
await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY)
).paths;
const sessionsBefore = await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
);
const ownedProcessIds = new Set();
const client = new Client({ name: "hopper-callers-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
@@ -95,7 +93,7 @@ const matchingRef = (ref, site, from, to) =>
ref.source_procedure?.address === from &&
ref.target_procedure?.address === to &&
ref.call?.classification === "direct";
await withVerifierCleanup(async () => {
try {
await run(clang, [
"-isysroot",
sdk,
@@ -131,6 +129,7 @@ await withVerifierCleanup(async () => {
await client.connect(transport);
if (transport.pid === null)
throw new Error("MCP process identity unavailable");
ownedProcessIds.add(transport.pid);
let notifications = 0;
const original = transport.onmessage;
transport.onmessage = (message) => {
@@ -288,36 +287,23 @@ await withVerifierCleanup(async () => {
sha256: beforeLarge,
}),
);
}, [
async () => {
try {
await closeVerifierResources([
() => client.close(),
() => transport.close(),
async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
const leaked = [
...(
await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY)
).paths,
].filter((path) => !sessionsBefore.has(path));
assert.deepEqual(
leaked,
[],
`Hopper resources remain: ${leaked.join(", ")}`,
);
},
]);
} catch (cause) {
throw new Error(
`Hopper cleanup failed; fixture retained at ${directory}`,
{
cause,
},
);
} finally {
if (stderr) process.stderr.write(stderr);
}
} finally {
await client.close();
await transport.close();
await new Promise((resolve) => setTimeout(resolve, 500));
const leaked = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
ownedProcessIds,
)),
].filter((path) => !sessionsBefore.has(path));
if (leaked.length === 0)
await rm(directory, { recursive: true, force: true });
},
]);
if (stderr) process.stderr.write(stderr);
assert.deepEqual(
leaked,
[],
`Hopper resources remain: ${leaked.join(", ")}; fixture retained at ${directory}`,
);
}
+22 -13
View File
@@ -1,11 +1,10 @@
import assert from "node:assert/strict";
import { copyFile, mkdtemp, realpath } from "node:fs/promises";
import { copyFile, mkdtemp, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { firstProcedureAddress } from "./lib/real-hopper-assertions.mjs";
import { HopperDeadlineCleanup } from "./lib/hopper-deadline-cleanup.mjs";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js";
@@ -30,7 +29,6 @@ const root = await realpath(
);
const target = join(root, "deadline-fixture");
const profileResources = new ArtifactResourceScope();
const cleanupOwner = new HopperDeadlineCleanup(root, profileResources);
const profileResourceScope = {
retain: (owner, resource) =>
profileResources.retain({
@@ -42,7 +40,6 @@ const profileResourceScope = {
};
let client;
let primaryFailure;
let operationFailed = false;
try {
await copyFile(fixtures.primary.path, target);
const parsed = await parseBinaryTarget(target);
@@ -151,19 +148,31 @@ try {
),
);
} catch (cause) {
operationFailed = true;
primaryFailure = cause;
throw cause;
} finally {
let cleanupFailure;
if (client !== undefined) {
const closed = await client.close();
if (!closed.ok) cleanupFailure = closed.error;
}
try {
if (client !== undefined) cleanupOwner.setClient(client);
await cleanupOwner.close();
} catch (cleanupFailure) {
throw operationFailed
? new AggregateError(
await profileResources.close();
} catch (cause) {
cleanupFailure =
cleanupFailure === undefined
? cause
: new AggregateError(
[cleanupFailure, cause],
"Hopper profile and client cleanup failed",
);
}
await rm(root, { recursive: true, force: true });
if (cleanupFailure !== undefined)
throw primaryFailure === undefined
? cleanupFailure
: new AggregateError(
[primaryFailure, cleanupFailure],
"Hopper verification and cleanup failed",
)
: cleanupFailure;
}
);
}
+37 -79
View File
@@ -23,10 +23,6 @@ import {
requireMcpOperationResult,
} from "./lib/mcp-verifier-results.mjs";
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js";
import { parseConfig } from "../dist/config/parseConfig.js";
import {
@@ -74,12 +70,14 @@ const intel = join(directory, "c-x86_64");
const fat = join(directory, "c-fat");
const fat64 = join(directory, "c-fat64");
const singleFat = join(directory, "c-single-fat");
const owned = new Set();
const extraFat64 = [];
const invalidFat64 = [];
let preparedImagePath;
const before = (
await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY)
).paths;
const before = await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
);
const client = new Client({ name: "hopper-fat-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
@@ -96,7 +94,7 @@ const ajv = new Ajv({ strict: false });
addFormats(ajv);
const observations = [];
const sourceBinding = [];
await withVerifierCleanup(async () => {
try {
await run(toolPaths.clang, [
"-isysroot",
sdk,
@@ -185,17 +183,9 @@ await withVerifierCleanup(async () => {
"overlap",
/slices overlap/u,
(bytes) => {
const firstOffset = bytes.readBigUInt64BE(16);
const secondOffset = bytes.readBigUInt64BE(48);
const earlier = firstOffset < secondOffset ? 8 : 40;
const laterOffset =
firstOffset < secondOffset ? secondOffset : firstOffset;
const earlierOffset =
firstOffset < secondOffset ? firstOffset : secondOffset;
bytes.writeBigUInt64BE(
laterOffset - earlierOffset + 16n,
earlier + 16,
);
const cpu = process.arch === "arm64" ? 0x100000c : 0x1000007;
const base = bytes.readUInt32BE(8) === cpu ? 8 : 40;
bytes.copy(bytes, base === 8 ? 40 : 8, base, base + 32);
},
],
]) {
@@ -221,14 +211,11 @@ await withVerifierCleanup(async () => {
}
const path = join(directory, "c-fat64-ambiguous");
await writeFile(path, ambiguous);
invalidFat64.push({
path,
reason:
/FAT binary declares several (?:arm64|x86_64) slices; no slice was selected/u,
});
invalidFat64.push({ path, reason: /selection.*ambiguous/u });
}
await client.connect(transport);
assert.ok(transport.pid !== null);
owned.add(transport.pid);
const catalog = await client.listTools();
const call = async (name, args = {}) => {
const reply = await client.callTool(
@@ -372,27 +359,14 @@ await withVerifierCleanup(async () => {
if (verifyFat64) {
await call("open_binary", { path: fat64 });
await call("list_procedures");
const session = await call("binary_session");
const runId = session.analysis_run?.run_id;
assert.ok(
typeof runId === "string" && runId.length > 0,
"Missing analysis run identity",
);
const resources = await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
runId,
);
const unresolvedNew = [...resources.unresolvedSessions].filter(
(path) => !before.has(path),
);
assert.deepEqual(
unresolvedNew,
[],
"Cannot identify a prepared image while new Hopper resource ownership is unresolved",
);
for (const root of resources.confirmedSessions) {
if (before.has(root)) continue;
const resources = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
owned,
)),
].filter((path) => !before.has(path));
for (const root of resources) {
const image = join(root, "image.macho");
if (
await access(image).then(
@@ -429,43 +403,27 @@ await withVerifierCleanup(async () => {
},
);
assert.ok(pending.child.pid !== undefined);
owned.add(pending.child.pid);
const cli = JSON.parse((await pending).stdout);
assert.deepEqual(cli.normalized_result, expected.mapping);
}
}, [
async () => {
try {
await closeVerifierResources([
() => client.close(),
() => transport.close(),
]);
} catch (cause) {
throw new Error(
`Hopper cleanup failed; fixture retained at ${directory}`,
{
cause,
},
);
}
try {
const retained = [
...(await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY))
.paths,
].filter((path) => !before.has(path));
assert.deepEqual(
retained,
[],
"Hopper resources remain or are unresolved",
);
} catch (cause) {
throw new Error(
`Hopper resource verification failed; fixture retained at ${directory}`,
{ cause },
);
}
await rm(directory, { recursive: true, force: true });
},
]);
} finally {
await client.close();
await transport.close();
await rm(directory, { recursive: true, force: true });
}
const retained = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
owned,
)),
].filter((path) => !before.has(path));
assert.deepEqual(
retained,
[],
"FAT verification retained owned Hopper sessions or leases",
);
console.log(
JSON.stringify(
{
+10 -6
View File
@@ -62,9 +62,10 @@ const parseServerArgs = (encoded) => {
};
const runtimeParent = process.platform === "darwin" ? "/tmp" : tmpdir();
const sessionsBefore = (
await snapshotHopperRuntime(runtimeParent, HOPPER_TARGET_LEASE_DIRECTORY)
).paths;
const sessionsBefore = await snapshotHopperRuntime(
runtimeParent,
HOPPER_TARGET_LEASE_DIRECTORY,
);
const ownedProcessIds = new Set();
const textValue = mcpTextValue;
@@ -556,8 +557,11 @@ try {
await new Promise((resolve) => setTimeout(resolve, 500));
const sessionsAfter = [
...(await snapshotHopperRuntime(runtimeParent, HOPPER_TARGET_LEASE_DIRECTORY))
.paths,
...(await snapshotHopperRuntime(
runtimeParent,
HOPPER_TARGET_LEASE_DIRECTORY,
ownedProcessIds,
)),
]
.filter((path) => !sessionsBefore.has(path))
.sort();
@@ -569,7 +573,7 @@ if (sessionsAfter.length > 0) {
retainedRuntimePaths: sessionsAfter,
});
throw new Error(
`Hopper resources remain or have unresolved ownership: ${sessionsAfter.join(", ")}`,
`The MCP runtime retained Hopper resources: ${sessionsAfter.join(", ")}`,
);
}
if (summary === undefined)
+2 -4
View File
@@ -40,7 +40,7 @@ try {
const expected = [];
for (const path of files.stdout
.split("\0")
.filter((path) => /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(path))) {
.filter((path) => path.endsWith(".test.ts"))) {
try {
if ((await stat(join(root, path))).isFile()) expected.push(path);
} catch (cause) {
@@ -65,6 +65,4 @@ try {
await rm(temporary, { recursive: true, force: true });
}
if (discovered !== undefined)
console.log(
`Discovered all ${discovered} test and spec files in src and tests exactly once.`,
);
console.log(`Discovered all ${discovered} test files exactly once.`);
+7 -2
View File
@@ -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}`);
}
+7 -3
View File
@@ -6,7 +6,7 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { parseBinaryTarget } from "../../../dist/application/BinaryTargetResolver.js";
import { withVerifierArtifactScope } from "../../lib/verifier-artifact-scope.mjs";
import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
import { inspectAppleDispatchMetadata } from "../../../dist/native/AppleDispatchMetadata.js";
import {
artifactCliEvidence,
@@ -57,7 +57,8 @@ const fixupCoverage = (metadata) =>
const inspect = async (path) => {
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
return withVerifierArtifactScope(async (resources) => {
const resources = new ArtifactResourceScope();
try {
return {
target: target.value,
metadata: (
@@ -66,7 +67,10 @@ const inspect = async (path) => {
)
).result,
};
});
} finally {
// Retry any descriptor whose close failed, surfacing unresolved cleanup.
await resources.close();
}
};
const checkObjc = (metadata, mode) => {
+32 -3
View File
@@ -5,12 +5,17 @@ import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
// Architecture guards for the strict-identity / best-effort-inference split.
// - Evidence ordering must be locale-independent (ICU varies by host), so
// src/domain orders with compareUnicodeCodePoints, never localeCompare.
// - Composite dedup keys must not NUL-join variable-length or free-form
// MIDDLE fields: ["a\0b"] collapses with ["a", "b"] and drops a finding.
// The text check below only flags one spelling; it is not a complete audit.
// compositeKey (JSON tuple) is the single owner for composite keys.
// - Transparent wrappers unwrap in exactly one place
// (javascriptAstValues.unwrapJavaScriptExpression); direct isTS* unwrapping
// elsewhere drifts (the `satisfies` class of bug).
// - AST walks go through traverseJavaScriptAst/childNodes: bespoke
// Object.values walkers diverge on loc/comment fields and overflow the
// stack on deep generated chains.
const root = fileURLToPath(new URL("../../", import.meta.url));
@@ -22,7 +27,6 @@ const listFiles = async (dir) => {
else if (
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".spec.ts") &&
!entry.name.endsWith(".fixture.ts")
)
output.push(path);
@@ -44,6 +48,8 @@ const files = await Promise.all(
content: stripComments(await readFile(path, "utf8")),
})),
);
const inDir = (dir) => files.filter((file) => file.path.startsWith(dir));
const failures = [];
const check = (label, selected, pattern, allow = []) => {
const offenders = selected.filter(
@@ -62,7 +68,21 @@ check(
/providers\/ProviderInspectionScope\.js/,
);
check("NUL-joined list key (use compositeKey)", files, /\.join\(["']\\0["']\)/);
check(
"src/domain uses localeCompare (use compareUnicodeCodePoints)",
inDir("src/domain/"),
/\.localeCompare\(/,
);
check(
"NUL-joined list key (use compositeKey)",
files,
/\.join\(["']\\0["']\)/,
[
// Sorted pair of constrained trace IDs; documented in plan.
"src/domain/process/processTraceSpecification.ts",
],
);
check(
"direct transparent-wrapper unwrap outside javascriptAstValues (use unwrapJavaScriptExpression)",
@@ -71,6 +91,13 @@ check(
["src/domain/javascript/javascriptAstValues.ts"],
);
check(
"bespoke AST child walker (use traverseJavaScriptAst/childNodes)",
files,
/VISITOR_KEYS/,
["src/domain/javascript/javascriptSemanticTraversal.ts"],
);
// A producer that pins both the soft and hard ceiling of every resource takes
// the vector from one owner. prlimitCeilingArguments is the command-less prefix
// of prlimitArgumentVector; both live in src/process/LinuxResourceLimits.ts.
@@ -136,6 +163,7 @@ check(
"src/application/GuidanceAccess.ts",
"src/application/javascript/ElectronBoundaryAnalysis.ts",
"src/application/javascript/ElectronBoundaryGraphContext.ts",
"src/application/javascript/JavaScriptArtifactGraphContext.ts",
"src/browser/CdpCaptureCompleteness.ts",
"src/browser/CdpCaptureEventHandlers.ts",
"src/browser/CdpPageCapture.ts",
@@ -151,6 +179,7 @@ check(
"src/domain/javascript/javascriptNativeHandoff.ts",
"src/domain/javascript/javascriptRuntimeReconciliationMatching.ts",
"src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts",
"src/domain/javascript/javascriptSemanticQuery.ts",
"src/domain/javascript/javascriptStaticAnalysisBundler.ts",
"src/domain/javascript/javascriptStaticAnalysisCalls.ts",
"src/domain/javascript/javascriptStaticAnalysisFindings.ts",
@@ -5,6 +5,7 @@ import { parseArgs } from "node:util";
import { readStableArtifact } from "../../../dist/artifacts/readStableArtifact.js";
import { ArtifactReaderFailure } from "../../../dist/artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
import {
ArtifactFileCleanupFailure,
artifactFileCompletion,
@@ -13,7 +14,6 @@ import {
import { reconcileJavaScriptRuntimeEvidence } from "../../../dist/application/javascript/JavaScriptRuntimeReconciliationService.js";
import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs";
import { withVerifierArtifactScope } from "../../lib/verifier-artifact-scope.mjs";
import {
completeVerifierRun,
createVerifierRun,
@@ -106,23 +106,33 @@ process.stdout.write(
);
async function readEvidenceFile(path) {
const scope = new ArtifactResourceScope();
let outcome;
try {
outcome = {
ok: true,
value: await withVerifierArtifactScope(async (scope) =>
readStableArtifact(
await realpath(path),
64 * 1024 * 1024,
undefined,
undefined,
scope,
),
value: await readStableArtifact(
await realpath(path),
64 * 1024 * 1024,
undefined,
undefined,
scope,
),
};
} catch (cause) {
outcome = { ok: false, cause };
}
try {
await scope.close();
} catch (cleanupCause) {
if (!outcome.ok)
throw new AggregateError(
[outcome.cause, cleanupCause],
"Verifier cleanup failed",
{ cause: outcome.cause },
);
throw cleanupCause;
}
if (!outcome.ok && outcome.cause instanceof ArtifactFileCleanupFailure) {
const completion = artifactFileCompletion(outcome.cause);
outcome =
+9 -5
View File
@@ -1,9 +1,10 @@
import { basename, dirname, isAbsolute, resolve } from "node:path";
import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
import {
readManagedSnapshot,
verifyManagedSourceIdentity,
} from "../../../dist/dotnet/ManagedSnapshot.js";
import { withVerifierArtifactScope } from "../../lib/verifier-artifact-scope.mjs";
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
import { parseBinaryTarget } from "../../../dist/application/BinaryTargetResolver.js";
import { inspectManagedArtifactBytes } from "../../../dist/dotnet/ManagedArtifactInspector.js";
@@ -23,10 +24,13 @@ import {
const READ_FILE_MAX_BYTES = 0x7fff_ffff;
export async function verifyManagedAppManifest(rawManifest, manifestPath) {
return withVerifierArtifactScope((resources) =>
resources.run(() =>
verifyManifestSnapshot(rawManifest, manifestPath, resources),
),
const resources = new ArtifactResourceScope();
return withVerifierCleanup(
() =>
resources.run(() =>
verifyManifestSnapshot(rawManifest, manifestPath, resources),
),
[() => resources.close()],
);
}
+7 -2
View File
@@ -1,6 +1,13 @@
/** Relative path of the registry. Its text is a declaration, not a producer. */
export const NUMERIC_LIMIT_DECLARATION: "src/domain/numericLimitRegistry.ts";
/** Limits that must become analysis settings and cannot be registry exceptions. */
export const REQUIRED_RUNTIME_LIMITS: readonly [
"REA_NTFS_MFT_MAX_FILE_BYTES",
"REA_REGF_HIVE_MAX_FILE_BYTES",
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
];
/** True for a numeric `REA_` limit, including the documented endpoint-candidate count. */
export function isNumericLimit(name: string): boolean;
@@ -48,8 +55,6 @@ export function discoverNumericLimitUses(
readonly unresolved: readonly {
readonly path: string;
readonly offset: number;
readonly line: number;
readonly column: number;
readonly reason: string;
}[];
};
+15 -53
View File
@@ -25,7 +25,6 @@ const listFiles = async (dir) => {
} else if (
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".spec.ts") &&
!entry.name.endsWith(".fixture.ts")
)
output.push(path);
@@ -40,7 +39,7 @@ export const NUMERIC_LIMIT_DECLARATION = "src/domain/numericLimitRegistry.ts";
* These limits are moving onto runtime analysis settings on their own PRs.
* A producer or a registry entry cannot satisfy that requirement.
*/
const REQUIRED_RUNTIME_LIMITS = [
export const REQUIRED_RUNTIME_LIMITS = [
"REA_NTFS_MFT_MAX_FILE_BYTES",
"REA_REGF_HIVE_MAX_FILE_BYTES",
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
@@ -95,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;
@@ -133,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();
@@ -171,16 +142,8 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
item.offset === offset &&
item.reason === reason,
)
) {
const location = sourceFile.getLineAndCharacterOfPosition(offset);
unresolved.push({
path,
offset,
line: location.line + 1,
column: location.character + 1,
reason,
});
}
)
unresolved.push({ path, offset, reason });
};
const initializer = (identifier) => {
if (!candidates.has(identifier.text)) return undefined;
@@ -189,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) {
@@ -928,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)
@@ -958,7 +920,7 @@ if (direct) {
for (const issue of discovered.unresolved)
console.log(
`${issue.path}:${issue.line}:${issue.column}: numeric limit candidate unresolved (${issue.reason})`,
`${issue.path}:${issue.offset}: numeric limit candidate unresolved (${issue.reason})`,
);
for (const [name, file] of [...discovered].sort(([left], [right]) =>
+6 -31
View File
@@ -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();
},
);
+60
View File
@@ -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");
+1 -1
View File
@@ -191,7 +191,7 @@ fi`,
await Promise.allSettled([inspection, closing]);
await provider.close();
}
});
}, 15_000);
it("fails closed when adb exits successfully without a recognized version", async () => {
const stdout = `unexpected adb output ${"x".repeat(700)}\n`;
+7 -5
View File
@@ -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,
+32 -48
View File
@@ -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(
+3 -5
View File
@@ -1,8 +1,6 @@
import type { JadxToolPort } from "./JadxAnalysis.js";
import {
androidNativeDeclarationsSchema,
type AndroidNativeDeclarations,
} from "../domain/android/androidNativeDeclarations.js";
import type { AndroidNativeDeclarations } from "../domain/android/androidNativeDeclarations.js";
import { jadxNativeDeclarationsSchema } from "./JadxProtocol.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
/** Validate the native-only batch at its producing boundary, preserving raw facts. */
@@ -11,7 +9,7 @@ export const readAndroidNativeInventory = async (
snapshot: string,
): Promise<AndroidNativeDeclarations> => {
const raw = await tools.json("rea_native_declarations", { path: snapshot });
const parsed = androidNativeDeclarationsSchema.safeParse(raw);
const parsed = jadxNativeDeclarationsSchema.safeParse(raw);
if (!parsed.success)
throw new AnalysisOutputError(
"inspect_android_jni_bindings",
+16 -12
View File
@@ -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" };
+5
View File
@@ -10,6 +10,11 @@ import {
dexDescriptorSchema,
dexTypeSchema,
} from "../domain/android/dexTypes.js";
import { androidNativeDeclarationsSchema } from "../domain/android/androidNativeDeclarations.js";
/** Native-only raw DEX facts authored before decompiler deduplication. */
export const jadxNativeDeclarationsSchema = androidNativeDeclarationsSchema;
const count = z.int().nonnegative();
/** Typed allocation failures authored before upstream erases the JVM exception. */
const jadxMemoryFailureSchema = z.strictObject({
+4 -4
View File
@@ -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;
}
+3 -3
View File
@@ -415,11 +415,11 @@ export class ApkSignatureProvider implements ApkSignaturePort {
const { v1, decoded: v1Decoded, duplicates } = v1Read.value;
const v2 = decodeScheme(block.value, APK_BLOCK_ID.signatureV2, "v2");
if (!v2.ok) return 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(
+6 -2
View File
@@ -1,6 +1,6 @@
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { stableArtifactInputFailure } from "../artifacts/StableArtifactFailure.js";
import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js";
import {
artifactFilePrimaryFacts,
type ArtifactFilePrimaryFacts,
@@ -231,7 +231,11 @@ const artifactReaderFailure = (
},
);
case "path":
return stableArtifactInputFailure(cause, OPERATION);
return new AnalysisInputError(
OPERATION,
{ cause, ...stableArtifactInputRecovery(cause) },
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
);
default:
return adapterFailure(cause, path);
}
+8 -16
View File
@@ -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();
+4 -23
View File
@@ -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");
-5
View File
@@ -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. */
+9
View File
@@ -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 },
});
+13 -9
View File
@@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { binaryPlistObjectTable } from "../../tests/fixtures/binaryPlistObjectTable.js";
import { 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;
+22 -4
View File
@@ -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") },
});
+3 -3
View File
@@ -208,7 +208,7 @@ export const parseBinaryTarget = async (
throwIfTargetResolutionCancelled(signal);
const resolved = await resolveAppBundle(canonical);
throwIfTargetResolutionCancelled(signal);
if (!resolved.ok) return 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"],
Loaded 100 of 1565 files, more files were not shown because too many files have changed in this diff. Show more