Compare commits

..
Author SHA1 Message Date
N0zoM1z0 c0d44fe8f5 fix(hopper): distinguish runtime processes from argument text
Parse procfs argument boundaries rather than searching rendered ps command text. Keep executable identity checks and deferred permission failures, eliminate the extra ps process, and put shared process observations in the serial process-boundary lane.
2026-10-13 06:44:26 +00:00
N0zoM1z0 11c188523a test(hopper): verify Linux demo setup and source-bound string coverage
Integrate the verification corrections from #1942 on current main, retaining their original commit history. Reuse the unchanged document inventory and verified source bytes, retain recognition addresses, and reject incomplete or mismatched string evidence.
2026-10-13 06:21:07 +00:00
Luis Taffelli 794debe565 chore(verification): sync Hopper validation with latest main
Integrate main 653067d7, preserving the tested Hopper corrections.
The incoming changes are JavaScript/Apple implementation and regression
updates; Hopper provider, verifier and dependency bytes are unchanged.

Real Hopper runs remain bound to exact head 97b76aa2 and tested checkout
f379bc38. This final integration has no new native execution claim.
26 focused tests, full static check and docs:check passed on this tree.
2026-10-12 19:33:06 -03:00
Luis Taffelli f379bc3882 fix(verification): integrate Linux Hopper lane with current contracts
Integrate main ae8051302 while retaining the Linux setup, orphan detection,
launcher restoration and explicit string-recognition coverage corrections.
Move verification guidance to its current owner and use the test-scoped
temporary-directory helper.

The checkout matches all 3,175 exported source-file digests of the tested
candidate. Git stores the Windows captureGhidraLaunch.cmd fixture with LF;
its exported checkout uses CRLF per .gitattributes. It is outside Linux
Hopper execution. No native run is attributed to different runtime bytes.
Real Hopper conformance: 9e7de373-4686-4820-ae48-801e536589b7.
Uninterrupted Linux setup: 93584b53-944b-47cf-a6a4-b0d4ff8a4961.
The exact 97b76aa2 run remains separate: ae4de7ff-dd2a-4c73-88dc-6dbf1ad7c537.

21 focused tests, check, docs:check, four fixtures, discovery, package
verification and pack dry-run passed. Linux native decoded omissions,
macOS/other builds and installed npm artifacts with Hopper remain unverified.

