Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f1b2bf07d | ||
|
|
b5a25fbdda | ||
|
|
7fa7ae2168 | ||
|
|
d02ebca539 | ||
|
|
811bd9f635 | ||
|
|
9e019ad27d | ||
|
|
117bd23dd5 | ||
|
|
6637161e02 | ||
|
|
dea770d5d9 | ||
|
|
48b92e1be1 | ||
|
|
003707f712 | ||
|
|
dd4bfed614 | ||
|
|
84ec40f1af | ||
|
|
5eb296649d | ||
|
|
cae7620b77 | ||
|
|
c44d17fd3c | ||
|
|
59d33a2733 | ||
|
|
11268fe241 | ||
|
|
e46bed73f1 | ||
|
|
ece15ed6a1 | ||
|
|
97dc6581f4 | ||
|
|
cdc41fc86c | ||
|
|
9c14999efd | ||
|
|
352c46aa75 | ||
|
|
91830f1e0e | ||
|
|
df04f99df0 | ||
|
|
27cc6c8e23 | ||
|
|
198afabf0f | ||
|
|
d2e16b2a49 | ||
|
|
928b4d5fe4 | ||
|
|
4ea24f9767 | ||
|
|
2a61deafc6 | ||
|
|
5f8af4ee5f | ||
|
|
e0bd31c18c | ||
|
|
8e6f37e2a0 | ||
|
|
43738bec57 | ||
|
|
03ab521db3 | ||
|
|
0ca70e121c | ||
|
|
f63674cac0 | ||
|
|
d3a55720fc | ||
|
|
36132d3e17 | ||
|
|
1ca5fd38b4 | ||
|
|
84cf7791a8 | ||
|
|
ec4f4ddc58 | ||
|
|
8823c55b0c | ||
|
|
2f3dee3fcf | ||
|
|
d853744969 | ||
|
|
fcfa5b7e88 | ||
|
|
a8b50ea147 | ||
|
|
eeeb4ca379 | ||
|
|
f974353c32 | ||
|
|
cd2ad1f240 | ||
|
|
f90382756d | ||
|
|
68ecdcd7a1 | ||
|
|
1dd6c2dff9 | ||
|
|
c95315d2ce | ||
|
|
2cb076e9b5 | ||
|
|
fb28ec1537 | ||
|
|
490a285070 | ||
|
|
fa0a9b2d32 | ||
|
|
c8d861f976 | ||
|
|
bd2c202dd9 | ||
|
|
9f3f392eb8 | ||
|
|
f0f09950f3 | ||
|
|
3af8f847f0 | ||
|
|
747e729914 | ||
|
|
2995ec6910 | ||
|
|
ec3db37457 | ||
|
|
f1f5582ce8 | ||
|
|
3d78a57043 | ||
|
|
b06751b703 | ||
|
|
806ff57567 | ||
|
|
ca07373e44 | ||
|
|
8583a190c0 | ||
|
|
fe496bd1cf | ||
|
|
f8e95799f9 | ||
|
|
6c816af119 | ||
|
|
ab02f3c8d6 | ||
|
|
9928f357fb | ||
|
|
d13c9f840d | ||
|
|
0cf224e762 | ||
|
|
52d4ce6ced | ||
|
|
e05d3399f1 | ||
|
|
db3c9f084b | ||
|
|
0c7f4dfbd9 | ||
|
|
c02fb0ea80 | ||
|
|
96fc84f1fb | ||
|
|
b69d20a300 | ||
|
|
11f8b76257 | ||
|
|
11f1d952e7 | ||
|
|
a0317cf732 | ||
|
|
67c7a24e60 | ||
|
|
c7a890d129 | ||
|
|
b955b825fc | ||
|
|
9cf49c7a78 | ||
|
|
c923fb0dc9 | ||
|
|
ded199abcd | ||
|
|
ed881c6d2e | ||
|
|
eaa9616585 | ||
|
|
bf34d0d2fc | ||
|
|
e802441bce | ||
|
|
e7882b34f4 | ||
|
|
9049dbaf0c | ||
|
|
7fc0b5ec9c | ||
|
|
6a05248d46 | ||
|
|
20f51d7286 | ||
|
|
6bd2b28161 | ||
|
|
d9e9fb2273 | ||
|
|
b953f96c0a | ||
|
|
4276aaf18b | ||
|
|
fe8d0bdd36 | ||
|
|
9c34c091b3 | ||
|
|
b9ee921560 | ||
|
|
15463bdbe6 | ||
|
|
55e21a2eec | ||
|
|
f6775a3669 | ||
|
|
a07ceaec36 | ||
|
|
6a04f922e1 | ||
|
|
e74e4e951b | ||
|
|
68ecf3df8c | ||
|
|
ab62ee9dcf | ||
|
|
2c6a4fac4f | ||
|
|
c6acd19b6d | ||
|
|
1a1b67c888 | ||
|
|
12303812a8 | ||
|
|
f6556ad2bc | ||
|
|
5f0f25ad61 | ||
|
|
d73ed6fce2 | ||
|
|
60f771a11e | ||
|
|
650237efb0 | ||
|
|
26bffb94bf | ||
|
|
7017c4d68c | ||
|
|
141d2095f5 | ||
|
|
1b953021ea | ||
|
|
e1cd292aa6 | ||
|
|
18831491cf |
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: rea-ci-tests-audit
|
||||
description: Audit REA CI and test suites for correctness, fixture drift, false passes, and measured performance; use for suite or workflow investigations and efficiency changes, rather than ordinary feature testing or a single test failure.
|
||||
---
|
||||
|
||||
# REA CI and tests audit
|
||||
|
||||
Establish which checks still catch real regressions and which costs can be
|
||||
removed without losing that protection. The outcome may be a verified fix,
|
||||
an evidence-backed recommendation, or an explicit decision to stop.
|
||||
|
||||
Follow the checkout's `AGENTS.md`, [testing guide](../../../docs/testing.md),
|
||||
and [contribution rules](../../../CONTRIBUTING.md). Use their current commands
|
||||
and pinned toolchain, rather than commands remembered from an earlier audit.
|
||||
Preserve the requested scope: investigation alone does not authorize edits;
|
||||
this skill does not grant permission to publish issues, push, or merge. Existing
|
||||
user authorization still applies. Keep audit notes and raw results outside the
|
||||
tracked tree, normally in a task-specific `/tmp` directory.
|
||||
|
||||
## Establish the executed baseline
|
||||
|
||||
Fetch when the user asks for latest main. Pin the baseline SHA and inspected
|
||||
workflow revision; use an isolated worktree when changing or comparing code.
|
||||
Do not modify another task's checkout. Record dirty changes and the exact
|
||||
command, toolchain, host, selected tests, instrumentation, and prerequisites.
|
||||
An old run is evidence for its executed revision, even if main advances.
|
||||
|
||||
Trace workflow triggers, changed-path selection, job dependencies, reusable
|
||||
actions, package scripts, test discovery, artifact producers, and report gates
|
||||
through to the commands that actually execute. Include scripts, generated data,
|
||||
runtime registration, and subprocess boundaries missing from import graphs.
|
||||
Check positive and negative selection paths: required files must be discovered
|
||||
once per intended test configuration; deliberate platform/configuration repeats
|
||||
are separate coverage. A missing explicit test or missing required report must
|
||||
not turn green. Optional lanes may skip; preserve their stated prerequisites.
|
||||
|
||||
Use existing timing and failure artifacts to select suspicious families. Run
|
||||
relevant checks to establish a baseline; reserve a full local suite for broad
|
||||
CI diagnosis or uncertainty that focused checks cannot resolve. Do not rebuild
|
||||
or rerun an unchanged complete suite after every documentation edit.
|
||||
|
||||
## Prove the failure or protection
|
||||
|
||||
For each candidate, state the expected observable behavior and its owner,
|
||||
then construct the smallest reproduction against production behavior. Follow
|
||||
the producer and affected consumers before deciding where to fix it.
|
||||
|
||||
- For drift, compare fixtures with current admission requirements, protocol
|
||||
replies, named contracts, tool versions, and actual public entrypoints. A
|
||||
plausible-looking mock can fail before the behavior its test claims to cover.
|
||||
Search the whole affected fixture family, including other version strings.
|
||||
Preserve intentionally malformed fixtures and test their intended rejection.
|
||||
- For a suspicious green test, perturb the relevant behavior in an isolated
|
||||
copy or inject a controlled failure. Verify that the existing assertion or
|
||||
gate detects it. A test may merely repeat its mock, import abandoned code,
|
||||
or hide a subprocess/checker failure. Restore mutations after the experiment.
|
||||
- For shared setup or pruning, map distinct assertions to their retained
|
||||
scenarios. Preserve malformed inputs, cancellation, cleanup failure,
|
||||
permissions, capacity, identity, and meaningful concurrency interactions.
|
||||
A faster run that skips a case or weakens its oracle is not an improvement.
|
||||
- For provider coverage, separate fake-producer, boundary, and actual-engine
|
||||
evidence. Inspect named skips and prerequisites; fixtures do not establish
|
||||
real Ghidra, LLDB, browser, or other provider behavior.
|
||||
|
||||
Classify findings as reproduced defects, supported costs, hypotheses, or
|
||||
unavailable evidence. Record the baseline command, observed output/exit status,
|
||||
expected result, affected scope, and competing explanation. A failure on main
|
||||
may be pre-existing; that does not by itself make it irrelevant to a CI audit.
|
||||
Attribute PR failures using the executed revision and a clean baseline, not
|
||||
just their resemblance to a previous failure. Retry only to test a specific
|
||||
transient explanation; retain the original failure and stop when repetition
|
||||
adds no evidence. A passing retry does not establish a root cause or a fix.
|
||||
|
||||
## Assess efficiency without losing the gate
|
||||
|
||||
Read [measurement and CI checks](references/measurement-and-ci.md) when
|
||||
investigating slowness, caching, runner waits, shared artifacts, or log noise.
|
||||
Compare equivalent completed work before claiming a speedup. Distinguish
|
||||
setup/build time, test execution, coverage instrumentation, dependency waits,
|
||||
and runner scheduling; state which costs remain unmeasured.
|
||||
|
||||
Prefer removing proven duplicate preparation, reusing immutable setup, and
|
||||
reducing repeated subprocess work before shrinking correctness coverage.
|
||||
Preserve independent sessions where lifecycle or concurrency is the oracle.
|
||||
Keep live tests fresh; a cached previous pass cannot validate current host,
|
||||
installed provider contents, permissions, or external state.
|
||||
|
||||
## Fix, verify, and hand off within scope
|
||||
|
||||
If implementation is authorized, make the smallest structural change at the
|
||||
owning boundary and add a regression that fails on the baseline for the right
|
||||
reason. Retest affected consumers and meaningful negative paths; verify cache
|
||||
invalidation or discovery when those mechanisms change. Read the installed
|
||||
Turbo documentation before changing its configuration or commands.
|
||||
|
||||
Run the repository's applicable checks and inspect complete current-head CI
|
||||
results before an authorized merge. Preserve required coverage and discovery
|
||||
gates. Do not suppress a failure, add blanket retries, raise timeouts, or delete
|
||||
distinct assertions solely to make CI green. Changes to these controls need
|
||||
their own demonstrated reason and retained failure detection.
|
||||
|
||||
Report each confirmed issue with exact revision, reproduction, consequence,
|
||||
proposed correction, and validation limits. Report performance with workload
|
||||
and measurements, not just a percentage. Use English issue/PR records when
|
||||
requested; follow the user's assignment, commit identity, review, and merge
|
||||
preferences without assuming them for other users. Open ready-for-review PRs
|
||||
only when that lifecycle is authorized. Resolve necessary review findings and
|
||||
check final main against the tested tree after merging.
|
||||
|
||||
Stop when the authorized work is verified, remaining benefits are unproven,
|
||||
or further changes would trade correctness for speculative speed. List genuine
|
||||
uncertainties without turning them into additional optimization rounds.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "REA CI and tests audit"
|
||||
short_description: "Audit CI/test correctness and measured performance"
|
||||
default_prompt: "Use $rea-ci-tests-audit to investigate CI and test correctness, drift, and performance before proposing changes."
|
||||
@@ -0,0 +1,65 @@
|
||||
# Measurement and CI checks
|
||||
|
||||
## Equivalent completed work
|
||||
|
||||
Record the source/workflow SHA, command, test identities and counts, skips,
|
||||
assertions or output contract, exit status, coverage scope and thresholds,
|
||||
toolchain/provider versions, host resources, and prepared artifacts. Inspect
|
||||
both the child result and its report: a profiling wrapper may exit successfully
|
||||
after the measured command failed. Focused tests can pass while global coverage
|
||||
thresholds fail; describe both rather than calling the command green.
|
||||
|
||||
Measure wall time, user/system CPU, and peak process-family memory for relevant
|
||||
completed tasks. Parent-only RSS misses workers and their children. On Linux,
|
||||
summed RSS can double-count shared pages; report RSS/PSS semantics, sampling
|
||||
interval, missing samples, and observed cleanup limits. Use equivalent host
|
||||
tools elsewhere. State unknown costs, including agent tokens; fewer log bytes
|
||||
alone prove neither fewer tokens nor faster tests.
|
||||
|
||||
Separate cold execution, warm build/file caches, and warm runtime caches.
|
||||
Use repeated or alternating runs when variance affects the decision, with
|
||||
enough observations to distinguish the proposed benefit from noise. Keep
|
||||
workload, instrumentation, and host conditions comparable; record interference.
|
||||
A run with fewer tests, early rejection, lost coverage, or a different runner
|
||||
does not prove suite acceleration. CI-wide claims need end-to-end measurements,
|
||||
not an extrapolation from one improved family.
|
||||
|
||||
## Workflow, caches, and shared artifacts
|
||||
|
||||
Trace each reused result from producer to consumers and identify its lifetime.
|
||||
Check key/input coverage against the actual file inventory and result-affecting
|
||||
configuration, environment, generated data, tool versions, and host state.
|
||||
Exercise an affected input change to prove invalidation; exercise an irrelevant
|
||||
change to support reuse where useful. Cache deterministic preparation and
|
||||
static analysis only when their inputs fully describe the result.
|
||||
|
||||
Verify downloaded artifacts match the expected revision, platform, runtime,
|
||||
and schema. Check restore misses, failed/partial producers, stale reports,
|
||||
removed output files, and consumers after producer failures. Shared writers
|
||||
need output ownership or locks; tests need isolated state, not an unnecessary
|
||||
global lock. Worker counts do not bound children spawned within each test.
|
||||
Preserve intentionally concurrent scenarios while bounding unrelated batches.
|
||||
|
||||
Check matrix expansion and all job pages before saying CI passed. Inspect
|
||||
required gates after failed, cancelled, and skipped dependencies. `always()`
|
||||
alone does not preserve failures; inspect the aggregation logic. Test/report
|
||||
uploads should retain available diagnostics after failure, while absent or
|
||||
malformed required results must fail closed. Do not demand an optional report
|
||||
from a lane intentionally excluded by the selector.
|
||||
|
||||
## Runner waits and console noise
|
||||
|
||||
Use per-job and per-step timestamps where available. Workflow creation to job
|
||||
start includes dependencies and other delays; it is not necessarily runner
|
||||
queue time. Identify when the job became eligible before attributing waits to
|
||||
runner capacity. If eligibility/queue timestamps are unavailable, report the
|
||||
combined delay and unknown split. Compare equivalent lanes and runner types
|
||||
across enough runs; a single busy runner is not a workflow regression.
|
||||
|
||||
For console reductions, retain checker exit codes, actionable errors, useful
|
||||
summary counts, and complete machine-readable artifacts. Verify authored code
|
||||
remains in the actual checker inventory when excluding generated outputs.
|
||||
Introduce an authored error as a control; it must still fail. An ignore pattern
|
||||
can hide real source as well as noise. Show that cached reports are restored,
|
||||
stale successful reports cannot mask current failures, and report parse errors
|
||||
remain failures. Measure readability or byte reduction separately from runtime.
|
||||
@@ -76,6 +76,9 @@ explicit path or endpoint and do not need it.
|
||||
Facts retain all-recorded-events-before-selection scope. Other qlog layouts
|
||||
and PCAP correlation are unsupported.
|
||||
- User-owned Electron runtime already open: `list_electron_targets`.
|
||||
- Packet-field discovery: `inspect_packet_fields` searches installed protocols
|
||||
and field names/types before choosing fields for `inspect_packet_capture`.
|
||||
Query/page the relevant records; registrations do not establish observed traffic.
|
||||
- Recorded packet captures: `inspect_packet_capture` with selected TShark fields,
|
||||
display filters and decode-as rules. Requires Linux, util-linux prlimit and `REA_TSHARK_COMMAND`; see
|
||||
[capture guide](https://github.com/morluto/rea/blob/main/docs/packet-captures.md).
|
||||
@@ -89,6 +92,9 @@ explicit path or endpoint and do not need it.
|
||||
[packet summaries](https://github.com/morluto/rea/blob/main/docs/pcap-analysis.md).
|
||||
Select an exact DNS question for address pages; page exhaustion does not prove
|
||||
network completeness. This reads recordings and does not acquire traffic.
|
||||
- Decoder discovery: `inspect_signal_decoders` searches installed decoder IDs
|
||||
or selected decoder help, including channels and options. Query and page the
|
||||
relevant details before choosing settings; this does not scan hardware.
|
||||
- Recorded logic/signal traces: `decode_signal_recording` with explicit decoder,
|
||||
channel and protocol settings. On Linux with util-linux prlimit, supply sigrok-cli 0.7.2 through
|
||||
`REA_SIGROK_COMMAND`; see [recording guide](https://github.com/morluto/rea/blob/main/docs/signal-recordings.md).
|
||||
|
||||
@@ -46,7 +46,7 @@ rea setup --client codex --dry-run --json
|
||||
Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`,
|
||||
`cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`,
|
||||
`copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `trae`, `grok_build`, `omp`, `pi`,
|
||||
`hermes`, `qoder`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
|
||||
`hermes`, `qoder`, `droid`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
|
||||
scope. `grok_bot` uses a chat registration step rather than local `mcp.json`.
|
||||
For unsupported clients, use version-pinned manual stdio registration or CLI.
|
||||
|
||||
|
||||
@@ -83,6 +83,14 @@ names, the native API record, and value-flow summaries. Pseudocode offsets and l
|
||||
code units; use the returned `next_offset` for continuation. Provider limitations
|
||||
and unavailable facts remain explicit; the complete parent Evidence is retained.
|
||||
|
||||
`inspect_macho` on a large framework returns megabytes of imports and exports.
|
||||
Select `detail: "summary"` for slice identity, UUID and per-collection counts,
|
||||
then page rows with `view: {kind: "macho", facet: "exports", offset: 0, limit: 64}`
|
||||
and `normalized_result.parent_evidence_id`. Facets are entrypoints,
|
||||
architectures, build_metadata, load_commands, dependencies, imports, exports,
|
||||
segments and provenance. `source_exhaustive: false` means unreturned symbols
|
||||
may exist.
|
||||
|
||||
To see which functions or Objective-C methods a Mach-O actually calls in one
|
||||
run, use `observe_native_calls` with explicit breakpoints and a bounded
|
||||
`duration_ms`/`max_events`. It launches a new process under LLDB, so the target
|
||||
@@ -111,6 +119,8 @@ loaded bytes, file mappings, relocations and the entry against the immutable
|
||||
snapshot. Keep `mismatch` and `unsupported` explicit. Verified import covers the
|
||||
reported static image, not runtime DOS or PC-98 hardware behavior.
|
||||
|
||||
For Ghidra decompilation, `procedure_pseudo_code` also accepts `procedure: "file-offset:0x..."`; CLI `rea decompile <path> --file-offset=0x... --provider ghidra` resolves the source byte offset internally. An unresolved 0x-prefixed numeric procedure selector also falls back to a unique file mapping. Existing symbols and analysis addresses win; multiple mappings fail as ambiguous. Preserve the returned file-offset and address locations and raw selection observations. ELF virtual addresses are distinct from file offsets; never assume an arbitrary offset plus image base is an analysis address.
|
||||
|
||||
Use `read_bytes` for initialized analysis memory and `address_to_file_offset` to
|
||||
anchor one observed address to original file bytes. Loader fixups can change a
|
||||
word; a source offset does not imply byte equality. Complete function body ranges
|
||||
@@ -214,10 +224,13 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu
|
||||
|
||||
For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ
|
||||
headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`,
|
||||
`arm`, or `arm64`), and `entry_point` (`0x` hex). File offset 0 is mapped at
|
||||
`arm`, `arm64`, or `6502`), and `entry_point` (`0x` hex). File offset 0 is mapped at
|
||||
that address. Hopper and IDA refuse this interpretation; Ghidra uses
|
||||
`BinaryLoader` with a little-endian generic language. Big-endian, Thumb-only,
|
||||
and Cortex-M variants are unsupported. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
|
||||
and Cortex-M variants are unsupported. `6502` selects NMOS 6502; the entry and
|
||||
complete file mapping must fit within `0x0000..0xffff`. 65C02, 65816, cartridge
|
||||
containers, bank switching, and mapper semantics are unsupported. Windows
|
||||
Ghidra P0 admits raw images only for `6502`. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
|
||||
|
||||
### Function annotations in Ghidra
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
name: Portable build cache
|
||||
description: Reuse Turbo build outputs without serializing verification jobs.
|
||||
description: Restore portable Turbo build outputs from the current run or cross-run cache.
|
||||
inputs:
|
||||
save:
|
||||
description: Save newly built outputs after the job succeeds.
|
||||
default: "false"
|
||||
build_cache_artifact:
|
||||
description: Reuse portable Turbo outputs uploaded by the current workflow run.
|
||||
default: ""
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Download portable build outputs from this workflow run
|
||||
if: inputs.build_cache_artifact != ''
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.build_cache_artifact }}
|
||||
path: .turbo/cache/
|
||||
- name: Resolve portable build task hashes
|
||||
id: plan
|
||||
if: inputs.build_cache_artifact == ''
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'package-lock.json') }}
|
||||
@@ -28,14 +38,14 @@ runs:
|
||||
console.log(`prefix=${process.env.BUILD_CACHE_PREFIX}-`);
|
||||
' >> "$GITHUB_OUTPUT"
|
||||
- name: Restore portable build outputs
|
||||
if: inputs.save != 'true'
|
||||
if: inputs.build_cache_artifact == '' && inputs.save != 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: .turbo/cache/
|
||||
key: ${{ steps.plan.outputs.key }}
|
||||
restore-keys: ${{ steps.plan.outputs.prefix }}
|
||||
- name: Restore and save portable build outputs
|
||||
if: inputs.save == 'true'
|
||||
if: inputs.build_cache_artifact == '' && inputs.save == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo/cache/
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Full PR qualification
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, "release/*"]
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
qualification:
|
||||
name: Qualify labeled PR revision
|
||||
if: github.event.label.name == 'ci:full'
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
source_sha: ${{ github.sha }}
|
||||
@@ -0,0 +1,46 @@
|
||||
name: CI timings
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-timings-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Collect recent completed workflow attempts
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# Consume only API data and execute batch code from the trusted default branch.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Record queue, execution, and matched step timing
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CI_REPOSITORY: ${{ github.repository }}
|
||||
run: node scripts/ci/job-timings-batch.mjs
|
||||
- name: Upload CI timing artifacts
|
||||
if: always() && hashFiles('.cache/ci-job-timings/*') != ''
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ci-job-timings-batch
|
||||
path: .cache/ci-job-timings/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 14
|
||||
+176
-71
@@ -5,42 +5,44 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main, "release/*"]
|
||||
schedule:
|
||||
- cron: "23 0-2,4-23 * * *"
|
||||
- cron: "23 3 * * *"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
source_sha:
|
||||
description: Exact qualification source; must match the caller revision.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
# The latest main push verifies the full tree, including earlier changes.
|
||||
# Publication uses its own release workflow and concurrency group.
|
||||
cancel-in-progress: true
|
||||
# Complete qualification finishes even while main receives more pushes.
|
||||
# Main feedback also finishes, coalescing newer pushes to one pending run.
|
||||
# Scheduled/manual main runs coalesce to one active and the latest pending;
|
||||
# release and label-triggered qualification use exact-revision groups.
|
||||
group: ci-${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full')) && 'qualification' || 'feedback' }}-${{ inputs.source_sha || github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
|
||||
jobs:
|
||||
pr-title:
|
||||
name: Conventional PR title
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Validate title for Release Please
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: >-
|
||||
node -e 'const title = process.env.PR_TITLE ?? "";
|
||||
const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u;
|
||||
if (!conventional.test(title)) {
|
||||
console.error("PR title must use Conventional Commits, for example: feat: add historical source import");
|
||||
process.exit(1);
|
||||
}'
|
||||
|
||||
changes:
|
||||
name: Classify changes
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
tier: ${{ steps.plan.outputs.tier }}
|
||||
qualification_run_id: ${{ steps.qualification.outputs.run_id }}
|
||||
code: ${{ steps.plan.outputs.code }}
|
||||
sqlite: ${{ steps.plan.outputs.sqlite }}
|
||||
runtime: ${{ steps.plan.outputs.runtime }}
|
||||
package: ${{ steps.plan.outputs.package }}
|
||||
apple: ${{ steps.plan.outputs.apple }}
|
||||
@@ -59,26 +61,58 @@ jobs:
|
||||
nativeaot: ${{ steps.plan.outputs.nativeaot }}
|
||||
website: ${{ steps.plan.outputs.website }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
test_selection: ${{ steps.plan.outputs.test_selection }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Bind qualification to the caller revision
|
||||
if: inputs.source_sha != ''
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_sha }}
|
||||
run: |
|
||||
test "${SOURCE_SHA}" = "${GITHUB_SHA}"
|
||||
test "${SOURCE_SHA}" = "$(git rev-parse HEAD)"
|
||||
- name: Find a verified ancestor for push selection
|
||||
id: baseline
|
||||
if: github.event_name == 'push' && inputs.source_sha == ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
CI_REPOSITORY: ${{ github.repository }}
|
||||
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: node scripts/ci/baseline.mjs
|
||||
- name: Admit scheduled full qualification
|
||||
id: qualification
|
||||
if: github.event_name == 'schedule' && inputs.source_sha == ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
CI_REPOSITORY: ${{ github.repository }}
|
||||
CI_REPOSITORY_ID: ${{ github.repository_id }}
|
||||
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
CI_FORCE_FULL: ${{ github.event.schedule == '23 3 * * *' }}
|
||||
run: node scripts/ci/qualification.mjs
|
||||
- name: Select verification lanes
|
||||
id: plan
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
CI_FULL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }}
|
||||
BASE_SHA: ${{ steps.baseline.outputs.base || github.event.pull_request.base.sha }}
|
||||
BASELINE_REASON: ${{ steps.baseline.outputs.reason }}
|
||||
HEAD_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }}
|
||||
CI_FULL: ${{ inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full') }}
|
||||
CI_SKIP_FULL: ${{ steps.qualification.outputs.run_full == 'false' }}
|
||||
run: node scripts/ci/plan.mjs
|
||||
|
||||
static:
|
||||
name: Static checks
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Require successful change classification
|
||||
env:
|
||||
@@ -90,6 +124,9 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Validate workflow syntax and expressions
|
||||
if: needs.changes.outputs.workflows == 'true'
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
|
||||
- if: needs.changes.outputs.code == 'true'
|
||||
run: npm run check:ci
|
||||
- if: needs.changes.outputs.code == 'true'
|
||||
@@ -114,6 +151,7 @@ jobs:
|
||||
needs: [changes, build]
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -129,7 +167,14 @@ jobs:
|
||||
with:
|
||||
name: rea-test-runtime
|
||||
path: .
|
||||
- run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
|
||||
- name: Run complete portable suite with coverage
|
||||
if: needs.changes.outputs.tier == 'full'
|
||||
run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
|
||||
env:
|
||||
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
|
||||
- name: Run complete portable suite for fast feedback
|
||||
if: needs.changes.outputs.tier == 'fast'
|
||||
run: npm run test:run -- --reporter=default --reporter=blob --shard=${{ matrix.shard }}
|
||||
env:
|
||||
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -145,8 +190,20 @@ jobs:
|
||||
build:
|
||||
name: Build package
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
# Reusable provider workflows consume this run's portable build artifact,
|
||||
# even when a workflow-only edit selects the lane without portable tests.
|
||||
if: >-
|
||||
needs.changes.outputs.code == 'true' ||
|
||||
needs.changes.outputs.browser == 'true' ||
|
||||
needs.changes.outputs.binary_layout == 'true' ||
|
||||
needs.changes.outputs.evm == 'true' ||
|
||||
needs.changes.outputs.javascript_recovery == 'true' ||
|
||||
needs.changes.outputs.recorded_crash == 'true' ||
|
||||
needs.changes.outputs.web_network == 'true' ||
|
||||
needs.changes.outputs.web_runtime == 'true' ||
|
||||
needs.changes.outputs.web_source_map == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -160,21 +217,17 @@ jobs:
|
||||
- name: Reuse portable build outputs across runs
|
||||
uses: ./.github/actions/portable-build-cache
|
||||
with:
|
||||
save: "true"
|
||||
save: ${{ github.event_name != 'pull_request' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
||||
- run: npm run test:prepare
|
||||
- name: Find successful default-branch timing history
|
||||
- name: Find available trusted full-suite timing history
|
||||
id: history
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CI_REPOSITORY: ${{ github.repository }}
|
||||
CI_REPOSITORY_ID: ${{ github.repository_id }}
|
||||
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
run_id="$(gh api --method GET \
|
||||
"repos/${CI_REPOSITORY}/actions/workflows/ci.yml/runs" \
|
||||
-f branch="${CI_DEFAULT_BRANCH}" -f event=push -f status=success -f per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty')"
|
||||
echo "run-id=${run_id}" >> "${GITHUB_OUTPUT}"
|
||||
run: node scripts/ci/test-history.mjs
|
||||
- name: Download previous timing report
|
||||
if: steps.history.outputs.run-id != ''
|
||||
continue-on-error: true
|
||||
@@ -210,15 +263,18 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
sqlite-runtime:
|
||||
needs: [changes, build]
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
# Preserve all selected coverage, but admit expensive host/provider work
|
||||
# only after static validation. Portable tests still provide early feedback.
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.sqlite == 'true'
|
||||
uses: ./.github/workflows/sqlite-runtime.yml
|
||||
|
||||
test:
|
||||
name: Test (coverage + timing)
|
||||
name: Portable tests
|
||||
needs: [changes, test-shard]
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Require successful change classification
|
||||
env:
|
||||
@@ -241,7 +297,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
- name: Merge coverage, JUnit, and timing reports
|
||||
id: merge
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success()
|
||||
run: >-
|
||||
npm run test:ci:merge --
|
||||
--coverage.reportsDirectory=coverage
|
||||
@@ -251,13 +307,28 @@ jobs:
|
||||
--reporter=default
|
||||
--reporter=junit
|
||||
--reporter=json
|
||||
--reporter=./scripts/ci/test-timing-reporter.mjs
|
||||
--outputFile.junit=test-results.xml
|
||||
--outputFile.json=test-results.json
|
||||
- name: Merge fast-suite JUnit and timing reports
|
||||
id: merge-fast
|
||||
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'fast' && success()
|
||||
run: >-
|
||||
npm exec -- vitest --merge-reports
|
||||
--reporter=default
|
||||
--reporter=junit
|
||||
--reporter=json
|
||||
--reporter=./scripts/ci/test-timing-reporter.mjs
|
||||
--outputFile.junit=test-results.xml
|
||||
--outputFile.json=test-results.json
|
||||
- name: Summarize slowest test files
|
||||
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
|
||||
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-timings.json') != ''
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.test-shard.result }}
|
||||
MERGE_RESULT: ${{ steps.merge.outcome }}
|
||||
MERGE_RESULT: ${{ needs.changes.outputs.tier == 'full' && steps.merge.outcome || steps.merge-fast.outcome }}
|
||||
CI_VERIFY_TEST_INVENTORY: "true"
|
||||
CI_TEST_SELECTION: ${{ needs.changes.outputs.test_selection }}
|
||||
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: node scripts/ci/test-report.mjs
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
|
||||
@@ -266,11 +337,14 @@ jobs:
|
||||
path: |
|
||||
test-results.xml
|
||||
test-results.json
|
||||
test-timings.json
|
||||
coverage/
|
||||
.cache/ci-test-selection/report.json
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
- name: Publish successful timing history
|
||||
if: needs.changes.outputs.code == 'true' && success() && needs.test-shard.result == 'success'
|
||||
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success() && needs.test-shard.result == 'success'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-timings
|
||||
@@ -287,7 +361,7 @@ jobs:
|
||||
|
||||
conformance-fixtures:
|
||||
name: Source-built conformance fixtures
|
||||
needs: changes
|
||||
needs: [changes, static]
|
||||
if: needs.changes.outputs.fixtures == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
@@ -298,7 +372,7 @@ jobs:
|
||||
|
||||
windows-native-build:
|
||||
name: Build bundled Windows x64 native controls
|
||||
needs: changes
|
||||
needs: [changes, static]
|
||||
if: needs.changes.outputs.runtime == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
@@ -334,7 +408,7 @@ jobs:
|
||||
|
||||
package-e2e:
|
||||
name: Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }})
|
||||
needs: [changes, build, windows-native-build]
|
||||
needs: [changes, static, build, windows-native-build]
|
||||
if: needs.changes.outputs.runtime == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -373,6 +447,8 @@ jobs:
|
||||
name: windows-native-controls
|
||||
path: native/windows/build/
|
||||
- run: npm run verify:windows-native:artifact
|
||||
- name: Verify NativeAOT diagnostic applicability on this host
|
||||
run: npm run test:focused -- tests/evaluation/package/nativeAotDiagnostics.test.ts
|
||||
- name: Resolve npm cache before verifier HOME isolation
|
||||
id: npm-cache
|
||||
run: |
|
||||
@@ -381,13 +457,24 @@ jobs:
|
||||
- run: npm run verify:package
|
||||
env:
|
||||
npm_config_cache: ${{ steps.npm-cache.outputs.path }}
|
||||
REA_VERIFIED_PACKAGE_DIRECTORY: ${{ needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64' && '.cache/verified-package' || '' }}
|
||||
- name: Verify real Node Inspector discovery and observation
|
||||
timeout-minutes: 10
|
||||
run: npm run verify:inspector
|
||||
- name: Retain the exact qualified release package
|
||||
if: needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: verified-release-package
|
||||
path: .cache/verified-package/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
apple-artifacts:
|
||||
name: Real Apple artifact CLI/MCP E2E
|
||||
needs: [changes, build]
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.apple == 'true'
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 15
|
||||
@@ -435,7 +522,7 @@ jobs:
|
||||
|
||||
reconstruction-readiness:
|
||||
name: Reconstruction readiness
|
||||
needs: changes
|
||||
needs: [changes, static]
|
||||
if: needs.changes.outputs.readiness == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -464,7 +551,7 @@ jobs:
|
||||
|
||||
windows-curated:
|
||||
name: Windows capability and package lane
|
||||
needs: [changes, build, windows-native-build]
|
||||
needs: [changes, static, build, windows-native-build]
|
||||
if: needs.changes.outputs.package == 'true'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 25
|
||||
@@ -510,6 +597,7 @@ jobs:
|
||||
tests/boundary/filesystem/setupUnavailableProvider.test.ts
|
||||
tests/boundary/filesystem/windowsNpxRegistration.test.ts
|
||||
tests/acceptance/setup/homeDirectoryFallback.test.ts
|
||||
tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts
|
||||
tests/boundary/filesystem/webScriptManifestPaths.test.ts
|
||||
tests/boundary/process/ProcessEvidence.host.test.ts
|
||||
tests/acceptance/webModuleCli.test.ts
|
||||
@@ -576,6 +664,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -595,73 +684,78 @@ jobs:
|
||||
skills/reverse-engineer-anything
|
||||
if-no-files-found: error
|
||||
|
||||
workflows:
|
||||
name: Validate workflows
|
||||
needs: changes
|
||||
if: needs.changes.outputs.workflows == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate workflow syntax and expressions
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
|
||||
|
||||
browser:
|
||||
needs: changes
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.browser == 'true'
|
||||
uses: ./.github/workflows/real-browser.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
binary_layout:
|
||||
needs: changes
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.binary_layout == 'true'
|
||||
uses: ./.github/workflows/real-binary-layout.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
evm:
|
||||
needs: changes
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.evm == 'true'
|
||||
uses: ./.github/workflows/real-evm-interface.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
javascript_recovery:
|
||||
needs: changes
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.javascript_recovery == 'true'
|
||||
uses: ./.github/workflows/real-javascript-recovery.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
recorded_crash:
|
||||
needs: changes
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.recorded_crash == 'true'
|
||||
uses: ./.github/workflows/real-recorded-crash.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
web_network:
|
||||
needs: changes
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.web_network == 'true'
|
||||
uses: ./.github/workflows/real-web-network-captures.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
web_runtime:
|
||||
needs: changes
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.web_runtime == 'true'
|
||||
uses: ./.github/workflows/real-web-runtime.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
web_source_map:
|
||||
needs: changes
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.web_source_map == 'true'
|
||||
uses: ./.github/workflows/real-web-source-map.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
nativeaot:
|
||||
needs: changes
|
||||
needs: [changes, static]
|
||||
if: needs.changes.outputs.nativeaot == 'true'
|
||||
uses: ./.github/workflows/nativeaot-fixtures.yml
|
||||
|
||||
website:
|
||||
needs: changes
|
||||
needs: [changes, static]
|
||||
if: needs.changes.outputs.website == 'true'
|
||||
uses: ./.github/workflows/website-check.yml
|
||||
|
||||
required:
|
||||
name: CI required
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
[
|
||||
changes,
|
||||
pr-title,
|
||||
static,
|
||||
build,
|
||||
test-shard,
|
||||
@@ -674,7 +768,6 @@ jobs:
|
||||
reconstruction-readiness,
|
||||
windows-curated,
|
||||
docs,
|
||||
workflows,
|
||||
browser,
|
||||
binary_layout,
|
||||
evm,
|
||||
@@ -687,12 +780,24 @@ jobs:
|
||||
website,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Require every selected lane to pass
|
||||
id: gate
|
||||
env:
|
||||
CI_JOBS: ${{ toJSON(needs) }}
|
||||
CI_QUALIFICATION_DIRECTORY: .cache/ci-qualification
|
||||
run: node scripts/ci/required.mjs
|
||||
- name: Upload full qualification receipt artifact
|
||||
if: needs.changes.outputs.tier == 'full' && success()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.gate.outputs.receipt_name }}
|
||||
path: ${{ steps.gate.outputs.receipt_path }}
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
@@ -12,7 +12,8 @@ jobs:
|
||||
name: Build Windows x64 NativeAOT fixture
|
||||
# .NET 8.0.22's bundled linker discovery supports VS 2019/2022.
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 15
|
||||
# Two bounded restore attempts and one compile can each take five minutes.
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_PROCESSOR_COUNT: "1"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: PR title
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, "release/*"]
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: pr-title-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pr-title:
|
||||
name: Conventional PR title
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Validate title for Release Please
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: >-
|
||||
node -e 'const title = process.env.PR_TITLE ?? "";
|
||||
const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u;
|
||||
if (!conventional.test(title)) {
|
||||
console.error("PR title must use Conventional Commits, for example: feat: add historical source import");
|
||||
process.exit(1);
|
||||
}'
|
||||
@@ -8,14 +8,16 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
build_cache_artifact:
|
||||
description: Portable build cache artifact from the calling CI run.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: real-binary-layout-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=768
|
||||
HUSKY: "0"
|
||||
@@ -33,6 +35,8 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/portable-build-cache
|
||||
with:
|
||||
build_cache_artifact: ${{ inputs.build_cache_artifact }}
|
||||
- name: Acquire exact upstream libraries in an isolated runner environment
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -2,12 +2,14 @@ name: Real browser verification
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
build_cache_artifact:
|
||||
description: Portable build cache artifact from the calling CI run.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: real-browser-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
|
||||
@@ -26,6 +28,8 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/portable-build-cache
|
||||
with:
|
||||
build_cache_artifact: ${{ inputs.build_cache_artifact }}
|
||||
- run: npm run verify:browser
|
||||
env:
|
||||
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome
|
||||
|
||||
@@ -3,14 +3,16 @@ name: Real offline EVM interface
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
build_cache_artifact:
|
||||
description: Portable build cache artifact from the calling CI run.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: real-evm-interface-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=768
|
||||
HUSKY: "0"
|
||||
@@ -27,6 +29,8 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/portable-build-cache
|
||||
with:
|
||||
build_cache_artifact: ${{ inputs.build_cache_artifact }}
|
||||
- name: Acquire unchanged fixture compiler in isolated runner storage
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -2,15 +2,17 @@ name: Real JavaScript recovery
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
build_cache_artifact:
|
||||
description: Portable build cache artifact from the calling CI run.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: javascript-recovery-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
NODE_OPTIONS: "--max-old-space-size=768"
|
||||
@@ -28,6 +30,8 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/portable-build-cache
|
||||
with:
|
||||
build_cache_artifact: ${{ inputs.build_cache_artifact }}
|
||||
- name: Fetch pinned Wakaru release into the job workspace
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- run: sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y tshark util-linux python3
|
||||
- run: npm ci
|
||||
- run: npm run build:cached
|
||||
- run: npm run test:focused -- src/process/LinuxResourceLimits.test.ts tests/boundary/captures/captureProvider.test.ts tests/boundary/captures/captureDescriptorCleanup.test.ts tests/boundary/captures/resourceLimits.test.ts tests/boundary/captures/packetOutput.test.ts tests/boundary/mcp/packetCaptureMcp.test.ts tests/boundary/cli/captureCleanup.test.ts
|
||||
- run: npm run test:focused -- tests/boundary/captures/fieldDiscovery.test.ts src/process/LinuxResourceLimits.test.ts tests/boundary/captures/captureProvider.test.ts tests/boundary/captures/captureDescriptorCleanup.test.ts tests/boundary/captures/resourceLimits.test.ts tests/boundary/captures/packetOutput.test.ts tests/boundary/mcp/packetCaptureMcp.test.ts tests/boundary/cli/captureCleanup.test.ts
|
||||
- run: node scripts/verify-packet-capture.mjs
|
||||
env:
|
||||
REA_TSHARK_COMMAND: /usr/bin/tshark
|
||||
|
||||
@@ -3,14 +3,16 @@ name: Real recorded crash evidence
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
build_cache_artifact:
|
||||
description: Portable build cache artifact from the calling CI run.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: real-recorded-crash-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=768
|
||||
HUSKY: "0"
|
||||
@@ -30,6 +32,8 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/portable-build-cache
|
||||
with:
|
||||
build_cache_artifact: ${{ inputs.build_cache_artifact }}
|
||||
- name: Install GDB on the disposable CI runner
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- run: sudo apt-get update && sudo apt-get install -y sigrok-cli util-linux python3
|
||||
- run: npm ci
|
||||
- run: npm run build:cached
|
||||
- run: npm run test:focused -- src/process/LinuxResourceLimits.test.ts src/domain/signals/annotationTextWindow.test.ts tests/boundary/signals/recordingProvider.test.ts tests/boundary/signals/recordingDescriptorCleanup.test.ts tests/boundary/signals/resourceLimits.test.ts tests/boundary/mcp/signalRecordingMcp.test.ts tests/boundary/cli/signalCleanup.test.ts
|
||||
- run: npm run test:focused -- tests/boundary/signals/decoderDiscovery.test.ts src/process/LinuxResourceLimits.test.ts src/domain/signals/annotationTextWindow.test.ts tests/boundary/signals/recordingProvider.test.ts tests/boundary/signals/recordingDescriptorCleanup.test.ts tests/boundary/signals/resourceLimits.test.ts tests/boundary/mcp/signalRecordingMcp.test.ts tests/boundary/cli/signalCleanup.test.ts
|
||||
- run: node scripts/verify-signal-recording.mjs
|
||||
env:
|
||||
REA_SIGROK_COMMAND: /usr/bin/sigrok-cli
|
||||
|
||||
@@ -3,14 +3,16 @@ name: Real historical web network captures
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
build_cache_artifact:
|
||||
description: Portable build cache artifact from the calling CI run.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: real-web-network-captures-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
NODE_OPTIONS: --max-old-space-size=768
|
||||
@@ -29,6 +31,8 @@ jobs:
|
||||
run: npm install --global npm@11.16.0
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/portable-build-cache
|
||||
with:
|
||||
build_cache_artifact: ${{ inputs.build_cache_artifact }}
|
||||
- name: Acquire fixed upstream native profile in runner temporary storage
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
name: Real website runtime attribution
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
build_cache_artifact:
|
||||
description: Portable build cache artifact from the calling CI run.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: real-web-runtime-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
HUSKY: "0"
|
||||
jobs:
|
||||
@@ -21,6 +24,8 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/portable-build-cache
|
||||
with:
|
||||
build_cache_artifact: ${{ inputs.build_cache_artifact }}
|
||||
- run: npm run verify:browser:runtime
|
||||
env:
|
||||
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
name: Real website source-map verification
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
build_cache_artifact:
|
||||
description: Portable build cache artifact from the calling CI run.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: real-web-source-map-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
HUSKY: "0"
|
||||
jobs:
|
||||
@@ -21,6 +24,8 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/portable-build-cache
|
||||
with:
|
||||
build_cache_artifact: ${{ inputs.build_cache_artifact }}
|
||||
- name: Install the pinned compiler into an isolated fixture directory
|
||||
run: npm install --prefix "$RUNNER_TEMP/rea-web-source-map-compiler" --no-audit --no-fund --package-lock=false esbuild@0.25.10
|
||||
- run: npm run verify:browser:source-maps
|
||||
|
||||
@@ -290,13 +290,25 @@ jobs:
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
run: npm run docs:check && git diff --exit-code
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
qualification:
|
||||
name: Qualify exact release revision
|
||||
needs: [merged-release, release-please]
|
||||
if: >-
|
||||
always() && !cancelled() &&
|
||||
((needs.merged-release.result == 'success' && needs.merged-release.outputs.release_created == 'true') ||
|
||||
(needs.release-please.result == 'success' && inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true'))
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
source_sha: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: [merged-release, release-please, qualification]
|
||||
if: >-
|
||||
always() && !cancelled() && needs.qualification.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -315,17 +327,20 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Prepare Windows native build toolchain
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64
|
||||
- name: Download the exact qualified package
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: verified-release-package
|
||||
path: .cache/verified-package/
|
||||
|
||||
- name: Build and verify bundled Windows native controls
|
||||
run: npm run build:windows-native && npm run verify:windows-native:artifact
|
||||
|
||||
- name: Verify packaged release before publishing
|
||||
run: npm run verify:package
|
||||
- name: Verify qualified package provenance and digest
|
||||
id: package
|
||||
run: node scripts/ci/verified-package.mjs validate .cache/verified-package
|
||||
|
||||
- name: Publish
|
||||
shell: bash
|
||||
env:
|
||||
VERIFIED_PACKAGE_TARBALL: ${{ steps.package.outputs.tarball }}
|
||||
run: |
|
||||
version="$(node --input-type=module -e 'import packageJson from "./package.json" with { type: "json" }; process.stdout.write(packageJson.version)')"
|
||||
published_version="$(npm view "rea-agents@${version}" version --json 2>/dev/null || true)"
|
||||
@@ -333,7 +348,7 @@ jobs:
|
||||
echo "rea-agents@${version} is already published; verifying the existing package."
|
||||
else
|
||||
tag="$(node scripts/release-npm-tag.mjs "${version}")"
|
||||
npm publish --access public --tag "${tag}"
|
||||
npm publish "${VERIFIED_PACKAGE_TARBALL}" --access public --tag "${tag}"
|
||||
fi
|
||||
|
||||
- name: Verify published CLI from npm
|
||||
|
||||
@@ -7,10 +7,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: website-check-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Website checks
|
||||
|
||||
@@ -36,6 +36,7 @@ coverage/
|
||||
.vitest/
|
||||
.vitest-reports/
|
||||
test-results.xml
|
||||
test-timings.json
|
||||
.turbo/
|
||||
.cache/
|
||||
docs/.vitepress/cache/
|
||||
@@ -72,3 +73,5 @@ Thumbs.db
|
||||
|
||||
# Complete static website output, including generated language editions.
|
||||
website/_site/
|
||||
# Temporary translation output prepared before replacing the complete site.
|
||||
website/.i18n-*/
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"threshold": 3.0,
|
||||
"threshold": 3.5,
|
||||
"reporters": ["consoleFull"],
|
||||
"ignore": [
|
||||
"node_modules/**",
|
||||
|
||||
@@ -142,6 +142,21 @@ caches deterministic builds and static checks across Git worktrees. After a pack
|
||||
Repository invariants live in [AGENTS.md](AGENTS.md). Keep provider-specific
|
||||
protocols in adapters and shared CLI/MCP workflows in the application layer.
|
||||
|
||||
### Windows source checkouts
|
||||
|
||||
The published npm package includes the Windows x64 native addon and its
|
||||
manifest. A source checkout does not: `native/windows/build/` is ignored by
|
||||
Git, and neither `npm ci` nor `npm run build:cached` (or `npm run build`)
|
||||
produces `rea-windows-x64.node` or `manifest.json` in that directory.
|
||||
Windows workflows and tests that require native controls need these artifacts;
|
||||
without them, those controls are unavailable even after a successful runtime build.
|
||||
|
||||
Follow the [Windows native build guide](native/windows/README.md#build) before
|
||||
running those workflows from source. The artifact build requires Linux,
|
||||
MinGW-w64, and `tar`; the build script does not run on Windows. See that guide
|
||||
for the build and artifact-verification commands and the separate real Windows
|
||||
verification prerequisites. Keep the generated addon and manifest uncommitted.
|
||||
|
||||
## Documentation website
|
||||
|
||||
The VitePress site uses the Markdown files in `docs/`. Run `npm run docs:dev`
|
||||
@@ -187,6 +202,10 @@ CI diagnosis. Packaging/setup/distribution changes additionally require
|
||||
`npm run verify:package` and `npm pack --dry-run`; provider changes require the
|
||||
matching real-provider lane. State which host/provider workflows ran.
|
||||
|
||||
For CI/test correctness, drift, or performance investigations, use the
|
||||
[`rea-ci-tests-audit` contributor skill](.agents/skills/rea-ci-tests-audit/SKILL.md).
|
||||
It guides reproduction and comparable measurements before changing checks.
|
||||
|
||||
`check:pr` reuses build and static-check outputs while executing the test suite
|
||||
fresh, including the fast Python producer checks. `docs:check` also runs the
|
||||
onboarding example consistency suite for README-only changes.
|
||||
|
||||
@@ -13,6 +13,8 @@ import java.net.UnixDomainSocketAddress;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.ServerSocketChannel;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
@@ -22,7 +24,9 @@ import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
@@ -82,6 +86,7 @@ import ghidra.program.model.address.SegmentedAddress;
|
||||
import ghidra.program.model.reloc.Relocation;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import ghidra.program.model.symbol.Namespace;
|
||||
import ghidra.program.model.symbol.FlowType;
|
||||
import ghidra.program.model.symbol.RefType;
|
||||
import ghidra.program.model.symbol.Reference;
|
||||
import ghidra.program.model.symbol.ReferenceIterator;
|
||||
@@ -100,12 +105,17 @@ import ghidra.program.model.pcode.PcodeBlock;
|
||||
import ghidra.program.model.pcode.PcodeBlockBasic;
|
||||
import ghidra.program.model.pcode.Varnode;
|
||||
import ghidra.program.model.pcode.VarnodeAST;
|
||||
import ghidra.program.util.ContextEvaluatorAdapter;
|
||||
import ghidra.program.util.SymbolicPropogator;
|
||||
import ghidra.program.util.VarnodeContext;
|
||||
|
||||
public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private static final int MAX_HIGH_PCODE_OPS = 3000;
|
||||
private static final int MAX_HIGH_PCODE_INPUTS_PER_OP = 64;
|
||||
private static final int MAX_HIGH_PCODE_DEF_USE_EDGES = 12000;
|
||||
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
|
||||
private final Map<FileBytes, Boolean> targetSourceIdentity = new IdentityHashMap<>();
|
||||
private String admittedTargetSha256;
|
||||
private static final Set<String> DESCRIPTOR_KEYS = Set.of(
|
||||
"transport",
|
||||
"endpoint_path",
|
||||
@@ -207,6 +217,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
"Ghidra imported-byte digest does not match the admitted target"
|
||||
);
|
||||
}
|
||||
admittedTargetSha256 = descriptor.targetSha256;
|
||||
// GhidraScript wraps run() in a transaction. End it before serving so
|
||||
// each edit owns a real outer transaction and can roll back immediately.
|
||||
end(true);
|
||||
@@ -633,7 +644,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonObject addressToFileOffset(JsonObject params) {
|
||||
private JsonObject addressToFileOffset(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of("document", "address"));
|
||||
requireDocument(params);
|
||||
Address address = requireAddress(params, "address");
|
||||
@@ -643,14 +654,15 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
Long fileOffset = null;
|
||||
int mappings = 0;
|
||||
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
|
||||
if (!source.contains(address) || source.getFileBytes().isEmpty()) continue;
|
||||
if (!source.contains(address) || source.getFileBytes().isEmpty() ||
|
||||
!isTargetSource(source.getFileBytes().get())) continue;
|
||||
long offset = source.getFileBytesOffset(address);
|
||||
if (offset < 0) continue;
|
||||
fileOffset = offset;
|
||||
mappings++;
|
||||
}
|
||||
if (mappings != 1)
|
||||
throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative source mappings: " + canonicalAddress(address));
|
||||
throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative target-file mappings: " + canonicalAddress(address));
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("address", canonicalAddress(address));
|
||||
result.addProperty("file_offset", fileOffset);
|
||||
@@ -673,14 +685,96 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private JsonElement procedurePseudocode(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of("document", "procedure"));
|
||||
requireDocument(params);
|
||||
Function function = resolveProcedure(requireString(params, "procedure"));
|
||||
String value = decompiledText(decompile(function));
|
||||
return functionObservation(
|
||||
value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value),
|
||||
noReturnCallLimitations(
|
||||
currentProgram.getListing().getInstructions(function.getBody(), true)
|
||||
)
|
||||
);
|
||||
String selector = requireString(params, "procedure");
|
||||
JsonObject selection = new JsonObject();
|
||||
Function function = resolveDecompileProcedure(selector, selection);
|
||||
try {
|
||||
String value = decompiledText(decompile(function));
|
||||
JsonObject result = functionObservation(
|
||||
value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value),
|
||||
noReturnCallLimitations(
|
||||
currentProgram.getListing().getInstructions(function.getBody(), true)
|
||||
)
|
||||
);
|
||||
if (selection.size() > 0) result.add("selection", selection);
|
||||
return result;
|
||||
} catch (RequestFailure failure) {
|
||||
if (selection.size() == 0) throw failure;
|
||||
// Preserve completed resolution facts without changing the backend's
|
||||
// failure code or suggesting that the successful mapping was invalid.
|
||||
throw new RequestFailure(failure.code, "Selector " + selector +
|
||||
" mapped to analysis address " + selection.get("mapped_address").getAsString() +
|
||||
" and procedure " + canonicalAddress(function.getEntryPoint()) +
|
||||
"; " + failure.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Function resolveDecompileProcedure(String selector, JsonObject selection) throws Exception {
|
||||
String prefix = "file-offset:";
|
||||
boolean explicitOffset = selector.startsWith(prefix);
|
||||
if (!explicitOffset) {
|
||||
try {
|
||||
return resolveProcedure(selector);
|
||||
} catch (RequestFailure failure) {
|
||||
// Existing names/addresses win. Only an unresolved numeric input
|
||||
// can fall back to source coordinates; ambiguity never falls back.
|
||||
if (!failure.code.equals("not_found") || !selector.matches("0x[0-9a-fA-F]+"))
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
String coordinate = explicitOffset ? selector.substring(prefix.length()) : selector;
|
||||
if (!coordinate.matches("0x[0-9a-fA-F]+"))
|
||||
throw new RequestFailure("invalid_request", "File offsets require hexadecimal file-offset:0x... syntax");
|
||||
BigInteger parsed = new BigInteger(coordinate.substring(2), 16);
|
||||
if (parsed.compareTo(BigInteger.valueOf(9007199254740991L)) > 0)
|
||||
throw new RequestFailure("invalid_request", "File offset exceeds the exact JSON integer range");
|
||||
long offset = parsed.longValueExact();
|
||||
monitor.checkCancelled();
|
||||
// Ghidra's convenience lookup spans every FileBytes and uses coordinates
|
||||
// local to each object. Only a complete, digest-matched target source
|
||||
// establishes that the caller's offset belongs to the admitted file.
|
||||
Set<Address> mappedAddresses = new java.util.LinkedHashSet<>();
|
||||
for (MemoryBlock block : currentProgram.getMemory().getBlocks()) {
|
||||
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
|
||||
monitor.checkCancelled();
|
||||
FileBytes backing = source.getFileBytes().orElse(null);
|
||||
if (backing == null || !isTargetSource(backing)) continue;
|
||||
Address mapped = source.locateAddressForFileOffset(offset);
|
||||
if (mapped != null) mappedAddresses.add(mapped);
|
||||
}
|
||||
}
|
||||
List<Address> candidates = new ArrayList<>(mappedAddresses);
|
||||
if (candidates.size() > 1)
|
||||
throw new RequestFailure("ambiguous", "File offset " + coordinate +
|
||||
" has multiple loaded mappings: " + candidates.stream().map(ReaGhidraBridge::canonicalAddress).toList() +
|
||||
"; select an explicit analysis address");
|
||||
if (candidates.isEmpty())
|
||||
throw new RequestFailure("not_found", "File offset " + coordinate +
|
||||
" has no loaded source mapping for the admitted target file; Ghidra image base: " + canonicalAddress(currentProgram.getImageBase()) +
|
||||
". Inspect mappings with rea inspect-native-load-image <path> --provider ghidra --json or MCP inspect_native_load_image.");
|
||||
Address mapped = candidates.get(0);
|
||||
Function function = functionAtOrContaining(mapped);
|
||||
if (function == null)
|
||||
throw new RequestFailure("not_found", "File offset " + coordinate +
|
||||
" maps to analysis address " + canonicalAddress(mapped) +
|
||||
", but no procedure starts at or contains that address in the current Ghidra analysis. " +
|
||||
"Choose an observed procedure with CLI rea search <path> . --kind procedures --mode regex --provider ghidra --json or MCP list_procedures.");
|
||||
selection.addProperty("file_offset", offset);
|
||||
selection.addProperty("mapped_address", canonicalAddress(mapped));
|
||||
selection.addProperty("procedure_address", canonicalAddress(function.getEntryPoint()));
|
||||
return function;
|
||||
}
|
||||
|
||||
private boolean isTargetSource(FileBytes source) throws Exception {
|
||||
Boolean observed = targetSourceIdentity.get(source);
|
||||
if (observed != null) return observed;
|
||||
// Original FileBytes are immutable even when analysis memory is edited.
|
||||
// Partial/auxiliary sources remain inspectable but cannot establish an
|
||||
// original-target offset from their object-local coordinates alone.
|
||||
boolean matched = source.getFileOffset() == 0 &&
|
||||
constantTimeEquals(hashFileBytes(source, false), admittedTargetSha256);
|
||||
targetSourceIdentity.put(source, matched);
|
||||
return matched;
|
||||
}
|
||||
|
||||
private JsonElement procedureAssembly(JsonObject params) throws Exception {
|
||||
@@ -718,7 +812,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return functionObservation(result, boundaryLimitations);
|
||||
}
|
||||
|
||||
private JsonArray procedureCalls(JsonObject params, boolean callers) throws Exception {
|
||||
private JsonObject procedureCalls(JsonObject params, boolean callers) throws Exception {
|
||||
requireKeys(params, Set.of("document", "procedure"));
|
||||
requireDocument(params);
|
||||
Function function = resolveProcedure(requireString(params, "procedure"));
|
||||
@@ -731,7 +825,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
for (Function item : ordered) {
|
||||
result.add(canonicalAddress(item.getEntryPoint()));
|
||||
}
|
||||
return result;
|
||||
return functionObservation(
|
||||
result,
|
||||
noReturnCallLimitations(
|
||||
currentProgram.getListing().getInstructions(function.getBody(), true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private JsonObject procedureInfo(JsonObject params) throws Exception {
|
||||
@@ -1233,6 +1332,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.add("outgoing_references", outgoing);
|
||||
result.add("referenced_strings", referencedStrings);
|
||||
result.add("referenced_names", referencedNames);
|
||||
boolean swift = swiftProgram();
|
||||
boolean aarch64 = currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:");
|
||||
SwiftStringLiteralDecode swiftLiterals = swift && aarch64 ? swiftStringLiterals(function) : null;
|
||||
if (swiftLiterals != null) {
|
||||
result.add("swift_string_literals", swiftLiterals.literals());
|
||||
result.add("swift_string_literal_omissions", swiftLiterals.omissions());
|
||||
}
|
||||
result.add("basic_blocks", blocks);
|
||||
result.add(
|
||||
"native_api",
|
||||
@@ -1252,6 +1358,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
limitations.add(
|
||||
"Synthetic Ghidra entry-point references without actionable memory sources are omitted."
|
||||
);
|
||||
if (swift && aarch64) {
|
||||
limitations.add(
|
||||
"Swift String literals are decoded from constant register pairs at calls, returns and paired stores; literals stored one word at a time, chosen by conditional selects, passed through unresolved jumps, or built at runtime are not reported."
|
||||
);
|
||||
if (swiftLiterals.omissions().size() > 0) {
|
||||
limitations.add(
|
||||
"Swift String literal records listed in swift_string_literal_omissions could not be retained or decoded within this dossier's remaining heap budget; later symbolic flows remain unexamined."
|
||||
);
|
||||
}
|
||||
} else if (swift) {
|
||||
limitations.add(
|
||||
"Swift String literal decoding is implemented for AArch64 register pairs only; no Swift literals were decoded for this architecture."
|
||||
);
|
||||
}
|
||||
List<String> boundaryLimitations = noReturnCallLimitations(scan.instructions);
|
||||
for (String limitation : boundaryLimitations) {
|
||||
limitations.add(limitation);
|
||||
@@ -1833,14 +1953,25 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
Address address = explicitAddress ? parseReaAddress(value) : tryParseAddress(value);
|
||||
if (address == null)
|
||||
throw new RequestFailure("not_found", "Unknown Ghidra procedure name or address: " + value);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
if (function == null && address.isMemoryAddress())
|
||||
function = currentProgram.getFunctionManager().getFunctionContaining(address);
|
||||
Function function = functionAtOrContaining(address);
|
||||
if (function == null)
|
||||
throw new RequestFailure("not_found", "No procedure exists at the requested address: " + value);
|
||||
// Other procedure operations retain their analysis-address semantics.
|
||||
throw new RequestFailure("not_found", "No procedure exists at the requested address: " + value +
|
||||
"; Ghidra image base: " + canonicalAddress(currentProgram.getImageBase()) +
|
||||
". Choose an observed analysis address. CLI: rea search <path> . --kind procedures --mode regex --provider ghidra --json; " +
|
||||
"MCP: list_procedures. Inspect the loaded base and source mappings with CLI rea analyze <path> --provider ghidra --json " +
|
||||
"and rea inspect-native-load-image <path> --provider ghidra --json, or MCP binary_overview and inspect_native_load_image. " +
|
||||
"Repeat the original target options. For decompile, select source coordinates explicitly with --file-offset=0x... or MCP procedure file-offset:0x....");
|
||||
return function;
|
||||
}
|
||||
|
||||
private Function functionAtOrContaining(Address address) {
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
return function == null && address.isMemoryAddress()
|
||||
? currentProgram.getFunctionManager().getFunctionContaining(address)
|
||||
: function;
|
||||
}
|
||||
|
||||
private JsonObject nativeApiBoundary(
|
||||
Function function,
|
||||
DecompileResults decompilation
|
||||
@@ -3294,6 +3425,349 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean swiftProgram() {
|
||||
for (MemoryBlock block : currentProgram.getMemory().getBlocks()) {
|
||||
String name = block.getName();
|
||||
if (name.startsWith("__swift5_") || name.startsWith("swift5_")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swift passes a String as two words: small literals carry their bytes in the words and
|
||||
* immortal large literals carry their count and a biased pointer to the bytes. Ghidra keeps
|
||||
* neither as a data reference, so decode constant pairs where Swift consumes them: argument
|
||||
* and return registers at calls, tail calls and returns, and both registers of one STP.
|
||||
* The canonical empty string is a small literal with a zero count.
|
||||
*/
|
||||
private SwiftStringLiteralDecode swiftStringLiterals(Function function) throws Exception {
|
||||
Register[] registers = new Register[31];
|
||||
for (int index = 0; index < registers.length; index++) {
|
||||
registers[index] = currentProgram.getRegister("x" + index);
|
||||
}
|
||||
TreeMap<String, JsonObject> observed = new TreeMap<>();
|
||||
TreeMap<String, JsonObject> omitted = new TreeMap<>();
|
||||
SwiftLiteralBudget budget = new SwiftLiteralBudget();
|
||||
Map<String, Boolean> selectedValues = new HashMap<>();
|
||||
SymbolicPropogator propagator = new SymbolicPropogator(currentProgram, false);
|
||||
propagator.flowConstants(function.getEntryPoint(), function.getBody(), new ContextEvaluatorAdapter() {
|
||||
@Override
|
||||
public boolean evaluateContextBefore(VarnodeContext context, Instruction instruction) {
|
||||
// Returning true ends only the current flow, so every queued flow checks the shared stop.
|
||||
if (budget.stopped()) return true;
|
||||
for (Register[] pair : swiftStringPairs(function, instruction, registers)) {
|
||||
Long word = registerConstant(context, pair[0]);
|
||||
Long object = registerConstant(context, pair[1]);
|
||||
if (word == null || object == null) continue;
|
||||
SwiftLiteralLayout layout = swiftLiteralLayout(word, object);
|
||||
if (layout == null) continue;
|
||||
// A selected word can combine with a path-dependent ordinary move. Cache the
|
||||
// provenance question per site/register, charging retained cache state to the
|
||||
// same dossier budget before allocating it.
|
||||
boolean selected = false;
|
||||
boolean provenanceOverCapacity = false;
|
||||
for (Register register : pair) {
|
||||
String provenanceKey = canonicalAddress(instruction.getAddress()) + "\u0000" +
|
||||
register.getBaseRegister().getName();
|
||||
Boolean value = selectedValues.get(provenanceKey);
|
||||
if (value == null) {
|
||||
if (!budget.takeState()) {
|
||||
provenanceOverCapacity = true;
|
||||
break;
|
||||
}
|
||||
value = selectedValue(function, instruction, register);
|
||||
selectedValues.put(provenanceKey, value);
|
||||
}
|
||||
if (value) {
|
||||
selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selected) continue;
|
||||
// Forks can reach one site with different constants, so the words are part of the key.
|
||||
String key = canonicalAddress(instruction.getAddress()) + "\u0000" + pair[0].getName() +
|
||||
"\u0000" + pair[1].getName() + "\u0000" + Long.toUnsignedString(word, 16) +
|
||||
"\u0000" + Long.toUnsignedString(object, 16);
|
||||
if (observed.containsKey(key) || omitted.containsKey(key)) continue;
|
||||
// Every record draws on the budget; the first one that does not fit is kept as an
|
||||
// omission and decoding stops, so later sites are never examined.
|
||||
String value = layout.smallValue();
|
||||
boolean overCapacity = provenanceOverCapacity || !budget.take(layout.byteLength());
|
||||
if (!overCapacity && value == null) {
|
||||
try {
|
||||
value = swiftLargeLiteralText(layout);
|
||||
} catch (OutOfMemoryError exhausted) {
|
||||
overCapacity = true;
|
||||
}
|
||||
}
|
||||
if (value == null && !overCapacity) continue;
|
||||
JsonObject item = overCapacity
|
||||
? swiftStringOmission(layout, word, object)
|
||||
: swiftStringItem(value, layout, word, object);
|
||||
item.addProperty("source_address", canonicalAddress(instruction.getAddress()));
|
||||
JsonArray pairRegisters = new JsonArray();
|
||||
pairRegisters.add(pair[0].getName());
|
||||
pairRegisters.add(pair[1].getName());
|
||||
item.add("registers", pairRegisters);
|
||||
(overCapacity ? omitted : observed).put(key, item);
|
||||
if (overCapacity) {
|
||||
budget.stop();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}, false, monitor);
|
||||
JsonArray literals = new JsonArray();
|
||||
observed.values().forEach(literals::add);
|
||||
JsonArray omissions = new JsonArray();
|
||||
omitted.values().forEach(omissions::add);
|
||||
return new SwiftStringLiteralDecode(literals, omissions);
|
||||
}
|
||||
|
||||
private static List<Register[]> swiftStringPairs(
|
||||
Function function,
|
||||
Instruction instruction,
|
||||
Register[] registers
|
||||
) {
|
||||
List<Register[]> pairs = new ArrayList<>();
|
||||
if (instruction.getMnemonicString().equals("stp")) {
|
||||
Register first = instruction.getRegister(0);
|
||||
Register second = instruction.getRegister(1);
|
||||
if (first != null && second != null && first.getBitLength() == 64 && second.getBitLength() == 64) {
|
||||
pairs.add(new Register[] {first, second});
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
FlowType flow = instruction.getFlowType();
|
||||
boolean returns = false;
|
||||
for (PcodeOp operation : instruction.getPcode()) {
|
||||
if (operation.getOpcode() == PcodeOp.RETURN) returns = true;
|
||||
}
|
||||
// A jump is a proven tail call only when it has resolved targets and all leave the
|
||||
// body. A targetless computed jump can still dispatch inside the function.
|
||||
Address[] targets = instruction.getFlows();
|
||||
boolean tailCall = flow.isJump() && !flow.isConditional() && targets.length > 0 &&
|
||||
Arrays.stream(targets).allMatch(target -> !function.getBody().contains(target));
|
||||
if (!flow.isCall() && !returns && !tailCall) return pairs;
|
||||
// A String occupies two consecutive registers among x0-x7, or x0 and x1 when returned.
|
||||
int lastFirstRegister = returns ? 0 : 6;
|
||||
for (int index = 0; index <= lastFirstRegister; index++) {
|
||||
pairs.add(new Register[] {registers[index], registers[index + 1]});
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
private static Long registerConstant(VarnodeContext context, Register register) {
|
||||
Varnode value = context.getRegisterVarnodeValue(register);
|
||||
return value != null && context.isConstant(value) ? value.getOffset() : null;
|
||||
}
|
||||
|
||||
// Search every predecessor path for the reaching definitions, following register copies,
|
||||
// since a select copied into an argument register or defined before a branch forks just as
|
||||
// independently as one written there directly.
|
||||
private boolean selectedValue(Function function, Instruction use, Register register) {
|
||||
ArrayDeque<Instruction> instructions = new ArrayDeque<>();
|
||||
ArrayDeque<Register> targets = new ArrayDeque<>();
|
||||
Set<String> visited = new HashSet<>();
|
||||
for (Instruction predecessor : predecessors(function, use)) {
|
||||
instructions.push(predecessor);
|
||||
targets.push(register.getBaseRegister());
|
||||
}
|
||||
while (!instructions.isEmpty()) {
|
||||
Instruction instruction = instructions.pop();
|
||||
Register target = targets.pop();
|
||||
if (!visited.add(canonicalAddress(instruction.getAddress()) + "\u0000" + target.getName())) continue;
|
||||
boolean defines = false;
|
||||
for (Object result : instruction.getResultObjects()) {
|
||||
if (result instanceof Register written && written.getBaseRegister().equals(target)) defines = true;
|
||||
}
|
||||
Register next = target;
|
||||
if (defines) {
|
||||
PcodeOp[] operations = instruction.getPcode();
|
||||
for (PcodeOp operation : operations) {
|
||||
if (operation.getOpcode() == PcodeOp.CBRANCH && operation.getInput(0).isConstant()) return true;
|
||||
}
|
||||
Register source = copiedRegister(operations);
|
||||
if (source == null) continue;
|
||||
next = source.getBaseRegister();
|
||||
}
|
||||
for (Instruction predecessor : predecessors(function, instruction)) {
|
||||
instructions.push(predecessor);
|
||||
targets.push(next);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Instruction> predecessors(Function function, Instruction instruction) {
|
||||
List<Instruction> result = new ArrayList<>();
|
||||
Address from = instruction.getFallFrom();
|
||||
if (from != null) {
|
||||
Instruction previous = currentProgram.getListing().getInstructionAt(from);
|
||||
if (previous != null) result.add(previous);
|
||||
}
|
||||
for (Reference reference : currentProgram.getReferenceManager().getReferencesTo(instruction.getAddress())) {
|
||||
if (!reference.getReferenceType().isFlow() || reference.getReferenceType().isCall()) continue;
|
||||
if (!function.getBody().contains(reference.getFromAddress())) continue;
|
||||
Instruction source = currentProgram.getListing().getInstructionAt(reference.getFromAddress());
|
||||
if (source != null) result.add(source);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Register copiedRegister(PcodeOp[] operations) {
|
||||
if (operations.length != 1) return null;
|
||||
int opcode = operations[0].getOpcode();
|
||||
Varnode input = operations[0].getInput(0);
|
||||
if ((opcode != PcodeOp.COPY && opcode != PcodeOp.INT_ZEXT) || input == null || !input.isRegister()) return null;
|
||||
return currentProgram.getRegister(input);
|
||||
}
|
||||
|
||||
/** Validate the ABI layout of a pair; a small literal is decoded here, a large one only located. */
|
||||
private SwiftLiteralLayout swiftLiteralLayout(long word, long object) {
|
||||
int discriminator = (int) (object >>> 60);
|
||||
if (discriminator == 0xE || discriminator == 0xA) {
|
||||
int count = (int) ((object >>> 56) & 0xF);
|
||||
byte[] bytes = new byte[15];
|
||||
for (int index = 0; index < 8; index++) bytes[index] = (byte) (word >>> (8 * index));
|
||||
for (int index = 0; index < 7; index++) bytes[8 + index] = (byte) (object >>> (8 * index));
|
||||
for (int index = count; index < bytes.length; index++) {
|
||||
if (bytes[index] != 0) return null;
|
||||
}
|
||||
// Count zero is the canonical empty string (object 0xE000000000000000, word 0).
|
||||
String value = swiftStringText(bytes, count, discriminator == 0xE);
|
||||
return value == null ? null : new SwiftLiteralLayout("small", null, count, true, value);
|
||||
}
|
||||
int flags = (int) (word >>> 60);
|
||||
long count = word & 0xFFFFFFFFFFFFL;
|
||||
// Immortal literals: ASCII and NFC or neither, tail allocated, with a pointer biased by 32.
|
||||
if ((object >>> 56) != 0x80 || (flags != 0xD && flags != 0x1) || ((word >>> 48) & 0xFFF) != 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress((object & 0x00FFFFFFFFFFFFFFL) + 0x20);
|
||||
MemoryBlock block = currentProgram.getMemory().getBlock(address);
|
||||
// The bytes and their terminator must lie in one initialized, non-executable block,
|
||||
// and Java arrays bound one read.
|
||||
if (block == null || !block.isInitialized() || block.isExecute() ||
|
||||
count >= block.getEnd().subtract(address) + 1 || count >= Integer.MAX_VALUE) {
|
||||
return null;
|
||||
}
|
||||
return new SwiftLiteralLayout("large", address, count, flags == 0xD, null);
|
||||
} catch (Exception unaddressable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String swiftLargeLiteralText(SwiftLiteralLayout layout) {
|
||||
int count = (int) layout.byteLength();
|
||||
byte[] bytes = new byte[count + 1];
|
||||
try {
|
||||
if (currentProgram.getMemory().getBytes(layout.address(), bytes) != bytes.length) return null;
|
||||
} catch (Exception unreadable) {
|
||||
return null;
|
||||
}
|
||||
return bytes[count] == 0 ? swiftStringText(bytes, count, layout.ascii()) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every literal record of one dossier draws on half of the heap that was free when decoding
|
||||
* began. Text is charged at the worst case per UTF-8 byte: two bytes of UTF-16 text, then up
|
||||
* to six escaped JSON characters ("\\u0000") held as UTF-16 in Gson's growing writer buffer
|
||||
* (up to twice its length), again in the returned String, and once more as UTF-8 on the
|
||||
* socket. Each record also pays a fixed cost for its map key, JSON members, addresses, words
|
||||
* and their serialized form, so many empty or small literals are bounded too. Retained
|
||||
* provenance-cache entries draw on that same fixed-state allowance.
|
||||
*/
|
||||
private static final class SwiftLiteralBudget {
|
||||
private static final long BYTES_PER_LITERAL_BYTE = 2 + 6 * 2 * 2 + 6 * 2 + 6;
|
||||
private static final long BYTES_PER_RECORD = 4096;
|
||||
private long remaining;
|
||||
private boolean stopped;
|
||||
|
||||
SwiftLiteralBudget() {
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
remaining = Math.max(0, (runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory()) / 2);
|
||||
}
|
||||
|
||||
boolean takeState() {
|
||||
if (remaining < BYTES_PER_RECORD) return false;
|
||||
remaining -= BYTES_PER_RECORD;
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean take(long bytes) {
|
||||
if (remaining < BYTES_PER_RECORD ||
|
||||
bytes > (remaining - BYTES_PER_RECORD) / BYTES_PER_LITERAL_BYTE) {
|
||||
return false;
|
||||
}
|
||||
remaining -= BYTES_PER_RECORD + bytes * BYTES_PER_LITERAL_BYTE;
|
||||
return true;
|
||||
}
|
||||
|
||||
void stop() {
|
||||
stopped = true;
|
||||
}
|
||||
|
||||
boolean stopped() {
|
||||
return stopped;
|
||||
}
|
||||
}
|
||||
|
||||
// Swift String values may contain any scalar, including controls; JSON escapes them.
|
||||
private static String swiftStringText(byte[] bytes, int length, boolean ascii) {
|
||||
if (length < 0 || length > bytes.length) return null;
|
||||
for (int index = 0; index < length; index++) {
|
||||
if (ascii && bytes[index] < 0) return null;
|
||||
}
|
||||
try {
|
||||
return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes, 0, length)).toString();
|
||||
} catch (CharacterCodingException invalid) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject swiftStringOmission(SwiftLiteralLayout layout, long word, long object) {
|
||||
JsonObject item = new JsonObject();
|
||||
item.addProperty("representation", layout.representation());
|
||||
if (layout.address() == null) {
|
||||
item.add("address", JsonNull.INSTANCE);
|
||||
} else {
|
||||
item.addProperty("address", canonicalAddress(layout.address()));
|
||||
}
|
||||
item.addProperty("byte_length", layout.byteLength());
|
||||
item.addProperty("reason", "capacity");
|
||||
item.add("words", swiftStringWords(word, object));
|
||||
return item;
|
||||
}
|
||||
|
||||
private static JsonObject swiftStringItem(
|
||||
String value,
|
||||
SwiftLiteralLayout layout,
|
||||
long word,
|
||||
long object
|
||||
) {
|
||||
JsonObject item = new JsonObject();
|
||||
item.addProperty("value", value);
|
||||
item.addProperty("representation", layout.representation());
|
||||
if (layout.address() == null) {
|
||||
item.add("address", JsonNull.INSTANCE);
|
||||
} else {
|
||||
item.addProperty("address", canonicalAddress(layout.address()));
|
||||
}
|
||||
item.add("words", swiftStringWords(word, object));
|
||||
return item;
|
||||
}
|
||||
|
||||
private static JsonArray swiftStringWords(long word, long object) {
|
||||
JsonArray words = new JsonArray();
|
||||
words.add("0x" + Long.toUnsignedString(word, 16));
|
||||
words.add("0x" + Long.toUnsignedString(object, 16));
|
||||
return words;
|
||||
}
|
||||
|
||||
private static String exclusiveAddress(Address inclusive) {
|
||||
Address next = inclusive.next();
|
||||
if (next != null && next.getAddressSpace().equals(inclusive.getAddressSpace())) {
|
||||
@@ -3833,6 +4307,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private record AddressNameRequest(String inputAddress, Address address, String name) {}
|
||||
private record FunctionEntry(Function function, InventoryItem item) {}
|
||||
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations) {}
|
||||
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
|
||||
private record SwiftLiteralLayout(
|
||||
String representation,
|
||||
Address address,
|
||||
long byteLength,
|
||||
boolean ascii,
|
||||
String smallValue
|
||||
) {}
|
||||
private record InstructionScan(List<Instruction> instructions, boolean truncated) {}
|
||||
private record SessionDescriptor(
|
||||
String transport,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.program.model.mem.MemoryBlock;
|
||||
import ghidra.program.model.mem.MemoryBlockSourceInfo;
|
||||
|
||||
/** Seed the caller-supplied raw-binary entry before default auto-analysis. */
|
||||
public final class ReaGhidraPrepareRaw extends GhidraScript {
|
||||
@@ -21,8 +23,19 @@ public final class ReaGhidraPrepareRaw extends GhidraScript {
|
||||
);
|
||||
}
|
||||
Address entry = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress(entryHex);
|
||||
Address start = currentProgram.getMinAddress();
|
||||
if (entry == null || !entry.equals(start)) {
|
||||
MemoryBlock block = entry == null ? null : currentProgram.getMemory().getBlock(entry);
|
||||
boolean fileStart = false;
|
||||
if (block != null) {
|
||||
// Processor-defined zero page/stack blocks can precede the imported file.
|
||||
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
|
||||
if (source.getFileBytes().isPresent() && source.getFileBytesOffset() == 0 &&
|
||||
entry.equals(source.getMinAddress())) {
|
||||
fileStart = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fileStart) {
|
||||
throw new IllegalArgumentException(
|
||||
"Raw binary import must map file offset 0 at the caller-supplied entry"
|
||||
);
|
||||
|
||||
@@ -93,7 +93,7 @@ def project_record(state, secrets):
|
||||
safe_bytes, credential_url = transport_value(value, path, transport_url)
|
||||
content_base64 = base64.b64encode(value).decode("ascii")
|
||||
digest = hashlib.sha256(value).hexdigest()
|
||||
hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest for secret in secrets)
|
||||
hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest or secret in str(len(value)) for secret in secrets)
|
||||
safe_text = None
|
||||
try:
|
||||
safe_text = safe_bytes.decode("utf-8", errors="strict")
|
||||
|
||||
@@ -174,7 +174,7 @@ Resolution follows dyld:
|
||||
- `@rpath` tries each `LC_RPATH` of the loading image first, then those of the
|
||||
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 request whose install name matches an
|
||||
loads once per process. A later @rpath request whose install name matches an
|
||||
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`.
|
||||
|
||||
@@ -98,8 +98,10 @@ section, symbol, mitigation or linkage views. The default `complete` detail and
|
||||
CLI output are unchanged.
|
||||
|
||||
Complete results have a 32 MiB input, 64 MiB reply, 1 MiB combined diagnostics
|
||||
and 30-second owned command deadline. The reply budget applies to the decoder
|
||||
record; the CLI/MCP Evidence envelope adds copies and encoding overhead. The
|
||||
and 30-second owned command deadline. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the
|
||||
input bound alone, up to 1 GiB; the reply and deadline bounds are unchanged. The
|
||||
reply budget applies to the decoder record; the CLI/MCP Evidence envelope adds
|
||||
copies and encoding overhead. The
|
||||
pinned MCP SDK defaults to a 10 MiB receive buffer. Large complete results need
|
||||
a caller-configured `StdioClientTransport({ maxBufferSize: ... })` and a matching
|
||||
server `REA_MCP_MAX_RESPONSE_BYTES`. See [MCP delivery budgets](mcp-contracts.md#tool-results).
|
||||
|
||||
@@ -80,6 +80,27 @@ google-chrome \
|
||||
|
||||
REA does not launch, own, or terminate this browser. Use a dedicated profile and stop it yourself when the investigation is complete. Do not expose the debugging port on a non-loopback interface.
|
||||
|
||||
Other Chromium-family browsers, such as Chromium or Brave, work the same way
|
||||
with the same two flags. On macOS, run the executable inside the application
|
||||
bundle, for example
|
||||
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"` or
|
||||
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"`. Fedora installs
|
||||
Chromium as `chromium-browser`.
|
||||
|
||||
REA needs the browser's HTTP discovery endpoint on a loopback port. A browser
|
||||
driven over `--remote-debugging-pipe` or a private channel, for example by an
|
||||
automation SDK, exposes no such endpoint, so `list_browser_targets` has nothing
|
||||
to attach to. Connection failures report `endpoint_unreachable`; a reachable
|
||||
HTTP service that returns a non-200 or non-JSON discovery response reports
|
||||
`invalid_endpoint_response`.
|
||||
Reproduce that flow in a Chromium-family browser started with
|
||||
`--remote-debugging-port`.
|
||||
|
||||
In a minimal Linux container, headed Chromium under Xvfb can keep running while
|
||||
its DevTools port never opens. Install the GLES, EGL, and Mesa DRI libraries
|
||||
(on Fedora: `libglvnd-gles`, `mesa-libEGL`, and `mesa-dri-drivers`), then
|
||||
restart the browser.
|
||||
|
||||
On Linux, a reachable but locked desktop keyring can stall Chrome before its
|
||||
first navigation. For a dedicated investigation profile, add
|
||||
`--password-store=basic` to bypass that keyring. Empty or placeholder frame URLs
|
||||
@@ -207,7 +228,7 @@ commands fail closed. A correlated reply's session ID must match the command's
|
||||
selected session. An optional method is unavailable only when the browser
|
||||
rejects it as unknown; other command rejections retain their reported failure.
|
||||
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Scripts are different: scripts the page parsed before REA attached are still reported under `scripts`. A page that loaded before attach can therefore list its scripts while `network.requests` lacks the requests that fetched them. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
|
||||
|
||||
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation has a 30-second deadline and a 64 MiB aggregate streamed-response budget across redirects. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
|
||||
|
||||
|
||||
+3
-1
@@ -174,7 +174,9 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat
|
||||
rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/path/to/analysis/program.json
|
||||
```
|
||||
|
||||
An exact CLI cache hit is read before starting a provider process.
|
||||
An exact CLI cache hit is normally read before starting a provider process.
|
||||
On Windows, Ghidra first admits the selected source through its native snapshot
|
||||
owner, so replay can start that provider.
|
||||
In MCP, `open_binary` accepts `snapshot_path` to import a snapshot atomically
|
||||
for its matching target; an MCP provider may still start before returning a
|
||||
cached result. `close_binary` accepts `snapshot_path` and optional
|
||||
|
||||
@@ -165,6 +165,14 @@ For [Qoder](https://docs.qoder.com/cli/mcp-reference), setup writes
|
||||
from `~/.qoder/skills` (following `QODER_CONFIG_DIR` when set), so setup
|
||||
installs its skill copy there.
|
||||
|
||||
## Factory Droid
|
||||
|
||||
For [Factory Droid](https://docs.factory.com/harness/mcp), setup writes
|
||||
`mcpServers.rea` into the user-level `~/.factory/mcp.json`. Entries carry
|
||||
`command`, `args`, and `env`. Droid discovers personal skills from
|
||||
`~/.factory/skills` ([skills](https://docs.factory.com/harness/skills)), so
|
||||
setup installs its skill copy there.
|
||||
|
||||
## Trae
|
||||
|
||||
For [Trae](https://docs.trae.ai/ide/add-mcp-servers), setup writes
|
||||
@@ -209,8 +217,15 @@ replaces) the REA entry in the home patch `$DSH_HOME/cordis.patch.yml`
|
||||
patches and comments. `rea doctor --client dsh` checks that same entry id.
|
||||
`rea uninstall` removes it by scanning supported clients; uninstall takes no
|
||||
`--client` flag. The home patch applies to every Harness profile, so the
|
||||
registration is written once and no per-profile edit is needed. The written
|
||||
entry is:
|
||||
registration is written once and no per-profile edit is needed.
|
||||
|
||||
Setup, doctor and uninstall resolve `DSH_HOME` and `DSH_AGENTS_HOME` as Harness
|
||||
does: unset, empty or whitespace-only values use `~/.dsh` and `~/.agents`,
|
||||
respectively. Bare `~`, `~/` and `~\` expand to the user home; other relative
|
||||
values resolve against the command's working directory. Nonblank values retain
|
||||
their original spaces, and the agents root is independent of the Harness home.
|
||||
Run REA and Harness from the same working directory when using relative overrides.
|
||||
The written entry is:
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
|
||||
@@ -18,14 +18,26 @@ pass `-- chat` to check plain chat with REA enabled. Qwen also reads the full
|
||||
result that its client offloads, and Pi exercises default codemode execution.
|
||||
Use `REA_VERIFY_RUNTIME_ROOT` to select a production-only installed REA package.
|
||||
Receipts retain client versions, result digests, host coverage and owned-process
|
||||
cleanup. These POSIX lanes use a deterministic loopback model, so they do not
|
||||
prove live model-provider or native Windows compatibility. Qwen, Pi and Hermes
|
||||
cleanup. These lanes use a deterministic loopback model and do not prove live
|
||||
model-provider compatibility. Pi and Hermes remain POSIX-only. Qwen, Pi and Hermes
|
||||
bind both `HOME` and `USERPROFILE` to the disposable account. Qwen and Pi discover
|
||||
the shared personal skill through their default search paths without an explicit
|
||||
skill setting; Hermes discovers it through the selected `HERMES_HOME`.
|
||||
Set `REA_VERIFY_HERMES_STICKY_PROFILE=1` to exercise native Hermes selection of
|
||||
a named sticky profile with `HERMES_HOME` still pointing to its root.
|
||||
|
||||
The Qwen lane also supports native Windows (verified with Qwen Code 0.25.1 and
|
||||
Node 24.19.0 for both `chat` and `call`). With Node 24.18.0, the same `call`
|
||||
journey reached full Evidence readback but the client aborted during exit with
|
||||
`UV_HANDLE_CLOSING`; that run remains a failure, not a compatibility claim.
|
||||
Select the npm install's
|
||||
`node_modules/@qwen-code/qwen-code/cli-entry.js` with `REA_VERIFY_QWEN_COMMAND`,
|
||||
because `execFile` cannot launch the `qwen.cmd` shim directly. A selected `.js`,
|
||||
`.mjs` or `.cjs` entry point runs under the verifier's Node. Windows path checks
|
||||
use native separators; persisted output is read through Qwen's real shell tool
|
||||
with a shell-independent encoded Node payload. Windows receipts report process
|
||||
lineage as unavailable when the verifier has no retained native Job Object lease.
|
||||
|
||||
`verify:gemini-client` requires an installed Gemini CLI (verified with
|
||||
`@google/gemini-cli@0.63.0` on POSIX and native Windows); select it with
|
||||
`REA_VERIFY_GEMINI_COMMAND`. A selected `.js`, `.mjs` or `.cjs` entry point runs
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ MIPS header and ABI record facts are committed in the analysis profile so
|
||||
interpretation changes invalidate profile-bound snapshots. The support-lane
|
||||
identity is versioned independently of file identity. Evidence, lifecycle
|
||||
outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters
|
||||
do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only.
|
||||
do not implicitly gain MIPS support. Windows Ghidra P0 does not admit MIPS targets.
|
||||
|
||||
### Soft-float prototype
|
||||
|
||||
|
||||
+78
-3
@@ -41,6 +41,80 @@ does not prove text is absent. Both list and search Evidence carry this
|
||||
limitation. When an address is known, use `read_bytes` and `xrefs` to inspect
|
||||
bytes and references.
|
||||
|
||||
## Loaded addresses and file offsets
|
||||
|
||||
`rea analyze <path> --provider ghidra --json` and MCP `binary_overview` report
|
||||
the observed loaded `image_base` in `normalized_result`. They retain each
|
||||
segment's reported `image_base` and `address_space`. A provider that does not
|
||||
report a base, or whose segments disagree, omits the overview field, following
|
||||
the existing contract on main. REA does not substitute zero or the first
|
||||
segment's address. All reported bases and address spaces remain visible in the
|
||||
complete segment inventory, including conflicting observations.
|
||||
|
||||
`function` and most MCP procedure selectors use procedure names or
|
||||
provider analysis addresses. Ghidra `decompile` / MCP `procedure_pseudo_code`
|
||||
also accept source-file byte offsets. Existing symbols and analysis addresses
|
||||
take precedence; a `0x`-prefixed number that resolves to no procedure falls back
|
||||
to its observed source mapping. Use `--file-offset=0x...` on the CLI or
|
||||
`procedure: "file-offset:0x..."` through MCP to select file coordinates explicitly.
|
||||
Multiple mapping candidates fail as ambiguous rather than selecting a bank or
|
||||
address space. A successful conversion retains the file offset, mapped address
|
||||
and procedure entry in raw Evidence and `locations`.
|
||||
|
||||
If an offset maps to loaded target bytes but no procedure starts at or contains
|
||||
the mapped address, the failure preserves both the original file offset and
|
||||
analysis address. This differs from an unmapped offset and does not recommend
|
||||
retrying the same offset syntax.
|
||||
If the function resolves but the backend fails or cancels decompilation, the
|
||||
failure retains the selector, mapped address and procedure entry without
|
||||
changing the backend failure code.
|
||||
|
||||
Both mapping directions require a complete original `FileBytes` source whose
|
||||
SHA-256 matches the admitted target. Auxiliary files and partial source objects
|
||||
remain visible in the load-image inventory but do not establish offsets in the
|
||||
original target. Ghidra's object-local offsets are not silently treated as
|
||||
original-file coordinates. Original-source identity is checked once per source
|
||||
object per session; analysis-memory edits do not change the original bytes.
|
||||
|
||||
ELF virtual addresses and file offsets are
|
||||
different coordinates. Ghidra can rebase a PIE ELF during import: in the
|
||||
source-owned verification fixture, ELF entry `0x1013` becomes Ghidra procedure
|
||||
`0x101013` with image base `0x100000`. That example is not a general conversion
|
||||
rule.
|
||||
|
||||
Use `list_procedures` to select an observed procedure address. To relate file
|
||||
bytes to loaded memory, call MCP `inspect_native_load_image` or:
|
||||
|
||||
```sh
|
||||
rea search <path> . --kind procedures --mode regex --provider ghidra --json
|
||||
rea inspect-native-load-image <path> --provider ghidra --json
|
||||
rea decompile <path> <observed-procedure-address> --provider ghidra --json
|
||||
rea decompile <path> --file-offset=0x1013 --provider ghidra --json
|
||||
rea address-to-file-offset <path> <observed-procedure-address> --provider ghidra --json
|
||||
```
|
||||
|
||||
The load-image result preserves source-file indexes, file offsets, address
|
||||
spaces, and inclusive mapping ranges. Follow the mapping for the selected
|
||||
source file and region; adding `image_base` to an arbitrary file offset is not
|
||||
valid. For a linear byte-addressed, file-backed mapping, a file offset `F` in
|
||||
`[mapping.file_offset, mapping.file_offset + mapping.length)` maps to
|
||||
`mapping.start + (F - mapping.file_offset)` in the reported `address_space`.
|
||||
If several mappings cover the same file offset, keep those candidates distinct
|
||||
and select the procedure from the observed inventory. Uninitialized regions may
|
||||
have no file backing. ELF mappings are observed
|
||||
without independent format-specific load-image verification, as the result's
|
||||
status and limitations state.
|
||||
|
||||
An unsuccessful numeric procedure lookup reports the current Ghidra image base
|
||||
and gives the CLI commands above alongside the corresponding MCP operations.
|
||||
Repeat any target-format, architecture, and entry-point options used to open the
|
||||
target. Other procedure operations keep their analysis-address semantics;
|
||||
file-offset fallback applies only to decompilation.
|
||||
|
||||
Overview workflow revision 3 binds this output to snapshot identity. Older
|
||||
serialized overview Evidence remains readable; it is not replayed as a current
|
||||
overview merely because its source artifact is unchanged.
|
||||
|
||||
## Admitted function-analysis semantics
|
||||
|
||||
| Concern | Ghidra contract |
|
||||
@@ -57,9 +131,10 @@ bytes and references.
|
||||
|
||||
Function extents and instruction completeness refer to Ghidra's analyzed body.
|
||||
An inferred no-return call can terminate that body before subsequent bytes.
|
||||
Pseudocode, assembly, procedure info, instruction windows, and dossiers report
|
||||
observed terminal call sites in Evidence limitations, including resolved callee
|
||||
names, addresses, and FunctionManager flags when available. Instruction windows
|
||||
Pseudocode, assembly, procedure info, instruction windows, callee and caller
|
||||
reads, and dossiers report observed terminal call sites in Evidence limitations,
|
||||
including resolved callee names, addresses, and FunctionManager flags when
|
||||
available. Instruction windows
|
||||
and dossiers also retain these warnings in their result limitations. An
|
||||
unresolved callee or unestablished terminal-flow basis remains explicit. These
|
||||
observations qualify the analysis; they do not prove that a no-return flag is
|
||||
|
||||
@@ -11,8 +11,8 @@ ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The
|
||||
target also retains the raw ELF declarations that decide a lane: class, byte
|
||||
order, `e_type`, and `e_flags`. Recognition does not mean every provider or
|
||||
RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets
|
||||
are unchanged, and caller-supplied headerless raw-binary entry still accepts only
|
||||
its existing four families.
|
||||
are unchanged. Headerless inputs use the separate [raw-binary admission
|
||||
boundary](raw-binary.md); raw RISC-V images are not supported.
|
||||
|
||||
The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that
|
||||
declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`,
|
||||
|
||||
@@ -88,6 +88,8 @@ REA copies the admitted binary into a private temporary directory, checks its di
|
||||
|
||||
One REA session owns one database. Stdio registrations default `IDA_MCP_MAX_WORKERS` to `1`; the explicit upstream `--max-workers 1` also bounds a supervisor. Database size and engine memory depend on the target and upstream; REA does not impose an engine memory limit. The original binary is never executed or overwritten. Optional `workspaceRoot` selects an existing absolute temporary-directory parent on the REA host. Ensure it has space for the input copy and IDA working files.
|
||||
|
||||
When a completed `idb_open` response reports success or is otherwise ambiguous, but no active owned private worker can be verified, REA retains the private workspace even if `idb_list` appears empty. A later close can retry verification if the original owned worker becomes discoverable. A completed explicit `idb_open` rejection retains the existing no-worker cleanup path. This is a local admission/ownership safeguard and **does not** provide a process-termination receipt or change the legacy reaped-worker protocol.
|
||||
|
||||
Headless cleanup uses the upstream database lifecycle. Abruptly terminating REA, its proxy, or the supervisor does not establish worker release. Prefer `close_binary` for MCP sessions; after an interrupted process, inspect upstream session inventory and retained workspace diagnostics before removing working files. This adapter does not claim Windows Job Object containment for the upstream detached workers.
|
||||
|
||||
## Connect an agent once
|
||||
@@ -126,6 +128,8 @@ For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <cl
|
||||
|
||||
All profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance.
|
||||
|
||||
The modern headless profile also supports `resolve_containing_procedure` and `list_documents`. An address resolves through upstream `lookup_funcs` to the canonical entry of the function that contains it; the body stays unavailable like other IDA identities. When no function contains it, REA reads the database-scoped minimal `survey_binary` and reports `not_in_procedure` for an address inside one of its segments (ends are exclusive) and `outside_segments` otherwise. A missing, truncated or inconsistent survey fails the read instead of guessing a reason. `list_documents` returns the single admitted program path, so `inspect_address_context` and `trace_feature` need no explicit document. `trace_feature` marks a trace truncated, with a residual unknown for that address, when upstream caps the references it returned. The legacy and native profiles do not declare these operations.
|
||||
|
||||
The legacy profile additionally supports direct callers. Its producer may report call-site or interior addresses; REA resolves the reported function symbol to its canonical entry. The modern and native profiles do not prove direct callers from its code/data xref labels, so that capability is unavailable and the dossier marks it unknown.
|
||||
|
||||
Complete body ranges, typed reference edges, CFG, referenced data, source locals, comment kinds, and database revision are unavailable. Empty unsupported dossier facets mean **unknown**, not observed absence. `binary_overview`, whole-symbol inventory, mutation, debugging, arbitrary Python forwarding, and requested GUI navigation are outside this adapter's coverage. Check session availability before composing a workflow that needs those facets.
|
||||
|
||||
@@ -57,6 +57,10 @@ Hopper installation or any file write. Declining or pressing Ctrl-C makes no
|
||||
changes. Agent configuration writes preserve unrelated entries and comments,
|
||||
create backups, use atomic replacement, and verify their result. Setup and
|
||||
uninstall retain an existing `.rea.backup` rather than replacing the first snapshot.
|
||||
If that snapshot differs from the current configuration, setup previews and
|
||||
preserves a verified `.rea.backup.<sha256>.recovery` copy of the current bytes.
|
||||
Both snapshots remain available; a failed rollback reports the current recovery
|
||||
copy's path and the restore failure.
|
||||
Setup also retains valid environment overrides in REA's existing server entry,
|
||||
such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`.
|
||||
Detected provider settings replace only matching environment keys.
|
||||
@@ -279,6 +283,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
|
||||
| Pi | `pi` |
|
||||
| Hermes | `hermes` |
|
||||
| Qoder | `qoder` |
|
||||
| Factory Droid | `droid` |
|
||||
| Trae | `trae` |
|
||||
| ZCode | `zcode` |
|
||||
| DeepSeek Harness | `dsh` |
|
||||
@@ -508,7 +513,10 @@ and `set_addresses_names` name a function entry or any other mapped address
|
||||
session metadata edits leave executable bytes unchanged and are discarded on close. GUI controls
|
||||
require Hopper; Windows P0 remains read-only.
|
||||
|
||||
Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes.
|
||||
Windows P0 admits native x86 and x86-64 PE applications and DLLs, plus explicitly
|
||||
selected [raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied
|
||||
entry at file offset 0, on fixed local NTFS volumes. Other raw CPU families remain
|
||||
unsupported on Windows.
|
||||
The npm package bundles native Job Object ownership, protected private runtime
|
||||
DACLs, and handle-based path admission; no separate addon installation is needed.
|
||||
See the [Windows Ghidra P0 guide](windows-ghidra-p0.md) for verified scope and
|
||||
|
||||
@@ -104,6 +104,15 @@ static inferences. Dynamic or ambiguous IPC is retained without a pairing edge.
|
||||
Runtime entities retain passive-CDP authority, while a successful static/runtime
|
||||
correspondence is a separately identified cross-layer inference.
|
||||
|
||||
Source module links retain their resolution mode. Static imports in `.ts` and
|
||||
`.tsx` are `compiler-dependent` when compiler options have not been retained.
|
||||
REA compares Node import and require resolution: a shared outcome remains
|
||||
available with a limitation; divergent outcomes remain unavailable with both
|
||||
conditional branches and their limitations. This models source relationships,
|
||||
not verified emitted code or execution. A declaration file found only through
|
||||
an emitted-extension fallback is retained as a candidate, not a runtime target;
|
||||
an explicitly selected path keeps its original identity.
|
||||
|
||||
## Evidence and authority
|
||||
|
||||
Every observation and edge carries all of the following:
|
||||
|
||||
@@ -23,6 +23,14 @@ artifact digest and structural graph ID. Semantic tracing requires the semantic
|
||||
relation graph and reports when it is unavailable rather than treating missing
|
||||
data as an empty graph.
|
||||
|
||||
Explicit TypeScript `import type` and `export type` declarations remain source
|
||||
facts and do not create runtime import or export relationships. Type export names
|
||||
are reported separately from value export names. Inline `type` specifiers can
|
||||
leave an empty import or re-export with side effects under
|
||||
[`verbatimModuleSyntax`](https://www.typescriptlang.org/tsconfig/verbatimModuleSyntax.html);
|
||||
REA retains those dependency candidates and reports the unavailable compiler
|
||||
options rather than assuming the dependency is erased.
|
||||
|
||||
The semantic graph wire format stores shared non-location provenance once in
|
||||
its required `evidence_contexts` table. Each node, relation, fingerprint, and
|
||||
unknown retains its exact `evidence.location` and names the owning context with
|
||||
@@ -200,6 +208,25 @@ arrays, and later explicit data properties and methods replace earlier object
|
||||
spread references. A known array prefix does not expose values from a following
|
||||
spread. Accessors, custom or uncertain iteration, unknown computed keys, and
|
||||
unknown spread lengths retain conservative uncertainty.
|
||||
Getter-local aliases of `this`, including direct destructuring and assignments,
|
||||
retain their receiver when a returned shared reference is written. These writes
|
||||
invalidate the earlier literal instead of claiming the getter executed or the
|
||||
new value is known. Straight-line alias reads retain their capture order;
|
||||
uncertain control flow remains conservative. A binding outside the getter or
|
||||
`this` in a nested ordinary function is not attributed to the getter's receiver.
|
||||
Private reads keep receiver projections separate from the private value's path.
|
||||
Receiver projections follow known data fields initialized from `this` or earlier
|
||||
self-referencing fields; static fields retain their declaring class identity.
|
||||
An alias cycle that repeatedly selects deeper children retains escape uncertainty
|
||||
at its recovered roots rather than claiming a fixed number of loop iterations.
|
||||
|
||||
Writes through an object getter's returned receiver retain that uncertainty
|
||||
when the object is an array element, nested inside mixed object/array
|
||||
containers, or assigned to a previously declared identifier. Saving the getter
|
||||
result in an alias does not make its old field values immutable. Known array
|
||||
indices include holes; an element after a spread has only a minimum possible
|
||||
index. Unrelated properties and known array prefixes remain precise, and a
|
||||
getter read without a write does not invalidate the receiver's fields.
|
||||
Nested callable returns are not assigned to their parent callable. Projected
|
||||
graph observations carry source ranges but never source text.
|
||||
|
||||
|
||||
@@ -147,6 +147,28 @@ opposite observation's selection unknown, and application graphs report their
|
||||
selected scope as partial. Neither makes an absence claim about the whole
|
||||
assembly. Older member Evidence without `selection` remains readable.
|
||||
|
||||
### Edge target declaring types
|
||||
|
||||
`member_refs[].parent_name`, `call_edges[].target_declaring_type`, and
|
||||
`field_accesses[].field_declaring_type` name the type that declares each
|
||||
target. Referenced spec signatures validate encoded type coordinates against
|
||||
the metadata tables, including nested type arguments and custom modifiers.
|
||||
TypeRef resolution scopes are checked transitively with cycle detection;
|
||||
readable names survive malformed scopes as partial evidence.
|
||||
TypeDef and TypeRef names use the namespace-qualified `full_name`
|
||||
form, and nested types keep bare names. A generic-instantiation TypeSpec
|
||||
parent names its generic type, such as ``System.Collections.Generic.List`1``.
|
||||
Other well-formed TypeSpec forms and ModuleRef or MethodDef parents stay
|
||||
`null`. A `method-spec` call edge reports the name and declaring type of the
|
||||
generic method it instantiates. A field access is named only by a field or a
|
||||
field-signature MemberRef. TypeSpec and MethodSpec blobs are decoded with the
|
||||
member-signature grammar, and a MethodSpec must instantiate a generic method
|
||||
with as many type arguments as it declares. Malformed or unsupported rows and
|
||||
blobs, and arity mismatches, make coverage partial. A readable MethodSpec
|
||||
method column that names a method, or a TypeRef whose resolution scope is
|
||||
invalid, keeps its name.
|
||||
Older member Evidence without these fields reads them as `null`: unknown.
|
||||
|
||||
## Analysis objective
|
||||
|
||||
Start by identifying the artifact with `inspect_managed_artifact`, then inspect
|
||||
|
||||
+15
-2
@@ -147,8 +147,8 @@ after target content was read.
|
||||
|
||||
## Retained application Evidence inputs
|
||||
|
||||
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`, and
|
||||
`inspect_wasm_artifact` accept `detail`:
|
||||
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`,
|
||||
`inspect_wasm_artifact`, and `inspect_macho` accept `detail`:
|
||||
`complete` (default) returns the complete analysis Evidence; `summary` records
|
||||
that complete Evidence in the session and returns the derived
|
||||
`inspect_analysis_view` summary Evidence instead. Its
|
||||
@@ -218,6 +218,19 @@ attempting oversized delivery, and returns the same summary with an exact
|
||||
`parent_evidence_id` for later export or facet reads. Delivery detail does not
|
||||
reach WABT or change the original source Evidence ID.
|
||||
|
||||
Completed `inspect_macho` Evidence supports a summary and
|
||||
`view: {kind: "macho", facet: "exports", offset: 0, limit: 64}`. Facets are
|
||||
`entrypoints`, `architectures`, `build_metadata`, `load_commands`,
|
||||
`dependencies`, `imports`, `exports`, `segments`, and `provenance`; pages return
|
||||
the original rows. The summary keeps the selected slice, header fields and UUID,
|
||||
and replaces each collection with its returned count, reported total and
|
||||
`exhaustive` flag. Each page repeats the paged collection's `source_total` and
|
||||
`source_exhaustive`; exhausting a non-exhaustive collection does not establish
|
||||
that unreturned symbols are absent. Large frameworks return megabytes of
|
||||
symbols (Electron Framework: 2.5 MB of imports and exports, 3.3 KB as a
|
||||
summary), so select `detail: "summary"` on the first call. Delivery detail
|
||||
never reaches the native tools or changes the source Evidence ID.
|
||||
|
||||
`trace_application_feature`, `trace_javascript_semantics`,
|
||||
`compare_application_versions`, `compare_source_to_bundle`, and
|
||||
`compare_javascript_export_shapes` accept complete inline application Evidence
|
||||
|
||||
@@ -476,8 +476,11 @@ the tool fails with a `debugger-attach-denied` reason.
|
||||
Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to
|
||||
use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires
|
||||
the matching native decompiler. Experimental Windows x64 P0 admits native
|
||||
x86 and x86-64 PE applications and DLLs on local NTFS using bundled Job Object ownership,
|
||||
protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
|
||||
x86 and x86-64 PE applications and DLLs, plus explicitly selected
|
||||
[raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied load address,
|
||||
on fixed local NTFS volumes. Other raw CPU families remain unsupported on Windows.
|
||||
The boundary uses bundled Job Object ownership, protected runtime DACLs, and
|
||||
handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
|
||||
[issue #527](https://github.com/morluto/rea/issues/527).
|
||||
On Linux and macOS, `annotate_native_function` atomically edits a function name
|
||||
and entry comments in the ephemeral database, returning refreshed analysis
|
||||
|
||||
@@ -128,3 +128,49 @@ Each provider admits one inspection at a time, including acquisition and cleanup
|
||||
An occupied provider returns a retryable capacity error before acquiring another
|
||||
file or worker. Closing the provider stops new admission and drains accepted work;
|
||||
failed cleanup remains owned and can be retried.
|
||||
|
||||
## Discover protocols and field names
|
||||
|
||||
`inspect_packet_fields` / `inspect-packet-fields` queries the installed TShark
|
||||
registration glossary before selecting fields for capture inspection:
|
||||
|
||||
```sh
|
||||
rea inspect-packet-fields --query usb
|
||||
rea inspect-packet-fields --kind fields --protocol usb --query idVendor
|
||||
rea inspect-packet-fields --kind fields --protocol udp --all
|
||||
```
|
||||
|
||||
`kind` defaults to `protocols`; `fields` selects field definitions. `protocol`
|
||||
selects an exact protocol abbreviation (the protocol itself or its child fields).
|
||||
`query` is a case-insensitive literal substring in name, abbreviation or field
|
||||
description. `offset` skips matching records; `limit` defaults to 50. MCP
|
||||
`limit: null` or CLI `--all` requests all remaining matches within the resource
|
||||
budget. `--all` and `--limit` conflict. The window reports examined records of
|
||||
the selected kind before filters, matching records, returned records and
|
||||
`has_more`; it never counts capture packets.
|
||||
|
||||
Returned records preserve field type, parent protocol, display base, bitmask,
|
||||
description and original glossary line number. Empty producer strings remain
|
||||
empty; protocol records have null field-only properties. Repeated abbreviations
|
||||
remain separate registrations. A listed field is an installation capability, not
|
||||
proof that the field appears in a selected recording.
|
||||
|
||||
The adapter uses the documented `tshark -G fields` grammar under its existing
|
||||
private empty profile and Linux process limits. TShark 4.6.9's full glossary was
|
||||
measured at 22,303,197 bytes; discovery admits at most **32 MiB** combined command
|
||||
output (capture inspection remains at **4 MiB**). Parsing walks lines once and
|
||||
constructs objects only for the selected page. Returned strings are copied so
|
||||
retained pages do not retain the complete glossary string. Before constructing a selected
|
||||
record it charges six times the source UTF-8 bytes plus 512 bytes against an
|
||||
**8 MiB** representation budget, covering JSON escaping and record bookkeeping.
|
||||
If that budget would be exceeded, narrow the query or page size; results are not
|
||||
silently truncated. This is a conservative admission budget, not measured heap
|
||||
usage. Parent pipe buffers and producer per-process limits remain distinct.
|
||||
|
||||
Tool/limiter identity, version, stderr and selected arguments are retained in
|
||||
Evidence. No capture, interface or device is opened. Discovery shares capture
|
||||
analysis's admission slot and cleanup owner. Each call rereads the glossary;
|
||||
installed plugins, libraries and global configuration remain trusted and are not
|
||||
fully fingerprinted. There is no cache or snapshot-consistent pagination claim.
|
||||
The real Linux lane checks public CLI/MCP queries against the direct glossary,
|
||||
including USB vendor-ID typing, negative queries and paging.
|
||||
|
||||
+53
-10
@@ -25,10 +25,12 @@ rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-bina
|
||||
}
|
||||
```
|
||||
|
||||
`architecture` is one of `x86`, `x86_64`, `arm`, or `arm64`. `entry_point` is a
|
||||
`architecture` is one of `x86`, `x86_64`, `arm`, `arm64`, or `6502`. `entry_point` is a
|
||||
hexadecimal address of the form `0x…`. The complete file is mapped starting at
|
||||
that address, and Ghidra seeds an `entry` function there before auto-analysis.
|
||||
ARM and AArch64 entries must be 4-byte aligned; x86 entries may be byte-aligned.
|
||||
ARM and AArch64 entries must be 4-byte aligned; x86 and 6502 entries may be byte-aligned.
|
||||
For 6502, the entry and complete mapped file must fit within `0x0000..0xffff`;
|
||||
images that cross the 16-bit boundary are rejected before provider startup.
|
||||
`open_binary` and an open `binary_session` return the canonical `entry_point`
|
||||
for raw targets so callers can verify the selected mapping directly.
|
||||
Omitted `format` keeps header-based detection; architecture and entry without
|
||||
@@ -37,12 +39,13 @@ Omitted `format` keeps header-based detection; architecture and entry without
|
||||
Ghidra imports a private snapshot with `BinaryLoader` and these little-endian
|
||||
generic languages and compiler specifications:
|
||||
|
||||
| architecture | language_id | compiler_spec_id |
|
||||
| ------------ | ------------------- | ---------------- |
|
||||
| x86 | `x86:LE:32:default` | `gcc` |
|
||||
| x86_64 | `x86:LE:64:default` | `gcc` |
|
||||
| arm | `ARM:LE:32:v7` | `default` |
|
||||
| arm64 | `AARCH64:LE:64:v8A` | `default` |
|
||||
| architecture | language_id | compiler_spec_id |
|
||||
| ------------ | -------------------- | ---------------- |
|
||||
| x86 | `x86:LE:32:default` | `gcc` |
|
||||
| x86_64 | `x86:LE:64:default` | `gcc` |
|
||||
| arm | `ARM:LE:32:v7` | `default` |
|
||||
| arm64 | `AARCH64:LE:64:v8A` | `default` |
|
||||
| 6502 | `6502:LE:16:default` | `default` |
|
||||
|
||||
Compiler specifications select analysis conventions; they do not identify the
|
||||
original compiler or ABI of a headerless target. Ghidra's x86 languages do not
|
||||
@@ -57,11 +60,51 @@ The mapped start is independent of Ghidra's program image base, which remains
|
||||
`0x0` for BinaryLoader. Ghidra may report additional language-defined entry
|
||||
points, including unmapped ARM vectors. Load-image verification requires the
|
||||
caller-supplied entry and preserves those other entries as observations.
|
||||
For 6502, BinaryLoader maps the file before adding its language-defined zero-page
|
||||
and stack blocks. Conflicting default blocks are skipped; low-address files stay
|
||||
in the default address space rather than an overlay. Non-conflicting uninitialized
|
||||
blocks remain observations, not file-backed bytes.
|
||||
Independent raw verification streams the complete private snapshot digest and
|
||||
extent with bounded memory and supports cancellation during the read.
|
||||
It also requires the source-backed block to be initialized, loaded, and
|
||||
non-overlay in the bound language's default address space. Contradictory mapping
|
||||
observations are retained with `mismatch`, even when the source hashes agree.
|
||||
|
||||
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 continues to admit
|
||||
native PE applications and DLLs only; raw-binary targets are unsupported.
|
||||
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 admits native PE
|
||||
applications/DLLs and explicitly selected raw 6502 images. Other raw CPU families
|
||||
remain outside the Windows boundary. Native path admission, private DACLs, Job
|
||||
Object ownership, immutable snapshots, and the no-mutation policy still apply.
|
||||
|
||||
## NMOS 6502
|
||||
|
||||
```bash
|
||||
rea function /absolute/path/to/image.bin 0x8001 --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json
|
||||
rea inspect-native-load-image /absolute/path/to/image.bin --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json
|
||||
```
|
||||
|
||||
Select only headerless NMOS 6502 code with a known entry at file offset 0.
|
||||
The built-in Ghidra processor supplies static instructions, functions,
|
||||
pseudocode, and references. Evidence preserves `architecture: "6502"`, the
|
||||
selected language, entry, file digest, and verified source mapping. Register and
|
||||
calling conventions are analysis conventions, not a recovered source ABI.
|
||||
|
||||
This lane does not interpret iNES headers, cartridge mappers, bank switching,
|
||||
or other console-specific memory layouts. 65C02 and 65816 programs need different
|
||||
processor semantics and are not admitted as those variants. REA does not infer
|
||||
the CPU from the file suffix, guess the reset vector, or emulate the target.
|
||||
|
||||
`npm run verify:ghidra:6502` assembles and links the source-owned fixture with
|
||||
`ca65` and `ld65` (tested from cc65 tag `V2.19`, whose tools report `V2.18`).
|
||||
Set `REA_CA65` and `REA_LD65` to explicit executable paths if needed. The lane
|
||||
checks real CLI/MCP load-image parity, mapped bytes, decoded calls, decompilation,
|
||||
invalid mappings, unchanged source bytes and runtime cleanup.
|
||||
Low-memory checks include a file spanning the zero-page/stack boundary at `0xff`
|
||||
and a complete 64-KiB image at `0x0`, with verified default-space mappings through
|
||||
both CLI and MCP. On Windows, the lane also checks that a caller-selected
|
||||
junction path is rejected at snapshot admission through CLI and MCP, with
|
||||
unchanged source bytes and complete runtime cleanup. Windows requires the
|
||||
matching native addon; the POSIX provider path has not been exercised by the
|
||||
Windows run.
|
||||
|
||||
## Limitations
|
||||
|
||||
|
||||
@@ -72,9 +72,13 @@ debugger context is `observed`.
|
||||
|
||||
Initial Linux x64 limits: 32 MiB input, 64 MiB decoder reply, 1 MiB diagnostics per
|
||||
stage, 30 seconds owned wall deadline per stage, 3 GiB address-space soft ceiling
|
||||
and 30 seconds CPU soft ceiling. Tighter inherited soft/hard limits are retained;
|
||||
hard limits are not raised. Complete oversized results fail explicitly. These
|
||||
process limits do not constitute a sandbox for caller-supplied engine code.
|
||||
and 30 seconds CPU soft ceiling. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the input
|
||||
bound alone, up to 1 GiB, and returns `provider_unavailable` when it is not a
|
||||
decimal integer in range; the reply, diagnostic and deadline bounds stay fixed, so a
|
||||
large core that outruns them still fails explicitly instead of partially. Tighter
|
||||
inherited soft/hard limits are retained; hard limits are not raised. Complete
|
||||
oversized results fail explicitly. These process limits do not constitute a sandbox
|
||||
for caller-supplied engine code.
|
||||
|
||||
Resource exit statuses need matching private failure-branch markers. Unmarked
|
||||
launcher failures retain their actual status/output and unknown resource cause.
|
||||
|
||||
+12
-3
@@ -6,7 +6,10 @@ checks to pass. Merging that PR into main starts publication automatically.
|
||||
|
||||
The Release workflow validates the merged release metadata and ancestry before
|
||||
creating a tag. npm and MCP Registry publication use the exact reviewed merge
|
||||
SHA returned by Release Please. Later main commits cannot change that source.
|
||||
SHA returned by Release Please. Before either registry is published, the release
|
||||
run calls full CI qualification at that exact revision, including coverage,
|
||||
portable providers, and every native package host. Later main commits cannot
|
||||
change or cancel that proof.
|
||||
Ordinary main pushes and closed, unmerged PRs cannot publish packages. Check
|
||||
both publication jobs and the published-package canary; a merged PR or GitHub
|
||||
tag alone does not establish that publication finished. Retry failed jobs in
|
||||
@@ -196,8 +199,14 @@ both registry publishes.
|
||||
See [npm's provenance implementation](https://github.com/npm/cli/blob/v11.16.0/workspaces/libnpmpublish/lib/provenance.js)
|
||||
for the use of GitHub's workflow ref and commit SHA.
|
||||
|
||||
The workflow builds the bundled Windows controls and verifies the packaged
|
||||
artifact before npm publication. It then verifies the published CLI, the
|
||||
Full qualification builds the bundled Windows controls and verifies the packaged
|
||||
artifact on its native hosts. The Linux x64 verifier retains the exact tarball
|
||||
it installed and tested, after its workspace cleanup completes. The npm job
|
||||
downloads that archive from the same workflow run and checks its source SHA,
|
||||
run identity/attempt, package and Node versions, host identity, and SHA-256.
|
||||
It publishes that archive without rebuilding or repacking it. Re-running failed
|
||||
publisher jobs can reuse a prior successful attempt from the same run.
|
||||
The workflow then verifies the published CLI, the
|
||||
capability-scoped MCP catalog, and the isolated package update path before
|
||||
publishing MCP Registry metadata.
|
||||
|
||||
|
||||
@@ -106,3 +106,37 @@ One decode is admitted at a time, including acquisition and cleanup. Excess
|
||||
requests receive a retryable capacity error before acquiring another file or
|
||||
worker. Shutdown closes admission and drains accepted work; failed cleanup
|
||||
remains owned and retryable.
|
||||
|
||||
## Discover decoders before choosing settings
|
||||
|
||||
`inspect_signal_decoders` / `inspect-signal-decoders` queries the **installed**
|
||||
sigrok catalog without opening a recording or scanning devices:
|
||||
|
||||
```sh
|
||||
rea inspect-signal-decoders --query uart
|
||||
rea inspect-signal-decoders --decoder uart --query baud
|
||||
rea inspect-signal-decoders --decoder i2c --offset 0 --limit 50
|
||||
```
|
||||
|
||||
Omit `decoder` to search decoder IDs and names. Supply one exact decoder ID to
|
||||
read its literal help, including required/optional channels, options, annotation
|
||||
classes and documentation. `query` is a case-insensitive literal substring;
|
||||
matching help retains original one-based line numbers. Empty query selects all.
|
||||
`offset` skips matches; `limit` defaults to 50. MCP `limit: null` or CLI `--all`
|
||||
returns all remaining matches; CLI `--all` and `--limit` conflict.
|
||||
The window reports examined, matching and returned records and `has_more`.
|
||||
A filtered empty page is not evidence that the installation has no decoders.
|
||||
|
||||
Discovery refuses more than 65,535 line separators before constructing record
|
||||
objects (65,536-line representation budget), in addition to the 4 MiB output cap.
|
||||
Exceeding that budget is a resource failure with captured output. Returned text
|
||||
is copied so a retained page does not keep the complete producer output alive.
|
||||
|
||||
Discovery shares decoding's owned process, admission slot, resource limits,
|
||||
cleanup retries and Evidence delivery. It records the executable and limiter
|
||||
identities, version banner and warnings. Failed version checks and malformed
|
||||
producer output preserve diagnostics. Help is producer text, not an independently
|
||||
validated parameter schema. Each request probes again; it does not cache or claim
|
||||
stable pages across installation changes. Libraries/decoder modules remain
|
||||
trusted and are not fully fingerprinted. Catalog output includes a SHA-256 of
|
||||
its executable, not a digest of the complete installation.
|
||||
|
||||
+224
-35
@@ -263,17 +263,62 @@ CI owns coverage; aggregate and domain/contract thresholds are maintained in
|
||||
Coverage does not replace named boundary or real-provider scenarios.
|
||||
|
||||
CI runs six native Vitest shards without retries. Each shard emits a blob
|
||||
report; the merge job produces aggregate coverage plus JUnit and JSON timing
|
||||
report; the merge job produces JUnit and JSON diagnostics, a module timing
|
||||
report, plus aggregate coverage during full qualification. It retains available
|
||||
reports even when a shard fails, uploads available diagnostics, and writes the
|
||||
slowest files to the workflow summary. Partial reports are marked diagnostic
|
||||
only. Failed, cancelled, or missing selected shards still fail the required gate,
|
||||
and aggregate coverage thresholds remain enforced by the merge command.
|
||||
Only successful complete merges publish the small `test-timings` artifact, with
|
||||
and aggregate coverage thresholds remain enforced in full qualification.
|
||||
Only successful complete full-tier merges publish the small `test-timings` artifact, with
|
||||
checkout-relative paths; the build job downloads that artifact for the sequencer
|
||||
instead of the coverage/JUnit bundle. Missing history falls back to native
|
||||
instead of the coverage/JUnit bundle. Timing weights come from Vitest module
|
||||
diagnostics and include tests and hooks, worker preparation, environment,
|
||||
imports, and setup. Import timings already include transform waits, which are
|
||||
not added again. The weights describe file costs under the measured worker and
|
||||
coverage settings; their sum is not workflow elapsed time. The diagnostic
|
||||
artifact retains the phase breakdown, while shard history keeps only each
|
||||
file's total. Legacy test-body-only history and missing history fall back to native
|
||||
sharding. Static checks, documentation, build, package verification, and
|
||||
real-system lanes remain separate jobs so one kind of evidence cannot stand in
|
||||
for another.
|
||||
for another. Workflow syntax and expression validation runs inside the required
|
||||
static-check job when workflow changes select it, avoiding a separate runner.
|
||||
Native, provider, fixture, SQLite compatibility, and website lanes wait for
|
||||
static checks to succeed before allocating their runners. This avoids spending
|
||||
those runner slots on revisions that already fail static validation. Portable
|
||||
test shards and documentation checks remain independent of static results, so
|
||||
they can still provide diagnostic feedback. The required gate rejects skipped
|
||||
selected lanes when static checks fail; passing revisions retain all selected
|
||||
coverage. This ordering reduces peak demand on invalid revisions but can add
|
||||
static-check waiting to otherwise passing runs; it is not a measured reduction
|
||||
in end-to-end latency.
|
||||
|
||||
Core jobs have execution limits of five minutes for classification, ten minutes
|
||||
for static checks, builds, docs, and report merging, and thirty minutes per
|
||||
portable test shard. These limits leave headroom for cold setup and full coverage
|
||||
while preventing a hung job from holding the active main run for hours. They do
|
||||
not bound runner queue waiting. Timeouts still fail the required gate.
|
||||
|
||||
The separate `CI timings` workflow batches recent completed CI and Release runs
|
||||
hourly or on manual dispatch. It uploads `ci-job-timings-batch` with raw API pages and
|
||||
a normalized point-in-time report for each collected workflow attempt, plus a
|
||||
batch manifest describing examined coverage, failures, and limits. Overlapping
|
||||
lookback windows may collect an attempt again; identify records by run ID and
|
||||
attempt. Each batch examines at most 100 completed runs per workflow created in
|
||||
the preceding 24 hours, with a four-minute API collection budget. It collects the
|
||||
latest attempt of each selected run and does not claim an exhaustive archive.
|
||||
It runs the reporter from the trusted default branch with read-only permissions,
|
||||
treating workflow runs as API data. It does not allocate a new reporter job for
|
||||
every completed, cancelled, or skipped workflow. Reports separate queue
|
||||
waiting (`created_at` to `started_at`) from completed execution and report
|
||||
percentiles plus matched `npm ci` and artifact-step durations.
|
||||
Queue excludes dependency waiting before job creation and does not establish
|
||||
whether account concurrency or runner availability caused the wait. Queued jobs with
|
||||
placeholder start times, skipped jobs, and missing or invalid timestamps remain
|
||||
unknown. The reporter targets each selected run and attempt explicitly.
|
||||
Summed job elapsed time is parallel work, not workflow wall time, CPU time,
|
||||
memory usage, or billing. Step names identify samples, not complete transfer
|
||||
costs. Its queue, execution, and failures are independent of the required gate
|
||||
and publication. CPU time and peak process-family memory are not measured by this API.
|
||||
|
||||
The build job saves portable Turbo outputs across runs. Eight Linux real-system
|
||||
lanes restore this cache without waiting for the build job. The shared action
|
||||
@@ -637,7 +682,9 @@ use `--second-procedure rea_fixture_subtract --second-marker='- 13'`.
|
||||
The lane invokes the production CLI dispatcher and connects the pinned MCP
|
||||
client SDK to the production REA server. It verifies function Evidence and
|
||||
CLI/MCP parity, inventory/search, pseudocode, instructions, xrefs, malformed
|
||||
input, original-input preservation, and lifecycle cleanup. For headless
|
||||
input, original-input preservation, and lifecycle cleanup. The headless
|
||||
profile also checks containing procedures, the admitted program as the
|
||||
document, address context, and feature tracing. For headless
|
||||
analysis it confirms that the owned database IDs disappear from upstream
|
||||
discovery and private workspaces are removed. For attached analysis it confirms
|
||||
the existing GUI target remains reachable with the same input identity.
|
||||
@@ -687,6 +734,16 @@ truncation constraint, while leading-zero coordinates still resolve correctly.
|
||||
It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS
|
||||
runtime or PC-98 device execution.
|
||||
|
||||
`npm run verify:ghidra:6502` uses `ca65` and `ld65` to build the source-owned
|
||||
NMOS 6502 fixture from `tests/conformance/ghidra/6502.s` and `6502.cfg`. Set
|
||||
`REA_CA65` and `REA_LD65` for non-PATH installations (tested with cc65 `V2.19`).
|
||||
With an installed Ghidra 12.1.4 and full JDK, it verifies real CLI/stdio MCP
|
||||
mapping parity, exact source bytes, functions, a direct call reference,
|
||||
decompilation, 16-bit overflow rejection without replacing the active session,
|
||||
and runtime cleanup. Windows additionally requires the matching native addon;
|
||||
its verifier lineage remains explicitly unavailable outside the provider's
|
||||
retained Job Object, as in the other Windows lanes.
|
||||
|
||||
`npm run verify:ghidra:raw` uses a generated ARM little-endian headerless image
|
||||
through the compiled CLI and stdio MCP server. With the same Ghidra/JDK
|
||||
prerequisites, it checks preparation and bridge startup, verified load mappings,
|
||||
@@ -697,6 +754,44 @@ specifications, independent mappings, unchanged bytes, and decompilation of a
|
||||
known return value. AArch64 remains covered by deterministic tests rather than
|
||||
this real-provider lane. See [raw binary analysis](raw-binary.md).
|
||||
|
||||
`npm run verify:ghidra:address-context` runs on Linux with a caller-installed
|
||||
Ghidra, full JDK, and an x86-64 ELF GCC toolchain (`CC` selects another compatible
|
||||
compiler). It builds
|
||||
`tests/conformance/c/pie-address-context.c` as a PIE ELF without executing it.
|
||||
The compiled CLI and real stdio MCP workflow must expose the measured image
|
||||
base, retain segment address spaces, agree on the overview, resolve explicit and
|
||||
numeric file offsets, and reject unmapped analysis addresses with actionable
|
||||
guidance. It checks the reverse `address-to-file-offset` mapping, file-coordinate
|
||||
Evidence locations, and the suggested CLI search and load-image commands.
|
||||
The verifier compares mapped bytes with the source file and checks source
|
||||
identity, session preservation after failed lookup, owned runtime cleanup, and
|
||||
verified empty process lineage. This lane does not establish Windows or macOS
|
||||
real-provider coverage.
|
||||
|
||||
For source-identity boundaries that a normal single-file import cannot create,
|
||||
`tests/conformance/ghidra/ReaFileOffsetSourceProbe.java` exercises the production
|
||||
bridge against real Ghidra `FileBytes`. It rejects auxiliary and partial sources,
|
||||
checks the original target's round trip, and retains ambiguity when duplicate
|
||||
target bytes have distinct loaded mappings. Injected decompiler failures and
|
||||
cancellation verify that completed source/function resolution facts and backend
|
||||
failure codes survive; these injections do not establish natural engine failures.
|
||||
On Windows, after creating the
|
||||
source-owned PE fixture, run from the repository root:
|
||||
|
||||
```powershell
|
||||
node scripts/create-ghidra-windows-fixture.mjs
|
||||
$proofRoot = Join-Path $env:TEMP 'rea-file-offset-source-proof'
|
||||
New-Item -ItemType Directory -Path $proofRoot -Force | Out-Null
|
||||
Copy-Item bridge/ghidra/ReaGhidraBridge.java, bridge/ghidra/ReaGhidraExtensions.java, tests/conformance/ghidra/ReaFileOffsetSourceProbe.java -Destination $proofRoot
|
||||
& "$env:GHIDRA_INSTALL_DIR/support/analyzeHeadless.bat" $proofRoot source-proof -import "$PWD/build/fixtures/rea-ghidra-windows.exe" -scriptPath $proofRoot -postScript ReaFileOffsetSourceProbe.java -deleteProject
|
||||
```
|
||||
|
||||
Require the `REA_FILE_OFFSET_SOURCE_OK` marker and no script errors; Ghidra may
|
||||
exit successfully even when a post-script fails. The probe edits only its
|
||||
temporary analysis database and does not execute the fixture. This producer
|
||||
regression supplements public CLI/MCP coverage and does not establish Linux
|
||||
host behavior or native process-supervision controls.
|
||||
|
||||
## Apple Interface Builder archives
|
||||
|
||||
`npm run verify:interface-builder` compiles the source-owned AppKit XIB into a
|
||||
@@ -717,11 +812,32 @@ or the GitHub issue and pull-request templates run formatting and
|
||||
generated-document validation, including the small onboarding example consistency
|
||||
suite, without the source-test shards or native package
|
||||
lanes. Classification compares the PR head with its merge base, so later
|
||||
base-branch changes do not expand that scope. Static and coverage aggregate jobs
|
||||
base-branch changes do not expand that scope. Static and portable-test aggregate jobs
|
||||
remain present and fail if classification fails. Every code change retains the
|
||||
complete six-shard Linux suite; individual test files are not selected by
|
||||
imports or filenames.
|
||||
|
||||
Ordinary PRs also produce an **observational** test-selection plan. The candidate
|
||||
leaf-adapter ownership table lives in `scripts/ci/test-selection.mjs`, separately
|
||||
from provider-job scopes. It proposes keeping affected owners plus every unowned
|
||||
test, including all CLI/MCP, acceptance, composition, conformance, and process
|
||||
tests. Shared inputs, unknown paths, missing owner tests, and removed tests fall
|
||||
back to the complete inventory. Renames account for both paths. Main, scheduled,
|
||||
manual, and `ci:full` runs always propose the full suite.
|
||||
|
||||
All six shards still execute the complete suite. The existing report merge
|
||||
compares the proposal with actual module outcomes and retains
|
||||
`.cache/ci-test-selection/report.json` in `test-results`, including selected and
|
||||
omitted paths, changed input paths, missed failures, unobserved files, scheduling weights, PR head and
|
||||
executed merge SHA. This adds no test reruns or runner jobs. Missing, duplicate,
|
||||
skipped, interrupted, or unhandled results cannot establish a complete comparison;
|
||||
even a complete passing comparison is not proof of ownership completeness.
|
||||
Before enabling omissions, review representative full-run comparisons and
|
||||
cross-owner consumers, including failures, and measure completed CI feedback and
|
||||
runner demand. There is no automatic promotion or switch to skip tests. The
|
||||
initial proposal measures only leaf producer-test omissions; it does not promise
|
||||
the savings of a broader affected-test planner.
|
||||
|
||||
`scripts/ci/plan.mjs` selects lanes using the ownership table in
|
||||
`scripts/ci/scopes.mjs`. Provider implementation changes run their real-provider
|
||||
verifiers and the Linux x64 installed-package/Inspector baseline. The `apple`
|
||||
@@ -736,9 +852,19 @@ lane. Edits to verifier entrypoints run only by manual, licensed or self-hosted
|
||||
workflows run static checks and the Linux suite. Website changes run website
|
||||
checks. Provider workflow changes validate the workflow and run that provider's
|
||||
verifier. Manual-provider/Android workflow changes run workflow validation only.
|
||||
The four-version SQLite compatibility matrix has its own `sqlite` scope for
|
||||
SQLite implementation, contracts, CLI/MCP registration, fixtures, and shared
|
||||
runtime boundaries. SQLite and server-lifecycle test edits and the SQLite npm
|
||||
scripts also select it. Unrelated code edits retain the complete portable suite
|
||||
on the pinned Node version without starting four additional compatibility jobs.
|
||||
Full qualification retains every SQLite version.
|
||||
Shared process/contract/CLI boundaries, dependencies, other packaging inputs,
|
||||
unknown paths, and changes to the planner select the full baseline. Package
|
||||
script-only edits select their owning verifier when its entrypoint is
|
||||
unknown paths, and changes to the planner select the full baseline.
|
||||
CI timing collection, timing history, and test-report helper edits retain static
|
||||
checks and the complete portable suite without selecting unrelated host/provider
|
||||
lanes. Classification, qualification, gate, release receipt, and unknown CI
|
||||
scripts keep the broad fallback.
|
||||
Package script-only edits select their owning verifier when its entrypoint is
|
||||
identifiable; unknown or shared script changes select everything. Android
|
||||
build-script edits retain the Linux suite and workflow validation; real Android
|
||||
verification stays manual. Renames account for both paths.
|
||||
@@ -746,38 +872,84 @@ verification stays manual. Renames account for both paths.
|
||||
This narrower selection trades PR-time coverage for runner capacity. A change to
|
||||
unowned shared runtime code that fails only on arm64, macOS, Windows, the Apple
|
||||
artifact lane, or a provider lane that does not own the file is not caught on
|
||||
the PR; the main-push full baseline reports it after merge. Ownership globs do
|
||||
the PR; scheduled full qualification reports it after merge. Ownership globs do
|
||||
not follow imports. Use the `ci:full` label when a change may have native-host
|
||||
or provider effects outside its owners.
|
||||
|
||||
Main pushes and manual dispatch retain the full baseline, including all portable
|
||||
provider lanes and the native matrix below. A `ci:full` PR label selects the full
|
||||
baseline on the next PR run; manual dispatch can request it immediately. Each
|
||||
Ordinary main pushes use the same owner scopes as PRs. Their diff starts at a
|
||||
successful ancestor push run of this CI workflow, rather than the preceding
|
||||
push: changes from failed or cancelled intermediate runs remain in scope.
|
||||
Missing, untrusted, or non-ancestor history selects every lane. The lookup is
|
||||
bounded to the last 100 successful push runs; lookup failures broaden selection.
|
||||
Every code change still runs all six portable shards, without coverage
|
||||
instrumentation in this fast tier. This deliberately avoids relying on an
|
||||
import graph for generated files, filesystem inputs, subprocesses, or bridges.
|
||||
|
||||
Scheduled qualification is admitted hourly at minute 23. A forced full run at
|
||||
03:23 UTC refreshes runner and dependency drift every day. Other hourly runs
|
||||
can reuse a successful full-qualification receipt for the exact source SHA
|
||||
from the same repository's trusted CI workflow on its default branch. Both the
|
||||
receipt and original workflow run must be less than 24 hours old, so retrying an
|
||||
old gate cannot renew stale proof. Missing, expired, failed, foreign, or uncertain proof runs full
|
||||
validation; API lookup failure also runs full. Lookups examine at most 100 named
|
||||
receipts and three candidate runs within a 20-second budget. Reused runs select
|
||||
no validation lanes, identify the original run, and produce no new receipt, so
|
||||
reuse cannot indefinitely extend proof freshness. They make no fresh provider
|
||||
observation. Manual dispatch, `ci:full`, and exact release qualification always
|
||||
run full validation.
|
||||
|
||||
Full qualification includes aggregate coverage,
|
||||
every portable provider lane, and all native hosts below. Adding `ci:full`
|
||||
immediately starts the separate `Full PR qualification` workflow against the
|
||||
label event's merge revision; subsequent PR revisions also select the full tier
|
||||
in normal CI while that label remains. Unrelated labels do not start validation
|
||||
jobs or replace the main CI checks. Manual dispatch also requests full validation.
|
||||
Fast-tier broad fallbacks retain all owner lanes and the three-host
|
||||
package matrix; Intel macOS and coverage remain full-tier obligations. Each
|
||||
classification job records its selection and broad-fallback reasons in the run
|
||||
summary. Representative Git-diff cases live in
|
||||
`tests/boundary/filesystem/ciChangeScope.test.ts`.
|
||||
|
||||
`CI required` checks classification and every selected lane, rejecting failed,
|
||||
cancelled, or unexpectedly skipped work. Existing required check names are
|
||||
preserved; changing branch protection to require the new aggregate is a separate
|
||||
repository-settings operation. Newer commits cancel superseded CI for the same
|
||||
PR or main branch. Release publication has its own concurrency group and retains
|
||||
its package verification and public-registry canary. Licensed/self-hosted
|
||||
`CI required` always runs and checks classification and every selected lane,
|
||||
rejecting failed, cancelled, or unexpectedly skipped work. It rejects missing or
|
||||
unknown tiers and requires every canonical scope to be explicitly `true` or
|
||||
`false`; incomplete classification cannot silently omit verification lanes.
|
||||
Branch protection
|
||||
should require this aggregate, `Static checks`, `Portable tests`, and
|
||||
`Conventional PR title`. Title validation runs separately on PR opening,
|
||||
reopening, commits, and edits; correcting a title refreshes only this lightweight
|
||||
check. It reads title text through an environment variable, executes no PR code,
|
||||
and has no token permissions. Its separate concurrency group cannot cancel
|
||||
test runs. Fast PR runs cancel superseded feedback for the same PR. Main push
|
||||
runs keep one active run and the latest pending run for that branch.
|
||||
Scheduled/manual qualification has a separate concurrency group: active work
|
||||
finishes and only the latest pending run is retained. Release qualification uses
|
||||
its exact source SHA and cannot be cancelled by newer main pushes. Provider and
|
||||
website lanes inherit their caller's scheduling; reusable
|
||||
workflows have no independent cancellation group. Direct manual lane dispatches
|
||||
run independently on disposable hosted runners. Publication
|
||||
requires that qualification to succeed and consumes its exact verified tarball,
|
||||
checking source SHA, workflow run, run attempt, package/Node version and SHA-256
|
||||
before npm publication. Earlier attempts from the same workflow run are accepted
|
||||
when rerunning failed publisher jobs; future attempts and other runs are rejected.
|
||||
The public-registry canary still verifies the published package. Licensed/self-hosted
|
||||
provider workflows and the Android smoke test retain their explicit triggers.
|
||||
|
||||
CI exercises the pinned Node.js runtime on native hosted runners:
|
||||
|
||||
| Host | Runner | Baseline checks |
|
||||
| --------------------- | ------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| Linux x64 | `ubuntu-latest` | Installed package and real Node Inspector CLI/MCP |
|
||||
| Linux arm64 (aarch64) | `ubuntu-24.04-arm` | Installed package and real Node Inspector CLI/MCP |
|
||||
| macOS 15 arm64 | `macos-15` | Installed package and real Node Inspector CLI/MCP |
|
||||
| macOS 15 x64 | `macos-15-intel` | Installed package and real Node Inspector CLI/MCP (main, dispatch and `ci:full` only) |
|
||||
| Windows x64 | `windows-latest` | Curated capabilities, native controls, installed package and real Node Inspector CLI/MCP |
|
||||
| Host | Runner | Baseline checks |
|
||||
| --------------------- | ------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| Linux x64 | `ubuntu-latest` | Installed package and real Node Inspector CLI/MCP |
|
||||
| Linux arm64 (aarch64) | `ubuntu-24.04-arm` | Installed package and real Node Inspector CLI/MCP |
|
||||
| macOS 15 arm64 | `macos-15` | Installed package and real Node Inspector CLI/MCP |
|
||||
| macOS 15 x64 | `macos-15-intel` | Installed package and real Node Inspector CLI/MCP (hourly, dispatch, release and `ci:full`) |
|
||||
| Windows x64 | `windows-latest` | Curated capabilities, native controls, installed package and real Node Inspector CLI/MCP |
|
||||
|
||||
macOS x64 is the slowest package host. Pull requests without the `ci:full` label
|
||||
omit it, so darwin x64 installed-package and Inspector regressions surface on
|
||||
the main-push full baseline rather than on the PR.
|
||||
macOS x64 is the slowest package host. Ordinary PRs and main pushes omit it,
|
||||
so darwin x64 installed-package and Inspector regressions surface in full
|
||||
qualification. Schedule admission can be delayed by GitHub; the hourly trigger
|
||||
does not guarantee an hourly completed result. Release publication waits for
|
||||
its own complete result at the exact released revision.
|
||||
|
||||
Package lanes assert the actual Node platform/architecture before verification
|
||||
and record those values with the Node version. The package matrix runs up to
|
||||
@@ -803,17 +975,34 @@ The Linux source-test shards download a shared runtime and generated test
|
||||
metadata snapshot produced by the build job with `npm run test:prepare`. The
|
||||
snapshot includes `dist/`, generated skills, the MCP tool catalog, product
|
||||
catalog, and managed-conformance metadata. Each shard still installs the locked
|
||||
dependencies and runs its assigned coverage-enabled tests; it no longer
|
||||
dependencies and runs its assigned tests; full qualification enables coverage. It no longer
|
||||
regenerates the same metadata independently.
|
||||
|
||||
The build job also retrieves the merged timing report from the last successful
|
||||
default-branch push and includes that same report in each shard's snapshot.
|
||||
The build job also finds an available `test-timings` artifact from a successful
|
||||
trusted default-branch scheduled or manual qualification and includes that same
|
||||
report in each shard's snapshot. The bounded lookup verifies the artifact's
|
||||
producer; newer schedules that reuse qualification without producing timings do
|
||||
not hide the last full run's artifact.
|
||||
CI assigns discovered files by historical duration, with a deterministic
|
||||
fallback weight for new files, rather than dividing only by file count. Every
|
||||
discovered file still belongs to exactly one shard; worker limits, project
|
||||
ordering, coverage instrumentation, and aggregate thresholds remain unchanged.
|
||||
discovered file still belongs to exactly one shard; worker limits and project
|
||||
ordering remain unchanged. The merge job rejects successful reports with missing,
|
||||
unexpected, or duplicate portable files. Coverage instrumentation and aggregate
|
||||
thresholds remain enforced in full qualification.
|
||||
Missing or unusable history falls back to Vitest's default assignment. Only
|
||||
timing data is reused; persistent compile caches remain disabled.
|
||||
timing data is reused; live provider results are never cached. Turbo outputs are
|
||||
restored by task hash, with trusted default-branch runs as cache writers and PRs
|
||||
as restore-only consumers. npm caches hold downloads, not `node_modules`.
|
||||
|
||||
The eight automatic browser, binary-layout, EVM, JavaScript-recovery,
|
||||
recorded-crash, and web provider lanes download `rea-build-cache` from their own
|
||||
run after the build and static jobs succeed. Their existing build commands still
|
||||
validate task hashes and rebuild mismatched inputs; provider observations always
|
||||
run. Provider-workflow-only edits also admit the build without enabling source
|
||||
test shards. Direct provider dispatches retain the cross-run restore-only cache.
|
||||
This removes repeated compilation on fresh source revisions, but adds the build
|
||||
as a prerequisite: a delayed build can delay provider admission. Compare queue
|
||||
and execution times before claiming an end-to-end speedup.
|
||||
|
||||
These native baseline checks complement the Linux source-test shards and the
|
||||
separate Apple-artifact and real-provider lanes. Actual Hopper, Ghidra, IDA,
|
||||
|
||||
@@ -17,14 +17,16 @@ additional permission flag or degraded mode is required.
|
||||
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+;
|
||||
- an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4);
|
||||
- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases);
|
||||
- an explicit native, non-managed x86 or x86-64 PE application or DLL; and
|
||||
- an explicit native, non-managed x86 or x86-64 PE application/DLL, or a
|
||||
[raw NMOS 6502 image](raw-binary.md#nmos-6502) with a caller-supplied entry; and
|
||||
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
|
||||
|
||||
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
|
||||
PE load-image inspection returns an explicit unsupported attestation status.
|
||||
Independent verification covers admitted DOS MZ, explicit COM, and explicit
|
||||
[raw-binary imports](raw-binary.md) on their supported hosts. This does not
|
||||
extend Windows P0 admission beyond native PE. For x64 PE
|
||||
[raw-binary imports](raw-binary.md) on their supported hosts, including the
|
||||
admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited
|
||||
to native PE and raw NMOS 6502 images. For x64 PE
|
||||
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
|
||||
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
|
||||
database mutation. A real Windows-host NativeAOT Ghidra run is still required
|
||||
@@ -35,6 +37,12 @@ state, annotations, other target architectures and formats, and Hopper-only
|
||||
operations remain unavailable through this provider. Managed PE/CLI inspection
|
||||
uses its separate execution-free provider.
|
||||
|
||||
Raw 6502 imports retain the same native filesystem and process ownership
|
||||
requirements. The complete image must fit in the 16-bit address space; no
|
||||
console-specific cartridge loader, CPU variant, or bank-switching model is
|
||||
implied. Run `npm run verify:ghidra:6502` with caller-installed `ca65`/`ld65`
|
||||
to check the source-built fixture through real CLI and MCP workflows.
|
||||
|
||||
REA does not install or upgrade Ghidra, Java, Python, npm, Node.js, Hopper, or a
|
||||
compiler. The adapter uses the packaged Java `HeadlessScript`; Python and
|
||||
PyGhidra are not prerequisites. Users do not build the native addon.
|
||||
@@ -153,8 +161,12 @@ the private runtime, isolating Ghidra preferences, logs, and caches.
|
||||
Process creation assigns a suspended child atomically to an owned Job Object.
|
||||
Membership and kill-on-owner-close policy are verified before resume.
|
||||
Breakaway is disabled. Only the child's default file owner is normalized;
|
||||
the caller token stays unchanged. Caller CPU affinity and priority are
|
||||
preserved. Close, cancellation, timeout, startup failure, and provider failure
|
||||
the caller token stays unchanged. Caller priority is preserved, and caller CPU
|
||||
affinity whenever the caller and the child share a processor group. A child that
|
||||
Windows places in another group keeps that group's default when the caller asked
|
||||
for no bound of its own; a caller pinned to fewer processors than its group
|
||||
holds is refused there rather than widened.
|
||||
Close, cancellation, timeout, startup failure, and provider failure
|
||||
terminate the job and verify all members have settled before runtime cleanup.
|
||||
Cleanup removes admitted objects by handle and unlinks reparse entries without
|
||||
following their targets. Abrupt owner death kills the job but can leave
|
||||
|
||||
@@ -94,6 +94,9 @@ def main():
|
||||
if undeclared:
|
||||
raise ValueError('Declare inherited provider settings explicitly in --providers: '
|
||||
+ ', '.join(undeclared) + '. Values are not logged.')
|
||||
source_auth_home = (Path(os.environ['CODEX_HOME']) if 'CODEX_HOME' in os.environ
|
||||
else Path.home() / '.codex')
|
||||
source_auth = source_auth_home / 'auth.json'
|
||||
args.output.mkdir(parents=True, exist_ok=False, mode=0o700)
|
||||
runtime_files, runtime_digest = runtime_fingerprint(repository)
|
||||
(args.output / 'runtime-files.json').write_text(json.dumps(runtime_files))
|
||||
@@ -103,7 +106,6 @@ def main():
|
||||
workspace = profile / 'workspace'
|
||||
for directory in [trial_codex_home, account, workspace]:
|
||||
directory.mkdir(mode=0o700)
|
||||
source_auth = Path(os.environ.get('CODEX_HOME', str(Path.home() / '.codex'))) / 'auth.json'
|
||||
auth = trial_codex_home / 'auth.json'
|
||||
started = time.monotonic()
|
||||
timeout = False
|
||||
|
||||
@@ -71,7 +71,9 @@ class ContextCaptureTests(unittest.TestCase):
|
||||
self.assertNotIn(marker, text)
|
||||
self.assertIn('caller-selected evidence and paths', text)
|
||||
self.assertIn('original-response', text)
|
||||
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
|
||||
# Windows mode bits do not describe access control; this is a POSIX assertion.
|
||||
if os.name == 'posix':
|
||||
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_missing_rollouts_are_unknown_and_malformed_lines_remain_partial(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -124,6 +126,7 @@ class ContextCaptureTests(unittest.TestCase):
|
||||
patch.object(agent.subprocess, 'check_output', return_value='v24.18.0\n'), \
|
||||
patch.object(agent.subprocess, 'Popen', return_value=child), \
|
||||
patch.object(agent, 'capture_tool_context', side_effect=OSError('fixture capture failure')), \
|
||||
patch.object(agent.Path, 'home', side_effect=AssertionError('Explicit CODEX_HOME must not resolve the default home')), \
|
||||
patch('builtins.print'):
|
||||
with self.assertRaisesRegex(OSError, 'fixture capture failure'):
|
||||
agent.main()
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Windows reconciliation of recorded POSIX paths
|
||||
|
||||
Follow-up to [#2422](https://github.com/morluto/rea/pull/2422), including
|
||||
@Aurantium-47's diagnosis of the host-dependent relative-path operation.
|
||||
|
||||
## Source pins and real data
|
||||
|
||||
The initial reproduction and passive producer used main
|
||||
fa4b8288655188426222773ead2ccbdaf76334fd. Before branching, main was updated
|
||||
to 8b181716aca2cb467be499cba226abfb5ae19141. The owning matcher and its
|
||||
affected reconciliation consumers were unchanged between those revisions.
|
||||
The newer baseline again reproduced both errors on the real Windows host.
|
||||
|
||||
Input: npm 11.16.0's published lib/utils/validate-lockfile.js, 1,023 bytes,
|
||||
SHA-256 2295e2dac1c5816e1eae2d417804960ebbef17b64a0a44db4f3483ce6e6d9526.
|
||||
The installed copy was independently checked against the exact module in the
|
||||
[official registry package](https://registry.npmjs.org/npm/11.16.0).
|
||||
The 2,945,850-byte tarball matched its published SHA-512 SRI before reading
|
||||
that member; no archive extraction or installation occurred, and the tarball
|
||||
was not retained.
|
||||
|
||||
Two actual Linux filesystem directories, Runtime/App and Runtime/app,
|
||||
held byte-identical module copies and had distinct directory identities.
|
||||
A controlled Node 24.18.0 process loaded the public module and ran its real
|
||||
function against 587 locked dependency records: compatible trees returned zero
|
||||
errors, and one controlled version mismatch returned one error. A passive REA
|
||||
Inspector capture recorded 114 loaded scripts, including the selected module.
|
||||
The provider did not evaluate, pause, resume or read source. The owned process
|
||||
exited normally. Function execution is established by the harness receipt;
|
||||
REA's passive script metadata establishes loaded status.
|
||||
|
||||
Production static analysis inspected both directories plus an independent
|
||||
Static/public-module copy. The latter prevents implicit root matching from
|
||||
hiding the explicit mapping result. Complete static/runtime Evidence was then
|
||||
consumed by the actual **Windows** CLI and SDK 2.3.1 stdio MCP server, using
|
||||
Windows 11 10.0.22631.0 and official Windows x64 Node 24.18.0.
|
||||
No Electron GUI, IDA or Ghidra engine was used.
|
||||
|
||||
## Correctness and provenance
|
||||
|
||||
| Windows reconciliation | Baseline | Candidate |
|
||||
| -------------------------------------- | ------------------------------ | ----------------------------------- |
|
||||
| Exact POSIX input root | Matched, artifact path | Same complete normalized result |
|
||||
| Case-mismatched POSIX input root | Incorrectly matched and loaded | Unknown; static load states unknown |
|
||||
| Exact explicit POSIX mapping | Matched, operator file mapping | Same complete normalized result |
|
||||
| Case-mismatched explicit POSIX mapping | Incorrectly matched and loaded | Unknown; static load states unknown |
|
||||
|
||||
All four CLI and same-connection MCP normalized results agree. The input
|
||||
Evidence, recorded runtime facts and caller-selected root spellings are
|
||||
preserved. Rejected location membership does not establish that a module was
|
||||
absent or unexecuted. The operation remains offline and performs no target or
|
||||
filesystem inspection.
|
||||
|
||||
The shared matcher now chooses posix.relative after identifying POSIX input;
|
||||
Windows coordinates continue to use win32.relative. Both location matching
|
||||
and active-layer scope use this owner. The obsolete host-default import is
|
||||
removed; no downstream repair or new path abstraction is introduced.
|
||||
|
||||
The related suite passed 102 tests: 15 workflow tests, 85 CI scope tests and
|
||||
two curated-path checks. Four simulated Electron Evidence controls cover
|
||||
POSIX mismatch/exact matching, Windows case-insensitive matching, the resulting
|
||||
asset load state and retained mapping spelling. They are service regressions,
|
||||
not real Electron-provider verification. The existing workflow test file is
|
||||
registered in both the Windows execution lane and its package-change scope.
|
||||
|
||||
## Delivery and resource observations
|
||||
|
||||
Probes ran serially at BelowNormal priority with one CPU and 512 MiB Node heap
|
||||
settings for CLI/server/client work. Heap settings do not bound aggregate memory.
|
||||
Four corrected CLI exit-time observations reported 7.343–15.827 seconds CPU and
|
||||
322,308–324,280 KiB peak working set, excluding the probe parent. Wall times
|
||||
ranged from 7.785 to 17.601 seconds. There is no paired resource baseline.
|
||||
|
||||
One complete MCP trial connected in 18,296 ms and discovered 189 tools in
|
||||
875 ms (2,588,336 serialized bytes). The four subsequent same-connection calls
|
||||
took 879, 320, 353 and 422 ms. Their serialized replies were 918,133 / 913,765 /
|
||||
918,571 / 914,171 bytes; inputs totalled 681,312 bytes. Discovery plus these
|
||||
inputs and replies totalled 6,934,288 bytes, excluding initialization, framing
|
||||
and notifications. This is delivery volume, not model token usage. Actual
|
||||
model context and total model task cost were not measured.
|
||||
|
||||
A process-generation sampler recorded 233 observations over 25,671 ms:
|
||||
15,797 ms aggregate observed CPU as a lower bound and a peak sampled working-set
|
||||
sum of 491,597,824 bytes (about 469 MiB). Exited-process tails can be missed;
|
||||
shared pages can be counted twice. This excludes the observer and is not an
|
||||
exact physical-memory peak. The server wrote four structured completion records
|
||||
to stderr; tool replies and normalized results passed the recorded checks.
|
||||
|
||||
Cold/warm caches and observer differences were not normalized. No latency,
|
||||
CPU, memory or model-token improvement is claimed. The fix adds no lookup I/O,
|
||||
engine invocation, retry or cache. Complete default replies remain available;
|
||||
only compact selected findings and hashes were sent to the investigating agent.
|
||||
|
||||
## Main integration replay
|
||||
|
||||
Runtime cb0d8a9fef2b907cff2ba27f1e017493e1173eb2, rebased onto main
|
||||
73dd8d6ed, again passed the 101 related tests and all four real Windows CLI/MCP
|
||||
cases using the unchanged older producer Evidence. Positive results and original
|
||||
runtime/layer facts remain identical. Typecheck, lint, dependency and metadata
|
||||
checks passed; all five changed files passed formatting independently.
|
||||
The full format gate at that main revision failed on the unchanged upstream
|
||||
GhidraDoctor.test.ts and GhidraSnapshotCache.test.ts files. The PR does not
|
||||
repair those unrelated baseline files. Resource figures above refer to the
|
||||
earlier candidate; the integration replay does not establish a speedup.
|
||||
|
||||
Review found that owning only the Windows test path would still skip that lane
|
||||
on a later implementation-only edit. The matcher implementation is now also
|
||||
registered in package scope. A source-only classifier case verifies that it
|
||||
selects both Windows package and JavaScript recovery lanes; 85 scope checks and
|
||||
two curated-path checks pass. Runtime code and the real-data checks are unchanged.
|
||||
|
||||
The original production fix merged as
|
||||
[1a1b67c88](https://github.com/morluto/rea/commit/1a1b67c88) while this follow-up
|
||||
was open. The follow-up was then rebased onto main fb28ec153 and contains only
|
||||
the stronger workflow assertions, Windows lane ownership, scope classification
|
||||
and this verification record. The updated 131 related tests, two representative
|
||||
Ghidra boundary tests, seven IDA input-thinning tests, 24 semantic-structure
|
||||
tests, 16 required-lane checks, 39 release-checkpoint tests and 11 dyld CLI
|
||||
tests passed after the rebase (230 total).
|
||||
Upstream a0317cf73 corrected the invalid FAT fixture and consolidated unrelated
|
||||
duplicated helpers; e05d3399f aligned the older parameter-provenance assertion
|
||||
with the upstream semantics. Those changes, the threshold change and the new
|
||||
CI qualification tiers are part of main, outside this follow-up's diff.
|
||||
Upstream 747e72991 restored release preflight coverage for reusable workflow
|
||||
jobs. The independently reviewed, fully green #2755 fixture correction merged
|
||||
as fb28ec153 and restored real x64 dyld CLI admission without altering the
|
||||
original same-named arm64e assertions.
|
||||
No production source is duplicated by the follow-up.
|
||||
@@ -69,10 +69,22 @@ without this lane reports Windows native controls as unavailable.
|
||||
limits only for its JavaScript helpers when running on Bun, which ignores
|
||||
their V8 heap flags; Node.js launches are unchanged.
|
||||
- Only the suspended child's default file owner is normalized. Caller token
|
||||
privileges and ownership remain unchanged. Caller CPU affinity and process
|
||||
priority are preserved in the child. Environment names are sorted and
|
||||
deduplicated using Windows ordinal comparison, preserving their native Unicode
|
||||
semantics and caller-selected values.
|
||||
privileges and ownership remain unchanged. Caller process priority is
|
||||
preserved in the child, and caller CPU affinity whenever both occupy the same
|
||||
processor group. Windows chooses a new process's group itself and rejects a
|
||||
mask belonging to another group, so a child placed elsewhere keeps that
|
||||
group's default when the caller asked for no bound of its own. A caller
|
||||
deliberately pinned to fewer processors than its group holds is a constraint
|
||||
such a child cannot express, and is refused rather than widened to that
|
||||
group's default. A mask covering its group's whole active set does not
|
||||
distinguish a caller Windows placed in that group from one that selected the
|
||||
group deliberately, so such a child can still be placed elsewhere: its
|
||||
processor count stays bounded, its group placement is not preserved, and
|
||||
neither that case nor the refusal above has a verification lane, because both
|
||||
need a host whose processors span more than one group.
|
||||
Environment names are sorted and deduplicated using Windows
|
||||
ordinal comparison, preserving their native Unicode semantics and
|
||||
caller-selected values.
|
||||
- Cleanup walks locked directory objects, removes files by handle, and unlinks
|
||||
reparse entries without traversing their targets. Failed removal retains the
|
||||
root and ancestor leases for retry; a partially removed runtime accepts only
|
||||
|
||||
@@ -68,6 +68,18 @@ static ULONGLONG jobMemoryLimit(napi_env env, napi_value value) {
|
||||
return limit;
|
||||
}
|
||||
|
||||
// An affinity mask is meaningful only inside one processor group, so only a
|
||||
// process occupying exactly one group has a group a child can inherit. A
|
||||
// process whose threads span groups, or whose membership cannot be read, has no
|
||||
// single group.
|
||||
static bool singleProcessorGroup(HANDLE process, USHORT& group) {
|
||||
USHORT count = 1;
|
||||
std::array<USHORT, 1> groups{};
|
||||
if (!GetProcessGroupAffinity(process, &count, groups.data()) || count != 1) return false;
|
||||
group = groups[0];
|
||||
return true;
|
||||
}
|
||||
|
||||
static void pipe(Handle& read, Handle& write, bool parentReads = true) {
|
||||
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
|
||||
HANDLE rawRead, rawWrite;
|
||||
@@ -168,11 +180,27 @@ std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::ws
|
||||
TOKEN_OWNER owner{reinterpret_cast<TOKEN_USER*>(tokenUser.data())->User.Sid};
|
||||
require(SetTokenInformation(token.get(), TokenOwner, &owner, sizeof(owner)),
|
||||
"Set suspended process file owner failed", command);
|
||||
// A mask names processors inside one processor group, and Windows assigns a
|
||||
// new process to a group of its own choosing, so a mask belonging to another
|
||||
// group is rejected (ERROR_INVALID_PARAMETER). An unconstrained caller, whose
|
||||
// mask is its group's whole active set, asks for no bound the child's own
|
||||
// group cannot give, so such a child keeps that group's default and the spawn
|
||||
// proceeds. A narrower mask is a deliberate CPU constraint that a child in
|
||||
// another group cannot express, and silently widening it would hand the
|
||||
// provider tree the whole group, so that spawn fails naming the constraint.
|
||||
DWORD_PTR affinity = 0, systemAffinity = 0;
|
||||
require(GetProcessAffinityMask(GetCurrentProcess(), &affinity, &systemAffinity),
|
||||
"Read caller process affinity failed", command);
|
||||
if (affinity != 0)
|
||||
USHORT callerGroup = 0, childGroup = 0;
|
||||
const bool sharedGroup = singleProcessorGroup(GetCurrentProcess(), callerGroup) &&
|
||||
singleProcessorGroup(result->process.get(), childGroup) &&
|
||||
callerGroup == childGroup;
|
||||
if (affinity != 0 && sharedGroup)
|
||||
require(SetProcessAffinityMask(result->process.get(), affinity), "Preserve caller process affinity failed", command);
|
||||
else
|
||||
require(affinity == 0 || affinity == systemAffinity,
|
||||
"Caller CPU affinity cannot bound an owned process in another processor group", command,
|
||||
ERROR_INVALID_PARAMETER);
|
||||
const DWORD priority = GetPriorityClass(GetCurrentProcess());
|
||||
require(priority != 0 && SetPriorityClass(result->process.get(), priority),
|
||||
"Preserve caller process priority failed", command);
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
|
||||
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
|
||||
"verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs",
|
||||
"verify:ghidra:6502": "npm run build:cached && node scripts/verify-real-ghidra-6502.mjs",
|
||||
"verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs",
|
||||
"verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs",
|
||||
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
|
||||
@@ -182,6 +183,7 @@
|
||||
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
|
||||
"verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs",
|
||||
"verify:ghidra:raw": "npm run build:cached && node scripts/verify-real-ghidra-raw.mjs",
|
||||
"verify:ghidra:address-context": "npm run build:cached && node scripts/verify-real-ghidra-address-context.mjs",
|
||||
"verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table",
|
||||
"verify:interface-builder": "npm run build:cached && node scripts/verify/apple/interface-builder.mjs",
|
||||
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
|
||||
const repository = process.env.CI_REPOSITORY;
|
||||
const branch = process.env.CI_DEFAULT_BRANCH;
|
||||
const head = process.env.HEAD_SHA;
|
||||
const shaPattern = /^[0-9a-f]{40}$/i;
|
||||
|
||||
function ancestor(base) {
|
||||
try {
|
||||
execFileSync("git", ["merge-base", "--is-ancestor", base, head], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function write(base, reason) {
|
||||
const output = { base, reason };
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`base=${base}\nreason=${reason.replace(/[\r\n]/g, " ")}\n`,
|
||||
);
|
||||
}
|
||||
console.log(JSON.stringify(output));
|
||||
}
|
||||
|
||||
if (!repository || !branch || !head || !shaPattern.test(head)) {
|
||||
write("", "Repository, default branch, or checked-out head is invalid");
|
||||
} else {
|
||||
try {
|
||||
const response = execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
"--method",
|
||||
"GET",
|
||||
`repos/${repository}/actions/workflows/ci.yml/runs`,
|
||||
"-f",
|
||||
`branch=${branch}`,
|
||||
"-f",
|
||||
"event=push",
|
||||
"-f",
|
||||
"status=success",
|
||||
"-F",
|
||||
"per_page=100",
|
||||
],
|
||||
{ encoding: "utf8", timeout: 15_000, maxBuffer: 4 * 1024 * 1024 },
|
||||
);
|
||||
const runs = JSON.parse(response).workflow_runs;
|
||||
if (!Array.isArray(runs))
|
||||
throw new Error("GitHub returned no workflow run list");
|
||||
const base = runs.find(
|
||||
(run) =>
|
||||
run?.event === "push" &&
|
||||
run?.conclusion === "success" &&
|
||||
run?.head_branch === branch &&
|
||||
run?.head_repository?.full_name === repository &&
|
||||
typeof run?.head_sha === "string" &&
|
||||
shaPattern.test(run.head_sha) &&
|
||||
run.head_sha.toLowerCase() !== head.toLowerCase() &&
|
||||
ancestor(run.head_sha),
|
||||
)?.head_sha;
|
||||
if (base) write(base, "");
|
||||
else
|
||||
write(
|
||||
"",
|
||||
"No trusted successful CI push baseline is an ancestor of this head",
|
||||
);
|
||||
} catch {
|
||||
write("", "Could not read a successful CI push baseline from GitHub");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { appendFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { collectJobTimings } from "./job-timings.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const OUTPUT_DIRECTORY = join(".cache", "ci-job-timings");
|
||||
const MAX_BUFFER = 16 * 1024 * 1024;
|
||||
const API_TIMEOUT_MS = 30_000;
|
||||
const WORKFLOWS = [
|
||||
{ id: "ci.yml", name: "CI" },
|
||||
{ id: "release.yml", name: "Release" },
|
||||
];
|
||||
const LOOKBACK_MS = 24 * 60 * 60 * 1_000;
|
||||
const MAX_RUNS_PER_WORKFLOW = 100;
|
||||
const TIME_BUDGET_MS = 240_000;
|
||||
|
||||
/** Collect a bounded recent sample of completed CI and Release attempts. */
|
||||
export async function collectJobTimingsBatch({
|
||||
repository,
|
||||
outputDirectory = OUTPUT_DIRECTORY,
|
||||
observedAt = new Date().toISOString(),
|
||||
env = process.env,
|
||||
now = Date.now,
|
||||
collect = collectJobTimings,
|
||||
} = {}) {
|
||||
const identity = validateRepository(repository);
|
||||
const started = now();
|
||||
const cutoff = new Date(started - LOOKBACK_MS).toISOString();
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
observedAt,
|
||||
repository: identity,
|
||||
selection: {
|
||||
workflows: WORKFLOWS,
|
||||
status: "completed",
|
||||
createdAfter: cutoff,
|
||||
lookbackHours: 24,
|
||||
overlap: "Each hourly run re-reads the preceding 24 hours.",
|
||||
pagination:
|
||||
"Run listings use the first 100; jobs are paginated within the remaining time and 16 MiB response limits.",
|
||||
maxRunsPerWorkflow: MAX_RUNS_PER_WORKFLOW,
|
||||
timeBudgetMs: TIME_BUDGET_MS,
|
||||
exhaustive: false,
|
||||
},
|
||||
coverage: {
|
||||
workflowsQueried: [],
|
||||
runsListed: 0,
|
||||
selectedRuns: 0,
|
||||
collectedRuns: 0,
|
||||
},
|
||||
truncation: [],
|
||||
errors: [],
|
||||
runs: [],
|
||||
elapsedMs: 0,
|
||||
};
|
||||
const budgetExpired = () => now() - started >= TIME_BUDGET_MS;
|
||||
|
||||
const selected = [];
|
||||
for (const workflow of WORKFLOWS) {
|
||||
if (budgetExpired()) {
|
||||
manifest.truncation.push("Time budget reached while listing workflows.");
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const response = await apiJson(
|
||||
`repos/${identity}/actions/workflows/${workflow.id}/runs?status=completed&created=%3E%3D${encodeURIComponent(cutoff)}&per_page=${String(MAX_RUNS_PER_WORKFLOW)}&page=1`,
|
||||
env,
|
||||
started,
|
||||
now,
|
||||
);
|
||||
if (!Array.isArray(response.workflow_runs))
|
||||
throw new Error("run listing must contain a workflow_runs array");
|
||||
manifest.coverage.workflowsQueried.push(workflow.name);
|
||||
manifest.coverage.runsListed += response.workflow_runs.length;
|
||||
if (response.workflow_runs.length >= MAX_RUNS_PER_WORKFLOW)
|
||||
manifest.truncation.push(
|
||||
`${workflow.name} run listing reached the 100-run first-page limit.`,
|
||||
);
|
||||
for (const run of response.workflow_runs.slice(
|
||||
0,
|
||||
MAX_RUNS_PER_WORKFLOW,
|
||||
)) {
|
||||
if (
|
||||
run.status !== "completed" ||
|
||||
!Number.isSafeInteger(run.id) ||
|
||||
!Number.isSafeInteger(run.run_attempt)
|
||||
) {
|
||||
manifest.errors.push({
|
||||
scope: `workflow:${workflow.name}`,
|
||||
runId: run.id ?? null,
|
||||
message:
|
||||
"Run record missing completed status, numeric id, or numeric latest attempt.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (run.created_at && Date.parse(run.created_at) < Date.parse(cutoff))
|
||||
continue;
|
||||
selected.push({
|
||||
id: run.id,
|
||||
runAttempt: run.run_attempt,
|
||||
workflow: workflow.name,
|
||||
conclusion: run.conclusion ?? null,
|
||||
createdAt: run.created_at ?? null,
|
||||
});
|
||||
}
|
||||
} catch (cause) {
|
||||
manifest.errors.push({
|
||||
scope: `workflow:${workflow.name}`,
|
||||
message: errorMessage(cause),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
selected.sort(
|
||||
(left, right) =>
|
||||
(Date.parse(right.createdAt ?? "") || 0) -
|
||||
(Date.parse(left.createdAt ?? "") || 0),
|
||||
);
|
||||
manifest.coverage.selectedRuns = selected.length;
|
||||
for (const run of selected) {
|
||||
if (budgetExpired()) {
|
||||
manifest.truncation.push(
|
||||
"Time budget reached before all selected runs were collected.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
const runRecord = {
|
||||
...run,
|
||||
status: "pending",
|
||||
rawPath: null,
|
||||
reportPath: null,
|
||||
error: null,
|
||||
};
|
||||
manifest.runs.push(runRecord);
|
||||
const runDirectory = join(
|
||||
outputDirectory,
|
||||
"runs",
|
||||
`${String(run.id)}-${String(run.runAttempt)}`,
|
||||
);
|
||||
try {
|
||||
const result = await collect({
|
||||
repository: identity,
|
||||
runId: String(run.id),
|
||||
runAttempt: String(run.runAttempt),
|
||||
outputDirectory: runDirectory,
|
||||
env,
|
||||
timeoutMs: Math.max(
|
||||
1,
|
||||
Math.min(API_TIMEOUT_MS, TIME_BUDGET_MS - (now() - started)),
|
||||
),
|
||||
});
|
||||
runRecord.status = "collected";
|
||||
runRecord.rawPath = result.rawPath;
|
||||
runRecord.reportPath = result.reportPath;
|
||||
manifest.coverage.collectedRuns += 1;
|
||||
} catch (cause) {
|
||||
runRecord.status = "error";
|
||||
runRecord.error = errorMessage(cause);
|
||||
manifest.errors.push({
|
||||
scope: "run",
|
||||
runId: run.id,
|
||||
runAttempt: run.runAttempt,
|
||||
message: runRecord.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (budgetExpired() && manifest.runs.length < manifest.coverage.selectedRuns)
|
||||
manifest.truncation.push(
|
||||
"Time budget reached; selected runs remain unattempted.",
|
||||
);
|
||||
return await writeManifest(outputDirectory, manifest, started, now, env);
|
||||
}
|
||||
|
||||
function validateRepository(repository) {
|
||||
if (
|
||||
typeof repository !== "string" ||
|
||||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
|
||||
)
|
||||
throw new Error(
|
||||
"CI_REPOSITORY must be an owner/name repository identifier",
|
||||
);
|
||||
return repository;
|
||||
}
|
||||
|
||||
async function apiJson(endpoint, env, started, now) {
|
||||
const timeout = Math.max(
|
||||
1,
|
||||
Math.min(API_TIMEOUT_MS, TIME_BUDGET_MS - (now() - started)),
|
||||
);
|
||||
const { stdout } = await execFileAsync("gh", ["api", endpoint], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
maxBuffer: MAX_BUFFER,
|
||||
timeout,
|
||||
});
|
||||
try {
|
||||
return JSON.parse(stdout);
|
||||
} catch (cause) {
|
||||
throw new Error("GitHub Actions API returned malformed JSON", { cause });
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManifest(outputDirectory, manifest, started, now, env) {
|
||||
manifest.elapsedMs = Math.max(0, now() - started);
|
||||
if (
|
||||
manifest.elapsedMs >= TIME_BUDGET_MS &&
|
||||
!manifest.truncation.some((item) => item.includes("Time budget"))
|
||||
)
|
||||
manifest.truncation.push("Time budget reached.");
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const manifestPath = join(outputDirectory, "batch-manifest.json");
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
if (env.GITHUB_STEP_SUMMARY)
|
||||
await appendBatchSummary(env.GITHUB_STEP_SUMMARY, manifest, manifestPath);
|
||||
return { manifestPath, manifest };
|
||||
}
|
||||
|
||||
async function appendBatchSummary(summaryPath, manifest, manifestPath) {
|
||||
const collected = manifest.runs.filter(
|
||||
({ status }) => status === "collected",
|
||||
).length;
|
||||
const failed = manifest.runs.filter(
|
||||
({ status }) => status === "error",
|
||||
).length;
|
||||
const lines = [
|
||||
"### Batched CI timing snapshot",
|
||||
"",
|
||||
`Observed ${manifest.observedAt}; collected ${String(collected)} of ${String(manifest.coverage.selectedRuns)} selected recent completed runs (${String(failed)} run errors).`,
|
||||
`Coverage uses the first ${String(MAX_RUNS_PER_WORKFLOW)} runs per workflow created in the preceding 24 hours and a ${String(TIME_BUDGET_MS / 1_000)} second total budget. This sample is bounded and is not exhaustive.`,
|
||||
`Manifest: \`${manifestPath}\`.`,
|
||||
...manifest.truncation.map((message) => `- Truncated: ${message}`),
|
||||
...manifest.errors.map(
|
||||
({ scope, message }) => `- Error (${scope}): ${message}`,
|
||||
),
|
||||
"",
|
||||
];
|
||||
await appendFile(summaryPath, lines.join("\n"), "utf8");
|
||||
}
|
||||
|
||||
function errorMessage(cause) {
|
||||
return cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] !== undefined &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
try {
|
||||
const result = await collectJobTimingsBatch({
|
||||
repository: process.env.CI_REPOSITORY,
|
||||
outputDirectory: process.env.CI_JOB_TIMINGS_DIRECTORY ?? OUTPUT_DIRECTORY,
|
||||
});
|
||||
process.stdout.write(
|
||||
`Wrote batch manifest for ${String(result.manifest.coverage.collectedRuns)} collected runs to ${result.manifestPath}\n`,
|
||||
);
|
||||
} catch (cause) {
|
||||
process.stderr.write(`CI timing batch failed: ${errorMessage(cause)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { execFile } from "node:child_process";
|
||||
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";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_BUFFER = 16 * 1024 * 1024;
|
||||
const TIMEOUT_MS = 30_000;
|
||||
const OUTPUT_DIRECTORY = join(".cache", "ci-job-timings");
|
||||
const STEP_COSTS = [
|
||||
{ key: "npmCi", pattern: /\bnpm ci\b/iu },
|
||||
{ key: "artifactUpload", pattern: /upload.*artifact/iu },
|
||||
{ key: "artifactDownload", pattern: /download.*artifact/iu },
|
||||
];
|
||||
|
||||
/** Fetch and summarize one workflow attempt without turning telemetry into a gate. */
|
||||
export async function collectJobTimings({
|
||||
repository,
|
||||
runId,
|
||||
runAttempt,
|
||||
outputDirectory = OUTPUT_DIRECTORY,
|
||||
summaryPath,
|
||||
observedAt = new Date().toISOString(),
|
||||
env = process.env,
|
||||
timeoutMs = TIMEOUT_MS,
|
||||
}) {
|
||||
const identity = validateIdentity({ repository, runId, runAttempt });
|
||||
const endpoint = `repos/${identity.repository}/actions/runs/${identity.runId}/attempts/${identity.runAttempt}/jobs?per_page=100`;
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
["api", "--paginate", "--slurp", endpoint],
|
||||
{ env, encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: timeoutMs },
|
||||
);
|
||||
const rawPath = join(outputDirectory, "jobs.json");
|
||||
const reportPath = join(outputDirectory, "report.json");
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
await writeFile(rawPath, stdout, "utf8");
|
||||
const pages = parsePages(stdout);
|
||||
const jobs = pages.flatMap(({ jobs: pageJobs }, index) =>
|
||||
validateJobs(pageJobs, index),
|
||||
);
|
||||
const report = normalizeReport({ identity, jobs, observedAt });
|
||||
|
||||
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
if (summaryPath !== undefined)
|
||||
await appendSummary(summaryPath, report, rawPath, reportPath);
|
||||
return { rawPath, reportPath, report };
|
||||
}
|
||||
|
||||
function validateIdentity({ repository, runId, runAttempt }) {
|
||||
if (
|
||||
typeof repository !== "string" ||
|
||||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
|
||||
)
|
||||
throw new Error(
|
||||
"CI_REPOSITORY must be an owner/name repository identifier",
|
||||
);
|
||||
return {
|
||||
repository,
|
||||
runId: parsePositiveInteger(runId, "CI_TIMING_RUN_ID"),
|
||||
runAttempt: parsePositiveInteger(runAttempt, "CI_TIMING_RUN_ATTEMPT"),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, name) {
|
||||
if (typeof value === "string" && /^[1-9]\d*$/u.test(value)) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) return parsed;
|
||||
}
|
||||
throw new Error(`${name} must be a positive safe integer`);
|
||||
}
|
||||
|
||||
function parsePages(stdout) {
|
||||
let pages;
|
||||
try {
|
||||
pages = JSON.parse(stdout);
|
||||
} catch (cause) {
|
||||
throw new Error("GitHub jobs API returned malformed JSON", { cause });
|
||||
}
|
||||
if (!Array.isArray(pages) || pages.length === 0)
|
||||
throw new Error("GitHub jobs API slurp must contain a nonempty page array");
|
||||
for (const page of pages)
|
||||
if (
|
||||
typeof page !== "object" ||
|
||||
page === null ||
|
||||
Array.isArray(page) ||
|
||||
!Array.isArray(page.jobs)
|
||||
)
|
||||
throw new Error("GitHub jobs API page must contain a jobs array");
|
||||
return pages;
|
||||
}
|
||||
|
||||
function validateJobs(jobs, pageIndex) {
|
||||
return jobs.map((job, jobIndex) => {
|
||||
if (
|
||||
typeof job !== "object" ||
|
||||
job === null ||
|
||||
Array.isArray(job) ||
|
||||
typeof job.name !== "string" ||
|
||||
job.name.length === 0 ||
|
||||
typeof job.status !== "string"
|
||||
)
|
||||
throw new Error(
|
||||
`GitHub jobs API page ${String(pageIndex + 1)} job ${String(jobIndex + 1)} is malformed`,
|
||||
);
|
||||
return job;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeReport({ identity, jobs, observedAt }) {
|
||||
const normalizedJobs = jobs.map(normalizeJob);
|
||||
const queues = normalizedJobs
|
||||
.map(({ queue }) => queue.seconds)
|
||||
.filter((value) => value !== null);
|
||||
const completedExecutions = normalizedJobs
|
||||
.filter(
|
||||
({ status, conclusion }) =>
|
||||
status === "completed" && conclusion !== "skipped",
|
||||
)
|
||||
.map(({ execution }) => execution.seconds)
|
||||
.filter((value) => value !== null);
|
||||
const completedDurationSumSeconds = completedExecutions.reduce(
|
||||
(sum, seconds) => sum + seconds,
|
||||
0,
|
||||
);
|
||||
const completedExecutionUnknown = normalizedJobs.filter(
|
||||
({ status, conclusion, execution }) =>
|
||||
status === "completed" &&
|
||||
conclusion !== "skipped" &&
|
||||
execution.seconds === null,
|
||||
).length;
|
||||
const skipped = normalizedJobs.filter(
|
||||
({ conclusion }) => conclusion === "skipped",
|
||||
).length;
|
||||
const costs = Object.fromEntries(STEP_COSTS.map(({ key }) => [key, []]));
|
||||
for (const job of normalizedJobs)
|
||||
for (const step of job.steps)
|
||||
if (step.costKey !== null)
|
||||
costs[step.costKey].push({ job: job.name, ...step });
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
run: {
|
||||
...identity,
|
||||
observedAt,
|
||||
snapshot:
|
||||
normalizedJobs.length > 0 &&
|
||||
normalizedJobs.every(({ status }) => status === "completed")
|
||||
? "completed job status snapshot; unknown timestamps remain unmeasured"
|
||||
: "partial; job statuses may still be queued or in progress",
|
||||
},
|
||||
counts: {
|
||||
jobs: normalizedJobs.length,
|
||||
completed: normalizedJobs.filter(({ status }) => status === "completed")
|
||||
.length,
|
||||
skipped,
|
||||
queueUnknown: normalizedJobs.filter(({ queue }) => queue.seconds === null)
|
||||
.length,
|
||||
executionUnknown: normalizedJobs.filter(
|
||||
({ execution }) => execution.seconds === null,
|
||||
).length,
|
||||
completedExecutionUnknown,
|
||||
},
|
||||
aggregates: {
|
||||
queueSeconds: percentileSummary(queues),
|
||||
completedExecutionSeconds: percentileSummary(completedExecutions),
|
||||
summedCompletedJobElapsedSeconds: {
|
||||
value:
|
||||
completedExecutions.length === 0
|
||||
? null
|
||||
: round(completedDurationSumSeconds),
|
||||
knownCount: completedExecutions.length,
|
||||
unknownCount: completedExecutionUnknown,
|
||||
label:
|
||||
"sum of known completed job elapsed durations; incomplete when timestamps are unknown; not CPU time, billing, or workflow wall time",
|
||||
},
|
||||
},
|
||||
jobs: normalizedJobs,
|
||||
stepCostInterpretation:
|
||||
"Matched install/artifact steps by step name; these are not total artifact transfer costs.",
|
||||
stepCosts: costs,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJob(job) {
|
||||
const skipped = job.conclusion === "skipped";
|
||||
const hasStarted = ["in_progress", "completed"].includes(job.status);
|
||||
const queue = skipped
|
||||
? unknownDuration("job was skipped")
|
||||
: !hasStarted
|
||||
? unknownDuration("job start is not established by its status")
|
||||
: duration(job.created_at, job.started_at);
|
||||
const execution = skipped
|
||||
? unknownDuration("job was skipped")
|
||||
: job.status === "completed"
|
||||
? duration(job.started_at, job.completed_at)
|
||||
: unknownDuration("job is not completed");
|
||||
const steps = Array.isArray(job.steps) ? job.steps.map(normalizeStep) : [];
|
||||
return {
|
||||
id: Number.isSafeInteger(job.id) && job.id > 0 ? job.id : null,
|
||||
url: stringOrNull(job.html_url),
|
||||
name: job.name,
|
||||
workflowName: stringOrNull(job.workflow_name),
|
||||
status: job.status,
|
||||
conclusion: stringOrNull(job.conclusion),
|
||||
createdAt: stringOrNull(job.created_at),
|
||||
startedAt: stringOrNull(job.started_at),
|
||||
completedAt: stringOrNull(job.completed_at),
|
||||
queue,
|
||||
execution,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStep(step) {
|
||||
if (
|
||||
typeof step !== "object" ||
|
||||
step === null ||
|
||||
Array.isArray(step) ||
|
||||
typeof step.name !== "string"
|
||||
)
|
||||
throw new Error("GitHub jobs API contains a malformed job step");
|
||||
const cost = STEP_COSTS.find(({ pattern }) => pattern.test(step.name));
|
||||
return {
|
||||
name: step.name,
|
||||
costKey: cost?.key ?? null,
|
||||
duration: duration(step.started_at, step.completed_at),
|
||||
};
|
||||
}
|
||||
|
||||
function duration(startValue, endValue) {
|
||||
if (typeof startValue !== "string" || typeof endValue !== "string")
|
||||
return unknownDuration("timestamp unavailable");
|
||||
const start = Date.parse(startValue);
|
||||
const end = Date.parse(endValue);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end))
|
||||
return unknownDuration("invalid timestamp");
|
||||
const milliseconds = end - start;
|
||||
if (!Number.isFinite(milliseconds) || milliseconds < 0)
|
||||
return unknownDuration("timestamps out of order");
|
||||
return { seconds: round(milliseconds / 1_000), reason: null };
|
||||
}
|
||||
|
||||
function unknownDuration(reason) {
|
||||
return { seconds: null, reason };
|
||||
}
|
||||
|
||||
function percentileSummary(values) {
|
||||
return {
|
||||
count: values.length,
|
||||
p50: nearestRank(values, 0.5),
|
||||
p95: nearestRank(values, 0.95),
|
||||
unit: "seconds",
|
||||
};
|
||||
}
|
||||
|
||||
function nearestRank(values, percentile) {
|
||||
if (values.length === 0) return null;
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
return round(sorted[Math.ceil(percentile * sorted.length) - 1]);
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return Math.round(value * 1_000) / 1_000;
|
||||
}
|
||||
|
||||
function stringOrNull(value) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
async function appendSummary(summaryPath, report, rawPath, reportPath) {
|
||||
await mkdir(dirname(summaryPath), { recursive: true });
|
||||
const rows = [
|
||||
["Queue", report.aggregates.queueSeconds],
|
||||
["Completed execution", report.aggregates.completedExecutionSeconds],
|
||||
]
|
||||
.map(
|
||||
([label, item]) =>
|
||||
`| ${label} | ${display(item.p50)} | ${display(item.p95)} | ${String(item.count)} |`,
|
||||
)
|
||||
.join("\n");
|
||||
const jobRows = report.jobs
|
||||
.map(
|
||||
(job) =>
|
||||
`| ${escapeCell(job.name)} | ${job.status} | ${job.conclusion ?? "unknown"} | ${display(job.queue.seconds)} | ${job.queue.seconds === null ? job.queue.reason : ""} | ${display(job.execution.seconds)} | ${job.execution.seconds === null ? job.execution.reason : ""} |`,
|
||||
)
|
||||
.join("\n");
|
||||
const costRows = STEP_COSTS.map(({ key }) => {
|
||||
const samples = report.stepCosts[key];
|
||||
const values = samples
|
||||
.map(({ duration: item }) => item.seconds)
|
||||
.filter((value) => value !== null);
|
||||
return `| ${key} | ${String(samples.length)} | ${display(nearestRank(values, 0.5))} | ${samples.filter(({ duration: item }) => item.seconds === null).length} |`;
|
||||
}).join("\n");
|
||||
const text = [
|
||||
"### CI job timing snapshot",
|
||||
"",
|
||||
`Observed at ${report.run.observedAt} for run ${String(report.run.runId)}, attempt ${String(report.run.runAttempt)}. This is a point-in-time snapshot of fetched jobs. Queue starts at job creation and excludes preceding dependency waiting. Missing, queued, in-progress, invalid, or out-of-order timestamps are reported as unknown. Elapsed durations are not CPU time or billing data.`,
|
||||
"",
|
||||
`Fetched ${String(report.counts.jobs)} jobs; ${String(report.counts.completed)} are completed (${String(report.counts.skipped)} skipped); ${String(report.counts.queueUnknown)} queue durations and ${String(report.counts.executionUnknown)} execution durations (${String(report.counts.completedExecutionUnknown)} for non-skipped completed jobs) are unknown.`,
|
||||
"",
|
||||
"| Metric | p50 (s) | p95 (s) | Samples |",
|
||||
"| --- | ---: | ---: | ---: |",
|
||||
rows,
|
||||
"",
|
||||
"| Job | Status | Conclusion | Queue (s) | Queue detail | Execution (s) | Execution detail |",
|
||||
"| --- | --- | --- | ---: | --- | ---: | --- |",
|
||||
jobRows,
|
||||
"",
|
||||
`Sum of known completed job elapsed durations: **${display(report.aggregates.summedCompletedJobElapsedSeconds.value)} s** across ${String(report.aggregates.summedCompletedJobElapsedSeconds.knownCount)} known and ${String(report.aggregates.summedCompletedJobElapsedSeconds.unknownCount)} unknown jobs; this is not CPU time, billing, or workflow wall time.`,
|
||||
"",
|
||||
"Matched install/artifact steps by name; these are not total artifact transfer costs.",
|
||||
"",
|
||||
"| Matched step group | Samples | p50 (s) | Unknown |",
|
||||
"| --- | ---: | ---: | ---: |",
|
||||
costRows,
|
||||
"",
|
||||
`Reports: \`${rawPath}\`, \`${reportPath}\`.`,
|
||||
"",
|
||||
].join("\n");
|
||||
await writeFile(summaryPath, text, { encoding: "utf8", flag: "a" });
|
||||
}
|
||||
|
||||
function display(value) {
|
||||
return value === null ? "unknown" : String(value);
|
||||
}
|
||||
|
||||
function escapeCell(value) {
|
||||
return value.replaceAll("|", "\\|").replaceAll("\n", " ");
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] !== undefined &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
try {
|
||||
const result = await collectJobTimings({
|
||||
repository: process.env.CI_REPOSITORY,
|
||||
runId: process.env.CI_TIMING_RUN_ID,
|
||||
runAttempt: process.env.CI_TIMING_RUN_ATTEMPT,
|
||||
outputDirectory: process.env.CI_JOB_TIMINGS_DIRECTORY ?? OUTPUT_DIRECTORY,
|
||||
summaryPath: process.env.GITHUB_STEP_SUMMARY,
|
||||
});
|
||||
process.stdout.write(
|
||||
`Wrote ${result.report.jobs.length} job timing records to ${result.reportPath}\n`,
|
||||
);
|
||||
} catch (cause) {
|
||||
process.stderr.write(
|
||||
`CI timing telemetry failed: ${cause instanceof Error ? cause.message : String(cause)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
+148
-103
@@ -1,30 +1,20 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { matchesGlob } from "node:path";
|
||||
import { MANUAL_VERIFIERS, SCOPES } from "./scopes.mjs";
|
||||
import { CI_FLAGS as flags, MANUAL_VERIFIERS, SCOPES } from "./scopes.mjs";
|
||||
import { planTestSelection } from "./test-selection.mjs";
|
||||
|
||||
const flags = [
|
||||
...new Set([
|
||||
"code",
|
||||
"runtime",
|
||||
"package",
|
||||
"apple",
|
||||
"fixtures",
|
||||
"readiness",
|
||||
"docs",
|
||||
"workflows",
|
||||
...Object.keys(SCOPES),
|
||||
]),
|
||||
];
|
||||
// Test files select the native-host lanes that run them explicitly. Provider
|
||||
// scopes remain implementation-owned, so test-only edits stay off those lanes.
|
||||
const NATIVE_TEST_OWNERS = ["apple", "package"];
|
||||
// Test files select the host/runtime lanes that execute their focused suites.
|
||||
const HOST_TEST_OWNERS = ["apple", "package", "sqlite"];
|
||||
// Actionlint validates workflow syntax only. The semantic suite asserts these
|
||||
// workflows' release, publication and self-hosted runner safeguards.
|
||||
const GUARDED_WORKFLOWS = new Set([
|
||||
".github/workflows/pr-title.yml",
|
||||
".github/workflows/ci-full-pr.yml",
|
||||
".github/workflows/release.yml",
|
||||
".github/workflows/real-ghidra-windows.yml",
|
||||
".github/workflows/sqlite-runtime.yml",
|
||||
".github/workflows/ci-timings.yml",
|
||||
]);
|
||||
// Sources compiled by build-conformance-fixtures.mjs. The readiness journey
|
||||
// analyzes the built C fixture as well as its own applications.
|
||||
@@ -36,6 +26,15 @@ const FIXTURE_SOURCES = [
|
||||
"tests/conformance/versions/**",
|
||||
];
|
||||
const READINESS_SOURCES = ["tests/conformance/readiness/**"];
|
||||
// These reporters affect CI output only; they do not select a host or provider
|
||||
// verification lane when changed on their own.
|
||||
const PORTABLE_CI_REPORTERS = new Set([
|
||||
"scripts/ci/job-timings.mjs",
|
||||
"scripts/ci/job-timings-batch.mjs",
|
||||
"scripts/ci/test-report.mjs",
|
||||
"scripts/ci/test-timing-reporter.mjs",
|
||||
"scripts/ci/test-history.mjs",
|
||||
]);
|
||||
const matchesAny = (file, patterns) =>
|
||||
patterns.some((pattern) => matchesGlob(file, pattern));
|
||||
const selected = Object.fromEntries(flags.map((flag) => [flag, false]));
|
||||
@@ -63,6 +62,10 @@ function classifyPackage(base, head) {
|
||||
]),
|
||||
].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]);
|
||||
for (const script of changed) {
|
||||
if (script === "test:sqlite" || script === "test:sqlite:run") {
|
||||
selected.sqlite = true;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
/^(test(?::|$)|check(?::|$)|lint(?::|$)|format(?::|$)|knip$|jscpd$)/.test(
|
||||
script,
|
||||
@@ -108,98 +111,134 @@ function classifyPackage(base, head) {
|
||||
}
|
||||
|
||||
const event = process.env.GITHUB_EVENT_NAME;
|
||||
let files = [];
|
||||
if (
|
||||
event === "push" ||
|
||||
if (!["pull_request", "push", "schedule", "workflow_dispatch"].includes(event))
|
||||
throw new Error(`Unsupported CI event: ${event}`);
|
||||
const requestedFull =
|
||||
event === "schedule" ||
|
||||
event === "workflow_dispatch" ||
|
||||
process.env.CI_FULL === "true"
|
||||
) {
|
||||
process.env.CI_FULL === "true";
|
||||
const tier =
|
||||
event === "schedule" &&
|
||||
process.env.CI_SKIP_FULL === "true" &&
|
||||
process.env.CI_FULL !== "true"
|
||||
? "reused"
|
||||
: requestedFull
|
||||
? "full"
|
||||
: "fast";
|
||||
let files = [];
|
||||
if (tier === "reused") {
|
||||
reasons.push(
|
||||
"Recent exact-SHA full qualification reused; no fresh provider observation",
|
||||
);
|
||||
} else if (tier === "full") {
|
||||
full("Full baseline requested by event or ci:full label");
|
||||
} else if (event === "pull_request") {
|
||||
const base = git("merge-base", process.env.BASE_SHA, process.env.HEAD_SHA);
|
||||
} else if (event === "pull_request" || event === "push") {
|
||||
const head = process.env.HEAD_SHA;
|
||||
// Disable rename detection so both the removed and added paths participate.
|
||||
files = git("diff", "--name-only", "--no-renames", "-z", base, head)
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
for (const file of files) {
|
||||
if (
|
||||
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test(
|
||||
file,
|
||||
) ||
|
||||
file.startsWith("docs/") ||
|
||||
file.startsWith(".github/ISSUE_TEMPLATE/")
|
||||
) {
|
||||
selected.docs = true;
|
||||
selected.website ||= matchesAny(file, SCOPES.website);
|
||||
continue;
|
||||
}
|
||||
if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) {
|
||||
selected.code = true;
|
||||
for (const owner of NATIVE_TEST_OWNERS)
|
||||
selected[owner] ||= matchesAny(file, SCOPES[owner]);
|
||||
continue;
|
||||
}
|
||||
if (matchesAny(file, MANUAL_VERIFIERS)) {
|
||||
selected.code = true;
|
||||
continue;
|
||||
}
|
||||
const owners = Object.entries(SCOPES)
|
||||
.filter(([, patterns]) =>
|
||||
patterns.some((pattern) => matchesGlob(file, pattern)),
|
||||
)
|
||||
.map(([owner]) => owner);
|
||||
for (const owner of owners) selected[owner] = true;
|
||||
if (
|
||||
file.startsWith(".github/workflows/") ||
|
||||
file === ".github/actionlint.yaml"
|
||||
) {
|
||||
selected.workflows = true;
|
||||
if (GUARDED_WORKFLOWS.has(file)) selected.code = true;
|
||||
if (file !== ".github/workflows/ci.yml") continue;
|
||||
}
|
||||
if (owners.length === 1 && owners[0] === "website") continue;
|
||||
if (/^src\/(contracts|domain)\//.test(file)) selected.docs = true;
|
||||
if (file === "package.json") {
|
||||
classifyPackage(base, head);
|
||||
continue;
|
||||
}
|
||||
selected.code = true;
|
||||
if (matchesAny(file, FIXTURE_SOURCES)) {
|
||||
selected.fixtures = true;
|
||||
selected.readiness = true;
|
||||
}
|
||||
if (matchesAny(file, READINESS_SOURCES)) selected.readiness = true;
|
||||
if (file.startsWith("tests/")) continue;
|
||||
if (
|
||||
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
|
||||
file,
|
||||
)
|
||||
) {
|
||||
full(`Shared boundary: ${file}`);
|
||||
} else if (owners.length) {
|
||||
if (file.startsWith("src/") || file.startsWith("bridge/"))
|
||||
selected.runtime = true;
|
||||
} else if (/^(src|bridge)\//.test(file)) {
|
||||
// Unowned runtime sources keep the Linux x64 installed-package baseline;
|
||||
// ci:full, main pushes and manual dispatch retain every native host.
|
||||
selected.runtime = true;
|
||||
reasons.push(`Unowned runtime source: ${file}`);
|
||||
} else if (file.startsWith(".agents/skills/")) {
|
||||
selected.runtime = true;
|
||||
selected.docs = true;
|
||||
} else {
|
||||
full(`Unclassified input: ${file}`);
|
||||
let base;
|
||||
if (event === "pull_request") {
|
||||
base = git("merge-base", process.env.BASE_SHA, head);
|
||||
} else {
|
||||
const candidate = process.env.BASE_SHA;
|
||||
const baselineReason = process.env.BASELINE_REASON;
|
||||
try {
|
||||
if (!candidate || !/^[0-9a-f]{40}$/i.test(candidate))
|
||||
throw new Error(
|
||||
baselineReason || "No valid successful push baseline was found",
|
||||
);
|
||||
execFileSync("git", ["merge-base", "--is-ancestor", candidate, head], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
base = candidate;
|
||||
} catch {
|
||||
full(
|
||||
baselineReason ||
|
||||
"No valid successful push baseline ancestor; selecting all lanes",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else throw new Error(`Unsupported CI event: ${event}`);
|
||||
if (base) {
|
||||
// Disable rename detection so both the removed and added paths participate.
|
||||
files = git("diff", "--name-only", "--no-renames", "-z", base, head)
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
for (const file of files) {
|
||||
if (
|
||||
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test(
|
||||
file,
|
||||
) ||
|
||||
file.startsWith("docs/") ||
|
||||
file.startsWith(".github/ISSUE_TEMPLATE/")
|
||||
) {
|
||||
selected.docs = true;
|
||||
selected.website ||= matchesAny(file, SCOPES.website);
|
||||
continue;
|
||||
}
|
||||
if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) {
|
||||
selected.code = true;
|
||||
for (const owner of HOST_TEST_OWNERS)
|
||||
selected[owner] ||= matchesAny(file, SCOPES[owner]);
|
||||
continue;
|
||||
}
|
||||
if (matchesAny(file, MANUAL_VERIFIERS)) {
|
||||
selected.code = true;
|
||||
continue;
|
||||
}
|
||||
const owners = Object.entries(SCOPES)
|
||||
.filter(([, patterns]) =>
|
||||
patterns.some((pattern) => matchesGlob(file, pattern)),
|
||||
)
|
||||
.map(([owner]) => owner);
|
||||
for (const owner of owners) selected[owner] = true;
|
||||
if (
|
||||
file.startsWith(".github/workflows/") ||
|
||||
file === ".github/actionlint.yaml"
|
||||
) {
|
||||
selected.workflows = true;
|
||||
if (GUARDED_WORKFLOWS.has(file)) selected.code = true;
|
||||
if (file !== ".github/workflows/ci.yml") continue;
|
||||
}
|
||||
if (owners.length === 1 && owners[0] === "website") continue;
|
||||
if (/^src\/(contracts|domain)\//.test(file)) selected.docs = true;
|
||||
if (file === "package.json") {
|
||||
classifyPackage(base, head);
|
||||
continue;
|
||||
}
|
||||
selected.code = true;
|
||||
if (matchesAny(file, FIXTURE_SOURCES)) {
|
||||
selected.fixtures = true;
|
||||
selected.readiness = true;
|
||||
}
|
||||
if (matchesAny(file, READINESS_SOURCES)) selected.readiness = true;
|
||||
if (file.startsWith("tests/")) continue;
|
||||
if (PORTABLE_CI_REPORTERS.has(file)) continue;
|
||||
if (
|
||||
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
|
||||
file,
|
||||
)
|
||||
) {
|
||||
full(`Shared boundary: ${file}`);
|
||||
} else if (owners.length) {
|
||||
if (file.startsWith("src/") || file.startsWith("bridge/"))
|
||||
selected.runtime = true;
|
||||
} else if (/^(src|bridge)\//.test(file)) {
|
||||
// Unowned runtime sources keep the Linux x64 installed-package baseline;
|
||||
// full qualification retains every native host.
|
||||
selected.runtime = true;
|
||||
reasons.push(`Unowned runtime source: ${file}`);
|
||||
} else if (file.startsWith(".agents/skills/")) {
|
||||
selected.runtime = true;
|
||||
selected.docs = true;
|
||||
} else {
|
||||
full(`Unclassified input: ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selected.runtime ||= selected.package;
|
||||
const linux = { os: "ubuntu-latest", platform: "linux", architecture: "x64" };
|
||||
// macOS x64 is the slowest package host. Pull requests run it only with ci:full;
|
||||
// main pushes, schedules and manual dispatch always include it.
|
||||
// macOS x64 is the slowest package host. Fast qualification omits it.
|
||||
const intel =
|
||||
event !== "pull_request" || process.env.CI_FULL === "true"
|
||||
tier === "full"
|
||||
? [{ os: "macos-15-intel", platform: "darwin", architecture: "x64" }]
|
||||
: [];
|
||||
const matrix = {
|
||||
@@ -212,17 +251,23 @@ const matrix = {
|
||||
]
|
||||
: [linux],
|
||||
};
|
||||
const plan = { ...selected, matrix, files, reasons };
|
||||
const testSelection = planTestSelection(files, event, tier);
|
||||
const plan = { ...selected, tier, matrix, files, reasons, testSelection };
|
||||
if (process.env.GITHUB_OUTPUT)
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
Object.entries({ ...selected, matrix: JSON.stringify(matrix) })
|
||||
Object.entries({
|
||||
...selected,
|
||||
tier,
|
||||
matrix: JSON.stringify(matrix),
|
||||
test_selection: JSON.stringify(testSelection),
|
||||
})
|
||||
.map(([key, value]) => `${key}=${value}\n`)
|
||||
.join(""),
|
||||
);
|
||||
if (process.env.GITHUB_STEP_SUMMARY)
|
||||
appendFileSync(
|
||||
process.env.GITHUB_STEP_SUMMARY,
|
||||
`### Selected CI lanes\n\n${flags.filter((flag) => selected[flag]).join(", ") || "Formatting only"}\n\n${reasons.join("\n\n")}\n`,
|
||||
`### Selected CI lanes (${tier})\n\n${flags.filter((flag) => selected[flag]).join(", ") || "Formatting only"}\n\n${reasons.join("\n\n")}\n`,
|
||||
);
|
||||
console.log(JSON.stringify(plan));
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ARTIFACT_NAME = "ci-full-qualification-v1";
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const positiveId = (value) =>
|
||||
(typeof value === "string" || typeof value === "number") &&
|
||||
/^[1-9]\d*$/.test(String(value)) &&
|
||||
Number.isSafeInteger(Number(value));
|
||||
const commitSha = (value) =>
|
||||
typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
|
||||
|
||||
/** Record only after the required gate has verified every full-tier obligation. */
|
||||
export function recordQualification(directory) {
|
||||
const sourceSha = process.env.GITHUB_SHA;
|
||||
const runId = process.env.GITHUB_RUN_ID;
|
||||
const runAttempt = process.env.GITHUB_RUN_ATTEMPT;
|
||||
const repository = process.env.GITHUB_REPOSITORY;
|
||||
if (
|
||||
!commitSha(sourceSha) ||
|
||||
!positiveId(runId) ||
|
||||
!positiveId(runAttempt) ||
|
||||
!repository
|
||||
)
|
||||
throw new Error(
|
||||
"Full qualification receipt requires source and workflow identity",
|
||||
);
|
||||
const checkoutSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
if (checkoutSha !== sourceSha)
|
||||
throw new Error("Qualification checkout differs from workflow source");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const path = resolve(directory, "receipt.json");
|
||||
writeFileSync(
|
||||
path,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
tier: "full",
|
||||
sourceSha,
|
||||
repository,
|
||||
runId: Number(runId),
|
||||
runAttempt: Number(runAttempt),
|
||||
qualifiedAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
if (process.env.GITHUB_OUTPUT)
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`receipt_name=${ARTIFACT_NAME}\nreceipt_path=${path}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function admit() {
|
||||
if (process.env.CI_FORCE_FULL === "true")
|
||||
return {
|
||||
run_full: true,
|
||||
reason: "Forced daily runner and dependency refresh",
|
||||
};
|
||||
const repository = process.env.CI_REPOSITORY;
|
||||
const repositoryId = Number(process.env.CI_REPOSITORY_ID);
|
||||
const branch = process.env.CI_DEFAULT_BRANCH;
|
||||
const head = process.env.HEAD_SHA;
|
||||
if (!repository || !positiveId(repositoryId) || !branch || !commitSha(head))
|
||||
return {
|
||||
run_full: true,
|
||||
reason: "Qualification lookup identity is incomplete",
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
const deadline = now + 20_000;
|
||||
const api = (...arguments_) => {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0)
|
||||
throw new Error("Qualification lookup budget exhausted");
|
||||
return JSON.parse(
|
||||
execFileSync("gh", ["api", "--method", "GET", ...arguments_], {
|
||||
encoding: "utf8",
|
||||
timeout: Math.min(15_000, remaining),
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}),
|
||||
);
|
||||
};
|
||||
try {
|
||||
const { artifacts } = api(
|
||||
`repos/${repository}/actions/artifacts`,
|
||||
"-f",
|
||||
`name=${ARTIFACT_NAME}`,
|
||||
"-F",
|
||||
"per_page=100",
|
||||
);
|
||||
if (!Array.isArray(artifacts))
|
||||
throw new Error("Missing qualification artifact inventory");
|
||||
const candidates = artifacts.filter((artifact) => {
|
||||
const lineage = artifact?.workflow_run;
|
||||
const age = now - Date.parse(artifact?.created_at);
|
||||
return (
|
||||
artifact?.name === ARTIFACT_NAME &&
|
||||
artifact?.expired === false &&
|
||||
typeof artifact?.created_at === "string" &&
|
||||
Number.isFinite(age) &&
|
||||
age >= 0 &&
|
||||
age < MAX_AGE_MS &&
|
||||
positiveId(lineage?.id) &&
|
||||
String(lineage.id) !== process.env.GITHUB_RUN_ID &&
|
||||
lineage?.repository_id === repositoryId &&
|
||||
lineage?.head_repository_id === repositoryId &&
|
||||
lineage?.head_branch === branch &&
|
||||
lineage?.head_sha === head
|
||||
);
|
||||
});
|
||||
// Artifact metadata narrows the search before run lookups. Never scan an
|
||||
// unbounded history or treat a missing receipt as a successful qualification.
|
||||
for (const artifact of candidates.slice(0, 3)) {
|
||||
const run = api(
|
||||
`repos/${repository}/actions/runs/${artifact.workflow_run.id}`,
|
||||
);
|
||||
// An old run can upload a new artifact when only its gate is rerun.
|
||||
// Bound the original run age as well so retries cannot renew stale proof.
|
||||
const runAge = Date.now() - Date.parse(run.created_at);
|
||||
if (
|
||||
typeof run.created_at === "string" &&
|
||||
Number.isFinite(runAge) &&
|
||||
runAge >= 0 &&
|
||||
runAge < MAX_AGE_MS &&
|
||||
run.id === artifact.workflow_run.id &&
|
||||
run.status === "completed" &&
|
||||
run.conclusion === "success" &&
|
||||
run.path === ".github/workflows/ci.yml" &&
|
||||
["schedule", "workflow_dispatch"].includes(run.event) &&
|
||||
run.head_sha === head &&
|
||||
run.head_branch === branch &&
|
||||
run.head_repository?.full_name === repository
|
||||
) {
|
||||
return {
|
||||
run_full: false,
|
||||
run_id: run.id,
|
||||
reason: `Reusing full qualification from run ${run.id}; no fresh provider observation`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
run_full: true,
|
||||
reason: "No trusted full qualification for this SHA within 24 hours",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
run_full: true,
|
||||
reason: "Qualification lookup unavailable; running full validation",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
|
||||
const decision = admit();
|
||||
if (process.env.GITHUB_OUTPUT)
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
Object.entries(decision)
|
||||
.map(([key, value]) => `${key}=${value}\n`)
|
||||
.join(""),
|
||||
);
|
||||
if (process.env.GITHUB_STEP_SUMMARY)
|
||||
appendFileSync(
|
||||
process.env.GITHUB_STEP_SUMMARY,
|
||||
`### Scheduled qualification\n\n${decision.reason}.\n`,
|
||||
);
|
||||
console.log(JSON.stringify(decision));
|
||||
}
|
||||
+40
-6
@@ -1,18 +1,46 @@
|
||||
import { CI_FLAGS } from "./scopes.mjs";
|
||||
import { recordQualification } from "./qualification.mjs";
|
||||
|
||||
const jobs = JSON.parse(process.env.CI_JOBS);
|
||||
const outputs = jobs.changes.outputs;
|
||||
const required = ["changes", "static", "test"];
|
||||
const outputs = jobs?.changes?.outputs;
|
||||
if (!["fast", "full", "reused"].includes(outputs?.tier))
|
||||
throw new Error(
|
||||
"CI classification requires a valid tier: fast, full, or reused",
|
||||
);
|
||||
const invalidFlags = CI_FLAGS.filter(
|
||||
(flag) => outputs[flag] !== "true" && outputs[flag] !== "false",
|
||||
);
|
||||
if (invalidFlags.length)
|
||||
throw new Error(
|
||||
`CI classification requires explicit true/false scope outputs: ${invalidFlags.join(", ")}`,
|
||||
);
|
||||
const selectedScopes = CI_FLAGS.filter((flag) => outputs[flag] === "true");
|
||||
const reused = outputs.tier === "reused";
|
||||
if (
|
||||
reused &&
|
||||
(process.env.GITHUB_EVENT_NAME !== "schedule" ||
|
||||
!/^[1-9]\d*$/.test(outputs.qualification_run_id ?? "") ||
|
||||
!Number.isSafeInteger(Number(outputs.qualification_run_id)) ||
|
||||
selectedScopes.length !== 0)
|
||||
)
|
||||
throw new Error(
|
||||
"Reused qualification requires a scheduled admission receipt and no selected lanes",
|
||||
);
|
||||
if (outputs.tier === "full" && selectedScopes.length !== CI_FLAGS.length)
|
||||
throw new Error("Full qualification requires every canonical CI scope");
|
||||
const required = reused ? ["changes"] : ["changes", "static", "test"];
|
||||
const lanes = {
|
||||
code: ["build", "test-shard", "sqlite-runtime"],
|
||||
code: ["build", "test-shard"],
|
||||
runtime: ["windows-native-build", "package-e2e"],
|
||||
package: ["windows-curated"],
|
||||
sqlite: ["sqlite-runtime"],
|
||||
apple: ["apple-artifacts"],
|
||||
fixtures: ["conformance-fixtures"],
|
||||
readiness: ["reconstruction-readiness"],
|
||||
docs: ["docs"],
|
||||
workflows: ["static"],
|
||||
};
|
||||
for (const [scope, value] of Object.entries(outputs)) {
|
||||
if (value === "true") required.push(...(lanes[scope] ?? [scope]));
|
||||
}
|
||||
for (const scope of selectedScopes) required.push(...(lanes[scope] ?? [scope]));
|
||||
const failed = [...new Set(required)].filter(
|
||||
(name) => jobs[name]?.result !== "success",
|
||||
);
|
||||
@@ -23,3 +51,9 @@ for (const [name, job] of Object.entries(jobs)) {
|
||||
if (failed.length)
|
||||
throw new Error(`CI lanes did not pass: ${failed.join(", ")}`);
|
||||
console.log("Every selected CI lane passed");
|
||||
if (reused)
|
||||
console.log(
|
||||
`Qualification reused from run ${outputs.qualification_run_id}; no fresh provider observation`,
|
||||
);
|
||||
if (outputs.tier === "full" && process.env.CI_QUALIFICATION_DIRECTORY)
|
||||
recordQualification(process.env.CI_QUALIFICATION_DIRECTORY);
|
||||
|
||||
@@ -56,6 +56,8 @@ export const SCOPES = {
|
||||
"tests/boundary/filesystem/setupUnavailableProvider.test.ts",
|
||||
"tests/boundary/filesystem/windowsNpxRegistration.test.ts",
|
||||
"tests/acceptance/setup/homeDirectoryFallback.test.ts",
|
||||
"tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts",
|
||||
"src/domain/javascript/javascriptRuntimeStaticCandidates.ts",
|
||||
"tests/boundary/filesystem/webScriptManifestPaths.test.ts",
|
||||
"tests/boundary/process/ProcessEvidence.host.test.ts",
|
||||
"tests/acceptance/webModuleCli.test.ts",
|
||||
@@ -85,6 +87,55 @@ export const SCOPES = {
|
||||
"tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts",
|
||||
"tests/boundary/process/sourceMapUncHosts.test.ts",
|
||||
],
|
||||
// Native SQLite runtime compatibility for the producer, public adapters,
|
||||
// fixtures, shared lifecycle, and the package script that defines its suite.
|
||||
sqlite: [
|
||||
"src/sqlite/**",
|
||||
"src/domain/sqlite/**",
|
||||
"src/contracts/sqlite/**",
|
||||
"src/application/sqlite/**",
|
||||
"src/composition/sqlite.ts",
|
||||
"src/cli/sqliteCommands.ts",
|
||||
"src/server/registerSqliteTools.ts",
|
||||
"src/application/DirectAnalysisStatus*.ts",
|
||||
"src/application/CapabilityInventory*.ts",
|
||||
"src/application/AnalysisProvider*.ts",
|
||||
"src/contracts/toolContracts.ts",
|
||||
"src/contracts/toolContractTypes.ts",
|
||||
"src/contracts/toolEffects*.ts",
|
||||
"src/contracts/toolOutputSchemaPrimitives.ts",
|
||||
"src/domain/analysisError*.ts",
|
||||
"src/domain/evidence*.ts",
|
||||
"src/domain/inputIssueProjection*.ts",
|
||||
"src/domain/jsonSerialization*.ts",
|
||||
"src/domain/jsonValue*.ts",
|
||||
"src/domain/localPath*.ts",
|
||||
"src/domain/providerAdapterError*.ts",
|
||||
"src/domain/providerCleanupError*.ts",
|
||||
"src/domain/result*.ts",
|
||||
"src/process/OwnedCommand*.ts",
|
||||
"src/process/ProviderProcess*.ts",
|
||||
"src/process/PrivateRuntimeRoot*.ts",
|
||||
"src/artifacts/ArtifactReader*.ts",
|
||||
"src/artifacts/ArtifactResourceScope*.ts",
|
||||
"src/artifacts/ArtifactPaths*.ts",
|
||||
"src/artifacts/readStableArtifact*.ts",
|
||||
"src/filesystem/OwnedFileHandle*.ts",
|
||||
"src/server/EvidenceMcpServer*.ts",
|
||||
"src/server/analysisAdmission*.ts",
|
||||
"src/server/contractToolHandler*.ts",
|
||||
"src/server/toolRegistrationOptions*.ts",
|
||||
"src/server/createServer*.ts",
|
||||
"src/cli.ts",
|
||||
"src/cliCommandNames.ts",
|
||||
"src/cliLogging.ts",
|
||||
"src/cli/commandCancellation.ts",
|
||||
"tests/boundary/sqlite/**",
|
||||
"tests/fixtures/sqlite/**",
|
||||
"tests/acceptance/sqliteDatabaseCli.test.ts",
|
||||
"tests/boundary/mcp/serverLifecycleMcp.test.ts",
|
||||
".github/workflows/sqlite-runtime.yml",
|
||||
],
|
||||
browser: [
|
||||
".github/workflows/real-browser.yml",
|
||||
"scripts/verify-real-browser.mjs",
|
||||
@@ -300,6 +351,7 @@ export const SCOPES = {
|
||||
nativeaot: [
|
||||
"bridge/nativeaot/**",
|
||||
"scripts/generate-nativeaot-fixtures.mjs",
|
||||
"scripts/lib/nativeaot-build.*",
|
||||
"tests/fixtures/nativeaot/**",
|
||||
".github/workflows/nativeaot-fixtures.yml",
|
||||
],
|
||||
@@ -312,6 +364,20 @@ export const SCOPES = {
|
||||
],
|
||||
};
|
||||
|
||||
export const CI_FLAGS = [
|
||||
...new Set([
|
||||
"code",
|
||||
"runtime",
|
||||
"package",
|
||||
"apple",
|
||||
"fixtures",
|
||||
"readiness",
|
||||
"docs",
|
||||
"workflows",
|
||||
...Object.keys(SCOPES),
|
||||
]),
|
||||
];
|
||||
|
||||
// Verifier entrypoints that only manual, licensed, or self-hosted workflows
|
||||
// run. No selected CI lane executes them, so their edits need only the static
|
||||
// checks and the source-test suite. Shared helpers under scripts/lib remain
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ARTIFACT_NAME = "test-timings";
|
||||
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const MAX_CANDIDATES = 5;
|
||||
const positiveId = (value) =>
|
||||
(typeof value === "string" || typeof value === "number") &&
|
||||
/^[1-9]\d*$/.test(String(value)) &&
|
||||
Number.isSafeInteger(Number(value));
|
||||
|
||||
function findTimingRun({ repository, branch, repositoryId, now = Date.now() }) {
|
||||
if (
|
||||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository ?? "") ||
|
||||
!branch ||
|
||||
!positiveId(repositoryId)
|
||||
)
|
||||
return "";
|
||||
const deadline = Date.now() + 20_000;
|
||||
const api = (...arguments_) => {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0)
|
||||
throw new Error("Timing history lookup budget exhausted");
|
||||
return JSON.parse(
|
||||
execFileSync("gh", ["api", "--method", "GET", ...arguments_], {
|
||||
encoding: "utf8",
|
||||
timeout: Math.min(10_000, remaining),
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const { artifacts } = api(
|
||||
`repos/${repository}/actions/artifacts`,
|
||||
"-f",
|
||||
`name=${ARTIFACT_NAME}`,
|
||||
"-F",
|
||||
"per_page=100",
|
||||
);
|
||||
if (!Array.isArray(artifacts))
|
||||
throw new Error("Missing timing artifact inventory");
|
||||
const candidates = artifacts
|
||||
.filter((artifact) => {
|
||||
const lineage = artifact?.workflow_run;
|
||||
const age = now - Date.parse(artifact?.created_at);
|
||||
return (
|
||||
artifact?.name === ARTIFACT_NAME &&
|
||||
artifact?.expired === false &&
|
||||
typeof artifact?.created_at === "string" &&
|
||||
Number.isFinite(age) &&
|
||||
age >= 0 &&
|
||||
age < MAX_AGE_MS &&
|
||||
positiveId(lineage?.id) &&
|
||||
positiveId(lineage?.repository_id) &&
|
||||
positiveId(lineage?.head_repository_id) &&
|
||||
lineage.repository_id === lineage.head_repository_id &&
|
||||
lineage.repository_id === Number(repositoryId) &&
|
||||
lineage.head_branch === branch
|
||||
);
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Date.parse(right.created_at) - Date.parse(left.created_at),
|
||||
)
|
||||
.slice(0, MAX_CANDIDATES);
|
||||
|
||||
for (const artifact of candidates) {
|
||||
const run = api(
|
||||
`repos/${repository}/actions/runs/${artifact.workflow_run.id}`,
|
||||
);
|
||||
const runAge = now - Date.parse(run?.created_at);
|
||||
if (
|
||||
typeof run?.created_at === "string" &&
|
||||
Number.isFinite(runAge) &&
|
||||
runAge >= 0 &&
|
||||
runAge < MAX_AGE_MS &&
|
||||
run.id === artifact.workflow_run.id &&
|
||||
run.status === "completed" &&
|
||||
run.conclusion === "success" &&
|
||||
run.path === ".github/workflows/ci.yml" &&
|
||||
["schedule", "workflow_dispatch"].includes(run.event) &&
|
||||
run.head_branch === branch &&
|
||||
run.head_repository?.full_name === repository
|
||||
)
|
||||
return String(run.id);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function write(runId) {
|
||||
if (process.env.GITHUB_OUTPUT)
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `run-id=${runId}\n`);
|
||||
console.log(JSON.stringify({ run_id: runId }));
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
||||
) {
|
||||
let runId = "";
|
||||
try {
|
||||
runId = findTimingRun({
|
||||
repository: process.env.CI_REPOSITORY,
|
||||
branch: process.env.CI_DEFAULT_BRANCH,
|
||||
repositoryId: process.env.CI_REPOSITORY_ID,
|
||||
});
|
||||
} catch {
|
||||
// History improves shard ordering but must never block CI.
|
||||
}
|
||||
write(runId);
|
||||
}
|
||||
+82
-17
@@ -1,28 +1,36 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { compareTestSelection } from "./test-selection.mjs";
|
||||
|
||||
const root = process.cwd();
|
||||
const historyDirectory = join(root, ".cache/ci-test-history");
|
||||
const historyPath = join(historyDirectory, "test-results.json");
|
||||
const selectionDirectory = join(root, ".cache/ci-test-selection");
|
||||
const selectionPath = join(selectionDirectory, "report.json");
|
||||
// Never let a failed or partial rerun leave a previous successful history file.
|
||||
await rm(historyPath, { force: true });
|
||||
await rm(selectionPath, { force: true });
|
||||
const report = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
success: z.boolean(),
|
||||
numFailedTests: z.number().int().nonnegative(),
|
||||
testResults: z.array(
|
||||
reason: z.string().optional(),
|
||||
unhandledErrors: z.number().int().nonnegative().optional(),
|
||||
files: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
startTime: z.number().finite(),
|
||||
endTime: z.number().finite(),
|
||||
durationMs: z.number().finite().nonnegative(),
|
||||
state: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.parse(JSON.parse(await readFile("test-results.json", "utf8")));
|
||||
.parse(JSON.parse(await readFile("test-timings.json", "utf8")));
|
||||
|
||||
const files = report.testResults.map(({ name, startTime, endTime }) => {
|
||||
const files = report.files.map(({ name, durationMs, state }) => {
|
||||
const path = relative(root, resolve(root, name));
|
||||
if (
|
||||
path === "" ||
|
||||
@@ -31,32 +39,85 @@ const files = report.testResults.map(({ name, startTime, endTime }) => {
|
||||
isAbsolute(path)
|
||||
)
|
||||
throw new Error(`Test report path is outside the checkout: ${name}`);
|
||||
const duration = endTime - startTime;
|
||||
if (!Number.isFinite(duration) || duration < 0)
|
||||
throw new Error(`Test report has an invalid duration: ${name}`);
|
||||
return { name: path.split(sep).join("/"), startTime: 0, endTime: duration };
|
||||
return { name: path.split(sep).join("/"), durationMs, state };
|
||||
});
|
||||
const complete = process.env.SHARD_RESULT === "success";
|
||||
const passed =
|
||||
complete &&
|
||||
process.env.MERGE_RESULT === "success" &&
|
||||
report.success &&
|
||||
report.numFailedTests === 0;
|
||||
complete && process.env.MERGE_RESULT === "success" && report.success;
|
||||
// Successful shards and merged reports must account for the complete portable
|
||||
// inventory. Coverage thresholds alone cannot prove that every file ran.
|
||||
const verifyInventory = process.env.CI_VERIFY_TEST_INVENTORY === "true";
|
||||
const expected =
|
||||
verifyInventory || process.env.CI_TEST_SELECTION
|
||||
? execFileSync("git", ["ls-files", "-z", "--", "src", "tests"], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
})
|
||||
.split("\0")
|
||||
.filter((path) => path.endsWith(".test.ts"))
|
||||
: [];
|
||||
let selection;
|
||||
if (process.env.CI_TEST_SELECTION) {
|
||||
const comparison = compareTestSelection(
|
||||
JSON.parse(process.env.CI_TEST_SELECTION),
|
||||
expected,
|
||||
files,
|
||||
);
|
||||
selection = {
|
||||
...comparison,
|
||||
complete:
|
||||
comparison.complete &&
|
||||
report.unhandledErrors === 0 &&
|
||||
["passed", "failed"].includes(report.reason) &&
|
||||
["success", "failure"].includes(process.env.SHARD_RESULT) &&
|
||||
["success", "failure"].includes(process.env.MERGE_RESULT),
|
||||
executionSha: process.env.GITHUB_SHA ?? null,
|
||||
prHeadSha: process.env.CI_PR_HEAD_SHA ?? null,
|
||||
runId: process.env.GITHUB_RUN_ID ?? null,
|
||||
runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null,
|
||||
};
|
||||
await mkdir(selectionDirectory, { recursive: true });
|
||||
await writeFile(selectionPath, JSON.stringify(selection));
|
||||
}
|
||||
if (passed && verifyInventory) {
|
||||
const actual = files.map(({ name }) => name);
|
||||
const expectedSet = new Set(expected);
|
||||
const actualSet = new Set(actual);
|
||||
const missing = expected.filter((path) => !actualSet.has(path));
|
||||
const unexpected = actual.filter((path) => !expectedSet.has(path));
|
||||
if (missing.length || unexpected.length || actualSet.size !== actual.length)
|
||||
throw new Error(
|
||||
`Incomplete portable test inventory: ${JSON.stringify({ missing, unexpected, duplicateFiles: actual.length - actualSet.size })}`,
|
||||
);
|
||||
}
|
||||
const summary = [
|
||||
complete ? "## Merged test diagnostics" : "## Partial test diagnostics",
|
||||
"",
|
||||
...(selection
|
||||
? [
|
||||
"### Observational PR test selection",
|
||||
"",
|
||||
`Plan: ${selection.plan.mode}. Proposed files: ${selection.selected.length}; omitted: ${selection.omitted.length}.`,
|
||||
`Full-run comparison: ${selection.complete ? "complete" : "incomplete"}; failures outside proposed selection: ${selection.missedFailures.length}.`,
|
||||
"The complete suite still ran. This report cannot enable omissions or qualify a revision. Skipped, missing, and interrupted observations do not establish parity.",
|
||||
"",
|
||||
]
|
||||
: []),
|
||||
`Shard result: ${process.env.SHARD_RESULT ?? "unknown"}; merge result: ${process.env.MERGE_RESULT ?? "unknown"}.`,
|
||||
passed
|
||||
? "Successful timing history is available."
|
||||
: "This report is diagnostic only and is not a successful timing baseline.",
|
||||
"",
|
||||
"Durations include tests and hooks, worker preparation, environment, imports, and setup. They are scheduling weights, not summed workflow wall time.",
|
||||
"",
|
||||
"| Slowest file | Duration (ms) |",
|
||||
"| --- | ---: |",
|
||||
...files
|
||||
.toSorted((left, right) => right.endTime - left.endTime)
|
||||
.toSorted((left, right) => right.durationMs - left.durationMs)
|
||||
.slice(0, 20)
|
||||
.map(
|
||||
({ name, endTime }) => `| ${name.replaceAll("|", "\\|")} | ${endTime} |`,
|
||||
({ name, durationMs }) =>
|
||||
`| ${name.replaceAll("|", "\\|")} | ${durationMs} |`,
|
||||
),
|
||||
"",
|
||||
].join("\n");
|
||||
@@ -68,6 +129,10 @@ if (passed) {
|
||||
await mkdir(historyDirectory, { recursive: true });
|
||||
await writeFile(
|
||||
historyPath,
|
||||
JSON.stringify({ success: true, numFailedTests: 0, testResults: files }),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
success: true,
|
||||
files: files.map(({ name, durationMs }) => ({ name, durationMs })),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface TestSelectionPlan {
|
||||
readonly version: 1;
|
||||
readonly mode: "full" | "candidate";
|
||||
readonly owners: readonly string[];
|
||||
readonly changedTests: readonly string[];
|
||||
readonly changedPaths: readonly string[];
|
||||
readonly reason: string;
|
||||
}
|
||||
export function planTestSelection(
|
||||
files: readonly string[],
|
||||
event: string,
|
||||
tier: string,
|
||||
): TestSelectionPlan;
|
||||
export function compareTestSelection(
|
||||
plan: unknown,
|
||||
inventory: readonly string[],
|
||||
results: readonly {
|
||||
readonly name: string;
|
||||
readonly durationMs: number;
|
||||
readonly state?: string;
|
||||
}[],
|
||||
): {
|
||||
readonly version: 1;
|
||||
readonly observational: true;
|
||||
readonly plan: TestSelectionPlan;
|
||||
readonly selected: readonly string[];
|
||||
readonly omitted: readonly string[];
|
||||
readonly missing: readonly string[];
|
||||
readonly unexpected: readonly string[];
|
||||
readonly duplicateFiles: number;
|
||||
readonly unobserved: readonly string[];
|
||||
readonly missedFailures: readonly string[];
|
||||
readonly complete: boolean;
|
||||
readonly omittedDurationMs: number;
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import { matchesGlob } from "node:path";
|
||||
|
||||
// Candidate ownership only: these leaf adapters and their producer tests can be
|
||||
// compared with full-suite outcomes. This table never changes test execution.
|
||||
// Shared application/domain code and dynamic CLI/MCP consumers are not leaf owners.
|
||||
const OWNERS = {
|
||||
adb: ["src/adb/**", "tests/boundary/providers/adb/**"],
|
||||
apktool: ["src/apktool/**"],
|
||||
dotnet: ["src/dotnet/**"],
|
||||
evm: ["src/evm/**", "tests/boundary/evm/**"],
|
||||
firmware: ["src/firmware/**", "tests/boundary/firmware/**"],
|
||||
flutter: ["src/flutter/**"],
|
||||
ghidra: ["src/ghidra/**", "tests/boundary/providers/ghidra/**"],
|
||||
hopper: ["src/hopper/**", "tests/boundary/providers/hopper/**"],
|
||||
ida: ["src/ida/**", "tests/boundary/providers/ida/**"],
|
||||
jeb: ["src/jeb/**"],
|
||||
pcap: ["src/pcap/**", "tests/boundary/pcap/**"],
|
||||
sqlite: ["src/sqlite/**", "tests/boundary/sqlite/**"],
|
||||
wasm: ["src/wasm/**"],
|
||||
};
|
||||
const ownersOf = (path) =>
|
||||
Object.entries(OWNERS)
|
||||
.filter(([, patterns]) => patterns.some((glob) => matchesGlob(path, glob)))
|
||||
.map(([owner]) => owner);
|
||||
const validPath = (path) =>
|
||||
typeof path === "string" &&
|
||||
/^(src|tests)\//.test(path) &&
|
||||
!/[\\\0\r\n]/.test(path) &&
|
||||
!path.split("/").some((part) => ["", ".", ".."].includes(part));
|
||||
const full = (reason, changedPaths = []) => ({
|
||||
version: 1,
|
||||
mode: "full",
|
||||
owners: [],
|
||||
changedTests: [],
|
||||
changedPaths,
|
||||
reason,
|
||||
});
|
||||
|
||||
/** Plan an observational PR subset from the classifier's existing Git diff. */
|
||||
export function planTestSelection(files, event, tier) {
|
||||
if (event !== "pull_request" || tier !== "fast")
|
||||
return full("Only ordinary PRs are candidates", files);
|
||||
if (!files.length) return full("No changed paths available");
|
||||
const owners = new Set();
|
||||
const changedTests = [];
|
||||
for (const path of files) {
|
||||
if (!validPath(path))
|
||||
return full(`Shared or unknown input: ${path}`, files);
|
||||
const matches = ownersOf(path);
|
||||
if (!matches.length) return full(`Shared or unknown input: ${path}`, files);
|
||||
for (const owner of matches) owners.add(owner);
|
||||
if (path.endsWith(".test.ts")) changedTests.push(path);
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
mode: "candidate",
|
||||
owners: [...owners].sort(),
|
||||
changedTests: [...new Set(changedTests)].sort(),
|
||||
changedPaths: files,
|
||||
reason:
|
||||
"Observe affected leaf owners; retain integration and unowned tests",
|
||||
};
|
||||
}
|
||||
|
||||
/** Compare a proposal with the full run; missing observations never prove parity. */
|
||||
export function compareTestSelection(plan, inventory, results) {
|
||||
if (
|
||||
plan?.version !== 1 ||
|
||||
!["full", "candidate"].includes(plan.mode) ||
|
||||
!Array.isArray(plan.owners) ||
|
||||
plan.owners.some((owner) => !Object.hasOwn(OWNERS, owner)) ||
|
||||
!Array.isArray(plan.changedTests) ||
|
||||
!Array.isArray(plan.changedPaths) ||
|
||||
plan.changedPaths.some((path) => typeof path !== "string") ||
|
||||
plan.changedTests.some(
|
||||
(path) => !validPath(path) || !path.endsWith(".test.ts"),
|
||||
) ||
|
||||
typeof plan.reason !== "string" ||
|
||||
(plan.mode === "candidate" && !plan.owners.length) ||
|
||||
(plan.mode === "full" && (plan.owners.length || plan.changedTests.length))
|
||||
)
|
||||
throw new Error("Invalid observational test selection plan");
|
||||
const expected = new Set(inventory);
|
||||
if (!expected.size || expected.size !== inventory.length)
|
||||
throw new Error("Test selection requires a nonempty unique inventory");
|
||||
const ownersByFile = new Map(
|
||||
plan.mode === "candidate"
|
||||
? inventory.map((path) => [path, ownersOf(path)])
|
||||
: [],
|
||||
);
|
||||
// Removed/renamed tests have no current executable identity. Keep all tests.
|
||||
const removed = plan.changedTests.filter((path) => !expected.has(path));
|
||||
const missingOwners = plan.owners.filter(
|
||||
(owner) =>
|
||||
![...ownersByFile.values()].some((owners) => owners.includes(owner)),
|
||||
);
|
||||
const effective = removed.length
|
||||
? full(
|
||||
`Changed tests absent from current inventory: ${removed.join(", ")}`,
|
||||
plan.changedPaths,
|
||||
)
|
||||
: missingOwners.length
|
||||
? full(
|
||||
`Owners without executable tests: ${missingOwners.join(", ")}`,
|
||||
plan.changedPaths,
|
||||
)
|
||||
: plan;
|
||||
const selected = [];
|
||||
const omitted = [];
|
||||
for (const path of inventory.toSorted()) {
|
||||
const owners = ownersByFile.get(path) ?? [];
|
||||
const keep =
|
||||
effective.mode === "full" ||
|
||||
!owners.length ||
|
||||
effective.changedTests.includes(path) ||
|
||||
owners.some((owner) => effective.owners.includes(owner));
|
||||
(keep ? selected : omitted).push(path);
|
||||
}
|
||||
const actual = new Set(results.map((file) => file.name));
|
||||
const missing = inventory.filter((path) => !actual.has(path));
|
||||
const unexpected = [...actual].filter((path) => !expected.has(path));
|
||||
const duplicateFiles = results.length - actual.size;
|
||||
const unobserved = results
|
||||
.filter((file) => !["passed", "failed"].includes(file.state))
|
||||
.map((file) => file.name);
|
||||
const omittedSet = new Set(omitted);
|
||||
const missedFailures = results
|
||||
.filter((file) => omittedSet.has(file.name) && file.state === "failed")
|
||||
.map((file) => file.name);
|
||||
return {
|
||||
version: 1,
|
||||
observational: true,
|
||||
plan: effective,
|
||||
selected,
|
||||
omitted,
|
||||
missing,
|
||||
unexpected,
|
||||
duplicateFiles,
|
||||
unobserved,
|
||||
missedFailures,
|
||||
complete:
|
||||
!missing.length &&
|
||||
!unexpected.length &&
|
||||
!duplicateFiles &&
|
||||
!unobserved.length,
|
||||
// Phase totals are scheduling weights, not an estimate of wall-time savings.
|
||||
omittedDurationMs: results
|
||||
.filter((file) => omittedSet.has(file.name))
|
||||
.reduce((sum, file) => sum + file.durationMs, 0),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
/** @implements {import("vitest/node").Reporter} */
|
||||
export default class TestTimingReporter {
|
||||
/**
|
||||
* @param {ReadonlyArray<import("vitest/node").TestModule>} modules
|
||||
* @param {ReadonlyArray<import("vitest/node").SerializedError>} errors
|
||||
* @param {import("vitest/node").TestRunEndReason} reason
|
||||
*/
|
||||
async onTestRunEnd(modules, errors, reason) {
|
||||
const files = modules.map((module) => {
|
||||
const diagnostic = module.diagnostic();
|
||||
// These disjoint phases match Vitest's per-file wall-time accounting.
|
||||
// Import timings already include transform waits; do not add them again.
|
||||
const phases = {
|
||||
execution: diagnostic.duration,
|
||||
preparation: diagnostic.prepareDuration,
|
||||
environment: diagnostic.environmentSetupDuration,
|
||||
imports: diagnostic.collectDuration,
|
||||
setup: diagnostic.setupDuration,
|
||||
};
|
||||
return {
|
||||
name: module.moduleId,
|
||||
state: module.state(),
|
||||
durationMs: Object.values(phases).reduce(
|
||||
(total, value) => total + value,
|
||||
0,
|
||||
),
|
||||
phases,
|
||||
};
|
||||
});
|
||||
await writeFile(
|
||||
"test-timings.json",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
success: reason === "passed" && errors.length === 0,
|
||||
reason,
|
||||
unhandledErrors: errors.length,
|
||||
files,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { copyFile, readFile, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const MANIFEST_NAME = "manifest.json";
|
||||
const TARBALL_NAME = "package.tgz";
|
||||
const MANIFEST_KEYS = [
|
||||
"schemaVersion",
|
||||
"sourceSha",
|
||||
"runId",
|
||||
"runAttempt",
|
||||
"platform",
|
||||
"architecture",
|
||||
"packageVersion",
|
||||
"nodeVersion",
|
||||
"sha256",
|
||||
].sort();
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Normalize workflow identity and host facts into the manifest-owned fields. */
|
||||
export function createVerifiedPackageMetadata({
|
||||
sourceSha,
|
||||
runId,
|
||||
runAttempt,
|
||||
platform,
|
||||
architecture,
|
||||
packageVersion,
|
||||
nodeVersion,
|
||||
}) {
|
||||
if (!/^[a-f0-9]{40}$/u.test(sourceSha))
|
||||
throw new Error("Source SHA must be a full commit SHA");
|
||||
return {
|
||||
sourceSha,
|
||||
runId: parsePositiveInteger(runId, "GITHUB_RUN_ID"),
|
||||
runAttempt: parsePositiveInteger(runAttempt, "GITHUB_RUN_ATTEMPT"),
|
||||
platform,
|
||||
architecture,
|
||||
packageVersion,
|
||||
nodeVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the single supported manifest representation for a verified tarball. */
|
||||
function createVerifiedPackageManifest(metadata) {
|
||||
const manifest = { schemaVersion: 1, ...metadata };
|
||||
validateManifestShape(manifest);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/** Write a manifest after the caller has completed qualification and cleanup. */
|
||||
export async function writeVerifiedPackageManifest(directory, metadata) {
|
||||
const manifest = createVerifiedPackageManifest(metadata);
|
||||
await writeFile(
|
||||
join(directory, MANIFEST_NAME),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/** Copy the qualified archive under its fixed name and prepare its manifest. */
|
||||
export async function retainVerifiedPackage({
|
||||
sourceTarball,
|
||||
directory,
|
||||
metadata,
|
||||
}) {
|
||||
const tarball = join(directory, TARBALL_NAME);
|
||||
await copyFile(sourceTarball, tarball);
|
||||
const sha256 = await sha256File(tarball);
|
||||
return {
|
||||
directory,
|
||||
metadata: createVerifiedPackageManifest({ ...metadata, sha256 }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate a retained package against the current checkout and workflow attempt. */
|
||||
async function validateVerifiedPackage({
|
||||
directory,
|
||||
sourceSha,
|
||||
runId,
|
||||
runAttempt,
|
||||
packageVersion,
|
||||
nodeVersion,
|
||||
}) {
|
||||
if (!/^[a-f0-9]{40}$/u.test(sourceSha))
|
||||
throw new Error("Expected source SHA must be a full commit SHA");
|
||||
if (!Number.isSafeInteger(runId) || runId <= 0)
|
||||
throw new Error("Expected workflow run ID must be a positive integer");
|
||||
if (!Number.isSafeInteger(runAttempt) || runAttempt <= 0)
|
||||
throw new Error("Expected workflow run attempt must be a positive integer");
|
||||
const manifestPath = join(directory, MANIFEST_NAME);
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
} catch (cause) {
|
||||
throw new Error(`Could not read verified package manifest: ${cause}`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
validateManifestShape(manifest);
|
||||
assertEqual(manifest.sourceSha, sourceSha, "source SHA");
|
||||
assertEqual(manifest.runId, runId, "workflow run ID");
|
||||
// A successful producer may belong to an earlier attempt when only failed
|
||||
// publisher jobs are rerun. The workflow still requires same-run qualification.
|
||||
if (manifest.runAttempt > runAttempt)
|
||||
throw new Error("Verified package workflow run attempt is from the future");
|
||||
assertEqual(manifest.platform, "linux", "verifier platform");
|
||||
assertEqual(manifest.architecture, "x64", "verifier architecture");
|
||||
assertEqual(manifest.packageVersion, packageVersion, "package version");
|
||||
assertEqual(manifest.nodeVersion, nodeVersion, "Node.js version");
|
||||
|
||||
// The artifact format deliberately fixes the name; no manifest value can
|
||||
// redirect validation to another path.
|
||||
const tarball = resolve(directory, TARBALL_NAME);
|
||||
const digest = await sha256File(tarball);
|
||||
assertEqual(manifest.sha256, digest, "tarball SHA-256");
|
||||
return tarball;
|
||||
}
|
||||
|
||||
function validateManifestShape(manifest) {
|
||||
if (
|
||||
typeof manifest !== "object" ||
|
||||
manifest === null ||
|
||||
Array.isArray(manifest)
|
||||
)
|
||||
throw new Error("Verified package manifest must be a JSON object");
|
||||
const keys = Object.keys(manifest).sort();
|
||||
if (
|
||||
keys.length !== MANIFEST_KEYS.length ||
|
||||
keys.some((key, index) => key !== MANIFEST_KEYS[index])
|
||||
)
|
||||
throw new Error("Verified package manifest has missing or unknown fields");
|
||||
if (manifest.schemaVersion !== 1)
|
||||
throw new Error("Unsupported verified package manifest schema version");
|
||||
if (
|
||||
typeof manifest.sourceSha !== "string" ||
|
||||
!/^[a-f0-9]{40}$/u.test(manifest.sourceSha)
|
||||
)
|
||||
throw new Error("Verified package manifest has an invalid source SHA");
|
||||
if (!Number.isSafeInteger(manifest.runId) || manifest.runId <= 0)
|
||||
throw new Error("Verified package manifest has an invalid workflow run ID");
|
||||
if (!Number.isSafeInteger(manifest.runAttempt) || manifest.runAttempt <= 0)
|
||||
throw new Error(
|
||||
"Verified package manifest has an invalid workflow run attempt",
|
||||
);
|
||||
for (const key of [
|
||||
"platform",
|
||||
"architecture",
|
||||
"packageVersion",
|
||||
"nodeVersion",
|
||||
])
|
||||
if (typeof manifest[key] !== "string" || manifest[key].length === 0)
|
||||
throw new Error(`Verified package manifest has an invalid ${key}`);
|
||||
if (
|
||||
typeof manifest.sha256 !== "string" ||
|
||||
!/^[a-f0-9]{64}$/u.test(manifest.sha256)
|
||||
)
|
||||
throw new Error("Verified package manifest has an invalid tarball SHA-256");
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, label) {
|
||||
if (actual !== expected)
|
||||
throw new Error(`Verified package ${label} mismatch`);
|
||||
}
|
||||
|
||||
async function sha256File(path) {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function validateFromCli(directory) {
|
||||
const sourceSha = process.env.GITHUB_SHA;
|
||||
const runId = parsePositiveInteger(
|
||||
process.env.GITHUB_RUN_ID,
|
||||
"GITHUB_RUN_ID",
|
||||
);
|
||||
const runAttempt = parsePositiveInteger(
|
||||
process.env.GITHUB_RUN_ATTEMPT,
|
||||
"GITHUB_RUN_ATTEMPT",
|
||||
);
|
||||
if (sourceSha === undefined || !/^[a-f0-9]{40}$/u.test(sourceSha))
|
||||
throw new Error("GITHUB_SHA must be a full commit SHA");
|
||||
const checkoutSha = (
|
||||
await execFileAsync("git", ["rev-parse", "HEAD"])
|
||||
).stdout.trim();
|
||||
assertEqual(checkoutSha, sourceSha, "checked out source SHA");
|
||||
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
|
||||
const tarball = await validateVerifiedPackage({
|
||||
directory,
|
||||
sourceSha,
|
||||
runId,
|
||||
runAttempt,
|
||||
packageVersion: packageJson.version,
|
||||
nodeVersion: process.version,
|
||||
});
|
||||
if (process.env.GITHUB_OUTPUT === undefined)
|
||||
throw new Error(
|
||||
"GITHUB_OUTPUT is required to publish the validated tarball path",
|
||||
);
|
||||
await writeFile(process.env.GITHUB_OUTPUT, `tarball=${tarball}\n`, {
|
||||
encoding: "utf8",
|
||||
flag: "a",
|
||||
});
|
||||
process.stdout.write(`${tarball}\n`);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, name) {
|
||||
if (value === undefined || !/^[1-9]\d*$/u.test(value))
|
||||
throw new Error(`${name} must be a positive integer`);
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed))
|
||||
throw new Error(`${name} must be a safe positive integer`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1];
|
||||
if (
|
||||
invokedPath !== undefined &&
|
||||
resolve(invokedPath) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
const [command, directory, ...extra] = process.argv.slice(2);
|
||||
if (command !== "validate" || directory === undefined || extra.length > 0) {
|
||||
process.stderr.write(
|
||||
"Usage: node scripts/ci/verified-package.mjs validate <artifact-directory>\n",
|
||||
);
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
await validateFromCli(directory);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,20 @@ class Generator:
|
||||
assert "ows-secret" not in json.dumps(projected), projected
|
||||
if isinstance(value, bytes):
|
||||
assert projected["binary_fields"][-1]["state"] == "redacted", projected
|
||||
body = b"A" * 12345
|
||||
state = {"response": {"content": body}}
|
||||
control = adapter.project_record(state, [])
|
||||
field = control["binary_fields"][0]
|
||||
assert field["state"] == "retained" and field["bytes"] == len(body), control
|
||||
for literal in ["12345", "2345"]:
|
||||
# Only the generated byte count matches this exclusion.
|
||||
assert literal not in control["reported"]["response"]["content"]
|
||||
assert literal not in field["content_base64"] and literal not in field["sha256"]
|
||||
projected = adapter.project_record(state, [literal])
|
||||
assert projected["reported"]["response"]["content"] is None, projected
|
||||
assert projected["binary_fields"] == [{"pointer": "/response/content", "representation": "producer-bytes", "state": "redacted", "content_base64": None, "bytes": None, "sha256": None}], projected
|
||||
assert {"pointer": "/response/content", "reason": "explicit-sensitive-value"} in projected["redactions"], projected
|
||||
assert literal not in json.dumps(projected), projected
|
||||
(root / "native-error-classifications.json").write_text(json.dumps({"passed": len(classifications) + 2}))
|
||||
first = http.HTTPFlow(connection.Client(peername=("127.0.0.1", 1), sockname=("127.0.0.1", 2)), connection.Server(address=("example.test", 80)))
|
||||
first.id = "original-producer-id"
|
||||
|
||||
@@ -4,9 +4,14 @@ import { createHash } from "node:crypto";
|
||||
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
buildNativeAotFixture,
|
||||
nativeAotCommandError,
|
||||
supportsNativeAotFixtureHost,
|
||||
} from "./lib/nativeaot-build.mjs";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
if (process.arch !== "x64" || !["linux", "win32"].includes(process.platform))
|
||||
if (!supportsNativeAotFixtureHost(process.platform, process.arch))
|
||||
throw new Error(
|
||||
"NativeAOT fixture lane requires Linux or Windows x64; it does not cross-compile NativeAOT.",
|
||||
);
|
||||
@@ -40,9 +45,7 @@ const run = (command, args) => {
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
if (result.error || result.status !== 0)
|
||||
throw new Error(
|
||||
`NativeAOT fixture lane requires/action failed ${command}: ${result.error?.message ?? ""}\n${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
);
|
||||
throw nativeAotCommandError(command, args[0], result);
|
||||
return result.stdout;
|
||||
};
|
||||
const dotnet = process.env.REA_NATIVEAOT_DOTNET ?? "dotnet";
|
||||
@@ -57,20 +60,12 @@ if (process.platform === "linux") {
|
||||
run("clang", ["--version"]);
|
||||
}
|
||||
const map = join(output, "NativeAotFixture.map");
|
||||
run(dotnet, [
|
||||
"publish",
|
||||
join(root, "tests/fixtures/nativeaot/NativeAotFixture.csproj"),
|
||||
"-r",
|
||||
await buildNativeAotFixture(run, {
|
||||
dotnet,
|
||||
project: join(root, "tests/fixtures/nativeaot/NativeAotFixture.csproj"),
|
||||
runtime,
|
||||
"-c",
|
||||
"Release",
|
||||
"-o",
|
||||
join(output, "symbols"),
|
||||
"--maxcpucount:1",
|
||||
`-p:BaseIntermediateOutputPath=${join(output, "obj")}/`,
|
||||
`-p:BaseOutputPath=${join(output, "bin")}/`,
|
||||
`-p:NativeAotMapPath=${map}`,
|
||||
]);
|
||||
output,
|
||||
});
|
||||
const name =
|
||||
process.platform === "win32" ? "NativeAotFixture.exe" : "NativeAotFixture";
|
||||
const binary = join(output, "symbols", name);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Observe leftover demo processes on a dedicated clean Linux runner, including
|
||||
@@ -14,6 +17,9 @@ export const assertCleanLinuxHopperRuntime = async (
|
||||
: [process.env.HOPPER_LAUNCHER_PATH]),
|
||||
],
|
||||
) => {
|
||||
const { stdout } = await execFileAsync("ps", ["-ax", "-o", "command="]);
|
||||
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(stdout))
|
||||
throw new Error("Clean Linux runner retained a Hopper demo process");
|
||||
const launchers = [];
|
||||
for (const path of launcherPaths) {
|
||||
try {
|
||||
@@ -22,34 +28,18 @@ export const assertCleanLinuxHopperRuntime = async (
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
if (launchers.length === 0) return;
|
||||
// /proc exposes the executed file even when argv[0] names a removed symlink.
|
||||
// Compare file identity, not a display path or a process name.
|
||||
let inspectionError;
|
||||
for (const entry of await readdir("/proc", { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue;
|
||||
let retainedRuntime;
|
||||
let executable;
|
||||
try {
|
||||
// procfs preserves argument boundaries. A caller merely mentioning a
|
||||
// runtime in its script text or other arguments is not that runtime.
|
||||
const arguments_ = (
|
||||
await readFile(`/proc/${entry.name}/cmdline`, "utf8")
|
||||
).split("\0");
|
||||
retainedRuntime =
|
||||
arguments_[0] === "/opt/hopper/bin/Hopper" ||
|
||||
basename(arguments_[0] ?? "") === "Xvfb" ||
|
||||
(basename(arguments_[0] ?? "") === "python3" &&
|
||||
basename(arguments_[1] ?? "") === "hopper-demo-x11.py");
|
||||
if (!retainedRuntime && launchers.length > 0) {
|
||||
const executable = await stat(`/proc/${entry.name}/exe`, {
|
||||
bigint: true,
|
||||
});
|
||||
retainedRuntime = launchers.some(
|
||||
({ dev, ino }) => dev === executable.dev && ino === executable.ino,
|
||||
);
|
||||
}
|
||||
executable = await stat(`/proc/${entry.name}/exe`, { bigint: true });
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT" || error?.code === "ESRCH") continue;
|
||||
// Other users' process facts may be unreadable. The setup lane launches
|
||||
// Other users' executables may be unreadable. The setup lane launches
|
||||
// providers as the caller; unreadable caller-owned processes fail closed.
|
||||
if (error?.code === "EACCES") {
|
||||
const owner = await stat(`/proc/${entry.name}`).catch((cause) => {
|
||||
@@ -63,7 +53,11 @@ export const assertCleanLinuxHopperRuntime = async (
|
||||
inspectionError ??= error;
|
||||
continue;
|
||||
}
|
||||
if (retainedRuntime)
|
||||
if (
|
||||
launchers.some(
|
||||
({ dev, ino }) => dev === executable.dev && ino === executable.ino,
|
||||
)
|
||||
)
|
||||
throw new Error("Clean Linux runner retained a Hopper demo process");
|
||||
}
|
||||
if (inspectionError !== undefined) throw inspectionError;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function assertFeatureTrace(
|
||||
trace: unknown,
|
||||
procedure: { readonly address: string },
|
||||
): void;
|
||||
|
||||
export function findMappedNonFunction(
|
||||
strings: readonly { readonly address: string }[],
|
||||
resolveAt: (address: string) => Promise<unknown>,
|
||||
): Promise<string | undefined>;
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const NEGATIVE_REASONS = new Set(["not_in_procedure", "outside_segments"]);
|
||||
|
||||
/**
|
||||
* Assert the `trace_feature` result of the real IDA lane for one selected
|
||||
* procedure. The query is a substring search, so the trace may also match other
|
||||
* procedures and strings; each reference is checked against its own matched
|
||||
* target, and a source outside every function (a data reference) is valid.
|
||||
*/
|
||||
export function assertFeatureTrace(trace, procedure) {
|
||||
assert.ok(
|
||||
trace.matches.some(
|
||||
({ type, address }) =>
|
||||
type === "procedure" && address === procedure.address,
|
||||
),
|
||||
"The trace omitted the selected procedure's match.",
|
||||
);
|
||||
assert.equal(typeof trace.truncated, "boolean");
|
||||
const matched = new Set(trace.matches.map(({ address }) => address));
|
||||
for (const reference of trace.references) {
|
||||
assert.ok(
|
||||
matched.has(reference.target_address),
|
||||
`A reference targets ${reference.target_address}, which is not a match.`,
|
||||
);
|
||||
const { containing_procedure: containing } = reference;
|
||||
if (containing.found)
|
||||
assert.equal(typeof containing.procedure.address, "string");
|
||||
else assert.ok(NEGATIVE_REASONS.has(containing.reason));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a string that IDA maps outside every function. A string inside a
|
||||
* function chunk (inline data in code) is valid and skipped, so the strings are
|
||||
* tried in order. Any other answer must be "not in a procedure": IDA lists only
|
||||
* mapped strings, so an unmapped answer is a containment defect and fails the
|
||||
* lane. Returns the address, or undefined when every string is inside a function.
|
||||
*/
|
||||
export async function findMappedNonFunction(strings, resolveAt) {
|
||||
for (const { address } of strings) {
|
||||
const resolved = await resolveAt(address);
|
||||
if (resolved.found) continue;
|
||||
assert.equal(
|
||||
resolved.reason,
|
||||
"not_in_procedure",
|
||||
`The listed string at ${address} resolved as ${resolved.reason}.`,
|
||||
);
|
||||
return address;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function supportsNativeAotFixtureHost(
|
||||
platform: string,
|
||||
architecture: string,
|
||||
): boolean;
|
||||
|
||||
export function buildNativeAotFixture(
|
||||
run: (command: string, args: string[]) => unknown,
|
||||
options: {
|
||||
dotnet: string;
|
||||
project: string;
|
||||
runtime: string;
|
||||
output: string;
|
||||
},
|
||||
): Promise<void>;
|
||||
|
||||
export function nativeAotCommandError(
|
||||
command: string,
|
||||
operation: string,
|
||||
result: {
|
||||
error?: { message: string };
|
||||
status: number | null;
|
||||
stdout?: string | null;
|
||||
stderr?: string | null;
|
||||
},
|
||||
): Error;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
/** NativeAOT fixtures use native toolchains, not cross-compilation. */
|
||||
export function supportsNativeAotFixtureHost(platform, architecture) {
|
||||
return architecture === "x64" && ["linux", "win32"].includes(platform);
|
||||
}
|
||||
|
||||
/** Keep network recovery separate from compilation and preserve restore failures. */
|
||||
export async function buildNativeAotFixture(run, options) {
|
||||
const { dotnet, project, runtime, output } = options;
|
||||
const common = [
|
||||
project,
|
||||
"-r",
|
||||
runtime,
|
||||
"--maxcpucount:1",
|
||||
"-p:Configuration=Release",
|
||||
`-p:BaseIntermediateOutputPath=${join(output, "obj")}/`,
|
||||
`-p:BaseOutputPath=${join(output, "bin")}/`,
|
||||
`-p:NativeAotMapPath=${join(output, "NativeAotFixture.map")}`,
|
||||
];
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
console.error(`NativeAOT dependency restore: attempt ${attempt}/2`);
|
||||
try {
|
||||
run(dotnet, ["restore", ...common, "--verbosity", "normal"]);
|
||||
break;
|
||||
} catch (cause) {
|
||||
if (attempt === 2)
|
||||
throw new Error(
|
||||
"NativeAOT dependency restore failed after two attempts",
|
||||
{
|
||||
cause,
|
||||
},
|
||||
);
|
||||
// Child diagnostics can contain authenticated feed URLs; keep retry logs static.
|
||||
console.error(
|
||||
"NativeAOT dependency restore failed; retrying in 1 second",
|
||||
);
|
||||
await delay(1_000);
|
||||
}
|
||||
}
|
||||
console.error("NativeAOT compilation (dependencies already restored)");
|
||||
run(dotnet, [
|
||||
"publish",
|
||||
...common,
|
||||
"--no-restore",
|
||||
"-o",
|
||||
join(output, "symbols"),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Sanitize child diagnostics before any retry, cause chain, or uncaught output. */
|
||||
export function nativeAotCommandError(command, operation, result) {
|
||||
const diagnostic = `NativeAOT fixture lane requires/action failed ${command} ${operation}: ${result.error?.message ?? `exit ${result.status}`}\n${result.stdout ?? ""}${result.stderr ?? ""}`;
|
||||
// Only URL authority userinfo is authentication; preserve host, path, query,
|
||||
// and unrelated diagnostic values. Never retain the raw result as a cause.
|
||||
return new Error(
|
||||
diagnostic.replace(/\b(https?:\/\/)[^\\\s/?#]*@/giu, "$1[redacted]@"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../../dist/domain/evidence.js";
|
||||
import {
|
||||
requireMcpOperationResult,
|
||||
requireMcpToolError,
|
||||
mcpTextValue,
|
||||
} from "./mcp-verifier-results.mjs";
|
||||
|
||||
/** Exercise file coordinates through public CLI/MCP using the source-owned PE fixture. */
|
||||
export async function verifyGhidraFileOffsets(
|
||||
targetPath,
|
||||
entrypoint,
|
||||
procedure,
|
||||
) {
|
||||
const original = await readFile(targetPath);
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-file-offsets-"));
|
||||
const runtime = join(workspace, "runtime");
|
||||
await mkdir(runtime);
|
||||
const env = {
|
||||
...process.env,
|
||||
TEMP: runtime,
|
||||
TMP: runtime,
|
||||
TMPDIR: runtime,
|
||||
REA_ANALYSIS_PROVIDER: "ghidra",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
};
|
||||
const client = new Client({ name: "rea-file-offsets", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
let connected = false;
|
||||
let complete = false;
|
||||
let primaryFailure;
|
||||
const call = async (name, arguments_ = {}) => {
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: arguments_ },
|
||||
{ timeout: 240_000 },
|
||||
);
|
||||
const projected = requireMcpOperationResult(result, name);
|
||||
return ["open_binary", "binary_session", "close_binary"].includes(name)
|
||||
? projected
|
||||
: (result.structuredContent ?? JSON.parse(mcpTextValue(result)));
|
||||
};
|
||||
try {
|
||||
await client.connect(transport);
|
||||
connected = true;
|
||||
await call("open_binary", { path: targetPath, provider_id: "ghidra" });
|
||||
const session = await call("binary_session");
|
||||
const mapping = parseEvidence(
|
||||
await call("address_to_file_offset", { address: procedure }),
|
||||
);
|
||||
const offset = mapping.normalized_result.file_offset;
|
||||
const coordinate = `0x${offset.toString(16)}`;
|
||||
const expected = parseEvidence(
|
||||
await call("procedure_pseudo_code", { procedure }),
|
||||
);
|
||||
const explicit = parseEvidence(
|
||||
await call("procedure_pseudo_code", {
|
||||
procedure: `file-offset:${coordinate}`,
|
||||
}),
|
||||
);
|
||||
const automatic = parseEvidence(
|
||||
await call("procedure_pseudo_code", { procedure: coordinate }),
|
||||
);
|
||||
for (const actual of [explicit, automatic]) {
|
||||
assert.equal(actual.normalized_result, expected.normalized_result);
|
||||
assert.equal(actual.raw_result.selection.file_offset, offset);
|
||||
assert.equal(actual.raw_result.selection.mapped_address, procedure);
|
||||
assert.ok(
|
||||
actual.locations.some(
|
||||
(location) =>
|
||||
location.kind === "file-offset" && location.offset === offset,
|
||||
),
|
||||
);
|
||||
}
|
||||
const invalid = await client.callTool({
|
||||
name: "procedure_pseudo_code",
|
||||
arguments: { procedure: "file-offset:0x20000000000000" },
|
||||
});
|
||||
assert.equal(requireMcpToolError(invalid).code, "invalid_request");
|
||||
const unmapped = await client.callTool({
|
||||
name: "procedure_pseudo_code",
|
||||
arguments: { procedure: "file-offset:0x100000" },
|
||||
});
|
||||
assert.ok(
|
||||
JSON.stringify(requireMcpToolError(unmapped)).includes(
|
||||
"no loaded source mapping",
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
(await call("binary_session")).analysis_run.run_id,
|
||||
session.analysis_run.run_id,
|
||||
);
|
||||
const image = parseEvidence(await call("inspect_native_load_image"));
|
||||
const headerMapping = image.normalized_result.observations.mappings.find(
|
||||
(item) => item.initialized && item.file_offset === 0,
|
||||
);
|
||||
assert.ok(
|
||||
headerMapping,
|
||||
"Fixture header must have an observed source mapping",
|
||||
);
|
||||
const headerOffset = parseEvidence(
|
||||
await call("address_to_file_offset", { address: headerMapping.start }),
|
||||
);
|
||||
assert.equal(headerOffset.normalized_result.file_offset, 0);
|
||||
for (const selector of ["file-offset:0x0", "0x0"]) {
|
||||
const missing = requireMcpToolError(
|
||||
await client.callTool({
|
||||
name: "procedure_pseudo_code",
|
||||
arguments: { procedure: selector },
|
||||
}),
|
||||
);
|
||||
assertMissingMappedProcedure(missing, headerMapping.start);
|
||||
}
|
||||
assert.equal(
|
||||
(await call("binary_session")).analysis_run.run_id,
|
||||
session.analysis_run.run_id,
|
||||
);
|
||||
await call("close_binary");
|
||||
const cli = JSON.parse(
|
||||
(
|
||||
await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"decompile",
|
||||
targetPath,
|
||||
`--file-offset=${coordinate}`,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 },
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
assert.equal(cli.normalized_result, explicit.normalized_result);
|
||||
assert.deepEqual(cli.locations, explicit.locations);
|
||||
const missingCli = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"decompile",
|
||||
targetPath,
|
||||
"--file-offset=0x0",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 },
|
||||
).then(
|
||||
() => {
|
||||
throw new Error(
|
||||
"Mapped header bytes must not decompile as a procedure",
|
||||
);
|
||||
},
|
||||
(cause) => JSON.parse(cause.stdout),
|
||||
);
|
||||
assertMissingMappedProcedure(missingCli, headerMapping.start);
|
||||
const alias = Buffer.from(original);
|
||||
const pe = alias.readUInt32LE(0x3c);
|
||||
if (alias.readUInt16LE(pe + 6) >= 2) {
|
||||
const secondSection = pe + 24 + alias.readUInt16LE(pe + 20) + 40;
|
||||
// Both sections now carry the same original bytes at distinct virtual addresses.
|
||||
alias.writeUInt32LE(0x200, secondSection + 20);
|
||||
const ambiguousPath = join(workspace, "ambiguous.exe");
|
||||
await writeFile(ambiguousPath, alias);
|
||||
await call("open_binary", { path: ambiguousPath, provider_id: "ghidra" });
|
||||
const ambiguous = await client.callTool(
|
||||
{
|
||||
name: "procedure_pseudo_code",
|
||||
arguments: { procedure: `file-offset:${coordinate}` },
|
||||
},
|
||||
{ timeout: 240_000 },
|
||||
);
|
||||
assert.ok(
|
||||
JSON.stringify(requireMcpToolError(ambiguous)).includes(
|
||||
"multiple loaded mappings",
|
||||
),
|
||||
);
|
||||
await call("close_binary");
|
||||
}
|
||||
assert.deepEqual(await readFile(targetPath), original);
|
||||
await client.close();
|
||||
connected = false;
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
complete = true;
|
||||
return {
|
||||
cli_mcp_parity: true,
|
||||
explicit_and_numeric_offsets: true,
|
||||
invalid_and_unmapped_offsets: true,
|
||||
mapped_offset_without_procedure: true,
|
||||
ambiguous_mappings:
|
||||
alias.readUInt16LE(pe + 6) >= 2
|
||||
? "verified"
|
||||
: "unavailable-single-section",
|
||||
source_unchanged: true,
|
||||
cleanup: "complete",
|
||||
};
|
||||
} catch (cause) {
|
||||
primaryFailure = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
const cleanupFailures = [];
|
||||
if (connected) {
|
||||
try {
|
||||
await call("close_binary");
|
||||
} catch (cause) {
|
||||
cleanupFailures.push(cause);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.close();
|
||||
} catch (cause) {
|
||||
cleanupFailures.push(cause);
|
||||
}
|
||||
if (cleanupFailures.length > 0) {
|
||||
process.stderr.write(
|
||||
`Retained file-offset verifier workspace: ${workspace}\n`,
|
||||
);
|
||||
throw new AggregateError(
|
||||
primaryFailure === undefined
|
||||
? cleanupFailures
|
||||
: [primaryFailure, ...cleanupFailures],
|
||||
"File-offset verifier cleanup failed",
|
||||
);
|
||||
}
|
||||
if (complete) await rm(workspace, { recursive: true, force: true });
|
||||
else
|
||||
process.stderr.write(
|
||||
`Retained file-offset verifier workspace: ${workspace}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertMissingMappedProcedure(error, mappedAddress) {
|
||||
assert.equal(error.code, "invalid_request");
|
||||
const message = error.details?.issues?.[0]?.message;
|
||||
assert.equal(typeof message, "string");
|
||||
assert.ok(
|
||||
message.includes("File offset 0x0 maps to analysis address"),
|
||||
message,
|
||||
);
|
||||
assert.ok(message.includes(mappedAddress), message);
|
||||
assert.ok(message.includes("no procedure starts at or contains"), message);
|
||||
assert.ok(!message.includes("--file-offset"), message);
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
|
||||
|
||||
type VerifierCall = (
|
||||
name: string,
|
||||
arguments_?: Record<string, unknown>,
|
||||
@@ -29,3 +25,6 @@ export function verifyHopperStringObjects(
|
||||
}[];
|
||||
readonly displayEscapesExcludedFromMatching: true;
|
||||
}>;
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, unlink } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
import { PRODUCT_IDENTITY } from "../dist/identity.js";
|
||||
@@ -21,12 +23,19 @@ import { verifyRepositorySkill } from "./verify-repository-skill.mjs";
|
||||
import { verifyManaged } from "./verify-package-managed.mjs";
|
||||
import { verifyUnknownProvider } from "./verify-package-unknown-provider.mjs";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import {
|
||||
createVerifiedPackageMetadata,
|
||||
retainVerifiedPackage,
|
||||
writeVerifiedPackageManifest,
|
||||
} from "./ci/verified-package.mjs";
|
||||
import {
|
||||
measureMcpStartup,
|
||||
profileMcpModuleLoading,
|
||||
} from "./lib/mcp-startup-probe.mjs";
|
||||
|
||||
const root = process.cwd();
|
||||
const execFileAsync = promisify(execFile);
|
||||
const retentionDirectory = process.env.REA_VERIFIED_PACKAGE_DIRECTORY;
|
||||
const verifierRun = createVerifierRun();
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-package-"));
|
||||
const evidenceRoot = join(workspace, "evidence");
|
||||
@@ -37,6 +46,8 @@ let mcpModuleLoading;
|
||||
let update;
|
||||
let repositorySkill;
|
||||
let report;
|
||||
let retainedPackage;
|
||||
let retentionMetadata;
|
||||
|
||||
const runStage = async (name, action) => {
|
||||
const started = performance.now();
|
||||
@@ -49,6 +60,27 @@ const runStage = async (name, action) => {
|
||||
};
|
||||
|
||||
try {
|
||||
if (retentionDirectory !== undefined && retentionDirectory !== "") {
|
||||
const sourceSha = (
|
||||
await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root })
|
||||
).stdout.trim();
|
||||
retentionMetadata = createVerifiedPackageMetadata({
|
||||
sourceSha,
|
||||
runId: process.env.GITHUB_RUN_ID,
|
||||
runAttempt: process.env.GITHUB_RUN_ATTEMPT,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
packageVersion: PRODUCT_IDENTITY.packageVersion,
|
||||
nodeVersion: process.version,
|
||||
});
|
||||
await mkdir(retentionDirectory, { recursive: true });
|
||||
await unlink(join(retentionDirectory, "manifest.json")).catch((cause) => {
|
||||
if (
|
||||
!(cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||
)
|
||||
throw cause;
|
||||
});
|
||||
}
|
||||
repositorySkill = await runStage("repository skill", () =>
|
||||
verifyRepositorySkill({ root, workspace }),
|
||||
);
|
||||
@@ -215,6 +247,13 @@ try {
|
||||
mcpStartup,
|
||||
mcpModuleLoading,
|
||||
};
|
||||
if (retentionMetadata !== undefined) {
|
||||
retainedPackage = await retainVerifiedPackage({
|
||||
sourceTarball: tarball,
|
||||
directory: retentionDirectory,
|
||||
metadata: retentionMetadata,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await runStage("workspace cleanup", () =>
|
||||
rm(workspace, { recursive: true, force: true }),
|
||||
@@ -223,6 +262,11 @@ try {
|
||||
const verifierLineage = await runStage("verifier lineage", () =>
|
||||
completeVerifierRun(verifierRun),
|
||||
);
|
||||
if (retainedPackage !== undefined)
|
||||
await writeVerifiedPackageManifest(
|
||||
retainedPackage.directory,
|
||||
retainedPackage.metadata,
|
||||
);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ verifier_run: verifierLineage, ...report })}\n`,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { estimateTokenCount } from "tokenx";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, readdir } from "node:fs/promises";
|
||||
import { readFile, writeFile, readdir, mkdir, rm } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
@@ -91,6 +92,20 @@ const source = pcap(1, [
|
||||
// LINKTYPE_BLUETOOTH_HCI_H4 = 187; H4 command packet, HCI Reset opcode 0x0c03, no parameters.
|
||||
const bluetoothSource = pcap(187, [Buffer.from("01030c00", "hex")]);
|
||||
let cases = 0;
|
||||
const discoveryMeasurements = [];
|
||||
const inheritedAddressSpace = (await readFile("/proc/self/limits", "utf8"))
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("Max address space "))
|
||||
?.trim()
|
||||
.match(/^Max address space\s+(\S+)\s+(\S+)\s+bytes$/u);
|
||||
assert.ok(inheritedAddressSpace);
|
||||
const expectedAddressSpace = Math.min(
|
||||
1073741824,
|
||||
...inheritedAddressSpace
|
||||
.slice(1)
|
||||
.map((value) => (value === "unlimited" ? Infinity : Number(value))),
|
||||
);
|
||||
|
||||
try {
|
||||
await writeFile(path, source);
|
||||
await writeFile(bluetooth, bluetoothSource);
|
||||
@@ -121,14 +136,16 @@ try {
|
||||
.digest("hex"),
|
||||
);
|
||||
assert.equal(report.hardware_acquisition, "not-performed");
|
||||
assert.equal(report.limits.address_space_bytes, 1073741824);
|
||||
assert.equal(report.limits.address_space_bytes, expectedAddressSpace);
|
||||
assert.equal(report.tool.configured_path, process.env.REA_TSHARK_COMMAND);
|
||||
assert.equal(
|
||||
report.resource_limiter.configured_path,
|
||||
process.env.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
|
||||
);
|
||||
assert.ok(
|
||||
report.resource_limiter.arguments.includes("--as=1073741824:1073741824"),
|
||||
report.resource_limiter.arguments.includes(
|
||||
`--as=${expectedAddressSpace}:${expectedAddressSpace}`,
|
||||
),
|
||||
);
|
||||
assert.equal(report.configuration, "private-empty-personal-profile");
|
||||
const flags = Object.entries(input)
|
||||
@@ -313,6 +330,133 @@ try {
|
||||
true,
|
||||
);
|
||||
cases++;
|
||||
const discovery = (await client.listTools()).tools.find(
|
||||
({ name }) => name === "inspect_packet_fields",
|
||||
);
|
||||
assert.ok(discovery?.outputSchema);
|
||||
assert.ok(ajv.validateSchema(discovery.inputSchema));
|
||||
assert.ok(ajv.validateSchema(discovery.outputSchema));
|
||||
const nativeProfile = join(root.path, "native-profile");
|
||||
await mkdir(nativeProfile);
|
||||
const native = (
|
||||
await execute(process.env.REA_TSHARK_COMMAND, ["-G", "fields"], {
|
||||
env: { ...environment, WIRESHARK_CONFIG_DIR: nativeProfile },
|
||||
timeout: 30000,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
})
|
||||
).stdout;
|
||||
await rm(nativeProfile, { recursive: true });
|
||||
const nativeLines = native.split(/\r?\n/);
|
||||
if (nativeLines.at(-1) === "") nativeLines.pop();
|
||||
for (const input of [
|
||||
{ query: "usb" },
|
||||
{ limit: 1 },
|
||||
{ offset: 1, limit: 2 },
|
||||
{ kind: "fields", protocol: "usb", query: "idVendor" },
|
||||
{ kind: "fields", protocol: "udp", limit: null },
|
||||
{ kind: "fields", query: "rea_no_such_field" },
|
||||
]) {
|
||||
const started = performance.now();
|
||||
const response = await client.callTool({
|
||||
name: discovery.name,
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
assert.ok(
|
||||
ajv.validate(discovery.outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
const report = evidence.normalized_result;
|
||||
const kind = input.kind ?? "protocols";
|
||||
const query = (input.query ?? "").toLowerCase();
|
||||
const expectedMatches = nativeLines.flatMap((line, index) => {
|
||||
const columns = line.split("\t");
|
||||
if ((kind === "protocols") !== (columns[0] === "P")) return [];
|
||||
if (
|
||||
input.protocol !== undefined &&
|
||||
(columns[0] === "P" ? columns[2] : columns[4]) !== input.protocol
|
||||
)
|
||||
return [];
|
||||
if (
|
||||
![
|
||||
columns[1],
|
||||
columns[2],
|
||||
...(columns[0] === "F" ? [columns[7]] : []),
|
||||
].some((value) => value.toLowerCase().includes(query))
|
||||
)
|
||||
return [];
|
||||
return [{ sourceLine: index + 1, columns }];
|
||||
});
|
||||
const offset = input.offset ?? 0;
|
||||
const limit = input.limit === null ? undefined : (input.limit ?? 50);
|
||||
const expectedPage = expectedMatches.slice(
|
||||
offset,
|
||||
limit === undefined ? undefined : offset + limit,
|
||||
);
|
||||
assert.deepEqual(
|
||||
report.records.map((record) => record.source_line),
|
||||
expectedPage.map((record) => record.sourceLine),
|
||||
);
|
||||
assert.deepEqual(report.output_window, {
|
||||
examined: nativeLines.filter((line) =>
|
||||
line.startsWith(kind === "protocols" ? "P\t" : "F\t"),
|
||||
).length,
|
||||
matching: expectedMatches.length,
|
||||
returned: expectedPage.length,
|
||||
has_more:
|
||||
Math.max(0, expectedMatches.length - offset) > expectedPage.length,
|
||||
});
|
||||
discoveryMeasurements.push({
|
||||
input,
|
||||
elapsed_ms: +(performance.now() - started).toFixed(2),
|
||||
response_bytes: Buffer.byteLength(JSON.stringify(response)),
|
||||
estimated_tokens_one_tool_request_response: estimateTokenCount(
|
||||
JSON.stringify(discovery) +
|
||||
JSON.stringify(input) +
|
||||
JSON.stringify(response),
|
||||
),
|
||||
});
|
||||
for (const record of report.records) {
|
||||
const columns = nativeLines[record.source_line - 1].split("\t");
|
||||
assert.equal(record.name, columns[1]);
|
||||
assert.equal(record.abbreviation, columns[2]);
|
||||
assert.equal(record.field_type, columns[0] === "F" ? columns[3] : null);
|
||||
assert.equal(
|
||||
record.parent_protocol,
|
||||
columns[0] === "F" ? columns[4] : null,
|
||||
);
|
||||
assert.equal(record.description, columns[0] === "F" ? columns[7] : null);
|
||||
assert.equal(record.display_base, columns[0] === "F" ? columns[5] : null);
|
||||
assert.equal(record.bitmask, columns[0] === "F" ? columns[6] : null);
|
||||
}
|
||||
if (input.query === "idVendor")
|
||||
assert.ok(
|
||||
report.records.some(
|
||||
(row) =>
|
||||
row.abbreviation === "usb.idVendor" &&
|
||||
row.field_type === "FT_UINT16",
|
||||
),
|
||||
);
|
||||
if (input.query === "usb")
|
||||
assert.ok(report.records.some((row) => row.abbreviation === "usb"));
|
||||
if (input.query === "rea_no_such_field")
|
||||
assert.equal(report.output_window.matching, 0);
|
||||
const flags = Object.entries(input).flatMap(([key, value]) =>
|
||||
value === null ? ["--all"] : [`--${key}`, String(value)],
|
||||
);
|
||||
const cli = JSON.parse(
|
||||
(
|
||||
await execute(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-packet-fields", ...flags, "--format", "json"],
|
||||
{ env: environment, timeout: 60000, maxBuffer: 16 * 1024 * 1024 },
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
assert.deepEqual(cli.normalized_result, report);
|
||||
cases++;
|
||||
}
|
||||
await client.close();
|
||||
assert.deepEqual((await readdir(root.path)).sort(), [
|
||||
"bluetooth.pcap",
|
||||
@@ -329,6 +473,9 @@ console.log(
|
||||
{
|
||||
status: "passed",
|
||||
cases,
|
||||
discovery_measurements: discoveryMeasurements,
|
||||
measurement_scope:
|
||||
"Warm MCP server, fresh TShark processes; tokenx estimates include one advertised tool/request/response, not billed usage. Model-driven selection, CPU and peak process-family memory are unmeasured; no cache/speedup claim.",
|
||||
host: `${process.platform}/${process.arch}`,
|
||||
scope:
|
||||
"real TShark CLI/MCP; synthetic Ethernet/IPv4/UDP/DNS and Bluetooth HCI PCAP; no hardware acquisition",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
realpath,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { exec } from "./lib/verify-package-core.mjs";
|
||||
@@ -18,10 +18,6 @@ import {
|
||||
toolContract,
|
||||
} from "../dist/contracts/toolContracts.js";
|
||||
|
||||
assert(
|
||||
process.platform !== "win32",
|
||||
"The Qwen real-client verifier currently requires a POSIX host; native Windows is unverified.",
|
||||
);
|
||||
const repo = fileURLToPath(new URL("..", import.meta.url));
|
||||
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||
const lab = await mkdtemp(join(tmpdir(), "rea-qwen-client-"));
|
||||
@@ -32,6 +28,13 @@ assert(
|
||||
"Usage: node scripts/verify-qwen-client.mjs [chat|call]",
|
||||
);
|
||||
const command = process.env.REA_VERIFY_QWEN_COMMAND ?? "qwen";
|
||||
// npm exposes only qwen.cmd on Windows. Resolve a selected JavaScript entry
|
||||
// before the verifier switches to its disposable workspace.
|
||||
const entryPoint = /\.(c|m)?js$/iu.test(command) ? resolve(command) : undefined;
|
||||
const launch = (args) =>
|
||||
entryPoint === undefined
|
||||
? [command, args]
|
||||
: [process.execPath, [entryPoint, ...args]];
|
||||
const verifier = createVerifierRun();
|
||||
const profile = join(lab, "selected profile");
|
||||
const account = join(lab, "account");
|
||||
@@ -54,14 +57,18 @@ const baseEnvironment = {
|
||||
let clientVersion;
|
||||
try {
|
||||
clientVersion = (
|
||||
await exec(command, ["--version"], {
|
||||
await exec(...launch(["--version"]), {
|
||||
env: baseEnvironment,
|
||||
timeout: 30_000,
|
||||
})
|
||||
).stdout.trim();
|
||||
} catch (cause) {
|
||||
const windowsHint =
|
||||
process.platform === "win32"
|
||||
? " On Windows, select node_modules/@qwen-code/qwen-code/cli-entry.js."
|
||||
: "";
|
||||
throw new Error(
|
||||
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND. ${String(cause)}`,
|
||||
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND.${windowsHint} ${String(cause)}`,
|
||||
);
|
||||
}
|
||||
const original = JSON.stringify(
|
||||
@@ -93,8 +100,8 @@ for (const step of [
|
||||
assert(
|
||||
result.plannedActions.every(
|
||||
(a) =>
|
||||
a.target.startsWith(profile + "/") ||
|
||||
a.target.startsWith(sharedSkills + "/"),
|
||||
a.target.startsWith(profile + sep) ||
|
||||
a.target.startsWith(sharedSkills + sep),
|
||||
),
|
||||
);
|
||||
await writeFile(
|
||||
@@ -163,7 +170,14 @@ const fixture = await createOpenAiModelFixture({
|
||||
skillSeen ||= JSON.stringify(body.messages ?? []).includes(
|
||||
"reverse-engineer-anything",
|
||||
);
|
||||
selectedSkillLoaded ||= JSON.stringify(body.messages ?? []).includes(
|
||||
const messageText = (body.messages ?? [])
|
||||
.map((message) =>
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: (message.content ?? []).map((part) => part.text ?? "").join("\n"),
|
||||
)
|
||||
.join("\n");
|
||||
selectedSkillLoaded ||= messageText.includes(
|
||||
join(sharedSkills, "reverse-engineer-anything"),
|
||||
);
|
||||
const toolText = JSON.stringify(
|
||||
@@ -243,9 +257,7 @@ const fixture = await createOpenAiModelFixture({
|
||||
const invoke = tools.find((t) => t.name === "tool_call");
|
||||
const skill = tools.find((t) => t.name === "skill");
|
||||
const shell = tools.find((t) => t.name === "run_shell_command");
|
||||
const persisted = toolText.match(
|
||||
/Full output saved to: ([^\\"\n]+)(?:\\n|\n)/u,
|
||||
);
|
||||
const persisted = messageText.match(/Full output saved to: ([^\r\n]+)/u);
|
||||
const call = (name, args) => ({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
@@ -268,13 +280,14 @@ const fixture = await createOpenAiModelFixture({
|
||||
artifactPath = persisted[1];
|
||||
assert(
|
||||
(await realpath(artifactPath)).startsWith(
|
||||
(await realpath(runtime)) + "/",
|
||||
(await realpath(runtime)) + sep,
|
||||
),
|
||||
);
|
||||
const script = `const fs=require('node:fs');const e=JSON.parse(fs.readFileSync(${JSON.stringify(artifactPath)},'utf8'));console.log(JSON.stringify({REA_CLIENT_FIXTURE:JSON.stringify(e.normalized_result).includes('REA_CLIENT_FIXTURE'),evidence_id:e.evidence_id,graph_nodes:e.normalized_result.graph.nodes.length}));`;
|
||||
const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
// Keep paths and JSON quotes out of shell syntax on Windows and POSIX.
|
||||
const encodedScript = Buffer.from(script).toString("base64");
|
||||
message = call(shell.name, {
|
||||
command: `node -e ${quote(script)}`,
|
||||
command: `node -e "eval(Buffer.from('${encodedScript}','base64').toString('utf8'))"`,
|
||||
description:
|
||||
"Read the complete saved REA Evidence and verify the fixture facts.",
|
||||
});
|
||||
@@ -329,7 +342,7 @@ const args = [
|
||||
let response;
|
||||
let failure;
|
||||
try {
|
||||
response = await exec(command, args, {
|
||||
response = await exec(...launch(args), {
|
||||
env,
|
||||
cwd: workspace,
|
||||
timeout: 120_000,
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { 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 { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
if (process.argv.length !== 2)
|
||||
throw new Error("Usage: node scripts/verify-real-ghidra-6502.mjs");
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
throw new Error(
|
||||
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
|
||||
);
|
||||
const exec = promisify(execFile);
|
||||
const run = createVerifierRun();
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
const ca65 = process.env.REA_CA65 ?? "ca65";
|
||||
const ld65 = process.env.REA_LD65 ?? "ld65";
|
||||
const toolchain = {};
|
||||
for (const [name, command] of Object.entries({ ca65, ld65 })) {
|
||||
const version = await exec(command, ["--version"], { timeout: 10000 });
|
||||
toolchain[name] = (version.stdout + version.stderr).trim();
|
||||
}
|
||||
await mkdir(join(root, "build"), { recursive: true });
|
||||
const workspace = await mkdtemp(join(root, "build", "rea-6502-proof-"));
|
||||
const runtime = join(workspace, "runtime");
|
||||
await mkdir(runtime);
|
||||
const path = join(workspace, "image.bin");
|
||||
const object = join(workspace, "image.o");
|
||||
const entrypoint = join(root, "scripts", "rea.mjs");
|
||||
const env = {
|
||||
...process.env,
|
||||
TMPDIR: runtime,
|
||||
TMP: runtime,
|
||||
TEMP: runtime,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_ANALYSIS_PROVIDER: "ghidra",
|
||||
REA_PROCESS_RUN_ID: run.run_id,
|
||||
};
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-real-6502-proof", version: "1" });
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
let report;
|
||||
try {
|
||||
await exec(
|
||||
ca65,
|
||||
[join(root, "tests/conformance/ghidra/6502.s"), "-o", object],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await exec(
|
||||
ld65,
|
||||
["-C", join(root, "tests/conformance/ghidra/6502.cfg"), "-o", path, object],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const bytes = await readFile(path);
|
||||
// Independently known opcodes and link addresses from the source fixture.
|
||||
assert.equal(bytes.toString("hex"), "a907201080c90ad003a92a60a9006018690360");
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
await client.connect(transport);
|
||||
const opened = await call("open_binary", {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0x008001",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(opened.architecture, "6502");
|
||||
assert.equal(opened.entry_point, "0x8001");
|
||||
const status = await call("binary_session");
|
||||
const image = evidence(await call("inspect_native_load_image"), sha256);
|
||||
assert.equal(
|
||||
image.normalized_result.status,
|
||||
"verified",
|
||||
JSON.stringify(image.normalized_result),
|
||||
);
|
||||
assert.equal(image.normalized_result.architecture, "6502");
|
||||
assert.equal(image.normalized_result.base_address, "0x8001");
|
||||
assert.equal(image.normalized_result.language_id, "6502:LE:16:default");
|
||||
assert.equal(image.normalized_result.compiler_spec_id, "default");
|
||||
assert.equal(image.normalized_result.file_bytes, bytes.length);
|
||||
const memory = evidence(
|
||||
await call("read_bytes", { address: "0x8001", length: bytes.length }),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
|
||||
const procedures = evidence(await call("list_procedures"), sha256);
|
||||
for (const address of ["0x8001", "0x8010"])
|
||||
assert.ok(
|
||||
procedures.normalized_result.some(
|
||||
(procedure) => procedure.address === address,
|
||||
),
|
||||
);
|
||||
const instruction = evidence(
|
||||
await call("inspect_native_instruction", { address: "0x8003" }),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR");
|
||||
assert.ok(
|
||||
instruction.normalized_result.references.some(
|
||||
(reference) => reference.call && reference.target_address === "0x8010",
|
||||
),
|
||||
);
|
||||
const pseudo = evidence(
|
||||
await call("procedure_pseudo_code", { procedure: "0x8010" }),
|
||||
sha256,
|
||||
);
|
||||
assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u);
|
||||
const filePseudo = evidence(
|
||||
await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(filePseudo.normalized_result, pseudo.normalized_result);
|
||||
assert.ok(
|
||||
filePseudo.locations.some(
|
||||
(location) => location.kind === "file-offset" && location.offset === 15,
|
||||
),
|
||||
);
|
||||
const rejected = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0xffff",
|
||||
},
|
||||
});
|
||||
assert.equal(rejected.isError, true);
|
||||
assert.match(JSON.stringify(rejected.content), /6502 address width/u);
|
||||
const preserved = await call("binary_session");
|
||||
assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id);
|
||||
assert.equal(preserved.entry_point, "0x8001");
|
||||
await call("close_binary");
|
||||
await client.ping();
|
||||
const { stdout } = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-native-load-image",
|
||||
path,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
"0x8001",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const cli = parseEvidence(JSON.parse(stdout));
|
||||
assert.deepEqual(cli.normalized_result, image.normalized_result);
|
||||
assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest);
|
||||
assert.equal(cli.subject.digest.sha256, sha256);
|
||||
const fileCli = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"decompile",
|
||||
path,
|
||||
"--file-offset=0xf",
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
"0x8001",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout));
|
||||
assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result);
|
||||
assert.deepEqual(fileCliEvidence.locations, filePseudo.locations);
|
||||
assert.equal(fileCliEvidence.subject.digest.sha256, sha256);
|
||||
assert.deepEqual(await readFile(path), bytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
const lowMemory = [];
|
||||
// BinaryLoader maps file bytes before trying processor-defined blocks. Prove
|
||||
// that overlapping zero-page/stack definitions never overlay the selected image.
|
||||
for (const [entry, fullAddressSpace] of [
|
||||
[0xff, false],
|
||||
[0, true],
|
||||
]) {
|
||||
const lowPath = join(workspace, `low-${entry}.bin`);
|
||||
await exec(
|
||||
ld65,
|
||||
[
|
||||
"-C",
|
||||
join(root, "tests/conformance/ghidra/6502.cfg"),
|
||||
"--start-addr",
|
||||
String(entry),
|
||||
"-o",
|
||||
lowPath,
|
||||
object,
|
||||
],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const linked = await readFile(lowPath);
|
||||
const expectedCode = Buffer.from(
|
||||
"a907200000c90ad003a92a60a9006018690360",
|
||||
"hex",
|
||||
);
|
||||
expectedCode.writeUInt16LE(entry + 15, 3);
|
||||
assert.deepEqual(linked, expectedCode);
|
||||
const lowBytes = fullAddressSpace
|
||||
? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)])
|
||||
: linked;
|
||||
await writeFile(lowPath, lowBytes);
|
||||
const lowSha256 = createHash("sha256").update(lowBytes).digest("hex");
|
||||
const lowEntry = `0x${entry.toString(16)}`;
|
||||
const lowOpened = await call("open_binary", {
|
||||
path: lowPath,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: lowEntry,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(lowOpened.entry_point, lowEntry);
|
||||
const lowImage = evidence(
|
||||
await call("inspect_native_load_image"),
|
||||
lowSha256,
|
||||
);
|
||||
assert.equal(
|
||||
lowImage.normalized_result.status,
|
||||
"verified",
|
||||
JSON.stringify(lowImage.normalized_result),
|
||||
);
|
||||
assert.equal(lowImage.normalized_result.base_address, lowEntry);
|
||||
assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length);
|
||||
const fileMappings =
|
||||
lowImage.normalized_result.observations.mappings.filter(
|
||||
(mapping) => mapping.source_file_index !== null,
|
||||
);
|
||||
assert.equal(fileMappings.length, 1);
|
||||
assert.equal(
|
||||
fileMappings[0].address_space,
|
||||
lowImage.normalized_result.observations.default_address_space,
|
||||
);
|
||||
assert.equal(fileMappings[0].overlay, false);
|
||||
assert.equal(fileMappings[0].start, lowEntry);
|
||||
assert.equal(fileMappings[0].length, lowBytes.length);
|
||||
const lowRead = evidence(
|
||||
await call("read_bytes", { address: lowEntry, length: linked.length }),
|
||||
lowSha256,
|
||||
);
|
||||
assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex"));
|
||||
const lowCall = evidence(
|
||||
await call("inspect_native_instruction", {
|
||||
address: `0x${(entry + 2).toString(16)}`,
|
||||
}),
|
||||
lowSha256,
|
||||
);
|
||||
assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR");
|
||||
assert.ok(
|
||||
lowCall.normalized_result.references.some(
|
||||
(reference) =>
|
||||
reference.call &&
|
||||
reference.target_address === `0x${(entry + 15).toString(16)}`,
|
||||
),
|
||||
);
|
||||
await call("close_binary");
|
||||
const lowCli = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-native-load-image",
|
||||
lowPath,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
lowEntry,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256);
|
||||
assert.deepEqual(
|
||||
lowCliEvidence.normalized_result,
|
||||
lowImage.normalized_result,
|
||||
);
|
||||
assert.equal(
|
||||
lowCliEvidence.analysis_profile.digest,
|
||||
lowImage.analysis_profile.digest,
|
||||
);
|
||||
assert.deepEqual(await readFile(lowPath), lowBytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
lowMemory.push({
|
||||
entry: lowEntry,
|
||||
bytes: lowBytes.length,
|
||||
sha256: lowSha256,
|
||||
});
|
||||
}
|
||||
let windowsReparseAdmission = "not-applicable";
|
||||
if (process.platform === "win32") {
|
||||
const junction = join(workspace, "linked");
|
||||
await symlink(workspace, junction, "junction");
|
||||
try {
|
||||
const linkedPath = join(junction, "image.bin");
|
||||
// Reopening the same bytes through a junction must replace the direct session.
|
||||
await call("open_binary", {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0x8001",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
const directImage = evidence(
|
||||
await call("inspect_native_load_image"),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(directImage.normalized_result.status, "verified");
|
||||
// Canonical file identity must not bypass admission of the new path.
|
||||
await call("open_binary", {
|
||||
path: linkedPath,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0x8001",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
// open_binary selects a lazy session; the first analysis admits the snapshot.
|
||||
const linkedImage = await client.callTool(
|
||||
{ name: "inspect_native_load_image", arguments: {} },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.equal(linkedImage.isError, true);
|
||||
assert.match(
|
||||
JSON.stringify(linkedImage),
|
||||
/Reparse path component is unsupported/u,
|
||||
);
|
||||
await call("close_binary");
|
||||
const snapshotPath = join(workspace, "direct-snapshot.json");
|
||||
const cliArgs = (selectedPath) => [
|
||||
entrypoint,
|
||||
"analyze",
|
||||
selectedPath,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
"0x8001",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--snapshot",
|
||||
snapshotPath,
|
||||
"--json",
|
||||
];
|
||||
const saved = await exec(process.execPath, cliArgs(path), {
|
||||
env,
|
||||
timeout: 240000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
assert.equal(
|
||||
evidence(JSON.parse(saved.stdout), sha256).operation,
|
||||
"binary_overview",
|
||||
);
|
||||
const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
|
||||
assert.ok(
|
||||
savedSnapshot.workflow_entries.some(
|
||||
({ operation }) => operation === "binary_overview",
|
||||
),
|
||||
);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
// Replaying a matching snapshot must still reach native path admission.
|
||||
await assert.rejects(
|
||||
exec(process.execPath, cliArgs(linkedPath), {
|
||||
env,
|
||||
timeout: 240000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
}),
|
||||
(cause) => {
|
||||
assert.match(
|
||||
cause.stdout + cause.stderr,
|
||||
/Reparse path component is unsupported/u,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await readFile(path), bytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
windowsReparseAdmission = "cli-snapshot-mcp-rejected";
|
||||
} finally {
|
||||
await unlink(junction);
|
||||
}
|
||||
}
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
lane: "ghidra-6502",
|
||||
host: process.platform,
|
||||
toolchain,
|
||||
provider: image.provider,
|
||||
target: {
|
||||
sha256,
|
||||
bytes: bytes.length,
|
||||
architecture: "6502",
|
||||
entry: "0x8001",
|
||||
},
|
||||
cli_mcp_load_image_parity: true,
|
||||
instructions_and_call_reference: true,
|
||||
decompilation: true,
|
||||
file_offset_decompilation_cli_mcp: true,
|
||||
invalid_mapping_preserves_session: true,
|
||||
low_memory_cli_mcp_parity: lowMemory,
|
||||
windows_reparse_admission: windowsReparseAdmission,
|
||||
source_unchanged: true,
|
||||
runtime_cleanup: "complete",
|
||||
};
|
||||
} catch (cause) {
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
throw cause;
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
const verifierRun = await completeVerifierRun(run);
|
||||
if (process.platform !== "win32") {
|
||||
assert.equal(verifierRun.process_lineage.status, "verified");
|
||||
assert.deepEqual(verifierRun.process_lineage.descendants, []);
|
||||
}
|
||||
// Keep the workspace for diagnosis unless verification and cleanup both pass.
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
process.stdout.write(
|
||||
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
|
||||
);
|
||||
|
||||
async function call(name, args = {}) {
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
if (["open_binary", "binary_session", "close_binary"].includes(name))
|
||||
return requireMcpOperationResult(result, name);
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
function evidence(value, sha256) {
|
||||
const result = parseEvidence(value);
|
||||
assert.equal(result.subject.digest.sha256, sha256);
|
||||
assert.equal(result.subject.architecture, "6502");
|
||||
assert.equal(result.subject.format, "raw-binary");
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { 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 { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
if (process.argv.length !== 2 || process.platform !== "linux")
|
||||
throw new Error("Run this ELF verifier on Linux without arguments.");
|
||||
if (!process.env.GHIDRA_INSTALL_DIR)
|
||||
throw new Error("Set GHIDRA_INSTALL_DIR to a caller-installed Ghidra.");
|
||||
const exec = promisify(execFile);
|
||||
const run = createVerifierRun();
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-address-context-"));
|
||||
const runtime = join(workspace, "runtime");
|
||||
await mkdir(runtime);
|
||||
const path = join(workspace, "pie.elf");
|
||||
const entrypoint = join(root, "scripts/rea.mjs");
|
||||
const env = {
|
||||
...process.env,
|
||||
TMPDIR: runtime,
|
||||
REA_ANALYSIS_PROVIDER: "ghidra",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
};
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-real-address-context", version: "1" });
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
let report;
|
||||
let connected = false;
|
||||
let primaryFailure;
|
||||
let verifierRun;
|
||||
try {
|
||||
const compiler = process.env.CC ?? "gcc";
|
||||
const version = await exec(compiler, ["--version"], { timeout: 10000 });
|
||||
await exec(
|
||||
compiler,
|
||||
[
|
||||
"-O0",
|
||||
"-g",
|
||||
"-fPIE",
|
||||
"-pie",
|
||||
"-nostdlib",
|
||||
"-Wl,-e,rea_pie_entry",
|
||||
"-Wl,--build-id=none",
|
||||
join(root, "tests/conformance/c/pie-address-context.c"),
|
||||
"-o",
|
||||
path,
|
||||
],
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const bytes = await readFile(path);
|
||||
assert.equal(bytes.subarray(0, 6).toString("hex"), "7f454c460201");
|
||||
assert.equal(bytes.readUInt16LE(16), 3); // ET_DYN: source-owned x86-64 PIE.
|
||||
assert.equal(bytes.readUInt16LE(18), 62);
|
||||
const declaredEntry = `0x${bytes.readBigUInt64LE(24).toString(16)}`;
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
await client.connect(transport);
|
||||
connected = true;
|
||||
await call("open_binary", { path, provider_id: "ghidra" });
|
||||
const overview = parseEvidence(await call("binary_overview"));
|
||||
assert.equal(overview.subject.digest.sha256, sha256);
|
||||
const base = overview.normalized_result.image_base;
|
||||
assert.match(base, /^0x[0-9a-f]+$/u);
|
||||
assert.notEqual(base, "0x0");
|
||||
const active = await call("binary_session");
|
||||
const image = parseEvidence(await call("inspect_native_load_image"));
|
||||
assert.equal(base, image.normalized_result.observations.image_base);
|
||||
assert.ok(overview.normalized_result.segments.length > 0);
|
||||
assert.ok(
|
||||
overview.normalized_result.segments.every(
|
||||
(segment) =>
|
||||
segment.image_base === base &&
|
||||
typeof segment.address_space === "string",
|
||||
),
|
||||
);
|
||||
const procedures = parseEvidence(await call("list_procedures"));
|
||||
const entry = procedures.normalized_result.find(
|
||||
(item) => item.value === "rea_pie_entry",
|
||||
);
|
||||
const addition = procedures.normalized_result.find(
|
||||
(item) => item.value === "rea_pie_add",
|
||||
);
|
||||
assert.ok(entry && addition);
|
||||
assert.notEqual(entry.address, declaredEntry);
|
||||
assert.ok(
|
||||
image.normalized_result.observations.entry_points.includes(entry.address),
|
||||
);
|
||||
const mapping = image.normalized_result.observations.mappings.find(
|
||||
(item) =>
|
||||
item.source_file_index === 0 &&
|
||||
item.file_offset !== null &&
|
||||
item.address_space === "ram" &&
|
||||
BigInt(item.start) <= BigInt(entry.address) &&
|
||||
BigInt(item.end) >= BigInt(entry.address),
|
||||
);
|
||||
assert.ok(mapping);
|
||||
const offset =
|
||||
mapping.file_offset + Number(BigInt(entry.address) - BigInt(mapping.start));
|
||||
const reverseMapping = parseEvidence(
|
||||
await call("address_to_file_offset", { address: entry.address }),
|
||||
);
|
||||
assert.equal(reverseMapping.normalized_result.file_offset, offset);
|
||||
const memory = parseEvidence(
|
||||
await call("read_bytes", { address: entry.address, length: 8 }),
|
||||
);
|
||||
assert.equal(
|
||||
memory.normalized_result.bytes_hex,
|
||||
bytes.subarray(offset, offset + 8).toString("hex"),
|
||||
);
|
||||
const fromOffset = parseEvidence(
|
||||
await call("procedure_pseudo_code", {
|
||||
procedure: `file-offset:0x${offset.toString(16)}`,
|
||||
}),
|
||||
);
|
||||
assert.ok(
|
||||
fromOffset.locations.some(
|
||||
(location) =>
|
||||
location.kind === "file-offset" && location.offset === offset,
|
||||
),
|
||||
);
|
||||
assert.equal(fromOffset.raw_result.selection.mapped_address, entry.address);
|
||||
const fromNumericOffset = parseEvidence(
|
||||
await call("procedure_pseudo_code", {
|
||||
procedure: `0x${offset.toString(16)}`,
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
fromNumericOffset.normalized_result,
|
||||
fromOffset.normalized_result,
|
||||
);
|
||||
const missingAddress = "ram:0xffffffff";
|
||||
const rejected = await client.callTool(
|
||||
{ name: "procedure_pseudo_code", arguments: { procedure: missingAddress } },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.equal(rejected.isError, true);
|
||||
assert.ok(
|
||||
JSON.stringify(rejected.content).includes(`Ghidra image base: ${base}`),
|
||||
);
|
||||
assert.ok(JSON.stringify(rejected.content).includes("list_procedures"));
|
||||
assert.ok(
|
||||
JSON.stringify(rejected.content).includes("inspect_native_load_image"),
|
||||
);
|
||||
const pseudo = parseEvidence(
|
||||
await call("procedure_pseudo_code", { procedure: addition.address }),
|
||||
);
|
||||
assert.match(pseudo.normalized_result, /\+\s*(?:7|0x7)\b/u);
|
||||
const retained = await call("binary_session");
|
||||
assert.equal(retained.analysis_run.run_id, active.analysis_run.run_id);
|
||||
await call("close_binary");
|
||||
const cli = async (args) =>
|
||||
exec(
|
||||
process.execPath,
|
||||
[entrypoint, ...args, "--provider", "ghidra", "--json"],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const cliOverview = parseEvidence(
|
||||
JSON.parse((await cli(["analyze", path])).stdout),
|
||||
);
|
||||
assert.deepEqual(cliOverview.normalized_result, overview.normalized_result);
|
||||
let cliFailure;
|
||||
try {
|
||||
await cli(["decompile", path, missingAddress]);
|
||||
} catch (cause) {
|
||||
cliFailure = cause;
|
||||
}
|
||||
assert.ok(cliFailure && typeof cliFailure.stdout === "string");
|
||||
const error = JSON.parse(cliFailure.stdout);
|
||||
assert.equal(error.code, "invalid_request");
|
||||
assert.ok(JSON.stringify(error).includes(`Ghidra image base: ${base}`));
|
||||
for (const command of [
|
||||
"rea search <path> . --kind procedures --mode regex",
|
||||
"rea analyze <path>",
|
||||
"rea inspect-native-load-image <path>",
|
||||
])
|
||||
assert.ok(JSON.stringify(error).includes(command));
|
||||
const cliProcedures = parseEvidence(
|
||||
JSON.parse(
|
||||
(
|
||||
await cli([
|
||||
"search",
|
||||
path,
|
||||
".",
|
||||
"--kind",
|
||||
"procedures",
|
||||
"--mode",
|
||||
"regex",
|
||||
])
|
||||
).stdout,
|
||||
),
|
||||
);
|
||||
const cliEntry = cliProcedures.normalized_result.find(
|
||||
(item) => item.value === "rea_pie_entry",
|
||||
);
|
||||
assert.ok(cliEntry);
|
||||
assert.equal(cliEntry.address, entry.address);
|
||||
const cliImage = parseEvidence(
|
||||
JSON.parse((await cli(["inspect-native-load-image", path])).stdout),
|
||||
);
|
||||
assert.equal(cliImage.normalized_result.observations.image_base, base);
|
||||
const cliPseudo = parseEvidence(
|
||||
JSON.parse((await cli(["decompile", path, cliEntry.address])).stdout),
|
||||
);
|
||||
const cliOffset = parseEvidence(
|
||||
JSON.parse(
|
||||
(await cli(["decompile", path, `--file-offset=0x${offset.toString(16)}`]))
|
||||
.stdout,
|
||||
),
|
||||
);
|
||||
assert.equal(cliOffset.normalized_result, fromOffset.normalized_result);
|
||||
assert.deepEqual(cliOffset.locations, fromOffset.locations);
|
||||
assert.match(cliPseudo.normalized_result, /rea_pie_add/u);
|
||||
assert.deepEqual(await readFile(path), bytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
provider: image.provider,
|
||||
compiler: version.stdout.split("\n")[0],
|
||||
sha256,
|
||||
declared_elf_entry: declaredEntry,
|
||||
loaded_entry: entry.address,
|
||||
observed_image_base: base,
|
||||
cli_mcp_overview_parity: true,
|
||||
failed_lookup_guidance: true,
|
||||
cli_recovery_commands: true,
|
||||
loaded_decompilation: true,
|
||||
file_offset_decompilation: true,
|
||||
address_file_offset_round_trip: true,
|
||||
source_unchanged: true,
|
||||
runtime_cleanup: "complete",
|
||||
};
|
||||
} catch (cause) {
|
||||
primaryFailure = { cause };
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
}
|
||||
|
||||
const cleanupFailures = [];
|
||||
// Await provider-owned cleanup before the SDK can terminate its MCP parent.
|
||||
if (connected) {
|
||||
try {
|
||||
await call("close_binary");
|
||||
} catch (cause) {
|
||||
cleanupFailures.push(cause);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.close();
|
||||
} catch (cause) {
|
||||
cleanupFailures.push(cause);
|
||||
}
|
||||
try {
|
||||
await transport.close();
|
||||
} catch (cause) {
|
||||
cleanupFailures.push(cause);
|
||||
}
|
||||
try {
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
verifierRun = await completeVerifierRun(run);
|
||||
assert.equal(verifierRun.process_lineage.status, "verified");
|
||||
assert.deepEqual(verifierRun.process_lineage.descendants, []);
|
||||
} catch (cause) {
|
||||
cleanupFailures.push(cause);
|
||||
}
|
||||
if (cleanupFailures.length === 0) {
|
||||
try {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
} catch (cause) {
|
||||
cleanupFailures.push(cause);
|
||||
}
|
||||
}
|
||||
if (cleanupFailures.length > 0) {
|
||||
process.stderr.write(
|
||||
`Retained verifier workspace after cleanup failure: ${workspace}\n`,
|
||||
);
|
||||
throw new AggregateError(
|
||||
primaryFailure === undefined
|
||||
? cleanupFailures
|
||||
: [primaryFailure.cause, ...cleanupFailures],
|
||||
"Ghidra address-context verifier cleanup failed",
|
||||
);
|
||||
}
|
||||
if (primaryFailure !== undefined) throw primaryFailure.cause;
|
||||
process.stdout.write(
|
||||
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
|
||||
);
|
||||
|
||||
async function call(name, args = {}) {
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
return ["open_binary", "binary_session", "close_binary"].includes(name)
|
||||
? requireMcpOperationResult(result, name)
|
||||
: result.structuredContent;
|
||||
}
|
||||
@@ -45,8 +45,13 @@ const transport = new StdioClientTransport({
|
||||
const source = fileURLToPath(
|
||||
new URL("../tests/conformance/swift/type-inventory.swift", import.meta.url),
|
||||
);
|
||||
const literalSource = fileURLToPath(
|
||||
new URL("../tests/conformance/swift/string-literals.swift", import.meta.url),
|
||||
);
|
||||
const binary = join(root, "swift-inventory");
|
||||
const literalBinary = join(root, "swift-literals");
|
||||
const reports = [];
|
||||
let literalReport;
|
||||
try {
|
||||
await execute("swiftc", ["-O", "-g", source, "-o", binary], {
|
||||
timeout: 60_000,
|
||||
@@ -54,6 +59,19 @@ try {
|
||||
await mkdir(join(root, "copied"));
|
||||
const copied = join(root, "copied", "swift-inventory");
|
||||
await copyFile(binary, copied);
|
||||
// Decoding covers AArch64 register pairs, so build that slice on any Mac host.
|
||||
await execute(
|
||||
"swiftc",
|
||||
[
|
||||
"-O",
|
||||
"-target",
|
||||
"arm64-apple-macosx13.0",
|
||||
literalSource,
|
||||
"-o",
|
||||
literalBinary,
|
||||
],
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await client.connect(transport);
|
||||
for (const [variant, path] of [
|
||||
["debug", binary],
|
||||
@@ -132,6 +150,7 @@ try {
|
||||
await call("close_binary", {});
|
||||
}
|
||||
}
|
||||
literalReport = await verifyStringLiterals();
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
@@ -143,6 +162,7 @@ console.log(
|
||||
ok: true,
|
||||
provider: "ghidra",
|
||||
fixtures: reports,
|
||||
swift_string_literals: literalReport,
|
||||
verifier_run: await completeVerifierRun(run),
|
||||
},
|
||||
null,
|
||||
@@ -159,3 +179,51 @@ async function call(name, arguments_) {
|
||||
toolContract(name).outputSchema.parse(response.structuredContent);
|
||||
return requireMcpOperationResult(response, name);
|
||||
}
|
||||
|
||||
// Literal text lives only in register constants, so it must come from the decoded pairs.
|
||||
async function verifyStringLiterals() {
|
||||
await call("open_binary", { path: literalBinary, provider_id: "ghidra" });
|
||||
try {
|
||||
const procedures = await call("list_procedures", {});
|
||||
const expected = {
|
||||
smallLiteral: [["SleepDisabled", "small"]],
|
||||
largeLiteral: [["The power setting could not be changed.", "large"]],
|
||||
unicodeLiterals: [
|
||||
["café", "small"],
|
||||
["Résumé of the naïve décor, written twice", "large"],
|
||||
],
|
||||
emptyAndControlLiterals: [
|
||||
["", "small"],
|
||||
["tab\there", "small"],
|
||||
],
|
||||
returnedLiteral: [["Holding awake", "small"]],
|
||||
};
|
||||
const decoded = {};
|
||||
for (const [name, literals] of Object.entries(expected)) {
|
||||
const procedure = procedures.find(({ value }) => value.includes(name));
|
||||
assert(procedure, `${name} is missing from the procedure inventory`);
|
||||
const dossier = await call("analyze_function", {
|
||||
procedure: procedure.address,
|
||||
});
|
||||
const observed = dossier.swift_string_literals;
|
||||
assert(Array.isArray(observed), `${name} recorded no Swift literals`);
|
||||
assert.deepEqual(dossier.swift_string_literal_omissions, []);
|
||||
assert.deepEqual(
|
||||
observed.map(({ value, representation }) => [value, representation]),
|
||||
literals,
|
||||
);
|
||||
for (const literal of observed) {
|
||||
assert.deepEqual(literal.registers, ["x0", "x1"]);
|
||||
assert.equal(
|
||||
literal.address === null,
|
||||
literal.representation === "small",
|
||||
`${literal.value} storage does not match its representation`,
|
||||
);
|
||||
}
|
||||
decoded[name] = observed.length;
|
||||
}
|
||||
return decoded;
|
||||
} finally {
|
||||
await call("close_binary", {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
|
||||
import { verifyGhidraFileOffsets } from "./lib/real-ghidra-file-offsets.mjs";
|
||||
|
||||
const verifierRun = createVerifierRun();
|
||||
const SESSION_CAPABILITIES = GHIDRA_SESSION_CAPABILITIES.filter(
|
||||
@@ -300,6 +301,11 @@ try {
|
||||
}
|
||||
if (report === undefined)
|
||||
throw new Error("Windows Ghidra report was not produced");
|
||||
report.file_offsets = await verifyGhidraFileOffsets(
|
||||
targetPath,
|
||||
resolve(root, "scripts", "rea.mjs"),
|
||||
architecture === "x86" ? "0x401010" : "0x140001010",
|
||||
);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), ...report })}\n`,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
assertFeatureTrace,
|
||||
findMappedNonFunction,
|
||||
} from "./lib/ida-feature-trace.mjs";
|
||||
import { requireMcpToolError } from "./lib/mcp-verifier-results.mjs";
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
@@ -6,7 +10,7 @@ import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { access, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { parseArgs, promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
@@ -261,6 +265,61 @@ const mcp = async (cliDossier) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (config.mode === "headless") {
|
||||
const documents = observe(await call("list_documents", {}));
|
||||
assert.equal(documents.length, 1);
|
||||
assert.equal(basename(documents[0]), basename(target));
|
||||
assert.ok(
|
||||
!workspaces.has(dirname(documents[0])),
|
||||
"list_documents reported the private workspace copy.",
|
||||
);
|
||||
const resolveAt = async (address) =>
|
||||
observe(await call("resolve_containing_procedure", { address }));
|
||||
const instructionAddresses = dossier.assembly
|
||||
.map((line) => /^(0x[0-9a-f]+)/iu.exec(line)?.[1])
|
||||
.filter((address) => address !== undefined);
|
||||
const queries = [
|
||||
dossier.procedure.address,
|
||||
...instructionAddresses.slice(1, 2),
|
||||
];
|
||||
for (const queried of queries) {
|
||||
const containing = await resolveAt(queried);
|
||||
assert.equal(containing.found, true);
|
||||
assert.equal(containing.procedure.address, dossier.procedure.address);
|
||||
assert.equal(containing.procedure.name, dossier.procedure.name);
|
||||
}
|
||||
const unmapped = await resolveAt("0xffffffffffffff00");
|
||||
assert.equal(unmapped.found, false);
|
||||
assert.equal(unmapped.reason, "outside_segments");
|
||||
const strings = observe(await call("list_strings", {}));
|
||||
const mappedString = await findMappedNonFunction(strings, resolveAt);
|
||||
if (mappedString === undefined)
|
||||
report.unverified.push(
|
||||
"Mapped non-function containment: no string of the target is mapped outside every function.",
|
||||
);
|
||||
const context = await client.callTool({
|
||||
name: "inspect_address_context",
|
||||
arguments: { address: dossier.procedure.address },
|
||||
});
|
||||
assert.notEqual(context.isError, true);
|
||||
const contextResult = context.structuredContent.result;
|
||||
assert.equal(contextResult.document, documents[0]);
|
||||
assert.equal(contextResult.procedure.state, "available");
|
||||
assert.equal(
|
||||
contextResult.procedure.value.procedure.address,
|
||||
dossier.procedure.address,
|
||||
);
|
||||
const traced = await client.callTool({
|
||||
name: "trace_feature",
|
||||
arguments: { query: dossier.procedure.name },
|
||||
});
|
||||
assert.notEqual(traced.isError, true);
|
||||
const trace = traced.structuredContent.normalized_result;
|
||||
assertFeatureTrace(trace, dossier.procedure);
|
||||
report.checks.push(
|
||||
"headless containing procedures, the admitted program as the document, address context, and the procedure match and references of feature tracing",
|
||||
);
|
||||
}
|
||||
if (values["expected-string"] === undefined)
|
||||
report.unverified.push(
|
||||
"String inventory/search correctness: no independent expected literal supplied.",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { estimateTokenCount } from "tokenx";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, readdir } from "node:fs/promises";
|
||||
@@ -68,6 +69,20 @@ for (const byte of Buffer.from("REA")) {
|
||||
lines.push(`#${tick + 1000}`, "1!");
|
||||
const source = lines.join("\n") + "\n";
|
||||
let cases = 0;
|
||||
const discoveryMeasurements = [];
|
||||
const inheritedAddressSpace = (await readFile("/proc/self/limits", "utf8"))
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("Max address space "))
|
||||
?.trim()
|
||||
.match(/^Max address space\s+(\S+)\s+(\S+)\s+bytes$/u);
|
||||
assert.ok(inheritedAddressSpace);
|
||||
const expectedAddressSpace = Math.min(
|
||||
1073741824,
|
||||
...inheritedAddressSpace
|
||||
.slice(1)
|
||||
.map((value) => (value === "unlimited" ? Infinity : Number(value))),
|
||||
);
|
||||
|
||||
try {
|
||||
await writeFile(path, source);
|
||||
await client.connect(transport);
|
||||
@@ -104,14 +119,16 @@ try {
|
||||
createHash("sha256").update(source).digest("hex"),
|
||||
);
|
||||
assert.equal(report.hardware_acquisition, "not-performed");
|
||||
assert.equal(report.limits.address_space_bytes, 1073741824);
|
||||
assert.equal(report.limits.address_space_bytes, expectedAddressSpace);
|
||||
assert.equal(report.tool.configured_path, process.env.REA_SIGROK_COMMAND);
|
||||
assert.equal(
|
||||
report.resource_limiter.configured_path,
|
||||
process.env.REA_SIGROK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
|
||||
);
|
||||
assert.ok(
|
||||
report.resource_limiter.arguments.includes("--as=1073741824:1073741824"),
|
||||
report.resource_limiter.arguments.includes(
|
||||
`--as=${expectedAddressSpace}:${expectedAddressSpace}`,
|
||||
),
|
||||
);
|
||||
assert.equal(report.diagnostics, "");
|
||||
const flags = Object.entries(input)
|
||||
@@ -245,6 +262,88 @@ try {
|
||||
});
|
||||
assert.equal(invalid.isError, true);
|
||||
cases++;
|
||||
const discoveryTool = (await client.listTools()).tools.find(
|
||||
({ name }) => name === "inspect_signal_decoders",
|
||||
);
|
||||
assert.ok(discoveryTool?.outputSchema);
|
||||
assert.ok(ajv.validateSchema(discoveryTool.inputSchema));
|
||||
assert.ok(ajv.validateSchema(discoveryTool.outputSchema));
|
||||
const nativeHelp = (
|
||||
await execute(
|
||||
process.env.REA_SIGROK_COMMAND,
|
||||
["--dont-scan", "--protocol-decoders=uart", "--show"],
|
||||
{ env: environment, timeout: 30000, maxBuffer: 4 * 1024 * 1024 },
|
||||
)
|
||||
).stdout.split(/\r?\n/);
|
||||
if (nativeHelp.at(-1) === "") nativeHelp.pop();
|
||||
for (const input of [
|
||||
{ query: "uart" },
|
||||
{ limit: 1 },
|
||||
{ offset: 1, limit: 2 },
|
||||
{ decoder: "uart", query: "BaUd" },
|
||||
{ decoder: "uart", offset: 50, limit: null },
|
||||
{ query: "rea_no_such_decoder" },
|
||||
]) {
|
||||
const started = performance.now();
|
||||
const response = await client.callTool({
|
||||
name: discoveryTool.name,
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
assert.ok(
|
||||
ajv.validate(discoveryTool.outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
const report = evidence.normalized_result;
|
||||
discoveryMeasurements.push({
|
||||
input,
|
||||
elapsed_ms: +(performance.now() - started).toFixed(2),
|
||||
response_bytes: Buffer.byteLength(JSON.stringify(response)),
|
||||
estimated_tokens_one_tool_request_response: estimateTokenCount(
|
||||
JSON.stringify(discoveryTool) +
|
||||
JSON.stringify(input) +
|
||||
JSON.stringify(response),
|
||||
),
|
||||
});
|
||||
if (input.decoder) {
|
||||
const matched = nativeHelp
|
||||
.map((text, i) => ({ line_number: i + 1, text }))
|
||||
.filter(({ text }) =>
|
||||
text.toLowerCase().includes((input.query ?? "").toLowerCase()),
|
||||
);
|
||||
assert.deepEqual(
|
||||
report.help_lines,
|
||||
matched.slice(
|
||||
input.offset ?? 0,
|
||||
input.limit === null
|
||||
? undefined
|
||||
: (input.offset ?? 0) + (input.limit ?? 50),
|
||||
),
|
||||
);
|
||||
} else if (input.query === "uart")
|
||||
assert.ok(report.decoders.some(({ id }) => id === "uart"));
|
||||
const flags = Object.entries(input).flatMap(([key, value]) =>
|
||||
value === null ? ["--all"] : [`--${key}`, String(value)],
|
||||
);
|
||||
const cli = JSON.parse(
|
||||
(
|
||||
await execute(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-signal-decoders", ...flags, "--format", "json"],
|
||||
{ env: environment, timeout: 60000, maxBuffer: 8 * 1024 * 1024 },
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
assert.deepEqual(cli.normalized_result, report);
|
||||
cases++;
|
||||
}
|
||||
const unavailable = await client.callTool({
|
||||
name: discoveryTool.name,
|
||||
arguments: { decoder: "rea_no_such_decoder" },
|
||||
});
|
||||
assert.equal(unavailable.isError, true);
|
||||
cases++;
|
||||
await client.close();
|
||||
assert.deepEqual((await readdir(root.path)).sort(), [
|
||||
"paged.vcd",
|
||||
@@ -260,6 +359,9 @@ console.log(
|
||||
{
|
||||
status: "passed",
|
||||
cases,
|
||||
discovery_measurements: discoveryMeasurements,
|
||||
measurement_scope:
|
||||
"Warm MCP server, fresh sigrok processes; tokenx estimates include one advertised tool/request/response, not billed model usage. No model-driven selection benchmark, CPU or peak process-family memory measurement; no cache or speedup claim.",
|
||||
host: `${process.platform}/${process.arch}`,
|
||||
scope:
|
||||
"real sigrok UART VCD through compiled CLI/MCP; no physical acquisition",
|
||||
|
||||
@@ -27,9 +27,9 @@ if (
|
||||
);
|
||||
const offset = bytes.length >= 64 ? bytes.readUInt32LE(0x3c) : bytes.length;
|
||||
if (
|
||||
bytes.toString("ascii", 0, 2) !== "MZ" ||
|
||||
bytes.toString("latin1", 0, 2) !== "MZ" ||
|
||||
offset > bytes.length - 26 ||
|
||||
bytes.toString("ascii", offset, offset + 4) !== "PE\0\0" ||
|
||||
bytes.toString("latin1", offset, offset + 4) !== "PE\0\0" ||
|
||||
bytes.readUInt16LE(offset + 4) !== 0x8664 ||
|
||||
bytes.readUInt16LE(offset + 24) !== 0x20b
|
||||
)
|
||||
|
||||
@@ -662,6 +662,42 @@ setInterval(()=>{},1000);\n`,
|
||||
unicodeNamesPreserved: true,
|
||||
});
|
||||
report.processes.windowsEnvironmentSemantics = true;
|
||||
// Observed through this process's own id: a separate probe process would
|
||||
// get its own processor group, so it cannot stand in for the process that
|
||||
// spawns the child.
|
||||
const callerAffinity = JSON.parse(
|
||||
execFileSync(boundaryFixture, ["affinity", String(process.pid)], {
|
||||
encoding: "utf8",
|
||||
}),
|
||||
);
|
||||
const affinityChild = native.call("process_spawn", [
|
||||
boundaryFixture,
|
||||
`${quote(boundaryFixture)} affinity`,
|
||||
workspace,
|
||||
environment,
|
||||
]);
|
||||
const observedAffinity = await wait(affinityChild);
|
||||
native.call("process_close", [affinityChild.handle]);
|
||||
assert.equal(observedAffinity.exitCode, 0);
|
||||
const childAffinity = JSON.parse(observedAffinity.output);
|
||||
// Masks cross as hexadecimal strings because a mask can exceed the
|
||||
// safe-integer range. Sharing one processor group makes the caller's mask
|
||||
// applicable, so it must be inherited exactly. Windows placing the child in
|
||||
// another group is only permitted when the caller asked for no bound of its
|
||||
// own, which its group's whole active set establishes; a narrower mask has
|
||||
// to fail the spawn rather than widen to that group's default.
|
||||
const sharedGroup =
|
||||
callerAffinity.singleProcessorGroup &&
|
||||
childAffinity.singleProcessorGroup &&
|
||||
callerAffinity.processorGroup === childAffinity.processorGroup;
|
||||
if (sharedGroup)
|
||||
assert.equal(childAffinity.affinityMask, callerAffinity.affinityMask);
|
||||
else assert.equal(callerAffinity.affinityMask, callerAffinity.systemMask);
|
||||
report.processes.callerAffinityBoundsOwnedProcessOrRefusesToWiden = true;
|
||||
report.callerAffinity = {
|
||||
...callerAffinity,
|
||||
sharedGroupWithChild: sharedGroup,
|
||||
};
|
||||
}
|
||||
const ownerFixture = join(workspace, "owner.mjs");
|
||||
const ownerPidFile = join(workspace, "crash-child.pid");
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
} from "node:fs/promises";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import * as nodePath from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
@@ -18,247 +11,348 @@ import { firmwareResultSchemas } from "../../../dist/domain/firmware/firmwareAna
|
||||
import { parseEvidence } from "../../../dist/domain/evidence.js";
|
||||
import { hashFirmwareFile } from "../../../dist/firmware/FirmwareFiles.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const entrypoint = resolve(
|
||||
const execFileAsync = promisify(execFile);
|
||||
const { join, resolve, dirname } = nodePath;
|
||||
|
||||
const CALL_TIMEOUT_MS = 150_000;
|
||||
const CONNECT_TIMEOUT_MS = 30_000;
|
||||
const SEVENZIP_TIMEOUT_MS = 10_000;
|
||||
const MAX_STDOUT_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const ENTRYPOINT = resolve(
|
||||
process.env.REA_FIRMWARE_TEST_ENTRYPOINT ??
|
||||
join(repository, "scripts/rea.mjs"),
|
||||
join(REPO_ROOT, "scripts/rea.mjs"),
|
||||
);
|
||||
if (process.platform !== "linux")
|
||||
throw new Error(
|
||||
"verify:firmware requires Linux; other hosts are not verified",
|
||||
);
|
||||
for (const variable of ["REA_BINWALK_COMMAND", "REA_UNBLOB_COMMAND"]) {
|
||||
const path = process.env[variable];
|
||||
if (path === undefined)
|
||||
throw new Error(
|
||||
`verify:firmware requires ${variable}; see docs/firmware-analysis.md`,
|
||||
);
|
||||
await access(path);
|
||||
}
|
||||
try {
|
||||
await exec("7z", ["i"], { timeout: 10_000 });
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:firmware prerequisite unavailable: Unblob's gzip/USTAR fixture extraction requires 7z on PATH",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
const fixtureRoot = resolve(
|
||||
const FIXTURE_ROOT = resolve(
|
||||
process.env.REA_FIRMWARE_FIXTURE_ROOT ??
|
||||
join(repository, "_reference/firmware-integration/generated"),
|
||||
join(REPO_ROOT, "_reference/firmware-integration/generated"),
|
||||
);
|
||||
await access(join(fixtureRoot, "oracle.json")).catch((cause) => {
|
||||
throw new Error(
|
||||
"verify:firmware requires generated fixtures; run npm run fixtures:firmware",
|
||||
{ cause },
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prerequisite checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function checkPrerequisites() {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error(
|
||||
"verify:firmware requires Linux; other hosts are not verified",
|
||||
);
|
||||
}
|
||||
|
||||
for (const name of ["REA_BINWALK_COMMAND", "REA_UNBLOB_COMMAND"]) {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) {
|
||||
throw new Error(
|
||||
`verify:firmware requires ${name}; see docs/firmware-analysis.md`,
|
||||
);
|
||||
}
|
||||
await fs.access(value);
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync("7z", ["i"], { timeout: SEVENZIP_TIMEOUT_MS });
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:firmware prerequisite unavailable: Unblob's gzip/USTAR fixture extraction requires 7z on PATH",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(join(FIXTURE_ROOT, "oracle.json"));
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:firmware requires generated fixtures; run npm run fixtures:firmware",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await checkPrerequisites();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const oracle = JSON.parse(
|
||||
await readFile(join(fixtureRoot, "oracle.json"), "utf8"),
|
||||
await fs.readFile(join(FIXTURE_ROOT, "oracle.json"), "utf8"),
|
||||
);
|
||||
const path = join(fixtureRoot, "firmware.bin");
|
||||
assert.equal(await hashFirmwareFile(path), oracle.sha256);
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-real-firmware-"));
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, value]) => value !== undefined),
|
||||
const firmwarePath = join(FIXTURE_ROOT, "firmware.bin");
|
||||
assert.equal(await hashFirmwareFile(firmwarePath), oracle.sha256);
|
||||
|
||||
const workDir = await fs.mkdtemp(join(tmpdir(), "rea-real-firmware-"));
|
||||
const baseEnv = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, v]) => v !== undefined),
|
||||
);
|
||||
const client = new Client({ name: "rea-real-firmware-verifier", version: "1" });
|
||||
const cli = async (args, env = environment) => {
|
||||
const response = await exec(
|
||||
const mcpClient = new Client({
|
||||
name: "rea-real-firmware-verifier",
|
||||
version: "1",
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invocation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runCli(args, env = baseEnv) {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[entrypoint, ...args, "--format", "json"],
|
||||
[ENTRYPOINT, ...args, "--format", "json"],
|
||||
{
|
||||
env,
|
||||
cwd: repository,
|
||||
timeout: 150_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
cwd: REPO_ROOT,
|
||||
timeout: CALL_TIMEOUT_MS,
|
||||
maxBuffer: MAX_STDOUT_BYTES,
|
||||
},
|
||||
);
|
||||
return parseEvidence(JSON.parse(response.stdout));
|
||||
};
|
||||
const call = async (name, args) => {
|
||||
const response = await client.callTool(
|
||||
return parseEvidence(JSON.parse(stdout));
|
||||
}
|
||||
|
||||
async function callTool(name, args) {
|
||||
const result = await mcpClient.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: 150_000 },
|
||||
{ timeout: CALL_TIMEOUT_MS },
|
||||
);
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
assert.ok(response.structuredContent !== undefined);
|
||||
return response.structuredContent;
|
||||
};
|
||||
const verifyRegions = (evidence) => {
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
assert.ok(result.structuredContent !== undefined);
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result verifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function verifyRegions(evidence) {
|
||||
assert.equal(evidence.subject.digest.sha256, oracle.sha256);
|
||||
const result = firmwareResultSchemas.inspect_firmware_regions.parse(
|
||||
|
||||
const parsed = firmwareResultSchemas.inspect_firmware_regions.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
const gzip = result.regions.find(
|
||||
(region) => region.signature === "gzip" && region.offset === oracle.offset,
|
||||
|
||||
const gzipRegion = parsed.regions.find(
|
||||
(r) => r.signature === "gzip" && r.offset === oracle.offset,
|
||||
);
|
||||
assert.ok(
|
||||
gzip !== undefined,
|
||||
gzipRegion !== undefined,
|
||||
"Binwalk must find the independently generated gzip offset",
|
||||
);
|
||||
assert.equal(gzip.reported_size, oracle.length);
|
||||
assert.equal(gzip.size_basis, "provider_reported_validation_unknown");
|
||||
return result.regions.map(
|
||||
({ provider_id: _provider_id, ...region }) => region,
|
||||
);
|
||||
};
|
||||
const verifyFiles = async (evidence) => {
|
||||
const result = firmwareResultSchemas.extract_firmware.parse(
|
||||
assert.equal(gzipRegion.reported_size, oracle.length);
|
||||
assert.equal(gzipRegion.size_basis, "provider_reported_validation_unknown");
|
||||
|
||||
// Strip provider-specific ids so CLI and MCP results compare equal.
|
||||
return parsed.regions.map((region) => {
|
||||
const { provider_id: _ignored, ...rest } = region;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyFiles(evidence) {
|
||||
const parsed = firmwareResultSchemas.extract_firmware.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
assert.ok(result.chunks.some((chunk) => chunk.handler === "gzip"));
|
||||
|
||||
assert.ok(parsed.chunks.some((c) => c.handler === "gzip"));
|
||||
|
||||
for (const [suffix, expected] of Object.entries(oracle.files)) {
|
||||
const file = result.files.find((candidate) =>
|
||||
candidate.relative_path.endsWith(`/${suffix}`),
|
||||
const entry = parsed.files.find((f) =>
|
||||
f.relative_path.endsWith(`/${suffix}`),
|
||||
);
|
||||
assert.ok(file !== undefined, `Missing extracted ${suffix}`);
|
||||
assert.equal(file.sha256, expected.sha256);
|
||||
assert.equal(file.size, expected.size);
|
||||
assert.equal(await hashFirmwareFile(file.path), expected.sha256);
|
||||
assert.equal(file.original_file_range, null);
|
||||
assert.equal(file.runtime_address, null);
|
||||
assert.ok(entry !== undefined, `Missing extracted ${suffix}`);
|
||||
assert.equal(entry.sha256, expected.sha256);
|
||||
assert.equal(entry.size, expected.size);
|
||||
assert.equal(await hashFirmwareFile(entry.path), expected.sha256);
|
||||
assert.equal(entry.original_file_range, null);
|
||||
assert.equal(entry.runtime_address, null);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
const inspected = await cli(["inspect-firmware-regions", path]);
|
||||
const cliRegions = verifyRegions(inspected);
|
||||
const extracted = await cli([
|
||||
"extract-firmware",
|
||||
path,
|
||||
join(root, "cli-output"),
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const toContentIdentity = (parsed) =>
|
||||
parsed.files.map(({ relative_path, sha256, size }) => ({
|
||||
relative_path,
|
||||
sha256,
|
||||
size,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verification stages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function stageCli() {
|
||||
const regionsEvidence = await runCli([
|
||||
"inspect-firmware-regions",
|
||||
firmwarePath,
|
||||
]);
|
||||
const cliFiles = await verifyFiles(extracted);
|
||||
assert.equal(cliFiles.coverage, "partial");
|
||||
const regions = verifyRegions(regionsEvidence);
|
||||
|
||||
const extractEvidence = await runCli([
|
||||
"extract-firmware",
|
||||
firmwarePath,
|
||||
join(workDir, "cli-output"),
|
||||
]);
|
||||
const files = await verifyFiles(extractEvidence);
|
||||
|
||||
assert.equal(files.coverage, "partial");
|
||||
assert.ok(
|
||||
cliFiles.chunks.some(
|
||||
(chunk) =>
|
||||
chunk.handler === null &&
|
||||
chunk.range.offset === 0 &&
|
||||
chunk.range.length === 64,
|
||||
files.chunks.some(
|
||||
(c) =>
|
||||
c.handler === null && c.range.offset === 0 && c.range.length === 64,
|
||||
),
|
||||
);
|
||||
await client.connect(
|
||||
new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
}),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const inspectedMcp = await call("inspect_firmware_regions", { path });
|
||||
const mcpEvidence = parseEvidence(inspectedMcp);
|
||||
assert.deepEqual(verifyRegions(mcpEvidence), cliRegions);
|
||||
const extractedMcp = await call("extract_firmware", {
|
||||
path,
|
||||
output_directory: join(root, "mcp-output"),
|
||||
|
||||
return { regions, files };
|
||||
}
|
||||
|
||||
async function connectMcp() {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [ENTRYPOINT, "mcp"],
|
||||
cwd: REPO_ROOT,
|
||||
env: baseEnv,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const mcpFiles = await verifyFiles(parseEvidence(extractedMcp));
|
||||
const contentIdentity = (result) =>
|
||||
result.files.map(({ relative_path, sha256, size }) => ({
|
||||
relative_path,
|
||||
sha256,
|
||||
size,
|
||||
}));
|
||||
assert.deepEqual(contentIdentity(mcpFiles), contentIdentity(cliFiles));
|
||||
const selected = await call("extract_firmware", {
|
||||
path,
|
||||
output_directory: join(root, "selected-output"),
|
||||
await mcpClient.connect(transport, { timeout: CONNECT_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function stageMcp(cli) {
|
||||
const regionsRaw = await callTool("inspect_firmware_regions", {
|
||||
path: firmwarePath,
|
||||
});
|
||||
assert.deepEqual(verifyRegions(parseEvidence(regionsRaw)), cli.regions);
|
||||
|
||||
const extractRaw = await callTool("extract_firmware", {
|
||||
path: firmwarePath,
|
||||
output_directory: join(workDir, "mcp-output"),
|
||||
});
|
||||
const mcpFiles = await verifyFiles(parseEvidence(extractRaw));
|
||||
assert.deepEqual(toContentIdentity(mcpFiles), toContentIdentity(cli.files));
|
||||
|
||||
const rangedRaw = await callTool("extract_firmware", {
|
||||
path: firmwarePath,
|
||||
output_directory: join(workDir, "selected-output"),
|
||||
range: { offset: oracle.offset, length: oracle.length },
|
||||
});
|
||||
const selectedFiles = await verifyFiles(parseEvidence(selected));
|
||||
assert.equal(selectedFiles.selection.offset, oracle.offset);
|
||||
assert.equal(selectedFiles.selection.length, oracle.length);
|
||||
const depth = await call("extract_firmware", {
|
||||
path,
|
||||
output_directory: join(root, "depth-output"),
|
||||
const ranged = await verifyFiles(parseEvidence(rangedRaw));
|
||||
assert.equal(ranged.selection.offset, oracle.offset);
|
||||
assert.equal(ranged.selection.length, oracle.length);
|
||||
|
||||
const depthRaw = await callTool("extract_firmware", {
|
||||
path: firmwarePath,
|
||||
output_directory: join(workDir, "depth-output"),
|
||||
max_depth: 1,
|
||||
});
|
||||
const depthResult = firmwareResultSchemas.extract_firmware.parse(
|
||||
depth.normalized_result,
|
||||
const depthParsed = firmwareResultSchemas.extract_firmware.parse(
|
||||
depthRaw.normalized_result,
|
||||
);
|
||||
assert.equal(depthResult.coverage, "partial");
|
||||
assert.ok(depthResult.depth_limited_paths.length > 0);
|
||||
assert.equal(depthParsed.coverage, "partial");
|
||||
assert.ok(depthParsed.depth_limited_paths.length > 0);
|
||||
|
||||
console.log(
|
||||
"PASS real Binwalk/Unblob: CLI, MCP, independent offsets/digests, range extraction, unknown chunks and depth limits",
|
||||
);
|
||||
if (process.env.REA_FIRMWARE_VERIFY_EXT4 === "1") {
|
||||
const ext4 = join(fixtureRoot, "ext4.bin");
|
||||
await access(ext4);
|
||||
const response = await call("extract_firmware", {
|
||||
path: ext4,
|
||||
output_directory: join(root, "ext4-output"),
|
||||
});
|
||||
const result = firmwareResultSchemas.extract_firmware.parse(
|
||||
response.normalized_result,
|
||||
|
||||
return mcpFiles;
|
||||
}
|
||||
|
||||
async function stageExt4() {
|
||||
const ext4Path = join(FIXTURE_ROOT, "ext4.bin");
|
||||
await fs.access(ext4Path);
|
||||
|
||||
const raw = await callTool("extract_firmware", {
|
||||
path: ext4Path,
|
||||
output_directory: join(workDir, "ext4-output"),
|
||||
});
|
||||
const parsed = firmwareResultSchemas.extract_firmware.parse(
|
||||
raw.normalized_result,
|
||||
);
|
||||
assert.ok(parsed.chunks.some((c) => c.handler === "extfs"));
|
||||
assert.equal(parsed.coverage, "complete");
|
||||
|
||||
for (const [suffix, expected] of Object.entries(oracle.files)) {
|
||||
const match = parsed.files.find((f) =>
|
||||
f.relative_path.endsWith(`/${suffix}`),
|
||||
);
|
||||
assert.ok(result.chunks.some((chunk) => chunk.handler === "extfs"));
|
||||
assert.equal(result.coverage, "complete");
|
||||
for (const [suffix, expected] of Object.entries(oracle.files))
|
||||
assert.equal(
|
||||
result.files.find((file) => file.relative_path.endsWith(`/${suffix}`))
|
||||
?.sha256,
|
||||
expected.sha256,
|
||||
);
|
||||
console.log("PASS real ext4 extraction with debugfs; no mounts");
|
||||
// Preserve the process-ownership utility while withholding all extractors.
|
||||
const withoutExtractors = join(root, "without-extractors");
|
||||
await access("/bin/ps");
|
||||
await mkdir(withoutExtractors);
|
||||
await symlink("/bin/ps", join(withoutExtractors, "ps"));
|
||||
const missing = await cli(
|
||||
["extract-firmware", ext4, join(root, "missing-extractor-output")],
|
||||
{ ...environment, PATH: withoutExtractors },
|
||||
);
|
||||
const partial = firmwareResultSchemas.extract_firmware.parse(
|
||||
missing.normalized_result,
|
||||
);
|
||||
assert.equal(partial.coverage, "partial");
|
||||
assert.ok(JSON.stringify(partial.diagnostics).includes("debugfs"));
|
||||
console.log(
|
||||
"PASS real missing-extractor diagnostics retained despite Unblob exit 1",
|
||||
);
|
||||
assert.equal(match?.sha256, expected.sha256);
|
||||
}
|
||||
if (process.env.REA_FIRMWARE_VERIFY_GHIDRA === "1") {
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
throw new Error(
|
||||
"Firmware Ghidra handoff requires GHIDRA_INSTALL_DIR and Java; see docs/ghidra.md",
|
||||
);
|
||||
const child = mcpFiles.files.find((file) =>
|
||||
file.relative_path.endsWith("/usr/bin/probe"),
|
||||
);
|
||||
assert.ok(child !== undefined);
|
||||
await call("open_binary", { path: child.path, provider_id: "ghidra" });
|
||||
const dossier = await call("analyze_function", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
const evidence = parseEvidence(dossier);
|
||||
assert.equal(
|
||||
evidence.subject.digest.sha256,
|
||||
oracle.files["usr/bin/probe"].sha256,
|
||||
);
|
||||
const pseudocode = await call("procedure_pseudo_code", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
assert.equal(parseEvidence(pseudocode).provider.id, "ghidra");
|
||||
assert.ok(
|
||||
JSON.stringify(evidence.normalized_result).includes("firmware_probe"),
|
||||
);
|
||||
await call("close_binary", {});
|
||||
console.log(
|
||||
"PASS real Ghidra handoff of the selected extracted host ELF, bound to its digest",
|
||||
console.log("PASS real ext4 extraction with debugfs; no mounts");
|
||||
|
||||
// Keep the process-ownership utility available but hide every extractor.
|
||||
const emptyBin = join(workDir, "without-extractors");
|
||||
await fs.access("/bin/ps");
|
||||
await fs.mkdir(emptyBin);
|
||||
await fs.symlink("/bin/ps", join(emptyBin, "ps"));
|
||||
|
||||
const degraded = await runCli(
|
||||
["extract-firmware", ext4Path, join(workDir, "missing-extractor-output")],
|
||||
{ ...baseEnv, PATH: emptyBin },
|
||||
);
|
||||
const degradedParsed = firmwareResultSchemas.extract_firmware.parse(
|
||||
degraded.normalized_result,
|
||||
);
|
||||
assert.equal(degradedParsed.coverage, "partial");
|
||||
assert.ok(JSON.stringify(degradedParsed.diagnostics).includes("debugfs"));
|
||||
console.log(
|
||||
"PASS real missing-extractor diagnostics retained despite Unblob exit 1",
|
||||
);
|
||||
}
|
||||
|
||||
async function stageGhidra(mcpFiles) {
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined) {
|
||||
throw new Error(
|
||||
"Firmware Ghidra handoff requires GHIDRA_INSTALL_DIR and Java; see docs/ghidra.md",
|
||||
);
|
||||
}
|
||||
|
||||
const probe = mcpFiles.files.find((f) =>
|
||||
f.relative_path.endsWith("/usr/bin/probe"),
|
||||
);
|
||||
assert.ok(probe !== undefined);
|
||||
|
||||
await callTool("open_binary", { path: probe.path, provider_id: "ghidra" });
|
||||
|
||||
const dossierRaw = await callTool("analyze_function", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
const dossier = parseEvidence(dossierRaw);
|
||||
assert.equal(
|
||||
await hashFirmwareFile(path),
|
||||
dossier.subject.digest.sha256,
|
||||
oracle.files["usr/bin/probe"].sha256,
|
||||
);
|
||||
|
||||
const pseudoRaw = await callTool("procedure_pseudo_code", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
assert.equal(parseEvidence(pseudoRaw).provider.id, "ghidra");
|
||||
assert.ok(
|
||||
JSON.stringify(dossier.normalized_result).includes("firmware_probe"),
|
||||
);
|
||||
|
||||
await callTool("close_binary", {});
|
||||
console.log(
|
||||
"PASS real Ghidra handoff of the selected extracted host ELF, bound to its digest",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
try {
|
||||
const cli = await stageCli();
|
||||
await connectMcp();
|
||||
const mcpFiles = await stageMcp(cli);
|
||||
|
||||
if (process.env.REA_FIRMWARE_VERIFY_EXT4 === "1") await stageExt4();
|
||||
if (process.env.REA_FIRMWARE_VERIFY_GHIDRA === "1")
|
||||
await stageGhidra(mcpFiles);
|
||||
|
||||
assert.equal(
|
||||
await hashFirmwareFile(firmwarePath),
|
||||
oracle.sha256,
|
||||
"Original firmware bytes must remain unchanged",
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mcpClient.close();
|
||||
await fs.rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ def shared_blocks(root, path, source):
|
||||
}
|
||||
from website_i18n import language_markup
|
||||
selector, alternates = language_markup(root, path.relative_to(site).as_posix())
|
||||
values["language_picker"] = selector if not metadata.noindex else ""
|
||||
values["language_picker"] = indent(selector, " ") if not metadata.noindex else ""
|
||||
values["alternates"] = alternates if not metadata.noindex else ""
|
||||
templates = root / "website/templates"
|
||||
return {
|
||||
|
||||
@@ -84,6 +84,24 @@ describe("parseAdbDevicesOutput", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts complete multiword states and rejects state prefixes", () => {
|
||||
const output = [
|
||||
"serial-no-permission no permissions usb:1-1",
|
||||
"serial-bad-device deviceXYZ ",
|
||||
"serial-bad-offline offline-ish",
|
||||
"serial-bad-auth unauthorizedXYZ",
|
||||
].join("\n");
|
||||
const { devices, unparsedLines } = parseAdbDevicesOutput(output);
|
||||
expect(devices.map(({ serial, state }) => [serial, state])).toEqual([
|
||||
["serial-no-permission", "no permissions"],
|
||||
]);
|
||||
expect(unparsedLines).toEqual([
|
||||
"serial-bad-device deviceXYZ ",
|
||||
"serial-bad-offline offline-ish",
|
||||
"serial-bad-auth unauthorizedXYZ",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns unrecognized lines instead of dropping them", () => {
|
||||
const output = [
|
||||
"List of devices attached",
|
||||
@@ -194,10 +212,10 @@ describe("parsePackageListOutput", () => {
|
||||
|
||||
it("keeps unrecognized lines for partial coverage reporting", () => {
|
||||
const { packages, unparsedLines } = parsePackageListOutput(
|
||||
"package:/data/app/x/base.apk=com.ok\ngarbage line\n",
|
||||
"package:/data/app/x/base.apk=com.ok\n garbage line \n",
|
||||
);
|
||||
expect(packages).toHaveLength(1);
|
||||
expect(unparsedLines).toEqual(["garbage line"]);
|
||||
expect(unparsedLines).toEqual([" garbage line "]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+14
-25
@@ -10,11 +10,15 @@ import {
|
||||
adbHostFileName,
|
||||
type AdbHostFileNameReason,
|
||||
} from "./AdbHostFileName.js";
|
||||
import {
|
||||
ADB_DEVICE_STATES,
|
||||
type AdbDeviceState,
|
||||
} from "../domain/adb/adbDeviceAnalysis.js";
|
||||
|
||||
/** One device exactly as `adb devices -l` reports it. */
|
||||
export interface AdbDeviceEntry {
|
||||
readonly serial: string;
|
||||
readonly state: string;
|
||||
readonly state: AdbDeviceState;
|
||||
readonly transport: "usb" | "tcp" | "unknown";
|
||||
readonly product: string | null;
|
||||
readonly model: string | null;
|
||||
@@ -24,25 +28,6 @@ export interface AdbDeviceEntry {
|
||||
readonly kind_basis: "emulator_serial_prefix" | "usb_transport" | "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Device states adb prints after the serial, longest first so two-word
|
||||
* states match before their single-word prefixes. A line whose remainder
|
||||
* starts with none of them is not a device line.
|
||||
*/
|
||||
const DEVICE_STATES = [
|
||||
"no permissions",
|
||||
"unauthorized",
|
||||
"authorizing",
|
||||
"offline",
|
||||
"recovery",
|
||||
"sideload",
|
||||
"connecting",
|
||||
"bootloader",
|
||||
"device",
|
||||
"unknown",
|
||||
"host",
|
||||
] as const;
|
||||
|
||||
const DEVICE_LINE = /^(\S+)\s+(.*)$/u;
|
||||
|
||||
const metadataValue = (remainder: string, key: string): string | null => {
|
||||
@@ -73,11 +58,15 @@ export const parseAdbDevicesOutput = (
|
||||
continue;
|
||||
}
|
||||
const match = DEVICE_LINE.exec(line);
|
||||
const state = DEVICE_STATES.find((candidate) =>
|
||||
match?.[2]?.startsWith(candidate),
|
||||
);
|
||||
const state = ADB_DEVICE_STATES.find((candidate) => {
|
||||
const remainder = match?.[2];
|
||||
if (remainder === undefined || !remainder.startsWith(candidate))
|
||||
return false;
|
||||
const next = remainder[candidate.length];
|
||||
return next === undefined || /\s/u.test(next);
|
||||
});
|
||||
if (match === null || state === undefined) {
|
||||
if (line.trim() !== "") unparsedLines.push(line);
|
||||
if (rawLine.trim() !== "") unparsedLines.push(rawLine);
|
||||
continue;
|
||||
}
|
||||
const serial = match[1]!;
|
||||
@@ -192,7 +181,7 @@ export const parsePackageListOutput = (
|
||||
if (line === "") continue;
|
||||
const match = PACKAGE_LINE.exec(line);
|
||||
if (match === null) {
|
||||
unparsedLines.push(line);
|
||||
unparsedLines.push(rawLine);
|
||||
continue;
|
||||
}
|
||||
packages.push({
|
||||
|
||||
@@ -121,6 +121,52 @@ fi`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses device listings with an invalid state token", async () => {
|
||||
const malformedLine = "serial deviceXYZ ";
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "devices" ]; then
|
||||
printf 'List of devices attached\\n${malformedLine}\\n'
|
||||
exit 0
|
||||
fi`,
|
||||
);
|
||||
const result = await providerOver(binary).execute({
|
||||
operation: "list_adb_devices",
|
||||
input: {},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error._tag).toBe("AnalysisProtocolError");
|
||||
expect(result.error.message).toContain(malformedLine);
|
||||
});
|
||||
|
||||
it("lists rescue and detached transports alongside online devices", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "devices" ]; then
|
||||
printf 'List of devices attached\\n'
|
||||
printf 'serial-online device transport_id:1\\n'
|
||||
printf 'serial-rescue rescue transport_id:2\\n'
|
||||
printf 'serial-detached detached transport_id:3\\n'
|
||||
exit 0
|
||||
fi`,
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "list_adb_devices",
|
||||
input: {},
|
||||
});
|
||||
expect(result.devices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ serial: "serial-online", state: "device" }),
|
||||
expect.objectContaining({ serial: "serial-rescue", state: "rescue" }),
|
||||
expect.objectContaining({
|
||||
serial: "serial-detached",
|
||||
state: "detached",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("inspects a device through the getprop whitelist", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
@@ -580,6 +626,34 @@ it("projects package details from dumpsys package output", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Android 10 and 11 append ": restricted=true" to hard- and soft-restricted
|
||||
// entries of the requested-permissions list (Settings.dumpPackageLPr).
|
||||
it("counts requested permissions that the device marks as restricted", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
"dumpsys:package": `Packages:
|
||||
Package [com.example] (abcdef):
|
||||
userId=10234
|
||||
versionName=1.2.3
|
||||
requested permissions:
|
||||
android.permission.INTERNET
|
||||
android.permission.WRITE_EXTERNAL_STORAGE: restricted=true
|
||||
android.permission.CAMERA
|
||||
install permissions:
|
||||
android.permission.INTERNET: granted=true
|
||||
`,
|
||||
}),
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "inspect_adb_package",
|
||||
input: { serial: "emulator-5554", package: "com.example" },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
requested_permissions_count: 3,
|
||||
coverage: "complete",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an uninstalled package from dumpsys refusal output", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({ "dumpsys:package": "Unable to find package: com.absent\n" }),
|
||||
@@ -736,6 +810,36 @@ exit 1`,
|
||||
expect(result.error.message).toContain("overwrite");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["offline", false, "error: device offline"],
|
||||
["unauthorized", false, "error: device unauthorized"],
|
||||
["offline", true, "error: device offline"],
|
||||
] as const)(
|
||||
"reports the %s device state when the push existence probe cannot run (overwrite %s)",
|
||||
async (state, overwrite, stderr) => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
(stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ]; then printf '%s\\n' "$3" >> "${stubRoot}/calls"; printf '%s\\n' "${stderr}" >&2; exit 1; fi`,
|
||||
);
|
||||
const localFile = join(root, "payload.bin");
|
||||
await writeFile(localFile, "x");
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
operation: "push_adb_file",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
local_path: localFile,
|
||||
device_path: "/data/local/tmp/payload.bin",
|
||||
overwrite,
|
||||
},
|
||||
});
|
||||
// Existence is unknown, so nothing is claimed about the path and no push runs.
|
||||
expect(projected).toMatchObject({ code: "unsupported_target" });
|
||||
expect(projected.message).toContain(state);
|
||||
expect(projected.message).not.toContain("already exists");
|
||||
expect(await readFile(join(root, "calls"), "utf8")).toBe("shell\n");
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a local push source that does not exist", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
|
||||
+8
-10
@@ -1623,17 +1623,15 @@ export class AdbProvider {
|
||||
{ timeoutMs: SETTINGS_TIMEOUT_MS, maxOutputBytes: 1024 },
|
||||
signal,
|
||||
);
|
||||
let deviceExists: boolean;
|
||||
if (existsProbe.ok) deviceExists = true;
|
||||
else if (existsProbe.error instanceof AnalysisProtocolError)
|
||||
deviceExists = false;
|
||||
else if (existsProbe.error instanceof AnalysisTimeoutError)
|
||||
// `test -e` exits nonzero with no adb diagnosis when the path is absent.
|
||||
// Any other probe failure leaves existence unknown, so it is reported as
|
||||
// is rather than read as an existing path or followed by a push.
|
||||
if (
|
||||
!existsProbe.ok &&
|
||||
!(existsProbe.error instanceof AnalysisProtocolError)
|
||||
)
|
||||
return existsProbe;
|
||||
else if (existsProbe.error instanceof AnalysisCancelledError)
|
||||
return existsProbe;
|
||||
else if (existsProbe.error instanceof AnalysisResourceConstraintError)
|
||||
return existsProbe;
|
||||
else deviceExists = true; // authorization/refusal states surface on push instead
|
||||
const deviceExists = existsProbe.ok;
|
||||
if (deviceExists && !input.overwrite)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
|
||||
@@ -236,7 +236,11 @@ export const parseDumpsysPackageOutput = (
|
||||
inPermissions = true;
|
||||
continue;
|
||||
}
|
||||
if (inPermissions && /^\s{2,}[A-Za-z0-9._]+$/.test(line)) {
|
||||
// Android 10 and 11 mark restricted entries as "NAME: restricted=true".
|
||||
if (
|
||||
inPermissions &&
|
||||
/^\s{2,}[A-Za-z0-9._]+(?:: restricted=true)?$/u.test(line)
|
||||
) {
|
||||
permissions += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -199,9 +199,11 @@ export class JadxMcpTransport implements Transport {
|
||||
throw new Error(`JADX MCP frame exceeds ${MAX_FRAME_BYTES} bytes`);
|
||||
this.#appendFrame(fragment, frameBytes);
|
||||
if (newline === -1) return;
|
||||
const line = this.#frameBuffer
|
||||
.subarray(0, this.#frameLength)
|
||||
.toString("utf8");
|
||||
const line = new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
// Preserve a leading BOM so the JSON-RPC parser can reject it.
|
||||
ignoreBOM: true,
|
||||
}).decode(this.#frameBuffer.subarray(0, this.#frameLength));
|
||||
this.#frameBuffer = Buffer.alloc(0);
|
||||
this.#frameLength = 0;
|
||||
this.onmessage?.(deserializeMessage(line));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user