Refs #339, #340
2026-10-12 19:31:20 -03:00
Luis Taffelli 97b76aa20a fix(verification): report full literal decoding coverage explicitly 2026-10-11 18:46:16 -03:00
Luis Taffelli 8ea0432ffc fix(verification): distinguish Hopper string recognition from byte fidelity 2026-10-11 18:31:29 -03:00
Luis Taffelli b9d9a474cf fix(verification): report identified Hopper leaks before proc errors 2026-10-11 18:28:41 -03:00
Luis Taffelli b88cdc248a fix(verification): detect Hopper through alternate launcher paths 2026-10-11 17:06:32 -03:00
Luis Taffelli c039dd81d5 fix(verification): retain orphan checks on clean Linux runners 2026-10-11 16:35:44 -03:00
Luis Taffelli 58d23e42b6 fix(verification): align Linux Hopper setup lane with current contracts 2026-10-11 16:06:09 -03:00
283 changed files with 2145 additions and 11167 deletions
@@ -76,9 +76,6 @@ 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).
@@ -92,9 +89,6 @@ 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).
@@ -83,14 +83,6 @@ 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
+16 -77
View File
@@ -5,25 +5,16 @@ on:
branches: [main]
pull_request:
branches: [main, "release/*"]
schedule:
- cron: "23 * * * *"
workflow_dispatch:
workflow_call:
inputs:
source_sha:
description: Exact release source; must match the caller revision.
required: true
type: string
permissions:
contents: read
concurrency:
# Complete qualification finishes even while main receives more pushes.
# Scheduled/manual main runs coalesce to one active and the latest pending;
# release qualification has its own exact-revision group.
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 != 'schedule' && github.event_name != 'workflow_dispatch' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}
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
env:
HUSKY: "0"
@@ -31,7 +22,7 @@ env:
jobs:
pr-title:
name: Conventional PR title
if: github.event_name == 'pull_request' && inputs.source_sha == ''
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Validate title for Release Please
@@ -48,11 +39,7 @@ jobs:
changes:
name: Classify changes
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
outputs:
tier: ${{ steps.plan.outputs.tier }}
code: ${{ steps.plan.outputs.code }}
runtime: ${{ steps.plan.outputs.runtime }}
package: ${{ steps.plan.outputs.package }}
@@ -75,34 +62,16 @@ jobs:
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 release 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: Select verification lanes
id: plan
env:
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') }}
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') }}
run: node scripts/ci/plan.mjs
static:
@@ -160,14 +129,7 @@ jobs:
with:
name: rea-test-runtime
path: .
- 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 }}
- run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
env:
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
- uses: actions/upload-artifact@v4
@@ -198,7 +160,7 @@ jobs:
- name: Reuse portable build outputs across runs
uses: ./.github/actions/portable-build-cache
with:
save: ${{ github.event_name != 'pull_request' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
save: "true"
- run: npm run test:prepare
- name: Find successful default-branch timing history
id: history
@@ -210,7 +172,7 @@ jobs:
run: |
run_id="$(gh api --method GET \
"repos/${CI_REPOSITORY}/actions/workflows/ci.yml/runs" \
-f branch="${CI_DEFAULT_BRANCH}" -f event=schedule -f status=success -f per_page=1 \
-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}"
- name: Download previous timing report
@@ -253,7 +215,7 @@ jobs:
uses: ./.github/workflows/sqlite-runtime.yml
test:
name: Portable tests
name: Test (coverage + timing)
needs: [changes, test-shard]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
@@ -279,7 +241,7 @@ jobs:
merge-multiple: true
- name: Merge coverage, JUnit, and timing reports
id: merge
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success()
if: needs.changes.outputs.code == 'true' && success()
run: >-
npm run test:ci:merge --
--coverage.reportsDirectory=coverage
@@ -291,22 +253,11 @@ jobs:
--reporter=json
--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
--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') != ''
env:
SHARD_RESULT: ${{ needs.test-shard.result }}
MERGE_RESULT: ${{ needs.changes.outputs.tier == 'full' && steps.merge.outcome || steps.merge-fast.outcome }}
CI_VERIFY_TEST_INVENTORY: "true"
MERGE_RESULT: ${{ steps.merge.outcome }}
run: node scripts/ci/test-report.mjs
- uses: actions/upload-artifact@v4
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
@@ -319,7 +270,7 @@ jobs:
if-no-files-found: error
retention-days: 30
- name: Publish successful timing history
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success() && needs.test-shard.result == 'success'
if: needs.changes.outputs.code == 'true' && success() && needs.test-shard.result == 'success'
uses: actions/upload-artifact@v4
with:
name: test-timings
@@ -430,20 +381,9 @@ 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
@@ -717,7 +657,7 @@ jobs:
required:
name: CI required
if: ${{ always() }}
if: ${{ !cancelled() }}
needs:
[
changes,
@@ -747,7 +687,6 @@ jobs:
website,
]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
+4
View File
@@ -12,6 +12,10 @@ on:
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"
+4
View File
@@ -4,6 +4,10 @@ on:
workflow_call:
workflow_dispatch:
concurrency:
group: real-browser-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
HUSKY: "0"
+4
View File
@@ -7,6 +7,10 @@ on:
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"
@@ -7,6 +7,10 @@ on:
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"
+1 -1
View File
@@ -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 -- 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: 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: node scripts/verify-packet-capture.mjs
env:
REA_TSHARK_COMMAND: /usr/bin/tshark
@@ -7,6 +7,10 @@ on:
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"
+1 -1
View File
@@ -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 -- 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: 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: node scripts/verify-signal-recording.mjs
env:
REA_SIGROK_COMMAND: /usr/bin/sigrok-cli
@@ -7,6 +7,10 @@ on:
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
+3
View File
@@ -4,6 +4,9 @@ on:
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:
@@ -4,6 +4,9 @@ on:
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:
+10 -25
View File
@@ -290,25 +290,13 @@ jobs:
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
run: npm run docs:check && git diff --exit-code
qualification:
name: Qualify exact release revision
publish:
name: Publish to npm
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
@@ -327,20 +315,17 @@ jobs:
- run: npm ci
- name: Download the exact qualified package
uses: actions/download-artifact@v4
with:
name: verified-release-package
path: .cache/verified-package/
- 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: Verify qualified package provenance and digest
id: package
run: node scripts/ci/verified-package.mjs validate .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: 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)"
@@ -348,7 +333,7 @@ jobs:
echo "rea-agents@${version} is already published; verifying the existing package."
else
tag="$(node scripts/release-npm-tag.mjs "${version}")"
npm publish "${VERIFIED_PACKAGE_TARBALL}" --access public --tag "${tag}"
npm publish --access public --tag "${tag}"
fi
- name: Verify published CLI from npm
+4
View File
@@ -7,6 +7,10 @@ on:
permissions:
contents: read
concurrency:
group: website-check-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check:
name: Website checks
-2
View File
@@ -72,5 +72,3 @@ 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
View File
@@ -1,5 +1,5 @@
{
"threshold": 3.5,
"threshold": 3.0,
"reporters": ["consoleFull"],
"ignore": [
"node_modules/**",
+1 -1
View File
@@ -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 or secret in str(len(value)) 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 for secret in secrets)
safe_text = None
try:
safe_text = safe_bytes.decode("utf-8", errors="strict")
+1 -1
View File
@@ -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 @rpath request whose install name matches an
loads once per process. A later 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`.
-4
View File
@@ -57,10 +57,6 @@ 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.
-9
View File
@@ -104,15 +104,6 @@ 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:
-8
View File
@@ -23,14 +23,6 @@ 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
+2 -15
View File
@@ -147,8 +147,8 @@ after target content was read.
## Retained application Evidence inputs
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`,
`inspect_wasm_artifact`, and `inspect_macho` accept `detail`:
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`, and
`inspect_wasm_artifact` 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,19 +218,6 @@ 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
-46
View File
@@ -128,49 +128,3 @@ 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.
+3 -12
View File
@@ -6,10 +6,7 @@ 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. 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.
SHA returned by Release Please. Later main commits cannot change that source.
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
@@ -199,14 +196,8 @@ 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.
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
The workflow builds the bundled Windows controls and verifies the packaged
artifact before npm publication. It then verifies the published CLI, the
capability-scoped MCP catalog, and the isolated package update path before
publishing MCP Registry metadata.
-34
View File
@@ -106,37 +106,3 @@ 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.
+29 -57
View File
@@ -263,13 +263,12 @@ 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 JUnit and JSON timing reports, plus aggregate
coverage during full qualification. It retains available
report; the merge job produces aggregate coverage plus JUnit and JSON timing
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 in full qualification.
Only successful complete full-tier merges publish the small `test-timings` artifact, with
and aggregate coverage thresholds remain enforced by the merge command.
Only successful complete 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
sharding. Static checks, documentation, build, package verification, and
@@ -718,7 +717,7 @@ 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 portable-test aggregate jobs
base-branch changes do not expand that scope. Static and coverage 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.
@@ -747,61 +746,38 @@ 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; scheduled full qualification reports it after merge. Ownership globs do
the PR; the main-push full baseline 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.
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.
Full qualification runs hourly at minute 23, on manual dispatch, and for the
exact release revision before publication. It includes aggregate coverage,
every portable provider lane, and all native hosts below. A `ci:full` PR label
selects full qualification on the next PR run; manual dispatch can request it
immediately. Fast-tier broad fallbacks retain all owner lanes and the three-host
package matrix; Intel macOS and coverage remain full-tier obligations. Each
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
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` always runs and checks classification and every selected lane,
rejecting failed, cancelled, or unexpectedly skipped work. Branch protection
should require this aggregate, `Static checks`, and `Portable tests`. Fast runs
cancel superseded feedback for the same PR or main branch. Scheduled/manual
qualification has a separate concurrency group: active work finishes and only
the latest pending run is retained. Release qualification is grouped by 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
`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
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 (hourly, dispatch, release and `ci:full`) |
| 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 (main, dispatch and `ci:full` only) |
| Windows x64 | `windows-latest` | Curated capabilities, native controls, installed package and real Node Inspector CLI/MCP |
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.
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.
Package lanes assert the actual Node platform/architecture before verification
and record those values with the Node version. The package matrix runs up to
@@ -827,21 +803,17 @@ 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 tests; full qualification enables coverage. It no longer
dependencies and runs its assigned coverage-enabled tests; it no longer
regenerates the same metadata independently.
The build job also retrieves the merged timing report from the last successful
default-branch scheduled qualification and includes that same report in each shard's snapshot.
default-branch push and includes that same report in each shard's snapshot.
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 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.
discovered file still belongs to exactly one shard; worker limits, project
ordering, coverage instrumentation, and aggregate thresholds remain unchanged.
Missing or unusable history falls back to Vitest's default assignment. Only
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`.
timing data is reused; persistent compile caches remain disabled.
These native baseline checks complement the Linux source-test shards and the
separate Apple-artifact and real-provider lanes. Actual Hopper, Ghidra, IDA,
+2 -6
View File
@@ -153,12 +153,8 @@ 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 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
the caller token stays unchanged. Caller CPU affinity and priority are
preserved. 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
+4 -16
View File
@@ -69,22 +69,10 @@ 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 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.
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.
- 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
+1 -29
View File
@@ -68,18 +68,6 @@ 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;
@@ -180,27 +168,11 @@ 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);
USHORT callerGroup = 0, childGroup = 0;
const bool sharedGroup = singleProcessorGroup(GetCurrentProcess(), callerGroup) &&
singleProcessorGroup(result->process.get(), childGroup) &&
callerGroup == childGroup;
if (affinity != 0 && sharedGroup)
if (affinity != 0)
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);
-76
View File
@@ -1,76 +0,0 @@
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");
}
}
+82 -107
View File
@@ -108,123 +108,98 @@ function classifyPackage(base, head) {
}
const event = process.env.GITHUB_EVENT_NAME;
if (!["pull_request", "push", "schedule", "workflow_dispatch"].includes(event))
throw new Error(`Unsupported CI event: ${event}`);
const tier =
let files = [];
if (
event === "push" ||
event === "schedule" ||
event === "workflow_dispatch" ||
process.env.CI_FULL === "true"
? "full"
: "fast";
let files = [];
if (tier === "full") {
) {
full("Full baseline requested by event or ci:full label");
} else if (event === "pull_request" || event === "push") {
} else if (event === "pull_request") {
const base = git("merge-base", process.env.BASE_SHA, process.env.HEAD_SHA);
const head = process.env.HEAD_SHA;
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",
);
// 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 (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 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;
}
if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) {
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;
// full qualification retains every native host.
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;
reasons.push(`Unowned runtime source: ${file}`);
} else if (file.startsWith(".agents/skills/")) {
selected.runtime = true;
selected.docs = true;
} else {
full(`Unclassified input: ${file}`);
}
} 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}`);
}
}
}
} else throw new Error(`Unsupported CI event: ${event}`);
selected.runtime ||= selected.package;
const linux = { os: "ubuntu-latest", platform: "linux", architecture: "x64" };
// macOS x64 is the slowest package host. Fast qualification omits it.
// macOS x64 is the slowest package host. Pull requests run it only with ci:full;
// main pushes, schedules and manual dispatch always include it.
const intel =
tier === "full"
event !== "pull_request" || process.env.CI_FULL === "true"
? [{ os: "macos-15-intel", platform: "darwin", architecture: "x64" }]
: [];
const matrix = {
@@ -237,17 +212,17 @@ const matrix = {
]
: [linux],
};
const plan = { ...selected, tier, matrix, files, reasons };
const plan = { ...selected, matrix, files, reasons };
if (process.env.GITHUB_OUTPUT)
appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries({ ...selected, tier, matrix: JSON.stringify(matrix) })
Object.entries({ ...selected, matrix: JSON.stringify(matrix) })
.map(([key, value]) => `${key}=${value}\n`)
.join(""),
);
if (process.env.GITHUB_STEP_SUMMARY)
appendFileSync(
process.env.GITHUB_STEP_SUMMARY,
`### Selected CI lanes (${tier})\n\n${flags.filter((flag) => selected[flag]).join(", ") || "Formatting only"}\n\n${reasons.join("\n\n")}\n`,
`### Selected CI lanes\n\n${flags.filter((flag) => selected[flag]).join(", ") || "Formatting only"}\n\n${reasons.join("\n\n")}\n`,
);
console.log(JSON.stringify(plan));
-21
View File
@@ -1,4 +1,3 @@
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";
@@ -43,26 +42,6 @@ const passed =
process.env.MERGE_RESULT === "success" &&
report.success &&
report.numFailedTests === 0;
// Successful shards and merged reports must account for the complete portable
// inventory. Coverage thresholds alone cannot prove that every file ran.
if (passed && process.env.CI_VERIFY_TEST_INVENTORY === "true") {
const expected = execFileSync(
"git",
["ls-files", "-z", "--", "src", "tests"],
{ encoding: "utf8", maxBuffer: 8 * 1024 * 1024 },
)
.split("\0")
.filter((path) => path.endsWith(".test.ts"));
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",
"",
-235
View File
@@ -1,235 +0,0 @@
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,20 +45,6 @@ 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"
+23 -17
View File
@@ -1,8 +1,5 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { readdir, stat } from "node:fs/promises";
const execFileAsync = promisify(execFile);
import { readFile, readdir, stat } from "node:fs/promises";
import { basename } from "node:path";
/**
* Observe leftover demo processes on a dedicated clean Linux runner, including
@@ -17,9 +14,6 @@ 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 {
@@ -28,18 +22,34 @@ 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 executable;
let retainedRuntime;
try {
executable = await stat(`/proc/${entry.name}/exe`, { bigint: true });
// 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,
);
}
} catch (error) {
if (error?.code === "ENOENT" || error?.code === "ESRCH") continue;
// Other users' executables may be unreadable. The setup lane launches
// Other users' process facts 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) => {
@@ -53,11 +63,7 @@ export const assertCleanLinuxHopperRuntime = async (
inspectionError ??= error;
continue;
}
if (
launchers.some(
({ dev, ino }) => dev === executable.dev && ino === executable.ino,
)
)
if (retainedRuntime)
throw new Error("Clean Linux runner retained a Hopper demo process");
}
if (inspectionError !== undefined) throw inspectionError;
+4 -3
View File
@@ -1,3 +1,7 @@
import type { z } from "zod";
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
type VerifierCall = (
name: string,
arguments_?: Record<string, unknown>,
@@ -25,6 +29,3 @@ export function verifyHopperStringObjects(
}[];
readonly displayEscapesExcludedFromMatching: true;
}>;
import type { z } from "zod";
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
+1 -45
View File
@@ -1,8 +1,6 @@
import { mkdir, mkdtemp, rm, unlink } from "node:fs/promises";
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
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";
@@ -23,19 +21,12 @@ 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");
@@ -46,8 +37,6 @@ let mcpModuleLoading;
let update;
let repositorySkill;
let report;
let retainedPackage;
let retentionMetadata;
const runStage = async (name, action) => {
const started = performance.now();
@@ -60,27 +49,6 @@ 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 }),
);
@@ -247,13 +215,6 @@ 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 }),
@@ -262,11 +223,6 @@ 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`,
);
+3 -150
View File
@@ -1,9 +1,8 @@
#!/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, mkdir, rm } from "node:fs/promises";
import { readFile, writeFile, readdir } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
@@ -92,20 +91,6 @@ 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);
@@ -136,16 +121,14 @@ try {
.digest("hex"),
);
assert.equal(report.hardware_acquisition, "not-performed");
assert.equal(report.limits.address_space_bytes, expectedAddressSpace);
assert.equal(report.limits.address_space_bytes, 1073741824);
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=${expectedAddressSpace}:${expectedAddressSpace}`,
),
report.resource_limiter.arguments.includes("--as=1073741824:1073741824"),
);
assert.equal(report.configuration, "private-empty-personal-profile");
const flags = Object.entries(input)
@@ -330,133 +313,6 @@ 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",
@@ -473,9 +329,6 @@ 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",
+2 -104
View File
@@ -1,6 +1,5 @@
#!/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";
@@ -69,20 +68,6 @@ 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);
@@ -119,16 +104,14 @@ try {
createHash("sha256").update(source).digest("hex"),
);
assert.equal(report.hardware_acquisition, "not-performed");
assert.equal(report.limits.address_space_bytes, expectedAddressSpace);
assert.equal(report.limits.address_space_bytes, 1073741824);
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=${expectedAddressSpace}:${expectedAddressSpace}`,
),
report.resource_limiter.arguments.includes("--as=1073741824:1073741824"),
);
assert.equal(report.diagnostics, "");
const flags = Object.entries(input)
@@ -262,88 +245,6 @@ 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",
@@ -359,9 +260,6 @@ 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",
-36
View File
@@ -662,42 +662,6 @@ 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");
+2 -20
View File
@@ -84,24 +84,6 @@ 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",
@@ -212,10 +194,10 @@ describe("parsePackageListOutput", () => {
it("keeps unrecognized lines for partial coverage reporting", () => {
const { packages, unparsedLines } = parsePackageListOutput(
"package:/data/app/x/base.apk=com.ok\n garbage line \n",
"package:/data/app/x/base.apk=com.ok\ngarbage line\n",
);
expect(packages).toHaveLength(1);
expect(unparsedLines).toEqual([" garbage line "]);
expect(unparsedLines).toEqual(["garbage line"]);
});
});
+25 -14
View File
@@ -10,15 +10,11 @@ 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: AdbDeviceState;
readonly state: string;
readonly transport: "usb" | "tcp" | "unknown";
readonly product: string | null;
readonly model: string | null;
@@ -28,6 +24,25 @@ 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 => {
@@ -58,15 +73,11 @@ export const parseAdbDevicesOutput = (
continue;
}
const match = DEVICE_LINE.exec(line);
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);
});
const state = DEVICE_STATES.find((candidate) =>
match?.[2]?.startsWith(candidate),
);
if (match === null || state === undefined) {
if (rawLine.trim() !== "") unparsedLines.push(rawLine);
if (line.trim() !== "") unparsedLines.push(line);
continue;
}
const serial = match[1]!;
@@ -181,7 +192,7 @@ export const parsePackageListOutput = (
if (line === "") continue;
const match = PACKAGE_LINE.exec(line);
if (match === null) {
unparsedLines.push(rawLine);
unparsedLines.push(line);
continue;
}
packages.push({
-74
View File
@@ -121,52 +121,6 @@ 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
@@ -626,34 +580,6 @@ 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" }),
+1 -5
View File
@@ -236,11 +236,7 @@ export const parseDumpsysPackageOutput = (
inPermissions = true;
continue;
}
// Android 10 and 11 mark restricted entries as "NAME: restricted=true".
if (
inPermissions &&
/^\s{2,}[A-Za-z0-9._]+(?:: restricted=true)?$/u.test(line)
) {
if (inPermissions && /^\s{2,}[A-Za-z0-9._]+$/.test(line)) {
permissions += 1;
continue;
}
+3 -5
View File
@@ -199,11 +199,9 @@ 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 = 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));
const line = this.#frameBuffer
.subarray(0, this.#frameLength)
.toString("utf8");
this.#frameBuffer = Buffer.alloc(0);
this.#frameLength = 0;
this.onmessage?.(deserializeMessage(line));
+2 -2
View File
@@ -397,7 +397,7 @@ const workflowAvailabilityFor = ({
remediation:
"On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.",
};
if (name === "inspect_packet_capture" || name === "inspect_packet_fields")
if (name === "inspect_packet_capture")
return policy.packetCaptureEnabled === true
? { reason: "available", remediation: null }
: {
@@ -405,7 +405,7 @@ const workflowAvailabilityFor = ({
remediation:
"On Linux, supply TShark 4.x via absolute REA_TSHARK_COMMAND and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.",
};
if (name === "decode_signal_recording" || name === "inspect_signal_decoders")
if (name === "decode_signal_recording")
return policy.signalRecordingEnabled === true
? { reason: "available", remediation: null }
: {
+2 -6
View File
@@ -78,9 +78,7 @@ export const readJsonObjectFile = (
value === null ||
Array.isArray(value)
)
return err(
invalidJson(operation, "JSON document root must be an object"),
);
throw new TypeError("JSON document root must be an object");
for (const members of scans)
for (const [key, member] of members) {
if (!Object.hasOwn(value, key)) continue;
@@ -179,9 +177,7 @@ export const readJsonObjectFile = (
signal === undefined ? {} : { signal },
);
if (!rootObject)
return err(
invalidJson(operation, "JSON document root must be an object"),
);
throw new TypeError("JSON document root must be an object");
const digest = hash.digest("hex");
if (previousDigest !== undefined && digest !== previousDigest)
throw new RegularFileChangedError(path);
+4 -23
View File
@@ -2,16 +2,12 @@ import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
installMacHopper,
type MacHopperBundleVerification,
type MacHopperInstallHost,
} from "./MacHopper.js";
import { installMacHopper, type MacHopperInstallHost } from "./MacHopper.js";
class FakeMacHopperHost implements MacHopperInstallHost {
readonly archive = new TextEncoder().encode("hopper-dmg");
archiveOk = true;
verification: MacHopperBundleVerification = "verified";
valid = true;
existing = false;
mountSucceeds = true;
copySucceeds = true;
@@ -19,7 +15,6 @@ class FakeMacHopperHost implements MacHopperInstallHost {
badHash = false;
unmounts = 0;
cleanups = 0;
installs = 0;
opens = 0;
replaces = false;
@@ -52,7 +47,7 @@ class FakeMacHopperHost implements MacHopperInstallHost {
return Promise.resolve();
};
appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app");
validBundle = () => Promise.resolve(this.verification);
validBundle = () => Promise.resolve(this.valid);
destinationExists = () => Promise.resolve(this.existing);
installBundle = (
_source: string,
@@ -60,7 +55,6 @@ class FakeMacHopperHost implements MacHopperInstallHost {
_stage: string,
replaceExisting: boolean,
) => {
this.installs += 1;
this.replaces = replaceExisting;
return Promise.resolve(this.copySucceeds);
};
@@ -121,7 +115,7 @@ describe("macOS Hopper installation", () => {
it("cleans and detaches after bundle validation fails", async () => {
const host = new FakeMacHopperHost();
host.verification = "invalid_signature";
host.valid = false;
await expect(installMacHopper({}, host)).resolves.toEqual({
status: "failed",
reason: "bundle",
@@ -129,17 +123,4 @@ describe("macOS Hopper installation", () => {
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
it("reports a publisher mismatch without copying or launching the bundle", async () => {
const host = new FakeMacHopperHost();
host.verification = "unverified_publisher";
await expect(installMacHopper({}, host)).resolves.toEqual({
status: "failed",
reason: "publisher_verification_failed",
});
expect(host.installs).toBe(0);
expect(host.opens).toBe(0);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
});
+22 -52
View File
@@ -24,35 +24,7 @@ const execFileAsync = promisify(execFile);
const RELEASES_URL =
"https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
/**
* Apple publisher identity pinned for the official Hopper macOS bundle. The
* team identifier was captured from `Hopper-6.6.0-demo.dmg` at
* `https://www.hopperapp.com:443/downloader/public/Hopper-6.6.0-demo.dmg`
* (34846271 bytes, SHA-1 0be530f038249742590992199c7b042ea20ca955) with
* `codesign -dvvv`, which reported
* `Authority=Developer ID Application: Cryptic Apps (2AMA2753NF)`.
*/
export const HOPPER_PUBLISHER = Object.freeze({
bundleIdentifier: "com.cryptic-apps.hopper-web-4",
teamIdentifier: "2AMA2753NF",
});
/**
* Code requirement enforcing the bundle identifier and signing team together.
* The identifier is read from the sealed CodeDirectory, so it cannot be changed
* by editing `Info.plist` the way the previous string comparison could.
*/
const HOPPER_PUBLISHER_REQUIREMENT =
`= identifier "${HOPPER_PUBLISHER.bundleIdentifier}"` +
` and anchor apple generic` +
` and certificate leaf[subject.OU] = "${HOPPER_PUBLISHER.teamIdentifier}"`;
/** Outcome of reading a mounted bundle's signature and publisher identity. */
export type MacHopperBundleVerification =
| "verified"
| "invalid_signature"
| "unverified_publisher";
const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4";
/** Stable network and command effects disclosed before macOS Hopper setup. */
export const macHopperInstallDisclosure = {
@@ -60,7 +32,6 @@ export const macHopperInstallDisclosure = {
commands: [
"hdiutil attach -readonly -nobrowse -mountpoint <private-mount> <verified-hopper.dmg>",
"codesign --verify --deep --strict <mounted-hopper.app>",
`codesign --verify --strict '-R${HOPPER_PUBLISHER_REQUIREMENT}' <mounted-hopper.app>`,
"ditto <mounted-hopper.app> <user-Applications-stage>",
"open <installed-hopper.app>",
] as const,
@@ -81,7 +52,6 @@ export type MacHopperInstallResult =
| "integrity"
| "mount"
| "bundle"
| "publisher_verification_failed"
| "destination_exists"
| "copy"
| "launcher_missing";
@@ -99,7 +69,7 @@ export interface MacHopperInstallHost {
mount(packagePath: string, mountPath: string): Promise<boolean>;
unmount(mountPath: string): Promise<void>;
appBundle(mountPath: string): Promise<string | undefined>;
validBundle(appPath: string): Promise<MacHopperBundleVerification>;
validBundle(appPath: string): Promise<boolean>;
destinationExists(path: string): Promise<boolean>;
installBundle(
source: string,
@@ -154,12 +124,8 @@ export const installMacHopper = async (
return { status: "failed", reason: "mount" };
mounted = true;
const source = await host.appBundle(mountPath);
if (source === undefined) return { status: "failed", reason: "bundle" };
const verification = await host.validBundle(source);
if (verification === "invalid_signature")
if (source === undefined || !(await host.validBundle(source)))
return { status: "failed", reason: "bundle" };
if (verification === "unverified_publisher")
return { status: "failed", reason: "publisher_verification_failed" };
const stage = `${destination}.rea-stage`;
if (
!(await host.installBundle(
@@ -256,21 +222,25 @@ export const systemMacHopperInstallHost = (
environment,
))
)
return "invalid_signature";
if (
!(await commandSucceeds(
"codesign",
[
"--verify",
"--strict",
`-R${HOPPER_PUBLISHER_REQUIREMENT}`,
appPath,
],
environment,
))
)
return "unverified_publisher";
return "verified";
return false;
try {
const identifier = (
await execFileAsync(
"/usr/libexec/PlistBuddy",
[
"-c",
"Print :CFBundleIdentifier",
join(appPath, "Contents/Info.plist"),
],
{ env: environment },
)
).stdout.trim();
return identifier === BUNDLE_IDENTIFIER;
} catch (cause: unknown) {
// best-effort cleanup: optional bundle probing; unreadable means invalid.
void cause;
return false;
}
},
async destinationExists(path) {
try {
+1 -1
View File
@@ -23,7 +23,7 @@ export const createProgressReporter = (
} = {},
): ProgressReporter => {
const minimumIntervalMs = options.minimumIntervalMs ?? 100;
const now = options.now ?? (() => performance.now());
const now = options.now ?? Date.now;
let lastCompleted = -1;
let lastSentAt = Number.NEGATIVE_INFINITY;
let sequence = 0;
@@ -12,14 +12,12 @@ import {
detectReferenceSourceLanguage,
} from "../domain/referenceSourceClassification.js";
import type { HistoricalSourceGraphInput } from "../domain/referenceSourceGraph.js";
import {
parseReferenceSourceImports,
PARSEABLE_REFERENCE_SOURCE_LANGUAGES,
} from "../domain/referenceSourceImportParsing.js";
import { parseReferenceSourceImports } from "../domain/referenceSourceImportParsing.js";
import type {
ReferenceSourceEntry,
ReferenceSourceRead,
} from "../reference/ReferenceSourceReaderTypes.js";
import { PARSEABLE_REFERENCE_SOURCE_LANGUAGES } from "./ReferenceSourceImportTypes.js";
import { typescriptEmittedSourceCandidates } from "./TypeScriptEmittedSourceCandidates.js";
export interface ParsedReferenceSourceEntries {
@@ -104,11 +102,7 @@ const resolveInternalSpecifier = (
]);
// Retain present runtime files first; substitute only a missing counterpart.
// Counterparts cover explicit filenames, not directory/index resolution.
candidates.push(
...typescriptEmittedSourceCandidates(fromPath, normalized).map(
({ path }) => path,
),
);
candidates.push(...typescriptEmittedSourceCandidates(fromPath, normalized));
const match = lookupPath.resolveCandidates(candidates);
return match === undefined
? { to: normalized, resolution: "unresolved" }
@@ -87,3 +87,6 @@ export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [
".coverage",
"*.log",
] as const;
export const PARSEABLE_REFERENCE_SOURCE_LANGUAGES: ReadonlySet<string> =
new Set(["JavaScript", "TypeScript", "JSX", "TSX"]);
+25 -40
View File
@@ -19,17 +19,6 @@ export interface RegularFileReadOptions {
readonly resources?: ArtifactResourceScope;
}
export class InvalidUtf8FileError extends TypeError {
readonly _tag = "InvalidUtf8FileError";
constructor(
readonly path: string,
cause: unknown,
) {
super(`File is not valid UTF-8: ${path}`, { cause });
this.name = this._tag;
}
}
export type RegularFileReadOutcome<Value> =
| { readonly kind: "completed"; readonly value: Value }
| { readonly kind: "failed"; readonly cause: unknown };
@@ -153,34 +142,7 @@ const withRegularFileOwned = async <Value>(
export const readRegularFile = (
path: string,
options: RegularFileReadOptions = {},
): Promise<Buffer> => readRegularFileContent(path, (bytes) => bytes, options);
/** Read valid UTF-8 without replacing source bytes or stripping an initial BOM. */
export const readRegularFileText = (
path: string,
options: RegularFileReadOptions = {},
): Promise<string> =>
readRegularFileContent(
path,
(bytes) => {
try {
return new TextDecoder("utf-8", {
fatal: true,
ignoreBOM: true,
}).decode(bytes);
} catch (cause: unknown) {
if (!(cause instanceof TypeError)) throw cause;
throw new InvalidUtf8FileError(path, cause);
}
},
options,
);
const readRegularFileContent = <Value>(
path: string,
decode: (bytes: Buffer) => Value,
options: RegularFileReadOptions,
): Promise<Value> =>
): Promise<Buffer> =>
withRegularFile(
path,
async (handle, stats) => {
@@ -191,7 +153,30 @@ const readRegularFileContent = <Value>(
);
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return decode(bytes);
return bytes;
},
options,
);
/** Read one admitted regular file as UTF-8 without waiting on a pipe. */
export const readRegularFileText = async (
path: string,
options: RegularFileReadOptions = {},
): Promise<string> => {
try {
return (await readRegularFile(path, options)).toString("utf8");
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure &&
cause.outcome.kind === "completed" &&
Buffer.isBuffer(cause.outcome.value)
)
throw new RegularFileCleanupFailure(
cause.path,
cause.owner,
{ kind: "completed", value: cause.outcome.value.toString("utf8") },
cause.cleanupCause,
);
throw cause;
}
};
-34
View File
@@ -54,40 +54,6 @@ describe("setup workflow", () => {
expect(result.remediation).toContain("could not be updated");
});
it("reports a failed config rollback with its destination and recovery backup", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [{ name: "codex", configPath: "/fixture/codex.json" }];
host.clientResults.set("codex", {
status: "failed",
reason: "rollback",
backupPath: "/fixture/codex.json.rea.backup",
failure: {
kind: "restore",
path: "/fixture/codex.json",
detail: "permission denied",
},
});
const result = await runSetup(
{ ...options(true), clientIds: ["codex"] },
host,
);
expect(result.clients.codex).toEqual({
status: "failed",
reason: "rollback",
backupPath: "/fixture/codex.json.rea.backup",
failure: {
kind: "restore",
path: "/fixture/codex.json",
detail: "permission denied",
},
});
expect(result.remediation).toContain("permission denied");
expect(result.remediation).toContain("/fixture/codex.json.rea.backup");
});
it("explains skill installation recovery", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
+15 -160
View File
@@ -19,7 +19,6 @@ import {
} from "./ClientConfigurationDocument.js";
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
import { constants as fsConstants } from "node:fs";
import { createHash } from "node:crypto";
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import writeFileAtomic from "write-file-atomic";
@@ -30,7 +29,6 @@ import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import {
readRegularFile,
readRegularFileText,
RegularFileCleanupFailure,
} from "./RegularFileRead.js";
@@ -42,26 +40,6 @@ import type {
} from "./SetupTypes.js";
import type { SetupClient } from "./SupportedClients.js";
export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanupFailure<string> {
constructor(
failure: RegularFileCleanupFailure<string>,
readonly configurationFailure: Extract<
ClientConfigurationResult,
{ readonly status: "failed"; readonly reason: "rollback" }
>,
) {
super(
failure.path,
failure.owner,
failure.outcome,
new AggregateError(
[failure.cleanupCause, new Error(configurationFailure.failure.detail)],
`Configuration readback cleanup and rollback failed: ${configurationFailure.failure.path}`,
),
);
}
}
/** Configure one supported client's native stdio MCP registration shape. */
export const configureClientConfiguration = (
client: SetupClient,
@@ -189,23 +167,11 @@ const persistClientConfiguration = async ({
}: ClientConfigurationUpdate): Promise<ClientConfigurationResult> => {
const backupPath =
original === undefined ? undefined : `${client.configPath}.rea.backup`;
let recoveryBackupPath: string | undefined;
if (backupPath !== undefined && original !== undefined) {
const selection = await selectCurrentRecoveryBackup(backupPath, original);
if (!selection.ok) return { status: "failed", reason: "backup" };
if (
!(await preserveConfigBackup(transactionPath, selection.value.backupPath))
)
return { status: "failed", reason: "backup" };
const verified = await selectCurrentRecoveryBackup(backupPath, original);
if (
!verified.ok ||
verified.value.backupPath !== selection.value.backupPath ||
!verified.value.present
)
return { status: "failed", reason: "backup" };
recoveryBackupPath = verified.value.backupPath;
}
if (
backupPath !== undefined &&
!(await preserveConfigBackup(transactionPath, backupPath))
)
return { status: "failed", reason: "backup" };
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
// otherwise conflict with it.
const legacyPath = legacyClientServerPath(
@@ -273,39 +239,19 @@ const persistClientConfiguration = async ({
!clientConfigurationValuesEqual(readback.servers[name], entry),
)
) {
return rollbackOrReadbackFailure(
transactionPath,
original,
recoveryBackupPath,
);
await restoreConfig(transactionPath, original);
return { status: "failed", reason: "readback" };
}
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) {
const rollback = await restoreConfig(transactionPath, original);
if (!rollback.ok)
throw new ClientConfigurationCleanupRollbackFailure(cause, {
status: "failed",
reason: "rollback",
...(recoveryBackupPath === undefined
? {}
: { backupPath: recoveryBackupPath }),
failure: rollback.error,
});
throw cause;
}
if (cause instanceof RegularFileCleanupFailure) throw cause;
// Readback failure restores the transaction before reporting.
void cause;
return rollbackOrReadbackFailure(
transactionPath,
original,
recoveryBackupPath,
);
await restoreConfig(transactionPath, original);
return { status: "failed", reason: "readback" };
}
return {
status: "configured",
...(recoveryBackupPath === undefined
? {}
: { backupPath: recoveryBackupPath }),
...(backupPath === undefined ? {} : { backupPath }),
};
};
@@ -460,18 +406,9 @@ export const inspectClientConfiguration = async (
"The existing configuration is malformed. Repair it before setup applies any other changes.",
};
}
const backupSelection = await selectCurrentRecoveryBackup(
`${client.configPath}.rea.backup`,
original,
);
if (!backupSelection.ok)
return {
status: "invalid",
remediation: backupSelection.error.detail,
};
return {
status: "update",
backupPath: backupSelection.value.backupPath,
backupPath: `${client.configPath}.rea.backup`,
};
};
@@ -536,101 +473,19 @@ const preserveConfigBackup = async (
}
};
interface RecoveryBackupSelection {
readonly backupPath: string;
readonly present: boolean;
}
interface RecoveryBackupSelectionFailure {
readonly kind: "unreadable" | "occupied";
readonly path: string;
readonly detail: string;
}
const selectCurrentRecoveryBackup = async (
firstBackupPath: string,
original: string,
): Promise<Result<RecoveryBackupSelection, RecoveryBackupSelectionFailure>> => {
const expected = Buffer.from(original, "utf8");
let firstBackup: Buffer | undefined;
try {
firstBackup = await readRegularFile(firstBackupPath, {
symlinks: "reject",
});
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (!isMissing(cause))
return err({
kind: "unreadable",
path: firstBackupPath,
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
});
}
if (firstBackup === undefined)
return ok({ backupPath: firstBackupPath, present: false });
if (firstBackup.equals(expected))
return ok({ backupPath: firstBackupPath, present: true });
const digest = createHash("sha256").update(original, "utf8").digest("hex");
const recoveryPath = `${firstBackupPath}.${digest}.recovery`;
let recovery: Buffer | undefined;
try {
recovery = await readRegularFile(recoveryPath, { symlinks: "reject" });
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (!isMissing(cause))
return err({
kind: "unreadable",
path: recoveryPath,
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
});
}
if (recovery !== undefined && !recovery.equals(expected))
return err({
kind: "occupied",
path: recoveryPath,
detail: `The recovery path already contains different data: ${recoveryPath}`,
});
return ok({ backupPath: recoveryPath, present: recovery !== undefined });
};
const restoreConfig = async (
path: string,
original: string | undefined,
): Promise<
Result<
void,
{ readonly kind: "restore"; readonly path: string; readonly detail: string }
>
> => {
): Promise<void> => {
try {
if (original === undefined) await rm(path, { force: true });
else await writeFile(path, original, { encoding: "utf8", mode: 0o600 });
return ok(undefined);
} catch (cause: unknown) {
return err({ kind: "restore", path, detail: errorDetail(cause) });
// The backup remains available for the remediation reported by setup.
void cause;
}
};
const rollbackOrReadbackFailure = async (
targetPath: string,
original: string | undefined,
backupPath: string | undefined,
): Promise<ClientConfigurationResult> => {
const rollback = await restoreConfig(targetPath, original);
return rollback.ok
? { status: "failed", reason: "readback" }
: {
status: "failed",
reason: "rollback",
...(backupPath === undefined ? {} : { backupPath }),
failure: rollback.error,
};
};
const errorDetail = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
/** Whether REA's entry matches, no legacy entry remains, and no client disable list suppresses it. */
const registrationCurrent = (
parsed: ClientConfigurationDocument,
+7 -11
View File
@@ -6,19 +6,15 @@ import type {
} from "./SetupTypes.js";
import type { SetupClient } from "./SupportedClients.js";
export const failedConfigurationMessage = (
result: Extract<ClientConfigurationResult, { readonly status: "failed" }>,
const failedConfigurationMessage = (
reason: "path" | "backup" | "write" | "readback",
): string => {
if (result.reason === "path")
if (reason === "path")
return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
if (result.reason === "backup")
if (reason === "backup")
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
if (result.reason === "write")
if (reason === "write")
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
if (result.reason === "rollback")
return result.backupPath === undefined
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
: `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}. Inspect the preserved backup at ${result.backupPath} and restore the intended configuration, then rerun setup.`;
return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
};
@@ -44,12 +40,12 @@ export const configureDetectedClients = async (options: {
);
options.clients[client.name] = result;
if (result.status === "failed") {
failure ??= failedConfigurationMessage(result);
failure ??= failedConfigurationMessage(result.reason);
options.onProgress?.({
actionId,
label,
state: "failed",
detail: failedConfigurationMessage(result),
detail: failedConfigurationMessage(result.reason),
});
} else if (result.status === "configured") {
options.appliedActions.push(`configured_${client.name}`);
+1 -7
View File
@@ -33,11 +33,9 @@ import {
} from "./SetupSkill.js";
import {
clientConfigurationAligned,
ClientConfigurationCleanupRollbackFailure,
configureClientConfiguration,
inspectClientConfiguration,
} from "./SetupClientConfiguration.js";
import { failedConfigurationMessage } from "./SetupClients.js";
import { setupInstallFailure } from "./SetupInstallFailure.js";
import {
RegularFileCleanupFailure,
@@ -277,12 +275,8 @@ const runClientConfigurationOperation = async <Value>(
cleanup.outcome.kind === "failed"
? cleanup.outcome.cause
: cause.cleanupCause;
const rollbackMessage =
cause instanceof ClientConfigurationCleanupRollbackFailure
? ` ${failedConfigurationMessage(cause.configurationFailure)}`
: "";
throw configurationCleanupError(
`Could not safely access client configuration at ${cause.path}: ${errorMessage(primary)}.${rollbackMessage}`,
`Could not safely access client configuration at ${cause.path}: ${errorMessage(primary)}`,
cause,
cleanup.cleanup,
);
@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { HOPPER_PUBLISHER } from "./MacHopper.js";
import { setupInstallFailure } from "./SetupInstallFailure.js";
describe("macOS Hopper setup failure projection", () => {
it("projects a publisher mismatch onto its own code and recovery", () => {
expect(setupInstallFailure("publisher_verification_failed")).toEqual({
status: "failed",
code: "publisher_verification_failed",
remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`,
});
});
it("keeps a broken bundle distinct from a publisher mismatch", () => {
expect(setupInstallFailure("bundle")).toEqual({
status: "failed",
code: "bundle_invalid",
remediation:
"The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.",
});
});
});
-10
View File
@@ -1,5 +1,3 @@
import { HOPPER_PUBLISHER } from "./MacHopper.js";
export type SetupFailureCode =
| "unsupported_host"
| "download_failed"
@@ -12,7 +10,6 @@ export type SetupFailureCode =
| "destination_exists"
| "mount_failed"
| "bundle_invalid"
| "publisher_verification_failed"
| "copy_failed"
| "skill_install_failed"
| "cleanup_failed";
@@ -42,7 +39,6 @@ export type HopperInstallFailureReason =
| "destination_exists"
| "mount"
| "bundle"
| "publisher_verification_failed"
| "copy";
/** Map provider installer failures to stable setup codes and safe recovery. */
@@ -119,12 +115,6 @@ export const setupInstallFailure = (
remediation:
"The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.",
};
case "publisher_verification_failed":
return {
status: "failed",
code: "publisher_verification_failed",
remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`,
};
case "copy":
return {
status: "failed",
+1 -1
View File
@@ -320,5 +320,5 @@ const clientConfigurationDetail = (
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
: " The HOPPER_LAUNCHER_PATH value shown above is the current fallback; if this plan installs Hopper, REA will replace it with the launcher path reported by that installation."
: "";
return `Add the REA MCP registration for ${client}; preserve unrelated configuration. The backup path reflects the configuration inspected for this plan; if that file changes before execution, setup will select a recovery path for its current contents. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
return `Add the REA MCP registration for ${client}; preserve unrelated configuration. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
};
-10
View File
@@ -26,16 +26,6 @@ export type ClientConfigurationResult =
| {
readonly status: "failed";
readonly reason: "path" | "backup" | "write" | "readback";
}
| {
readonly status: "failed";
readonly reason: "rollback";
readonly backupPath?: string;
readonly failure: {
readonly kind: "restore";
readonly path: string;
readonly detail: string;
};
};
/** Read-only state used to block unsafe setup plans before any mutation. */
@@ -8,11 +8,6 @@ const EMITTED_SUFFIXES: ReadonlyArray<
[".jsx", [".tsx", ".d.ts"]],
];
export interface TypeScriptEmittedSourceCandidate {
readonly path: string;
readonly kind: "implementation" | "declaration";
}
/**
* Return TypeScript source counterparts for an explicit emitted filename.
* Callers retain exact runtime-file precedence and must not use these for
@@ -21,7 +16,7 @@ export interface TypeScriptEmittedSourceCandidate {
export const typescriptEmittedSourceCandidates = (
sourcePath: string,
candidate: string,
): readonly TypeScriptEmittedSourceCandidate[] => {
): readonly string[] => {
if (!/\.(?:ts|tsx|mts|cts)$/u.test(sourcePath)) return [];
const emitted = EMITTED_SUFFIXES.find(([suffix]) =>
candidate.endsWith(suffix),
@@ -30,8 +25,5 @@ export const typescriptEmittedSourceCandidates = (
// Strip the matched suffix by length. posix.extname would miss dotfile
// names such as ".js", whose whole basename is the emitted suffix itself.
const stem = candidate.slice(0, candidate.length - emitted[0].length);
return emitted[1].map((suffix) => ({
path: `${stem}${suffix}`,
kind: suffix.startsWith(".d.") ? "declaration" : "implementation",
}));
return emitted[1].map((suffix) => `${stem}${suffix}`);
};
+2 -10
View File
@@ -25,11 +25,7 @@ import {
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { dshDocument } from "./DshCordisPatch.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import {
InvalidUtf8FileError,
RegularFileCleanupFailure,
readRegularFileText,
} from "./RegularFileRead.js";
import { readRegularFileText } from "./RegularFileRead.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
manualRegistrationRemediation,
@@ -273,17 +269,13 @@ const readClientConfiguration = async (
try {
original = await fileSystem.readText(transactionPath);
} catch (cause: unknown) {
const readFailure =
cause instanceof RegularFileCleanupFailure ? cause.cause : cause;
return itemRead(
isMissing(cause)
? item(client.name, "skipped", "Configuration does not exist.")
: item(
client.name,
"failed",
readFailure instanceof InvalidUtf8FileError
? "Configuration is not valid UTF-8 and was not changed. Repair it, then rerun uninstall."
: "Configuration could not be read. Check file permissions, then rerun uninstall.",
"Configuration could not be read. Check file permissions, then rerun uninstall.",
),
);
}
-1
View File
@@ -22,7 +22,6 @@ export interface WebModuleArtifacts extends SelectedWebScriptArtifacts {
/** Read selected local artifacts without refetching website assets. */
export interface WebModuleArtifactPort {
close?(): Promise<void>;
load(
input: WebModuleTraceInput,
options?: ExecutionOptions,
+1 -8
View File
@@ -1,4 +1,3 @@
import { closeOwnedWebResources } from "./closeOwnedWebResources.js";
import { isAbsolute } from "node:path";
import { z } from "zod";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
@@ -71,13 +70,7 @@ export class WebModuleTraceService {
) {}
close(): Promise<void> {
return closeOwnedWebResources(
[
this.artifacts.close?.bind(this.artifacts),
this.resolver.close?.bind(this.resolver),
],
"Web module trace cleanup failed",
);
return this.resolver.close?.() ?? Promise.resolve();
}
async trace(
-2
View File
@@ -12,6 +12,4 @@ export interface WebNetworkCapturePort {
input: InspectWebNetworkCaptureInput,
options?: ExecutionOptions,
): Promise<Result<WebNetworkCapture, AnalysisError>>;
/** Retry retained reader/runtime cleanup when the owning service shuts down. */
close?(): Promise<void>;
}
@@ -27,11 +27,6 @@ const OPERATION = "inspect_web_network_capture";
export class WebNetworkCaptureService {
constructor(readonly port: WebNetworkCapturePort) {}
/** Release resources retained by the selected capture adapter. */
close(): Promise<void> {
return this.port.close?.() ?? Promise.resolve();
}
/** Return selected complete records inline, without inventing live browser attribution. */
async inspect(
rawInput: unknown,
-1
View File
@@ -8,7 +8,6 @@ import type { ExecutionOptions } from "./AnalysisProvider.js";
/** Read the caller-selected capture export and verify the selected source identity. */
export interface WebScriptArtifactPort {
close?(): Promise<void>;
load(
input: SelectedWebScriptInput,
options?: ExecutionOptions,
@@ -20,7 +20,6 @@ export interface WebSourceLocationArtifacts extends SelectedWebScriptArtifacts {
/** Replaceable artifact acquisition boundary for a caller-selected pairing. */
export interface WebSourceLocationArtifactPort {
close?(): Promise<void>;
load(
input: WebSourceLocationInput,
options?: ExecutionOptions,
+1 -9
View File
@@ -1,4 +1,3 @@
import { closeOwnedWebResources } from "./closeOwnedWebResources.js";
import { createHash } from "node:crypto";
import { isAbsolute } from "node:path";
import { z } from "zod";
@@ -72,13 +71,7 @@ export class WebSourceLocationService {
) {}
close(): Promise<void> {
return closeOwnedWebResources(
[
this.artifacts.close?.bind(this.artifacts),
this.decoder.close?.bind(this.decoder),
],
"Web source-location cleanup failed",
);
return this.decoder.close?.() ?? Promise.resolve();
}
/** Shared CLI/MCP workflow preserves raw declarations and every equal-position match. */
@@ -181,7 +174,6 @@ export class WebSourceLocationService {
);
}
}
const invalidOutput = (message: string): Result<never, AnalysisError> =>
err(new AnalysisOutputError(OPERATION, message));
const invalid = (
@@ -12,7 +12,6 @@ import type { Result } from "../../domain/result.js";
/** Stable read and PE decoding boundary, independent of an active disassembler session. */
export interface PeResourcesPort {
readonly identity: ProviderIdentity;
close?(): Promise<void>;
inspect(
input: InspectPeResourcesInput,
options?: ExecutionOptions,
@@ -21,10 +21,6 @@ import { err, ok, type Result } from "../../domain/result.js";
export class PeResourcesService {
constructor(readonly provider: PeResourcesPort) {}
close(): Promise<void> {
return this.provider.close?.() ?? Promise.resolve();
}
async inspect(
rawInput: unknown,
options?: ExecutionOptions,
@@ -1,9 +1,3 @@
import {
inspectPacketFieldsInputSchema,
packetFieldsCatalogSchema,
type InspectPacketFieldsInput,
} from "../../domain/captures/packetFields.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { isAbsolute } from "node:path";
import type {
AnalysisExecution,
@@ -30,10 +24,6 @@ export interface PacketCapturePort {
input: InspectPacketCaptureInput,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>>;
inspectFields(
input: InspectPacketFieldsInput,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>>;
close(): Promise<void>;
}
@@ -84,58 +74,14 @@ export class PacketCaptureService {
"Capture analyzer returned malformed data or changed the selected artifact identity/options.",
),
);
return this.#evidence(operation, input.data, execution, options);
}
async inspectFields(
raw: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
const operation = "inspect_packet_fields";
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(operation));
const input = inspectPacketFieldsInputSchema.safeParse(raw);
if (!input.success)
return err(
analysisInputErrorFromIssues(operation, input.error.issues, raw, {
cause: input.error,
}),
);
const decoded = await this.provider.inspectFields(input.data, options);
if (!decoded.ok) return decoded;
const execution = decoded.value;
const report = packetFieldsCatalogSchema.safeParse(execution.result);
if (
!report.success ||
JSON.stringify(report.data.selection) !== JSON.stringify(input.data) ||
execution.subject?.path !== report.data.tool.path ||
execution.subject.sha256 !== report.data.tool.sha256
)
return err(
new AnalysisOutputError(
operation,
"Field discovery returned malformed data or changed the selected installation/options.",
),
);
return this.#evidence(operation, input.data, execution, options);
}
#evidence(
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
execution: AnalysisExecution,
options?: ExecutionOptions,
): Result<Evidence, AnalysisError> {
const evidence = createEvidence(
execution.subject ?? undefined,
execution.provider,
{
operation,
parameters,
result: execution.result,
confidence: "observed",
locations: execution.locations,
limitations: execution.limitations,
},
);
const evidence = createEvidence(execution.subject, execution.provider, {
operation,
parameters: input.data,
result: report.data,
confidence: "observed",
locations: execution.locations,
limitations: execution.limitations,
});
return options?.signal?.aborted
? err(
new AnalysisCancelledError(operation, {
-15
View File
@@ -1,15 +0,0 @@
/** Attempt every web workflow cleanup owner and report all failures. */
export const closeOwnedWebResources = async (
close: readonly ((() => Promise<void>) | undefined)[],
message: string,
): Promise<void> => {
const results = await Promise.allSettled(
close.flatMap((owner) =>
owner === undefined ? [] : [Promise.resolve().then(owner)],
),
);
const failures = results.flatMap((result) =>
result.status === "rejected" ? [result.reason] : [],
);
if (failures.length > 0) throw new AggregateError(failures, message);
};
@@ -809,42 +809,6 @@ it("keeps an active script message bucket after its last message is evicted", as
await manager.closeAll();
});
it("retains the newest contiguous messages across repeated budget evictions", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
expect(started.ok).toBe(true);
if (!started.ok) return;
bindings.device.session.nextPayload = { index: -1 };
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send({index: -1});",
});
expect(loaded.ok).toBe(true);
const script = bindings.device.session.scripts[0];
expect(script).toBeDefined();
if (script === undefined) return;
// About 500 of these fit the 16 MiB budget, so 2,000 sends evict and
// compact the queue several times.
const filler = "x".repeat(32_000);
for (let index = 0; index < 2_000; index += 1)
script.message.emit({ type: "send", payload: { index, filler } }, null);
const status = manager.status(started.value.sessionId);
expect(status?.messagesTruncated).toBe(true);
const indexes = (status?.messages ?? []).map(
(message) => (message as { payload: { index: number } }).payload.index,
);
expect(indexes.length).toBeGreaterThan(400);
expect(indexes.at(-1)).toBe(1_999);
expect(indexes).toEqual(
Array.from(
{ length: indexes.length },
(_, offset) => 2_000 - indexes.length + offset,
),
);
await manager.closeAll();
});
it("serializes a final remote release against a new same-endpoint session", async () => {
const bindings = new FakeDeviceManager();
let enteredRemoval: (() => void) | undefined;
-1
View File
@@ -12,7 +12,6 @@ import type { Result } from "../../domain/result.js";
/** Replaceable offline build-information reader with no target-execution authority. */
export interface GoBinaryPort {
readonly identity: ProviderIdentity;
close?(): Promise<void>;
inspect(
input: InspectGoBinaryInput,
options?: ExecutionOptions,
-4
View File
@@ -34,10 +34,6 @@ const architectureForSubject = (architecture: string) => {
export class GoBinaryService {
constructor(readonly provider: GoBinaryPort) {}
close(): Promise<void> {
return this.provider.close?.() ?? Promise.resolve();
}
/** Inspect one explicitly selected file without acquiring a binary session. */
async inspect(
rawInput: unknown,
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { moduleResolutionModeForJavaScriptReference } from "../../domain/javascript/javascriptModuleResolutionMode.js";
import { moduleKindForJavaScriptReference } from "./JavaScriptArtifactPathResolution.js";
import { setImmediate } from "node:timers/promises";
import { resolveJavaScriptSourceMapReferences } from "../../domain/javascript/javascriptSourceMapPaths.js";
@@ -384,10 +384,7 @@ function* analyzeArtifactFileSteps<
analysis.parse_status === "complete" || analysis.parse_status === "partial"
? yield* analyzeParsedJavaScriptSemanticsSteps(
parsed,
moduleResolutionModeForJavaScriptReference(
file.path,
"static-import",
),
moduleKindForJavaScriptReference(file.path, "static-import"),
)
: null;
const projected =
@@ -66,7 +66,6 @@ export interface AstEdgeInput {
/** Inferred static relationship edge values. */
export interface InferenceEdgeInput extends AstEdgeInput {
readonly limitations?: readonly string[];
readonly relation:
| "imports"
| "loads"
@@ -163,9 +162,6 @@ export const addStaticInferenceEdge = (
range: input.range,
operation: "map-static-relationship",
coverage: input.coverage,
...(input.limitations === undefined
? {}
: { limitations: input.limitations }),
}),
});
};
@@ -351,7 +347,7 @@ export const resolveArtifactPath = (
specifier: string,
sourcePath: string,
files: ReadonlyMap<string, JavaScriptArtifactFile>,
moduleKind?: "import" | "require" | "compiler-dependent",
moduleKind?: "import" | "require",
): string | null =>
resolveArtifactPathByContext({
declaredPath: specifier,
@@ -12,6 +12,7 @@ import {
javascriptAnalysisCoverage,
linkElectronRoleToAsset,
moduleLookupKey,
resolveArtifactPath,
selfReferenceOmissions,
sourceNodeFor,
type JavaScriptArtifactGraphContext,
@@ -22,11 +23,9 @@ import {
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import {
artifactPathResolutionProperties,
moduleKindForJavaScriptReference,
resolveArtifactPathByContext,
type ArtifactPathResolution,
} from "./JavaScriptArtifactPathResolution.js";
import { moduleResolutionModeForJavaScriptReference } from "../../domain/javascript/javascriptModuleResolutionMode.js";
type StaticReference = JavaScriptStaticAnalysis["references"][number];
type StaticEndpoint = JavaScriptStaticAnalysis["endpoints"][number];
@@ -42,12 +41,9 @@ interface FindingInput<Value> {
}
interface ResolvedReference {
readonly path: string | null;
readonly file: JavaScriptArtifactFile | null;
readonly path: string;
readonly file: JavaScriptArtifactFile;
readonly node: ApplicationNode;
readonly limitations: readonly string[];
readonly resolutionStatus?: string;
readonly resolution?: ArtifactPathResolution;
}
/** Project imports, workers, roles, endpoints, storage, and source-map links. */
@@ -94,7 +90,7 @@ const addReference = (
const resolved =
input.value.specifier === null
? null
: resolveReference(context, input.file.path, input.value, input.coverage);
: resolveReference(context, input.file.path, input.value);
if (input.value.kind === "worker" || input.value.kind === "service-worker") {
const worker = context.accumulator.addNode({
kind: input.value.kind === "worker" ? "worker" : "service-worker",
@@ -130,7 +126,7 @@ const addReference = (
relation: "loads",
properties: { kind: input.value.kind },
});
if (resolved !== null && resolved.path !== null)
if (resolved !== null)
addStaticInferenceEdge(context, {
source: worker,
target: resolved.node,
@@ -139,7 +135,6 @@ const addReference = (
coverage: input.coverage,
relation: "maps_to",
properties: { resolved_path: resolved.path },
limitations: resolved.limitations,
});
return;
}
@@ -159,25 +154,13 @@ const addReference = (
file: input.file,
range: input.value.location,
coverage: input.coverage,
relation: resolved?.file?.kind === "native-addon" ? "loads" : "imports",
relation: resolved?.file.kind === "native-addon" ? "loads" : "imports",
properties: {
kind: input.value.kind,
specifier: input.value.specifier,
expression: input.value.expression,
resolution_mode:
input.value.kind === "static-import" ||
input.value.kind === "dynamic-import" ||
input.value.kind === "require"
? moduleResolutionModeForJavaScriptReference(
input.file.path,
input.value.kind,
)
: null,
resolved_path: resolved?.path ?? null,
resolution_status: resolved?.resolutionStatus ?? null,
...artifactPathResolutionProperties(resolved?.resolution),
},
...(resolved === null ? {} : { limitations: resolved.limitations }),
});
};
@@ -390,7 +373,6 @@ const resolveReference = (
context: JavaScriptArtifactGraphContext,
sourcePath: string,
reference: StaticReference,
coverage: JavaScriptArtifactGraphCoverage,
): ResolvedReference | null => {
const specifier = reference.specifier;
if (specifier === null) return null;
@@ -410,70 +392,30 @@ const resolveReference = (
const file = context.filesByPath.get(sourcePath);
return file === undefined
? null
: {
path: `${sourcePath}#module:${specifier}`,
file,
node: module,
limitations: [],
};
: { path: `${sourcePath}#module:${specifier}`, file, node: module };
}
// Worker scripts are URLs relative to the script, not module specifiers.
const resolution = isWorker
const path = isWorker
? resolveArtifactPathByContext({
declaredPath: specifier,
sourcePath,
context: "url-reference",
files: context.filesByPath,
})
: resolveArtifactPathByContext({
declaredPath: specifier,
}).resolved_path
: resolveArtifactPath(
specifier,
sourcePath,
context: "module-specifier",
files: context.filesByPath,
...("require" === reference.kind ||
"static-import" === reference.kind ||
"dynamic-import" === reference.kind
? {
moduleKind: moduleResolutionModeForJavaScriptReference(
sourcePath,
reference.kind,
),
}
: {}),
});
const path = resolution.resolved_path;
if (path === null) {
if (isWorker) return null;
const source = context.filesByPath.get(sourcePath);
return source === undefined
? null
: {
path: null,
file: null,
node: unresolvedReferenceNode(
context,
source,
reference,
coverage,
resolution,
),
limitations: resolution.limitations,
resolutionStatus: resolution.resolution_status,
resolution,
};
}
context.filesByPath,
reference.kind === "require" ||
reference.kind === "static-import" ||
reference.kind === "dynamic-import"
? moduleKindForJavaScriptReference(sourcePath, reference.kind)
: undefined,
);
if (path === null) return null;
const file = context.filesByPath.get(path);
const node = context.fileNodes.get(path);
return file === undefined || node === undefined
? null
: {
path,
file,
node,
limitations: resolution.limitations,
resolutionStatus: resolution.resolution_status,
resolution,
};
return file === undefined || node === undefined ? null : { path, file, node };
};
const resolveBundlerChunkReference = (
@@ -511,7 +453,7 @@ const resolveBundlerChunkReference = (
const node = unique[0];
return node === undefined
? null
: { path: `${sourcePath}#chunk:${key}`, file, node, limitations: [] };
: { path: `${sourcePath}#chunk:${key}`, file, node };
};
const unresolvedReferenceNode = (
@@ -519,7 +461,6 @@ const unresolvedReferenceNode = (
file: JavaScriptArtifactFile,
reference: StaticReference,
coverage: JavaScriptArtifactGraphCoverage,
resolution?: ArtifactPathResolution,
): ApplicationNode =>
context.accumulator.addNode({
kind: "javascript-module",
@@ -536,10 +477,6 @@ const unresolvedReferenceNode = (
specifier: reference.specifier,
expression: reference.expression,
resolution: "unresolved",
...(resolution === undefined
? {}
: { resolution_status: resolution.resolution_status }),
...artifactPathResolutionProperties(resolution),
},
evidence: staticInferenceEvidence({
sha256: file.sha256,
@@ -550,7 +487,6 @@ const unresolvedReferenceNode = (
confidence: "low",
limitations: [
"The static reference could not be resolved within the inventoried artifact.",
...(resolution?.limitations ?? []),
],
}),
},
@@ -1890,40 +1890,21 @@ describe("TypeScript NodeNext emitted-extension resolution", () => {
).toMatchObject({ resolution_status: "resolved", resolved_path: target });
},
);
});
describe("TypeScript declaration fallback ownership", () => {
const container = "container";
it.each([
["./dep.js", "src/dep.d.ts"],
["./cfg.mjs", "src/cfg.d.mts"],
["./legacy.cjs", "src/legacy.d.cts"],
] as const)(
"retains %s as compile-time evidence without inventing a runtime target",
"resolves %s to a declaration counterpart",
(specifier, target) => {
const files = fileMap([
file("src/main.ts", container),
file(target, container, "export const value = 1;"),
]);
for (const moduleKind of ["import", "require"] as const) {
expect(
resolve({
declaredPath: specifier,
sourcePath: "src/main.ts",
context: "module-specifier",
moduleKind,
files,
}),
).toMatchObject({
resolution_status: "unavailable",
resolved_path: null,
candidate_path: target,
});
}
expect(
resolve({
declaredPath: "./" + target.slice(4),
declaredPath: specifier,
sourcePath: "src/main.ts",
context: "module-specifier",
moduleKind: "import",
@@ -19,13 +19,7 @@ import {
classifyPackageImportTargetString,
lookupExactPackageImport,
} from "../../domain/javascript/javascriptPackageImportAliases.js";
import {
typescriptEmittedSourceCandidates,
type TypeScriptEmittedSourceCandidate,
} from "../TypeScriptEmittedSourceCandidates.js";
import { isRelativeJavaScriptModuleSpecifier } from "../../domain/javascript/javascriptModuleSpecifier.js";
import type { JavaScriptModuleResolutionMode } from "../../domain/javascript/javascriptModuleResolutionMode.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { typescriptEmittedSourceCandidates } from "../TypeScriptEmittedSourceCandidates.js";
type ArtifactPathResolutionContext =
| "package-entrypoint"
@@ -44,16 +38,6 @@ interface ArtifactPathResolutionBase {
readonly declared_path: string;
readonly resolution_context: ArtifactPathResolutionContext;
readonly limitations: readonly string[];
readonly conditional_resolutions?: readonly ConditionalModuleResolution[];
readonly candidate_path?: string;
}
export interface ConditionalModuleResolution {
readonly mode: "import" | "require";
readonly resolution_status: UnresolvedArtifactPathStatus | "resolved";
readonly resolved_path: string | null;
readonly candidate_path?: string;
readonly limitations: readonly string[];
}
/** Explicit outcome of artifact-confined path resolution. */
@@ -77,13 +61,26 @@ export interface ResolveArtifactPathInput {
readonly files: ReadonlyMap<string, JavaScriptArtifactFile>;
readonly htmlBaseHref?: string | null;
/** Import/require semantics; runtime resource paths omit this provenance. */
readonly moduleKind?: JavaScriptModuleResolutionMode;
readonly moduleKind?: "import" | "require";
}
/** Loader selected by a source-level JavaScript module reference. */
export const moduleKindForJavaScriptReference = (
sourcePath: string,
referenceKind: "static-import" | "dynamic-import" | "require",
resolutionMode?: "import" | "require",
): "import" | "require" =>
resolutionMode ??
(referenceKind === "require" ||
(referenceKind === "static-import" &&
posix.extname(sourcePath).toLowerCase() === ".cts")
? "require"
: "import");
/** Preserve an explicitly retained loader mode on graph relationships. */
export const moduleResolutionModeProperties = (
mode: JavaScriptModuleResolutionMode | undefined,
): Readonly<Record<string, JavaScriptModuleResolutionMode>> =>
mode: "import" | "require" | undefined,
): Readonly<Record<string, "import" | "require">> =>
mode === undefined ? {} : { resolution_mode: mode };
const EXTENSIONS = [
@@ -112,116 +109,43 @@ interface PackageFileTarget {
readonly url: URL;
}
type CandidateResolution = {
readonly limitations: readonly string[];
readonly candidatePath?: string;
readonly conditionalResolutions?: readonly ConditionalModuleResolution[];
} & (
| { readonly resolvedPath: string; readonly status: "resolved" }
type CandidateResolution =
| {
readonly resolvedPath: string;
readonly status: "resolved";
readonly limitations: readonly string[];
}
| {
readonly resolvedPath: null;
readonly status: UnresolvedArtifactPathStatus;
readonly limitations: readonly string[];
/** A truthy package main failed, so Node must not try ancestor packages. */
readonly stopsAncestorLookup?: true;
}
);
};
/** Resolve one declaration while preserving its original input identity. */
export const resolveArtifactPathByContext = (
input: ResolveArtifactPathInput,
): ArtifactPathResolution => {
const resolution = resolvePath(input);
const facts = {
declared_path: input.declaredPath,
resolution_context: input.context,
limitations: resolution.limitations,
...(resolution.candidatePath === undefined
? {}
: { candidate_path: resolution.candidatePath }),
...(resolution.conditionalResolutions === undefined
? {}
: { conditional_resolutions: resolution.conditionalResolutions }),
};
return resolution.status === "resolved"
? {
...facts,
declared_path: input.declaredPath,
resolution_context: input.context,
resolved_path: resolution.resolvedPath,
resolution_status: resolution.status,
limitations: resolution.limitations,
}
: { ...facts, resolved_path: null, resolution_status: resolution.status };
: {
declared_path: input.declaredPath,
resolution_context: input.context,
resolved_path: null,
resolution_status: resolution.status,
limitations: resolution.limitations,
};
};
/** Project retained conditional identities without flattening them into display text. */
export const artifactPathResolutionProperties = (
resolution: ArtifactPathResolution | undefined,
): Readonly<Record<string, JsonValue>> => ({
...(resolution?.candidate_path === undefined
? {}
: { candidate_path: resolution.candidate_path }),
...(resolution?.conditional_resolutions === undefined
? {}
: {
conditional_resolutions: resolution.conditional_resolutions.map(
(branch) => ({
mode: branch.mode,
resolution_status: branch.resolution_status,
resolved_path: branch.resolved_path,
limitations: [...branch.limitations],
...(branch.candidate_path === undefined
? {}
: { candidate_path: branch.candidate_path }),
}),
),
}),
});
const conditionalResolution = (
mode: "import" | "require",
result: CandidateResolution,
): ConditionalModuleResolution => ({
mode,
resolution_status: result.status,
resolved_path: result.resolvedPath,
limitations: result.limitations,
...(result.candidatePath === undefined
? {}
: { candidate_path: result.candidatePath }),
});
const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
if (input.moduleKind === "compiler-dependent") {
const imported = resolvePath({ ...input, moduleKind: "import" });
const required = resolvePath({ ...input, moduleKind: "require" });
const sameOutcome =
imported.status === required.status &&
imported.resolvedPath === required.resolvedPath &&
imported.candidatePath === required.candidatePath;
const limitation =
"Static TypeScript loader mode is compiler-dependent without retained compiler options. " +
(sameOutcome
? "Node import and require resolution agree on this outcome."
: "Node import and require resolution disagree; neither branch is an established target.");
return sameOutcome
? {
...imported,
limitations: [
...new Set([
...imported.limitations,
...required.limitations,
limitation,
]),
],
}
: {
resolvedPath: null,
status: "unavailable",
limitations: [limitation],
conditionalResolutions: [
conditionalResolution("import", imported),
conditionalResolution("require", required),
],
};
}
// Package import keys are literal lookup identities, not artifact paths.
// The selected target is admitted separately by resolveExportTarget.
if (
@@ -242,7 +166,7 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
);
const isModuleUrlPath =
input.context === "module-specifier" &&
(isRelativeJavaScriptModuleSpecifier(input.declaredPath) ||
(input.declaredPath.startsWith(".") ||
input.declaredPath.startsWith("/") ||
fileUrlPath(input.declaredPath) !== undefined);
const emittedSourceCandidates =
@@ -251,47 +175,12 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
: [];
if (isModuleUrlPath && input.moduleKind === "import")
return (
resolveRuntimeSourceCandidates(
input,
confined,
emittedSourceCandidates,
) ?? notFoundCandidate()
resolveFileCandidates(input, [confined, ...emittedSourceCandidates]) ??
notFoundCandidate()
);
return resolveCandidate(input, confined, new Set(), emittedSourceCandidates);
};
const resolveRuntimeSourceCandidates = (
input: ResolveArtifactPathInput,
exactPath: string,
sourceCandidates: readonly TypeScriptEmittedSourceCandidate[],
): CandidateResolution | null => {
const source = input.files.get(input.sourcePath);
const exactCandidate: TypeScriptEmittedSourceCandidate = {
path: exactPath,
kind: "implementation",
};
for (const candidate of [exactCandidate, ...sourceCandidates]) {
const target = input.files.get(candidate.path);
if (
target === undefined ||
(source !== undefined &&
target.container_sha256 !== source.container_sha256)
)
continue;
return candidate.kind === "declaration"
? {
resolvedPath: null,
status: "unavailable",
candidatePath: candidate.path,
limitations: [
"Only a TypeScript declaration fallback was found; it does not establish the requested runtime module.",
],
}
: { resolvedPath: candidate.path, status: "resolved", limitations: [] };
}
return null;
};
const rejectDeclaration = (declared: string): CandidateResolution | null => {
if (declared.length === 0)
return unresolvedCandidate("rejected", ["The declared path is empty."]);
@@ -340,10 +229,7 @@ const contextualCandidate = (
return unresolvedCandidate("external", [
"URL and Node builtin schemes are outside static artifact module resolution.",
]);
if (
!isRelativeJavaScriptModuleSpecifier(declared) &&
!declared.startsWith("/")
)
if (!declared.startsWith(".") && !declared.startsWith("/"))
return bareModuleCandidate(input);
if (input.moduleKind !== "require") {
const decoded = decodeModuleUrlPath(stripQueryAndFragment(declared));
@@ -389,10 +275,7 @@ const contextualCandidate = (
"URL",
);
}
const candidate = relativeCandidate(input.sourcePath, declared);
return context === "module-specifier" && input.moduleKind === "import"
? exactUrlPathCandidate(declared, candidate, "module URL")
: candidate;
return relativeCandidate(input.sourcePath, declared);
};
const decodeModuleUrlPath = (
@@ -427,12 +310,9 @@ const bareModuleCandidate = (
return unresolvedCandidate("external", [
"The bare specifier is a Node builtin or invalid package name.",
]);
if (
input.moduleKind === "import" &&
(request.packageName.startsWith(".") || request.packageName.includes("%"))
)
if (input.moduleKind === "import" && request.packageName.includes("%"))
return unresolvedCandidate("rejected", [
"ES module package names cannot start with a dot or contain percent signs.",
"Percent signs are not admitted in ES module package names.",
]);
const self = packageSelfCandidate(input, request);
if (self !== null) return self;
@@ -855,7 +735,7 @@ const htmlCandidate = (
const exactUrlPathCandidate = (
declared: string,
candidate: string,
kind: "HTML" | "URL" | "module URL",
kind: "HTML" | "URL",
): string | CandidateResolution => {
if (!urlPathIsDirectory(declared)) return candidate;
const confined = confineCandidate(candidate);
@@ -959,10 +839,12 @@ const resolveCandidate = (
input: ResolveArtifactPathInput,
candidate: string,
packageChain: ReadonlySet<string>,
emittedSourceCandidates: readonly TypeScriptEmittedSourceCandidate[] = [],
emittedSourceCandidates: readonly string[] = [],
): CandidateResolution =>
resolveFileCandidates(input, fileCandidates(candidate, input.moduleKind)) ??
resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates) ??
resolveFileCandidates(input, [
...fileCandidates(candidate, input.moduleKind),
...emittedSourceCandidates,
]) ??
resolveDirectory(
input,
candidate,
@@ -1048,26 +930,11 @@ const resolveFileCandidates = (
(source === undefined ||
target.container_sha256 === source.container_sha256)
)
return isRuntimeDeclarationPath(input, path)
? {
resolvedPath: null,
status: "unavailable",
candidatePath: path,
limitations: [
`The TypeScript declaration ${path} is compile-time evidence and cannot identify a runtime module implementation.`,
],
}
: { resolvedPath: path, status: "resolved", limitations: [] };
return { resolvedPath: path, status: "resolved", limitations: [] };
}
return null;
};
const isRuntimeDeclarationPath = (
input: ResolveArtifactPathInput,
path: string,
): boolean =>
input.moduleKind !== undefined && /\.d\.(?:ts|mts|cts)$/iu.test(path);
const NODE_MODULE_EXTENSIONS = [".js", ".json", ".node"] as const;
const fileCandidates = (
@@ -18,7 +18,6 @@ import type {
JavaScriptModuleOrigin,
JavaScriptSemanticModuleLink,
} from "../../domain/javascript/javascriptSemanticIr.js";
import { moduleResolutionModeForJavaScriptReference } from "../../domain/javascript/javascriptModuleResolutionMode.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
addAstContainsEdge,
@@ -31,7 +30,7 @@ import {
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import {
artifactPathResolutionProperties,
moduleKindForJavaScriptReference,
moduleResolutionModeProperties,
resolveArtifactPathByContext,
type ArtifactPathResolution,
@@ -55,7 +54,6 @@ interface RelationshipInput {
type ResolvedModuleTarget = {
readonly node: ApplicationNode;
readonly limitations: readonly string[];
readonly resolution?: ArtifactPathResolution;
} & (
| {
readonly path: string;
@@ -83,13 +81,8 @@ export const addJavaScriptSourceModules = (
if (asset === undefined || semantic === null || program === undefined)
continue;
const exports = semantic.ir.moduleLinks
.flatMap(({ exportedName, kind }) =>
exportedName === null || kind === "type-import" ? [] : [exportedName],
)
.sort(compareUnicodeCodePoints);
const typeExports = semantic.ir.moduleLinks
.flatMap(({ exportedName, kind }) =>
exportedName === null || kind !== "type-import" ? [] : [exportedName],
.flatMap(({ exportedName }) =>
exportedName === null ? [] : [exportedName],
)
.sort(compareUnicodeCodePoints);
const retainedExports = [...new Set(exports)];
@@ -112,12 +105,8 @@ export const addJavaScriptSourceModules = (
import_relationships: semantic.ir.moduleLinks.filter(
({ kind }) => kind === "import" || kind === "require",
).length,
type_relationships: semantic.ir.moduleLinks.filter(
({ kind }) => kind === "type-import",
).length,
export_relationships: exports.length,
export_names: retainedExports,
type_export_names: [...new Set(typeExports)],
omitted_export_names: 0,
},
evidence: astObservationEvidence({
@@ -163,7 +152,6 @@ export function* addJavaScriptModuleRelationshipsSteps(
for (const link of semantic.ir.moduleLinks) {
// One module can export thousands of bindings, each with return shapes.
yield;
if (link.kind === "type-import") continue;
const input = { context, file, semantic, source, link };
if (isExportLink(link)) addExportRelationship(input, omissions);
else if (link.specifier !== null)
@@ -289,7 +277,7 @@ const resolveModuleTarget = (
sourcePath: input.file.path,
context: "module-specifier",
files: input.context.filesByPath,
moduleKind: moduleResolutionModeForJavaScriptReference(
moduleKind: moduleKindForJavaScriptReference(
input.file.path,
input.link.kind === "require" || input.link.kind === "commonjs-export"
? "require"
@@ -299,12 +287,15 @@ const resolveModuleTarget = (
});
if (resolution.resolution_status !== "resolved")
return {
node: unresolvedModuleNode(input, specifier, resolution),
node: unresolvedModuleNode(
input,
specifier,
resolution.resolution_status,
),
path: null,
file: null,
status: resolution.resolution_status,
limitations: resolution.limitations,
resolution,
};
const path = resolution.resolved_path;
const file = input.context.filesByPath.get(path);
@@ -321,24 +312,20 @@ const resolveModuleTarget = (
file,
status: "resolved",
limitations: resolution.limitations,
resolution,
};
};
const unresolvedModuleNode = (
input: RelationshipInput,
specifier: string,
resolution: Extract<
ArtifactPathResolution,
{ resolution_status: Exclude<ResolvedModuleTarget["status"], "resolved"> }
>,
status: Exclude<ResolvedModuleTarget["status"], "resolved">,
): ApplicationNode =>
input.context.accumulator.addNode({
kind: "javascript-module",
identity: artifactLocalIdentity(
input.file.sha256,
"unresolved-semantic-module",
`${resolution.resolution_status}:${specifier}`,
`${status}:${specifier}`,
),
observations: [
{
@@ -346,8 +333,7 @@ const unresolvedModuleNode = (
properties: {
semantic_role: "module-reference",
declared_specifier: specifier,
resolution_status: resolution.resolution_status,
...artifactPathResolutionProperties(resolution),
resolution_status: status,
},
evidence: staticInferenceEvidence({
sha256: input.file.sha256,
@@ -368,9 +354,6 @@ const moduleOriginForExport = (
): JavaScriptModuleOrigin | null => {
if (link.specifier !== null)
return {
...(link.resolutionMode === undefined
? {}
: { resolutionMode: link.resolutionMode }),
specifier: link.specifier,
importedPath: link.importedName === null ? [] : [link.importedName],
};
@@ -400,7 +383,6 @@ const relationshipProperties = (
exported_name: link.exportedName,
resolved_path: target?.path ?? null,
resolution_status: target?.status ?? null,
...artifactPathResolutionProperties(target?.resolution),
target_file_kind: target?.file?.kind ?? null,
target_json_status:
target?.file?.kind === "json" || target?.file?.kind === "package-json"
@@ -449,7 +431,6 @@ const moduleFormat = (
const extension = posix.extname(path).toLowerCase();
if (extension === ".mjs" || extension === ".mts") return "esm";
if (extension === ".cjs" || extension === ".cts") return "commonjs";
if (extension === ".ts" || extension === ".tsx") return "unknown";
const esm = ir.moduleLinks.some(({ kind }) =>
["import", "export", "re-export"].includes(kind),
);
@@ -1,9 +1,3 @@
import {
inspectSignalDecodersInputSchema,
signalDecodersSchema,
type InspectSignalDecodersInput,
} from "../../domain/signals/signalDecoders.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { isAbsolute } from "node:path";
import type {
AnalysisExecution,
@@ -30,10 +24,6 @@ export interface SignalRecordingPort {
input: DecodeSignalRecordingInput,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>>;
inspectDecoders(
input: InspectSignalDecodersInput,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>>;
close(): Promise<void>;
}
@@ -84,58 +74,14 @@ export class SignalRecordingService {
"Decoder returned malformed data or changed the selected recording identity/options.",
),
);
return this.#evidence(operation, input.data, execution, options);
}
async inspectDecoders(
raw: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
const operation = "inspect_signal_decoders";
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(operation));
const input = inspectSignalDecodersInputSchema.safeParse(raw);
if (!input.success)
return err(
analysisInputErrorFromIssues(operation, input.error.issues, raw, {
cause: input.error,
}),
);
const decoded = await this.provider.inspectDecoders(input.data, options);
if (!decoded.ok) return decoded;
const execution = decoded.value;
const report = signalDecodersSchema.safeParse(execution.result);
if (
!report.success ||
JSON.stringify(report.data.selection) !== JSON.stringify(input.data) ||
execution.subject?.path !== report.data.tool.path ||
execution.subject.sha256 !== report.data.tool.sha256
)
return err(
new AnalysisOutputError(
operation,
"Decoder discovery returned malformed data or changed the selected installation/options.",
),
);
return this.#evidence(operation, input.data, execution, options);
}
#evidence(
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
execution: AnalysisExecution,
options?: ExecutionOptions,
): Result<Evidence, AnalysisError> {
const evidence = createEvidence(
execution.subject ?? undefined,
execution.provider,
{
operation,
parameters,
result: execution.result,
confidence: "observed",
locations: execution.locations,
limitations: execution.limitations,
},
);
const evidence = createEvidence(execution.subject, execution.provider, {
operation,
parameters: input.data,
result: report.data,
confidence: "observed",
locations: execution.locations,
limitations: execution.limitations,
});
return options?.signal?.aborted
? err(
new AnalysisCancelledError(operation, {
-4
View File
@@ -57,10 +57,6 @@ export class ArtifactResourceScope {
#draining: Promise<void> | undefined;
#closed = false;
get hasPending(): boolean {
return this.#pending.size > 0;
}
/** Admit a resource operation while the scope remains open. */
async run<T>(operation: () => Promise<T>): Promise<T> {
while (true) {
+4 -8
View File
@@ -8,11 +8,11 @@ import {
type NativeCommandRunner,
} from "../native/CommandRunner.js";
import {
lipoSubtypeBits,
parseLipoArchitectures,
type LipoArchitecture,
} from "../native/parsers/lipo.js";
import type { MachoSlice } from "../domain/apple/dylibResolution.js";
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { readMachoImage, type ReadAt } from "./apple/MachoLoadCommandReader.js";
import {
@@ -81,9 +81,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
if (!captured.ok)
throw new ArtifactReaderFailure(
captured.error.reason === "cancelled" ? "cancelled" : "unavailable",
captured.error.detail === null
? "lipo could not enumerate universal Mach-O slices"
: `lipo could not enumerate universal Mach-O slices: ${captured.error.detail}`,
"lipo could not enumerate universal Mach-O slices",
{
cause: captured.error,
...(captured.error.cleanupFailure === null &&
@@ -471,10 +469,8 @@ const lipoSubtypeMatches = (
): boolean => {
if (reported.cpu_subtype_code !== null)
return reported.cpu_subtype_code === observedCpuSubtype;
return (
reported.cpu_subtype_base === null ||
reported.cpu_subtype_base === machoCpuSubtypeBase(observedCpuSubtype)
);
const bits = lipoSubtypeBits(reported.cpu_subtype);
return bits === null || bits === (observedCpuSubtype & 0x00ffffff);
};
/**
+11 -22
View File
@@ -280,11 +280,9 @@ export class SafeOutputTree {
}
/** Sync the owned output tree and prevent further writes through this instance. */
async commit(signal?: AbortSignal): Promise<void> {
async commit(): Promise<void> {
this.#assertWritable();
abortIfNeeded(signal);
await this.#assertOwnedDirectories();
abortIfNeeded(signal);
// Windows has no directory fsync; file contents are already synced in write().
if (this.platform === "win32") {
this.#published = true;
@@ -317,7 +315,6 @@ export class SafeOutputTree {
);
await output.handle.sync();
await parent.handle.sync();
abortIfNeeded(signal);
this.#published = true;
} catch (cause: unknown) {
sync = { kind: "failed", cause };
@@ -339,26 +336,23 @@ export class SafeOutputTree {
closeFailure,
this.#outputRoot,
);
if (sync.kind === "failed") {
const primary = isCancellationFailure(sync.cause)
? sync.cause
: new ArtifactReaderFailure(
"path",
"Could not durably sync extraction output",
{ cause: sync.cause },
);
throw ArtifactReaderFailure.withCleanup(primary, cleanup);
}
if (sync.kind === "failed")
throw ArtifactReaderFailure.withCleanup(
new ArtifactReaderFailure(
"path",
"Could not durably sync extraction output",
{ cause: sync.cause },
),
cleanup,
);
throw ArtifactReaderFailure.withCleanup(closeFailure, cleanup);
}
if (sync.kind === "failed") {
if (isCancellationFailure(sync.cause)) throw sync.cause;
if (sync.kind === "failed")
throw new ArtifactReaderFailure(
"path",
"Could not durably sync extraction output",
{ cause: sync.cause },
);
}
}
/** Remove only this operation's unsealed tree and verify absence. */
@@ -596,11 +590,6 @@ const abortIfNeeded = (signal?: AbortSignal): void => {
);
};
const isCancellationFailure = (
cause: unknown,
): cause is ArtifactReaderFailure =>
cause instanceof ArtifactReaderFailure && cause.reason === "cancelled";
const isAlreadyExists = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "EEXIST";
+10 -4
View File
@@ -17,7 +17,6 @@ import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import { SafeOutputTree } from "../SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../SafeOutputTreeCreationFailure.js";
import { ZipArtifactReader } from "../ZipArtifactReader.js";
import { isZipPackageFormat } from "../../domain/zipPackageFormat.js";
import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js";
import {
artifactExtractionResultSchema,
@@ -249,7 +248,7 @@ const materializeSelection = async ({
compareUnicodeCodePoints(left.relative_path, right.relative_path),
);
completed = createExtractionResult(input, inventory, selected, extracted);
await output.commit(signal);
await output.commit();
return completed;
} catch (cause: unknown) {
const cleanupFailures = await releaseExtractionOwners(
@@ -401,6 +400,13 @@ interface ExtractionInventory {
readonly integrityContradictions: readonly IntegrityContradiction[];
}
const ZIP_FORMATS = ["ipa", "apk", "msix", "appx", "zip"] as const;
const isZipFormat = (
format: BinaryTarget["format"],
): format is (typeof ZIP_FORMATS)[number] =>
(ZIP_FORMATS as readonly string[]).includes(format);
/**
* Refuse a format without an extraction reader before inventory work starts.
* The target kind, not the host, is unsupported by extraction. The error keeps
@@ -416,7 +422,7 @@ const requireExtractableFormat = async (
!directory &&
format !== "asar" &&
!isMachOFormat(format) &&
!isZipPackageFormat(format)
!isZipFormat(format)
)
throw new AnalysisUnsupportedTargetError(
"extract_artifact",
@@ -437,7 +443,7 @@ const createReader = async (
if (await requireExtractableFormat(path, input))
return new DirectoryArtifactReader(path);
if (format === "asar") return new AsarArtifactReader(path);
if (isZipPackageFormat(format)) return new ZipArtifactReader(path, format);
if (isZipFormat(format)) return new ZipArtifactReader(path, format);
return new MachOSliceArtifactReader(path, input.environment);
};
+2 -10
View File
@@ -38,13 +38,6 @@ const afterValidation = async <T>(
}
};
const assertReadActive = (signal: AbortSignal | undefined): void => {
if (signal?.aborted)
throw new ArtifactReaderFailure("cancelled", "Artifact read cancelled", {
cause: signal.reason,
});
};
/** Read one bounded regular file and reject replacement or in-place changes. */
export const readStableArtifact = async (
path: string,
@@ -53,7 +46,7 @@ export const readStableArtifact = async (
fileSystem: StableArtifactFileSystem = FILE_SYSTEM,
resourceScope?: ArtifactResourceScope,
): Promise<{ readonly bytes: Buffer; readonly sha256: string }> => {
assertReadActive(signal);
signal?.throwIfAborted();
const before = await fileSystem.lstat(path);
if (!before.isFile() || before.isSymbolicLink())
throw new ArtifactReaderFailure(
@@ -91,7 +84,7 @@ export const readStableArtifact = async (
autoClose: false,
highWaterMark: 65536,
})) {
assertReadActive(signal);
signal?.throwIfAborted();
const value: unknown = chunk;
if (!Buffer.isBuffer(value))
throw new ArtifactReaderFailure(
@@ -127,7 +120,6 @@ export const readStableArtifact = async (
"integrity",
`Artifact changed during read: ${path}`,
);
assertReadActive(signal);
const bytes = Buffer.concat(chunks, count);
return { bytes, sha256: createHash("sha256").update(bytes).digest("hex") };
} catch (cause: unknown) {
@@ -91,7 +91,7 @@ export const browserScenarioCaptureData = (input: {
network_content: input.scenario.capture.network,
event_families: [...new Set(input.scenario.capture.events)].sort(),
},
duration_ms: Math.max(0, Math.round(performance.now() - input.startedAt)),
duration_ms: Date.now() - input.startedAt,
steps: [...input.steps],
events,
limitations: [
@@ -303,7 +303,6 @@ describe("PlaywrightBrowserScenarioProvider", () => {
});
expect(result.value.limitations).toContain(session.eventGaps[0]);
});
it("returns an initial state and every step beyond the former action ceiling", async () => {
const session = new FakeSession("launch");
const provider = new PlaywrightBrowserScenarioProvider(() =>
@@ -377,35 +376,6 @@ describe("PlaywrightBrowserScenarioProvider", () => {
});
});
describe("PlaywrightBrowserScenarioProvider timing", () => {
it("keeps capture elapsed values valid across a backward wall-clock jump", async () => {
let wallClock = 10_000;
vi.spyOn(Date, "now").mockImplementation(() => wallClock);
const session = new FakeSession("launch");
session.onPerform = () => {
wallClock = 1;
};
const provider = new PlaywrightBrowserScenarioProvider(() =>
Promise.resolve(session),
);
try {
const result = await provider.captureScenario(scenario());
if (!result.ok) throw result.error;
expect(wallClock).toBe(1);
expect(Number.isInteger(result.value.duration_ms)).toBe(true);
expect(result.value.duration_ms).toBeGreaterThanOrEqual(0);
expect(
result.value.steps.every(
({ elapsed_ms }) => Number.isInteger(elapsed_ms) && elapsed_ms >= 0,
),
).toBe(true);
} finally {
vi.restoreAllMocks();
}
});
});
describe("PlaywrightBrowserScenarioProvider comparison coverage", () => {
it("keeps quiet captures with different event families from comparing unchanged", async () => {
const capture = async (events: readonly BrowserScenarioEventFamily[]) => {
@@ -172,7 +172,7 @@ const executeStep = async (input: {
),
});
const actionStartedAt = performance.now();
const actionStartedAt = Date.now();
session.setStep(stepIndex);
const requestCancelled = (): boolean => signal?.aborted === true;
let outcome: BrowserScenarioStepOutcome = {
@@ -200,7 +200,7 @@ const executeStep = async (input: {
stepId: action.step_id,
action: action.action,
...outcome,
elapsedMs: Math.max(0, Math.round(performance.now() - actionStartedAt)),
elapsedMs: Date.now() - actionStartedAt,
beforeUrl,
afterUrl: session.sanitizeUrl(session.currentUrl()),
eventStart,
@@ -233,7 +233,7 @@ const runScenario = async (
): Promise<BrowserScenarioCapture> => {
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
const startedAt = performance.now();
const startedAt = Date.now();
const session = await openSession(scenario, {
...(options.signal === undefined ? {} : { signal: options.signal }),
retainCleanup,
@@ -250,7 +250,7 @@ const runScenario = async (
await initialStep({
session,
scenario,
elapsedMs: Math.max(0, Math.round(performance.now() - startedAt)),
elapsedMs: Date.now() - startedAt,
signal: options.signal,
}),
);
+4 -10
View File
@@ -72,7 +72,7 @@ export const runElectronActions = async (
): Promise<ElectronActions> => {
const actions: ElectronActions = [];
for (const action of input.actions) {
const actionStartedAt = performance.now();
const actionStartedAt = Date.now();
const windowIndex = actionWindowIndex(action);
let selectedWindow: Page | undefined;
try {
@@ -107,10 +107,7 @@ export const runElectronActions = async (
target:
selectedWindow === undefined ? null : `window:${String(windowIndex)}`,
status: "completed",
elapsed_ms: Math.max(
0,
Math.round(performance.now() - actionStartedAt),
),
elapsed_ms: Date.now() - actionStartedAt,
error: null,
});
} catch (cause: unknown) {
@@ -121,10 +118,7 @@ export const runElectronActions = async (
target:
selectedWindow === undefined ? null : `window:${String(windowIndex)}`,
status: options.signal?.aborted ? "cancelled" : "failed",
elapsed_ms: Math.max(
0,
Math.round(performance.now() - actionStartedAt),
),
elapsed_ms: Date.now() - actionStartedAt,
error: safeActionErrorMessage(cause),
});
break;
@@ -372,7 +366,7 @@ export const runWithExecutionLimits = async <Value>(
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new BrowserObservationError(OPERATION, "timeout")),
Math.max(1, deadline - performance.now()),
Math.max(1, deadline - Date.now()),
);
}),
);
@@ -86,7 +86,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
await prepareProcessOwnershipInspection(options.signal);
const startupDeadline = performance.now() + STARTUP_TIMEOUT_MS;
const startupDeadline = Date.now() + STARTUP_TIMEOUT_MS;
paths = {
executable: input.executable_path,
application: input.application_path,
@@ -101,7 +101,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
cwd: paths.root,
env: safeElectronEnvironment(this.environment, runId),
args: ["-r", hookPath, paths.application, ...input.args],
timeout: Math.max(1, startupDeadline - performance.now()),
timeout: Math.max(1, startupDeadline - Date.now()),
});
const leaderPid = application.process().pid;
if (
@@ -128,7 +128,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
const state = await runWithExecutionLimits(
readApplicationState(application),
undefined,
options.signal?.aborted ? performance.now() + 5_000 : undefined,
options.signal?.aborted ? Date.now() + 5_000 : undefined,
);
capture = projectElectronActiveCapture(paths, actions, state);
outcome = options.signal?.aborted
@@ -187,7 +187,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
await runWithExecutionLimits(
application.close(),
undefined,
performance.now() + 5_000,
Date.now() + 5_000,
);
} catch (cause: unknown) {
closeError = cause;
@@ -7,10 +7,7 @@ import {
} from "../domain/analysisErrorCore.js";
describe("withPlaywrightExecutionBoundary", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
afterEach(() => vi.useRealTimers());
it("rejects work that exceeds its absolute remaining duration", async () => {
vi.useFakeTimers();
@@ -34,20 +31,4 @@ describe("withPlaywrightExecutionBoundary", () => {
controller.abort();
await expect(result).rejects.toBeInstanceOf(AnalysisCancelledError);
});
it("keeps its timeout stable across a wall-clock jump", async () => {
vi.spyOn(Date, "now")
.mockReturnValueOnce(1_000)
.mockReturnValue(10_000_000);
await expect(
withPlaywrightExecutionBoundary(
() =>
new Promise<string>((resolve) =>
setTimeout(() => resolve("done"), 5),
),
1_000,
),
).resolves.toBe("done");
});
});
+2 -2
View File
@@ -19,10 +19,10 @@ export const withPlaywrightExecutionBoundary = <Value>(
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline =
timeoutMs === undefined ? undefined : performance.now() + timeoutMs;
timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
const scheduleTimeout = (): void => {
if (deadline === undefined) return;
const remaining = deadline - performance.now();
const remaining = deadline - Date.now();
if (remaining <= 0) {
fail(new AnalysisTimeoutError(OPERATION, timeoutMs ?? 0));
return;
+1 -1
View File
@@ -133,7 +133,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
const secrets = BrowserScenarioSecrets.resolve(scenario, environment);
if (secrets === undefined)
throw new BrowserObservationError(OPERATION, "secret_unavailable");
const startedAt = performance.now();
const startedAt = Date.now();
const opening = openPlaywrightScenarioBrowser(scenario, environment, {
signal: options.signal,
...(options.retainCleanup === undefined
+3 -3
View File
@@ -86,7 +86,7 @@ export const fetchWebSourceMaps = async (
signal?.addEventListener("abort", abortFromCaller, { once: true });
const operationSignal = operationController.signal;
const deadlineAt =
performance.now() + (host.timeoutMs ?? SOURCE_MAP_FETCH_TIMEOUT_MS);
Date.now() + (host.timeoutMs ?? SOURCE_MAP_FETCH_TIMEOUT_MS);
const budget = {
retainedBytes: 0,
deadlineAt,
@@ -110,7 +110,7 @@ export const fetchWebSourceMaps = async (
}
const expandedBytesBeforeItem = budget.decodedRecords.expandedBytes;
budget.decodedRecords.expandedBytes += reservation;
if (operationSignal.aborted || performance.now() >= deadlineAt) {
if (operationSignal.aborted || Date.now() >= deadlineAt) {
items.push(
emptySourceMapItem(
request,
@@ -789,7 +789,7 @@ const checkOperation = (
): void => {
if (callerSignal?.aborted === true) throw callerSignal.reason;
if (operationSignal?.aborted === true) throw operationSignal.reason;
if (performance.now() >= deadlineAt) throw new SourceMapDeadlineError();
if (Date.now() >= deadlineAt) throw new SourceMapDeadlineError();
};
const approvedUrl = (
+1 -31
View File
@@ -1,7 +1,6 @@
import { lstat, realpath } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js";
import type { WebScriptArtifactPort } from "../../application/WebScriptArtifactPort.js";
@@ -20,36 +19,10 @@ import {
/** Read one digest-verified captured source under the manifest's current location. */
export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
constructor(
readonly operation: WebArtifactReadContext["operation"],
readonly resources = new ArtifactResourceScope(),
) {}
close(): Promise<void> {
return this.resources.close();
}
constructor(readonly operation: WebArtifactReadContext["operation"]) {}
/** Read the selected source; relocated exports retain their reported original root. */
async load(input: SelectedWebScriptInput, options?: ExecutionOptions) {
try {
return await this.resources.run(() => this.#load(input, options));
} catch (cause: unknown) {
return err(
webArtifactReadError(
cause,
{
operation: this.operation,
field: ["manifest_path"],
targetPath: input.manifest_path,
callerSelected: true,
},
options?.signal,
),
);
}
}
async #load(input: SelectedWebScriptInput, options?: ExecutionOptions) {
let field: readonly (string | number)[] = ["manifest_path"];
let targetPath = input.manifest_path;
let callerSelected = true;
@@ -58,7 +31,6 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
input.manifest_path,
32 * 1024 * 1024,
options?.signal,
this.resources,
);
const manifest = webScriptExportManifestSchema.parse(manifestBytes.value);
field = ["script_index"];
@@ -93,8 +65,6 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
sourcePath,
16 * 1024 * 1024,
options?.signal,
undefined,
this.resources,
);
await assertContained(root, sourcePath);
if (

Some files were not shown because too many files have changed in this diff Show More