Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f62632e7a6 | ||
|
|
322506d0c0 | ||
|
|
7582f0dff2 | ||
|
|
864314ac85 | ||
|
|
0351328e54 | ||
|
|
81f7638203 | ||
|
|
7e5cffa2ae | ||
|
|
1d826f304f | ||
|
|
7fb0a0cc57 | ||
|
|
e5deb832cb | ||
|
|
cf806ea02a | ||
|
|
ee5cfbe05c | ||
|
|
459c0951f2 | ||
|
|
a154b9752c | ||
|
|
ffeb2d4367 | ||
|
|
5f0065c542 | ||
|
|
7c962cc70b | ||
|
|
57290a01ac | ||
|
|
535c8ef303 | ||
|
|
e5828596c5 | ||
|
|
04345767a8 | ||
|
|
ca7450beff | ||
|
|
cd88163e42 | ||
|
|
ccd748635c | ||
|
|
12b3cdb804 | ||
|
|
eef4a0bfb4 | ||
|
|
f2f83934d2 | ||
|
|
f14c2d4df3 | ||
|
|
56128a8424 | ||
|
|
5b3400d5d9 | ||
|
|
57bb7ec5ca | ||
|
|
187f113f79 | ||
|
|
dd1c879580 | ||
|
|
c482ddd6ad | ||
|
|
69b8509602 | ||
|
|
cf6ea09711 | ||
|
|
90d4098a01 | ||
|
|
bdacd4140e | ||
|
|
307af044bc | ||
|
|
9385060f9c | ||
|
|
1c296f11a1 | ||
|
|
fb752d2293 | ||
|
|
8dde6370d0 | ||
|
|
102c5a3ae5 | ||
|
|
11d38cbc00 | ||
|
|
7777abf473 | ||
|
|
27311f88d4 | ||
|
|
eb00112a3f | ||
|
|
76fa5304a8 | ||
|
|
6396851958 | ||
|
|
b8eea783da | ||
|
|
9d00d7d7fa | ||
|
|
74afb04f32 | ||
|
|
7df85f16a0 | ||
|
|
4c401458da | ||
|
|
ada133c256 | ||
|
|
b57a054d5e |
No files matched your search
@@ -1,5 +1,5 @@
|
||||
name: Bug report
|
||||
description: Report a reproducible problem in REA
|
||||
description: Report a problem in REA with observations or source evidence
|
||||
title: "[Bug] "
|
||||
labels:
|
||||
- bug
|
||||
@@ -8,8 +8,12 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please report one primary behavior per issue. Keep observed behavior
|
||||
separate from suspected causes, and remove credentials and private paths.
|
||||
Report one primary behavior per issue. Keep answers concise and grounded
|
||||
in available evidence. Distinguish observed behavior, source-derived
|
||||
reasoning, and suspected causes; do not claim a reproduction you did not run.
|
||||
If a narrative answer is not known, write `NOT_ENOUGH_INFO` and describe
|
||||
what is missing. Intermittent failures and clearly labeled source-only
|
||||
reports are welcome. Do not include credentials or sensitive target data.
|
||||
|
||||
- type: checkboxes
|
||||
id: before-submitting
|
||||
@@ -42,7 +46,7 @@ body:
|
||||
id: observed-behavior
|
||||
attributes:
|
||||
label: Observed behavior
|
||||
description: What happened? Include the command, MCP tool, provider, target type, and conditions that matter.
|
||||
description: Describe the actual result, including the exact command or MCP request, relevant provider/target, and supporting evidence below. Label source-only findings instead of claiming execution.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -50,7 +54,7 @@ body:
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Reproduction
|
||||
description: Give the smallest realistic command, configuration, input, or event sequence that demonstrates the problem.
|
||||
description: Give the shortest steps you actually ran, with the relevant input or configuration. State how often they reproduced the problem when known. If reproduction is unavailable, say so and provide the observations or source reference you have.
|
||||
placeholder: |
|
||||
1. Configure or start ...
|
||||
2. Run ...
|
||||
@@ -62,7 +66,7 @@ body:
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What result, contract, invariant, recovery action, or state transition should have occurred?
|
||||
description: State the expected result, contract, or recovery action. Cite documentation, source, or a known-good version when available; mark an unverified expectation or unknown reference clearly.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -70,7 +74,7 @@ body:
|
||||
id: impact-scope
|
||||
attributes:
|
||||
label: Impact and scope
|
||||
description: What is affected, what is not affected, and does this affect evidence integrity, process isolation, compatibility, or recovery?
|
||||
description: Describe observed impact and frequency, including effects on evidence integrity, process isolation, compatibility, or recovery when known. Leave untested scope and severity unknown rather than guessing.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -79,7 +83,7 @@ body:
|
||||
attributes:
|
||||
label: REA version
|
||||
description: Use `rea --version` or provide the source revision.
|
||||
placeholder: 3.1.0 or git revision
|
||||
placeholder: Exact rea --version output or source commit SHA
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -116,7 +120,7 @@ body:
|
||||
id: failure-stage
|
||||
attributes:
|
||||
label: Earliest failure stage
|
||||
description: Select where the incorrect behavior first becomes observable.
|
||||
description: Select where the incorrect behavior first becomes observable. Choose Unknown if this has not been established.
|
||||
options:
|
||||
- Request parsing or validation
|
||||
- Setup or provider admission
|
||||
@@ -133,8 +137,14 @@ body:
|
||||
id: relevant-evidence
|
||||
attributes:
|
||||
label: Relevant output or evidence
|
||||
description: Paste the smallest useful CLI output, MCP result, error, trace, or evidence excerpt. Redact sensitive values.
|
||||
render: text
|
||||
description: Include the smallest useful output, MCP result, error, screenshot, artifact, or exact source reference. Say how it was obtained and which claim it supports. If unavailable, write NOT_ENOUGH_INFO and explain what is missing. Redact credentials and sensitive target data.
|
||||
placeholder: |
|
||||
Command or MCP request actually run:
|
||||
Relevant result, error, or artifact:
|
||||
For source-only findings, link the file/revision and relevant code:
|
||||
Missing evidence or remaining uncertainty:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: typed-outcome
|
||||
@@ -146,10 +156,10 @@ body:
|
||||
id: analysis
|
||||
attributes:
|
||||
label: Analysis or suspected cause
|
||||
description: Optional. Clearly mark source-derived reasoning or a hypothesis; do not present an unverified cause as fact.
|
||||
description: Optional. Link supporting source and distinguish source-derived reasoning from a hypothesis. A root-cause analysis or proposed fix is not required.
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add links, reduced fixtures, screenshots, or a proposed regression test.
|
||||
description: Add reduced fixtures, screenshots, or related issues. For observed regressions, include the last known good and first known bad versions when available. Label untested checks as proposals.
|
||||
@@ -41,7 +41,7 @@ body:
|
||||
id: context-evidence
|
||||
attributes:
|
||||
label: Context and evidence
|
||||
description: Explain the problem or motivation. If you have evidence, distinguish observed behavior from ideas or guesses.
|
||||
description: Explain the problem or motivation and link relevant evidence when available. Distinguish observed behavior, source-derived reasoning, proposed behavior, and unknowns. A question without measurements or a finished design is welcome.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ labels:
|
||||
- documentation
|
||||
- triage
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Report one documentation problem with the exact location and the smallest
|
||||
useful supporting passage, example, or result. Keep answers concise.
|
||||
If something is unknown, write `NOT_ENOUGH_INFO` and explain what is missing.
|
||||
|
||||
- type: checkboxes
|
||||
id: before-submitting
|
||||
attributes:
|
||||
@@ -28,7 +35,7 @@ body:
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What is unclear, missing, outdated, or incorrect, and who is affected?
|
||||
description: Quote or describe the current content, explain what is unclear, missing, outdated, or incorrect, and say how to observe the problem. State who is affected when known.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -36,7 +43,7 @@ body:
|
||||
id: expected-content
|
||||
attributes:
|
||||
label: Expected content
|
||||
description: Describe the accurate behavior or example that should be documented.
|
||||
description: Describe the accurate behavior or example that should be documented, with a reference when available. Label a proposed clarification or unknown behavior clearly.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -44,7 +51,9 @@ body:
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Supporting evidence
|
||||
description: Add source, command output, provider documentation, or a link that supports the correction.
|
||||
description: Include an exact passage, source reference, command output, provider documentation, or screenshot supporting the report. For missing or unclear guidance, point to the relevant section and explain the gap. If evidence is unavailable, write NOT_ENOUGH_INFO and explain what is missing.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
|
||||
@@ -11,6 +11,8 @@ body:
|
||||
Describe one problem and the outcome you would like. Short answers are
|
||||
welcome. Choose "Not sure yet" wherever needed; you do not need code, a
|
||||
finished design, or a complete test plan to propose an idea.
|
||||
Use concrete examples when available, distinguish current limitations
|
||||
from proposed behavior, and leave unmeasured impact or unknowns explicit.
|
||||
|
||||
Read the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope).
|
||||
Maintainers can help resolve scope and technical questions. Unresolved
|
||||
@@ -58,7 +60,7 @@ body:
|
||||
id: use-cases
|
||||
attributes:
|
||||
label: Use cases
|
||||
description: Optional additional examples of applications, targets, or callers. Leave blank if covered above.
|
||||
description: Optional examples of affected workflows, targets, or callers and their practical impact. Include observed frequency or measurements when available; do not invent them. Leave blank if covered above.
|
||||
|
||||
- type: textarea
|
||||
id: desired-outcome
|
||||
@@ -138,4 +140,4 @@ body:
|
||||
id: references
|
||||
attributes:
|
||||
label: References
|
||||
description: Link relevant provider documentation, source, measurements, artifacts, issues, or discussions.
|
||||
description: Optional supporting examples, provider documentation, source, measurements, artifacts, issues, or prior art. Distinguish observed results from expected benefits; an idea without evidence is welcome.
|
||||
@@ -24,7 +24,7 @@ Unresolved scope, authority, side effects, or required verification block
|
||||
integration. Link the required maintainer scope decision for new providers or
|
||||
capabilities involving target execution, injection, mutation, or protection
|
||||
recovery; ordinary fixes within existing boundaries may state "not applicable".
|
||||
Draft PRs may leave the scope decision pending and the final review checklist
|
||||
Draft PRs may leave the scope decision pending and the readiness and merge checklists
|
||||
unchecked. Maintainers can help resolve open questions.
|
||||
|
||||
## Contract and boundary impact
|
||||
@@ -55,7 +55,10 @@ Unchecked items do not establish absence; leave uncertainties visible.
|
||||
## Evidence and regression coverage
|
||||
|
||||
<!-- State whether evidence is executed, source-derived, or proposed. For fixes,
|
||||
explain how the regression would fail on the affected base revision when practical. -->
|
||||
show the original failure and explain how the regression would fail on the affected
|
||||
base revision when practical. Include the most useful proof inline; link long logs
|
||||
or artifacts, or put them in optional details. Documentation-only changes can use
|
||||
source, link, or rendered-document checks. -->
|
||||
|
||||
- Tests added or updated:
|
||||
- Base reproduction or other evidence:
|
||||
@@ -71,7 +74,8 @@ For evidence-bearing changes:
|
||||
## Validation performed
|
||||
|
||||
<!-- List only commands that actually ran, with observed results. Include the
|
||||
relevant real-provider or platform workflow, or explain why it was unavailable. -->
|
||||
relevant real-provider or platform workflow, or explain why it was unavailable.
|
||||
Keep these results current after changes; a command without its result is incomplete. -->
|
||||
|
||||
- `command` — result
|
||||
|
||||
@@ -90,6 +94,23 @@ limits, security/privacy/containment implications, and meaningful proof gaps. --
|
||||
- Package or release metadata impact:
|
||||
- Security, privacy, process, or containment review:
|
||||
|
||||
## Author readiness
|
||||
|
||||
<!-- The human owner completes these declarations before requesting review.
|
||||
The PR author is the owner by default. A different owner must explicitly accept
|
||||
ownership on this PR.
|
||||
AI agents must leave these declarations to a human to dictate to them or there could be catastrophic consequences for many users of REA.
|
||||
Draft PRs may leave them unchecked. These declarations record initial readiness
|
||||
when opening the PR for review; later pushes do not require a new human sign-off.
|
||||
Keep the description and validation evidence current as the work changes.
|
||||
See CONTRIBUTING.md#pull-request-ownership-and-readiness. -->
|
||||
|
||||
Human owner: <!-- @GitHub-handle -->
|
||||
|
||||
- [ ] I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work.
|
||||
- [ ] I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps.
|
||||
- [ ] I declare this PR ready for initial maintainer review.
|
||||
|
||||
## Before merge
|
||||
|
||||
<!-- Draft PRs may leave pending items unchecked. Resolve material scope and
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: PR readiness
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main, "release/*"]
|
||||
types:
|
||||
[
|
||||
opened,
|
||||
edited,
|
||||
synchronize,
|
||||
reopened,
|
||||
ready_for_review,
|
||||
converted_to_draft,
|
||||
]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pull_request_number:
|
||||
description: Open PR to recheck
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: pr-readiness-${{ github.event.pull_request.number || inputs.pull_request_number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
readiness:
|
||||
name: Validate author declaration
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 3
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
steps:
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
- name: Check initial readiness
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
with:
|
||||
script: |
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const entry = pathToFileURL(
|
||||
`${process.env.GITHUB_WORKSPACE}/scripts/ci/pr-readiness.mjs`,
|
||||
);
|
||||
const { runReadinessCheck } = await import(entry.href);
|
||||
await runReadinessCheck({ github, context, core });
|
||||
@@ -78,6 +78,37 @@ For test changes or provider claims, use [docs/testing.md](docs/testing.md). Pre
|
||||
|
||||
Use Conventional Commit subjects and PR titles. Mark breaking changes with `!` or a `BREAKING CHANGE:` footer. Describe behavior changes, relevant validation, and real-provider verification scope.
|
||||
|
||||
## Issue Reports
|
||||
|
||||
Follow [issue reporting guidance](CONTRIBUTING.md#reporting-issues) and the matching
|
||||
issue form. Keep reports concise and cite the smallest useful evidence.
|
||||
Distinguish observed results, source-derived reasoning, hypotheses, and proposed
|
||||
checks. Never claim runtime reproduction or measurements you did not perform.
|
||||
Use `NOT_ENOUGH_INFO` with an explanation for missing narrative answers or evidence.
|
||||
Incomplete reports, source-only findings, and early feature or design ideas are
|
||||
welcome when their limitations are explicit; a completed diagnosis or fix is not
|
||||
required to open an issue.
|
||||
|
||||
## Pull Request Readiness
|
||||
|
||||
Follow [human ownership and readiness](CONTRIBUTING.md#pull-request-ownership-and-readiness)
|
||||
when preparing a PR. AI-assisted contributions are welcome; a human must review
|
||||
and own the final diff, check the validation evidence and its limitations, and
|
||||
explicitly declare the PR ready for initial maintainer review.
|
||||
|
||||
Keep the PR body concise and current, with the concrete problem, observable
|
||||
outcome, approach, and evidence. Report only checks that actually ran as executed;
|
||||
distinguish source-derived conclusions, proposed checks, and unavailable coverage.
|
||||
Address actionable automated review findings and explain disagreements.
|
||||
|
||||
Leave the template's **Author readiness** declarations unchecked for the human
|
||||
owner to complete. Never attest to human review on their behalf. Keep the PR in
|
||||
draft until the human declares the PR initially ready. Later pushes do not
|
||||
invalidate that declaration or require another human sign-off. Keep the PR body
|
||||
and affected validation current as work changes. The readiness check validates
|
||||
the filled declaration; CI and automated review results do not establish that
|
||||
human review occurred, maintainer scope approval, or merge permission.
|
||||
|
||||
<!-- BEGIN:turborepo-agent-rules -->
|
||||
|
||||
# This is NOT the Turborepo you know
|
||||
|
||||
@@ -24,6 +24,82 @@ costs change.
|
||||
|
||||
When adding or changing an MCP tool, follow the [tool design guide](docs/tool-design.md) and preserve the canonical contracts and generated catalog.
|
||||
|
||||
## Reporting issues
|
||||
|
||||
Use the issue form that fits the report and keep it concise. For bugs, include
|
||||
the actual result, the shortest reproduction you ran, the tested version and
|
||||
environment, and the smallest useful output, artifact, screenshot, or source
|
||||
reference. Ground expected behavior in documentation, source, or a known-good
|
||||
version when available. State observed impact and frequency; keep untested scope
|
||||
and suspected causes explicit.
|
||||
|
||||
Incomplete reports and intermittent failures are welcome. If a narrative answer
|
||||
or supporting evidence is unavailable, write `NOT_ENOUGH_INFO` and explain the
|
||||
gap. Clearly label source-only findings and proposed checks; do not claim runtime
|
||||
reproduction or results you did not observe. A root-cause analysis or fix is not
|
||||
required to report a problem. Documentation reports should identify the relevant
|
||||
passage or missing guidance and explain the correction or confusion.
|
||||
|
||||
Feature and design proposals can start with a problem and desired outcome.
|
||||
Examples, prior art, and measurements help when available; ideas without them
|
||||
are welcome. Maintainers can investigate reports and help resolve open questions.
|
||||
Human ownership and readiness declarations apply to PRs as described below.
|
||||
|
||||
## Pull request ownership and readiness
|
||||
|
||||
AI-assisted contributions are welcome. Every PR must have a human owner who
|
||||
takes responsibility for the complete change, including code, tests,
|
||||
documentation, and claims produced with AI assistance. The PR author is the
|
||||
owner unless another human is named in the PR and explicitly accepts ownership.
|
||||
These requirements also apply to maintainer-authored PRs.
|
||||
|
||||
Before requesting maintainer review, the human owner must:
|
||||
|
||||
- Review the final diff and relevant existing code. Be able to explain the
|
||||
problem, the chosen approach, how it fits REA, and meaningful edge cases.
|
||||
- Provide evidence that the change works. Summarize what was checked, the
|
||||
observed results, and meaningful gaps in the PR body. Use focused tests,
|
||||
representative CLI/MCP output, CI artifacts, screenshots, or other checks
|
||||
appropriate to the change. For fixes, show the original failure and why the
|
||||
regression coverage catches it when practical. Documentation-only changes
|
||||
can use source, link, or rendered-document checks. Label source-derived
|
||||
conclusions and proposed checks accurately; never report an unrun check as
|
||||
passing. Follow the [verification requirements](#development-feedback-and-pr-verification)
|
||||
for required checks and real-provider coverage.
|
||||
- Complete the **Author readiness** declaration in the PR template, explicitly
|
||||
stating that the PR is ready for initial review.
|
||||
|
||||
Keep the PR description concise and current. Explain the problem, observable
|
||||
outcome, approach, and useful evidence; link long logs or put them in optional
|
||||
details. Address actionable automated review findings and explain disagreements.
|
||||
Human ownership and review remain required when CI or an automated reviewer
|
||||
reports success. Readiness requests maintainer review; it does not grant scope
|
||||
approval or permission to merge.
|
||||
|
||||
Draft PRs may request early feedback with open questions and incomplete checks.
|
||||
Leave the readiness declaration unchecked until the human owner has completed
|
||||
it. The declaration records initial readiness; later pushes do not invalidate
|
||||
it or require another human sign-off. Keep the PR description and affected
|
||||
validation current as work changes. AI agents must leave human declarations to
|
||||
the human owner and must not attest to a human review on their behalf.
|
||||
|
||||
The `PR readiness` check reports pending for drafts and requires a named human
|
||||
owner and all three template declarations for non-drafts. It checks that the
|
||||
declaration is filled, not whether the code or evidence is correct. Existing PRs
|
||||
can copy the **Author readiness** section from the current template.
|
||||
Open PRs must have distinct head commits because GitHub shares commit statuses.
|
||||
|
||||
Maintainers enable merge enforcement after the workflow is merged: run
|
||||
`gh workflow run pr-readiness.yml -f pull_request_number=NUMBER` for existing
|
||||
open PRs, then add `PR readiness` from GitHub Actions to the branch's required
|
||||
status checks alongside its existing checks. Deploy the workflow to each
|
||||
protected target branch before requiring it there. The workflow reads PR
|
||||
metadata using trusted default-branch code and writes only a commit status.
|
||||
|
||||
Maintainers may return a PR to the author or close it when the problem is
|
||||
unsupported, validation is missing, or the owner cannot explain the change.
|
||||
Contributors are responsible for completing this work before requesting review.
|
||||
|
||||
## Contribution scope
|
||||
|
||||
REA focuses on software structure, runtime behavior, and evidence through
|
||||
|
||||
@@ -26,6 +26,9 @@ Readiness is `rea inspect-adb-client`: it runs `adb version` and reports the
|
||||
resolved binary, its version, and where it was selected from. A missing
|
||||
binary reports `capability_unavailable` with the `REA_ADB_PATH` remediation
|
||||
rather than attempting an installation.
|
||||
The `binary_session` inventory uses the same bounded client-version probe; it
|
||||
does not query devices, start the adb server, or establish that a requested
|
||||
serial is reachable. Device state is checked by each device operation.
|
||||
|
||||
## Answer an analyst question
|
||||
|
||||
@@ -94,12 +97,17 @@ existing analysis families directly, for example
|
||||
| `capture_adb_screen` | read | One `screencap -p` frame (exec-out, no device-side file) with dimensions |
|
||||
| `collect_adb_bugreport` | read | Device-generated bugreport zip, digested |
|
||||
| `push_adb_file` | **mutates device** | One local file to a device path (e.g. pushing tools into `/data/local/tmp`); checks for existing targets and refuses them without explicit overwrite (the check is not atomic); compares digests when device `sha256sum` is available |
|
||||
| `install_adb_package` | **mutates device** | Local APK via `adb install [-r]`; preserves the device's Success/Failure verdict |
|
||||
| `install_adb_package` | **mutates device** | Local APK via `adb install [-r]`; preserves its Success/Failure verdict, records `replace_requested`, and leaves actual replacement `null` because prior package state is not observed |
|
||||
| `uninstall_adb_package` | **mutates device** | Removes a package and its data for user 0 |
|
||||
| `start_adb_app` | **mutates device** | Resolves the package's launcher activity (`cmd package resolve-activity --brief`) and starts it (`am start -n`) |
|
||||
| `start_adb_activity` | **mutates device** | Starts an intent (`am start -a` with optional URI, component, and up to 16 typed extras); preserves the activity manager's verdict |
|
||||
| `stop_adb_app` | **mutates device** | Force-stops every process of a package, ending a dynamic-analysis run |
|
||||
|
||||
For `install_adb_package`, `replace_requested` records whether `-r` was sent.
|
||||
The `replaced` result is `null`: the successful install response does not say
|
||||
whether the package was installed fresh or replaced, and REA does not query the
|
||||
device first. The install verdict and host APK digest remain available.
|
||||
|
||||
## Interpret the result
|
||||
|
||||
- **Directory names retain whitespace and newlines.** Symlink targets do too,
|
||||
|
||||
@@ -21,6 +21,8 @@ Readiness is `rea inspect-apktool-client`: it runs `apktool --version` and
|
||||
reports the resolved launcher, its selection source, and its version. A
|
||||
missing launcher reports `capability_unavailable` with the
|
||||
`REA_APKTOOL_COMMAND` remediation rather than attempting an installation.
|
||||
`binary_session` uses the same host probe for tool availability; it does not
|
||||
validate a particular APK or guarantee that the selected input can be decoded.
|
||||
|
||||
On Windows, a `.bat` or `.cmd` launcher (such as Chocolatey's `apktool.bat`)
|
||||
runs through an explicit `cmd.exe /d /s /c` call. That call has no AutoRun,
|
||||
|
||||
@@ -20,6 +20,17 @@ Inspect native SAZ or HAR 1.2 offline with REA, or connect Everywhere's separate
|
||||
- `observe_web_session` / `rea observe-web-session` arms an observation window of the requested duration for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata.
|
||||
- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete observed `input_schema` declaration and canonical `input_schema_sha256` digest inline. Declarations are untrusted JSON data, including declared types, required fields, enums, examples and reference URLs. REA does not resolve schema references. These fields replace `input_schema_shape`. Registrations with the same name and URL in distinct observed frames retain separate identities. `tool_key` includes the observed frame ID; it is not a stable identity across navigation or browser sessions. Same-frame registration updates replace that owner only, and removal leaves other owners intact. Declared `readOnly`, `untrustedContent`, `consequential`, and `autosubmit` annotation hints are reported per registration: `true`/`false` are declared values, `null` means the declaration did not provide the hint in that capture, and a missing `consequential` key means the hint was not recorded, either in a historical capture or because the observed annotation was invalid. Consult discovery completeness and exclusions to distinguish these cases. Malformed annotation values retain the registration with incomplete coverage and an `invalid_protocol_value` exclusion. Other malformed protocol payloads can fail discovery explicitly. REA never exposes `WebMCP.invokeTool`.
|
||||
- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence.
|
||||
|
||||
Passive DOM comparison checks complete node inventories and complete frame
|
||||
parent/child structure independently. Frame IDs and sibling enumeration order
|
||||
are capture-local and do not establish a change; duplicate IDs, missing parents,
|
||||
and cyclic ancestry remain unknown. A complete frame difference can still prove
|
||||
change when DOM nodes are incomplete, and vice versa. Partial DOM or metadata
|
||||
collection digests do not prove modification: a missing node or link can result
|
||||
from a protocol omission. Rejected DOM facts also mark derived URL and agent-hint
|
||||
metadata incomplete, while valid facts from other nodes and documents remain
|
||||
available in the capture.
|
||||
|
||||
- `capture_web_screenshot` / `rea capture-web-screenshot` returns a content-addressed visible-viewport PNG.
|
||||
- `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services.
|
||||
- The CLI comparison commands accept each capture, screenshot artifact or normalization policy as inline JSON or a JSON file path. Use files for screenshot artifacts and scenario captures, whose embedded PNG bytes can exceed the host's command-line length limit.
|
||||
@@ -294,6 +305,18 @@ validation and resource bounds. Omitted script metadata makes its affected
|
||||
section explicitly truncated.
|
||||
Disallowed-origin entries are filtered before retention.
|
||||
|
||||
Resource-tree inventories distinguish a valid empty `resources` array from a
|
||||
missing or malformed collection. Invalid resource entries are omitted with
|
||||
incomplete coverage, while valid resources from that frame and other frames
|
||||
remain available. Comparing an incomplete resource inventory cannot prove
|
||||
addition, removal, or equality; a complete empty inventory can prove removal.
|
||||
Resource records require a nonempty type and a string MIME type; absent or
|
||||
malformed required fields are recorded as omissions instead of invented defaults.
|
||||
An explicitly empty MIME type is valid. The optional content size may be absent;
|
||||
an invalid supplied size remains unknown with incomplete resource coverage while
|
||||
the resource's other facts are retained. Zero and nonnegative fractional sizes
|
||||
are preserved.
|
||||
|
||||
CDP discovery, connection, and commands wait for the peer or caller cancellation;
|
||||
a stalled peer can leave an operation waiting. WebSocket messages remain subject
|
||||
to the [64 MiB limit](#request-boundary). REA does not impose a message-count or
|
||||
|
||||
+7
-3
@@ -152,9 +152,13 @@ overrides it. In MCP, pass `provider_id` to `open_binary`:
|
||||
The selected provider remains bound to the session until an explicit switch
|
||||
or close. Provider failures are returned with their original reason. For an
|
||||
`ambiguous` selection error, choose from `details.candidate_ids`; for
|
||||
`provider_unavailable`, run `rea doctor --provider ID --json` to diagnose the
|
||||
selected engine. See [task readiness](installation.md#check-readiness-for-your-task)
|
||||
and [provider selection](mcp-contracts.md#identity-and-discovery).
|
||||
`provider_unavailable`, follow `message`, `remediation.action`, and the original
|
||||
reasons in `details.rejections`. For Hopper, Ghidra, IDA, and JADX, run
|
||||
`rea doctor --provider ID --json` to diagnose the selected engine. Other adapters,
|
||||
such as `pwntools-elf`, report their configuration requirements directly and are
|
||||
not accepted by `doctor --provider`. See
|
||||
[task readiness](installation.md#check-readiness-for-your-task) and
|
||||
[provider selection](mcp-contracts.md#identity-and-discovery).
|
||||
|
||||
The session reports work still in progress through `analysis_activity`.
|
||||
A client timeout can end its wait while the provider continues analyzing.
|
||||
|
||||
+29
-8
@@ -122,6 +122,16 @@ See [client configuration](client-configuration.md) for client-specific roots,
|
||||
profiles, and environment overrides. A mixed selection plans each distinct
|
||||
destination. Selecting the skill without a client uses `~/.agents/skills`.
|
||||
Setup leaves existing skill copies in other locations untouched.
|
||||
Setup refuses symbolic links at the selected skill directory or its managed
|
||||
`references` directory, matching uninstall and update's ownership rules. Use a
|
||||
regular skill directory, or select `--skill=false` to configure the client
|
||||
registration without installing the bundled skill.
|
||||
The final selected directories are checked before approval and again before
|
||||
setup changes any client or Hopper installation. Unselected client destinations
|
||||
do not block other setup choices. Doctor reports linked or non-directory skill
|
||||
paths as `unmanaged`, names the affected paths, and provides those alternatives;
|
||||
an unreadable skill identity remains `unknown`. Uninstall retains and reports
|
||||
files it cannot safely remove, including their ownership manifest.
|
||||
|
||||
`rea setup --skill-client claude_code --yes --json` refreshes and verifies the
|
||||
selected skill destination independently of MCP registration selection. The
|
||||
@@ -567,8 +577,13 @@ sessions before removing that recovery directory and retrying.
|
||||
|
||||
### Launcher paths and troubleshooting
|
||||
|
||||
On macOS, REA uses
|
||||
`/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` by default.
|
||||
On macOS, REA checks executable Hopper launchers in this order: the canonical
|
||||
system application, the canonical application installed by `rea setup` under
|
||||
`~/Applications`, then other `Hopper*.app` bundles in `/Applications` and
|
||||
`~/Applications`. The Homebrew cask location is an optional `rea doctor`
|
||||
diagnostic; to use a Homebrew-installed launcher for CLI/MCP startup, set
|
||||
`HOPPER_LAUNCHER_PATH` to the path reported by doctor. REA does not run Homebrew
|
||||
while loading runtime configuration.
|
||||
On Linux, it prefers executable `/opt/hopper/bin/Hopper`, then executable
|
||||
`~/.local/share/rea/hopper/bin/Hopper`, followed by `/opt/hopper-v4/bin/hopper`
|
||||
and `/usr/bin/hopper`; if none exists, the former remains the diagnostic
|
||||
@@ -629,12 +644,18 @@ memory has no original file offset. For FAT Mach-O, offsets refer to the origina
|
||||
container file. Results retain Hopper's image-relative offset, the observed slice
|
||||
base, and the source executable path.
|
||||
Loader selection reads the Mach-O container header, including FAT files with a
|
||||
single architecture. For FAT64, REA validates the architecture table and selected
|
||||
Mach-O header, prepares a private thin image, and loads it with Hopper's native
|
||||
Mach-O loader. It checks the entire source's SHA-256 while copying the slice;
|
||||
source identity and reported offsets still refer to the original container.
|
||||
An ambiguous architecture subtype requires an explicitly extracted thin image;
|
||||
REA does not guess. Configured loader arguments remain explicit overrides.
|
||||
single architecture. For FAT32 and FAT64 containers, REA validates the
|
||||
architecture table and selected Mach-O header, prepares a private thin image,
|
||||
and loads it with Hopper's native Mach-O loader instead of Hopper's implicit
|
||||
FAT slice choice. For arm64 targets, REA ranks subtype bases 0 (plain arm64) and
|
||||
2 (arm64e), ignoring capability bits for ranking while retaining the raw subtype
|
||||
for slice identity. These slices' function-entry encodings decode under
|
||||
`--aarch64`, ahead of Apple's arm64e.x1 variant (base subtype 12), which is
|
||||
selected only when it is the arm64 family's only candidate. REA checks the
|
||||
entire source's SHA-256 while copying the slice; source identity and reported
|
||||
offsets still refer to the original container. Any other ambiguous architecture
|
||||
subtype requires an explicitly extracted thin image; REA does not guess.
|
||||
Configured loader arguments remain explicit overrides.
|
||||
FAT64 is distinct from the CPU architecture: FAT32 containers can contain 64-bit
|
||||
executable images. This preparation avoids the Raw Binary loader dialog observed
|
||||
with native FAT64 loading on Hopper 6.1.0-demo.
|
||||
|
||||
@@ -86,7 +86,14 @@ outside this mutation pass.
|
||||
|
||||
Function fingerprints commit normalized syntax, control-flow shape, relation
|
||||
shape, literal sets, arity, and detected effects without using local names or
|
||||
source offsets. Equal duplicate fingerprints remain ambiguous. Dynamic
|
||||
source offsets. Function-local directive literals contribute their values while
|
||||
preserving escape spelling: `"use\x20strict"` differs from `"use strict"`.
|
||||
Quote style, formatting, and local names remain normalized. Equal duplicate
|
||||
fingerprints remain ambiguous.
|
||||
Fingerprints are static candidates, not behavioral-equivalence proofs or a
|
||||
version-independent identity. Re-analyze affected sources and obtain new seeds
|
||||
after analyzer corrections; previously retained Evidence remains queryable using
|
||||
its original component commitments. Dynamic
|
||||
calls/properties/names, parser recovery, unresolved handles, and unsupported API
|
||||
shapes stay explicit unknowns; a missing relation is never reported as proven
|
||||
behavioral absence.
|
||||
|
||||
@@ -273,6 +273,12 @@ RPC client syntax also contributes network endpoint candidates, in both
|
||||
`OperationDefinition` document object. A graphql-tag `loc.source.body` copy is
|
||||
not reported twice. Anonymous operations are not reported.
|
||||
|
||||
Literal link options and precompiled document fields follow object property
|
||||
order: the final definition owns the key. A later spread, dynamic computed key,
|
||||
method, or accessor can make a field unknown, so an overwritten literal is not
|
||||
reported as the endpoint or operation. A final explicit property after a spread
|
||||
or dynamic key remains usable.
|
||||
|
||||
A procedure path is the server-side procedure name, not a resolved URL; the
|
||||
transport URL depends on the link configuration and batching. When the proxy is
|
||||
reached through a member expression, such as a webpack module binding
|
||||
|
||||
@@ -54,6 +54,22 @@ resource limit; use selected fields, a display filter or an input-packet limit
|
||||
when dissection output is too large. Each call checks the recording's current
|
||||
digest, so pages from different source digests must not be combined.
|
||||
|
||||
For a question about a known input prefix, select `packet_limit` for that
|
||||
prefix, the fields needed for the answer, and a matching output window. For
|
||||
example, a question about the first twelve PostgreSQL TCP packets within
|
||||
original frames 1–2,000 can use fields `tcp.srcport`, `tcp.dstport`, `tcp.flags`,
|
||||
filter `tcp.port == 5432`, `packet_limit: 2000` and `match_limit: 12`.
|
||||
The matching count then describes that input prefix. A filter containing
|
||||
`frame.number <= 2000` without an input limit still reads the entire capture;
|
||||
returning twelve rows does not limit producer output. These controls answer a
|
||||
bounded question and must not be used to infer whole-capture absence.
|
||||
|
||||
Discover unfamiliar field names with `inspect_packet_fields`, using an exact
|
||||
protocol and a narrow query. If the installed names are already established,
|
||||
direct field selection avoids an additional glossary probe. Count discovery
|
||||
and any retries when comparing investigation cost; a small page alone does
|
||||
not establish a faster workflow.
|
||||
|
||||
Additional selections:
|
||||
|
||||
- `display_filter`: Wireshark display-filter syntax; omitted means no filter.
|
||||
|
||||
+14
-3
@@ -383,13 +383,24 @@ exit comparison like `settlement.elapsed_ms`.
|
||||
Every capture requires `truncation_details`, with separate accounting for:
|
||||
|
||||
- `raw_terminal`: original UTF-8 PTY chunk bytes and observed/retained chunk
|
||||
counts. A chunk that does not fit `limits.output_bytes` is omitted whole;
|
||||
a later smaller chunk can still fit.
|
||||
counts. A chunk that does not fit either its remaining original-text budget
|
||||
or normalized-text budget is omitted whole; a later smaller chunk can still
|
||||
fit. A normalized-text omission also omits its original chunk, so raw and
|
||||
normalized coverage can both be incomplete for that event.
|
||||
- `normalized_terminal`: normalized comparison-text bytes retained from raw
|
||||
chunks. This is optional when reading historical captures. A raw-fitting
|
||||
chunk is omitted if an ordered normalization stage would exceed the remaining
|
||||
`output_bytes` budget before the final normalized size is known. Its final
|
||||
size is unknown, not assumed to equal the budget. The capture records the
|
||||
omitted-frame count and a limitation; later smaller chunks remain eligible.
|
||||
- `rendered_terminal`: cumulative UTF-8 serialized-state and visible-line
|
||||
bytes, plus observed/retained frame counts. This is independent of the raw
|
||||
chunk budget and does not count JSON encoding. Repeated screen snapshots can
|
||||
exhaust it even when all raw output fits. Rendered states use retained PTY
|
||||
input, so raw omissions also limit rendered coverage.
|
||||
input, so raw omissions also limit rendered coverage. When bounded
|
||||
normalization omits a rendered frame before its final size is known,
|
||||
`unknown_size_frames` reports that count; `observed_bytes` includes only
|
||||
rendered frame sizes that were determined exactly.
|
||||
- `filesystem_before` and `filesystem_after`: file-count/depth limits,
|
||||
enumeration failures, whole-file hash budget and bytes successfully hashed.
|
||||
Directory names are read incrementally within the remaining entry capacity.
|
||||
|
||||
@@ -66,6 +66,8 @@ REA는 에이전트를 분석 도구에 연결해 네이티브 바이너리, Jav
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
Linux에서 Ghidra, JADX, 펌웨어 도구를 실행하거나 정적 JavaScript/Electron 분석을 하려면, 프로세스 소유권 확인을 위해 REA의 `PATH`에 procps 호환 `ps`가 있어야 합니다. 해결 방법은 `docs/installation.md`의 "Linux owned-process prerequisite" 항목에 있습니다.
|
||||
|
||||
### 에이전트 설정
|
||||
|
||||
Node.js와 npm이 설치된 환경에서 다음 명령을 실행하세요.
|
||||
@@ -151,7 +153,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
|
||||
| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) |
|
||||
| SQLite 데이터베이스 | 테이블, 열, 인덱스, 뷰, 트리거, 선택한 일반 테이블의 행 | 로컬 데이터베이스/WAL 스냅샷; Node.js 24.15 이상의 24.x 또는 26 이상(네이티브 SQLite 한도 적용) |
|
||||
| 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](../javascript-application-workflows.md) |
|
||||
| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; Linux: procps 호환 `ps`; [애플리케이션 분석](../javascript-application-workflows.md) |
|
||||
| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](../browser-observation.md) |
|
||||
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR 또는 Fiddler Everywhere SAZ; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) |
|
||||
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 별도 도구 불필요(정적 검사만); [관리 코드 가이드](../managed-code-analysis.md) |
|
||||
|
||||
@@ -69,6 +69,13 @@ The observed mitmproxy 12.2.3 `SaveHar` profile can emit `postData.text: null`
|
||||
for a missing request body. REA preserves that reported null, omitting the
|
||||
optional field only from its validation copy for this exact producer profile;
|
||||
it does not invent an empty string or body bytes.
|
||||
The observed Playwright 1.58.2 profile can report a bracketed IPv6
|
||||
`serverIPAddress`, forwarded from Chromium's `remoteIPAddress` through
|
||||
[Playwright's HAR tracer](https://github.com/microsoft/playwright/blob/v1.58.2/packages/playwright-core/src/server/har/harTracer.ts).
|
||||
REA preserves the original spelling in reported Evidence and removes brackets
|
||||
only from its validation copy, only for this declared producer and a valid IPv6
|
||||
literal. Invalid addresses, bracketed IPv4, ports and zone identifiers remain
|
||||
validation failures. Producer declarations do not authenticate origin.
|
||||
|
||||
Native mitmproxy decoding requires a caller-supplied **mitmdump 12.2.3 on
|
||||
Linux**. Set its absolute executable path:
|
||||
|
||||
@@ -72,6 +72,12 @@ checkpoint commits retain their original receipts; no history was rewritten.
|
||||
|
||||
## Optimization priorities and gradual migration
|
||||
|
||||
The [larger/non-HTTP packet checkpoint](packet-field-workflows.md) records
|
||||
same-question SDK and actual Codex comparisons using existing input limits,
|
||||
field/filter selection, discovery and output windows. Lower delivered JSON did
|
||||
not establish stable total-token or speed gains; failed plans and negative
|
||||
controls remain visible.
|
||||
|
||||
Agent token consumption and end-to-end task time are the primary objectives.
|
||||
Memory and CPU consumption are also measured and controlled. Correctness,
|
||||
Evidence provenance, caller choices and existing-user compatibility are
|
||||
|
||||
@@ -181,6 +181,7 @@ def main():
|
||||
'inspect_android_class', 'inspect_android_method',
|
||||
'inspect_firmware_regions', 'inspect_managed_artifact',
|
||||
'inspect_sqlite_database', 'inspect_pcap_capture',
|
||||
'inspect_packet_capture', 'inspect_packet_fields',
|
||||
'inspect_managed_members', 'inspect_managed_native_boundaries',
|
||||
'inspect_artifact', 'inspect_wasm_artifact',
|
||||
'inspect_evm_interface', 'get_evidence_bundle', 'export_evidence_bundle',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from "node:util";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { PACKET_ARTIFACTS } from "./packet-entry.mjs";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
@@ -33,6 +34,8 @@ const sameSettings = [
|
||||
"provider_environment_policy_sha256",
|
||||
"measure_source_sha256",
|
||||
"native_workflow_source_sha256",
|
||||
"packet_workflow_source_sha256",
|
||||
"packet_oracle_source_sha256",
|
||||
"package_lock_sha256",
|
||||
"node_version",
|
||||
"sdk_client_version",
|
||||
@@ -76,6 +79,7 @@ for (const [side, answer] of [
|
||||
// coverage. Repeated equivalent deliveries must also match every observation
|
||||
// field except the receipt and parent IDs, which identify their own invocations.
|
||||
if (
|
||||
Object.hasOwn(PACKET_ARTIFACTS, left.artifact) ||
|
||||
["wireshark-http.cap", "wireshark-http_redirects.pcapng"].includes(
|
||||
left.artifact,
|
||||
)
|
||||
@@ -94,7 +98,11 @@ if (
|
||||
path: ["delivery_observations"],
|
||||
value: answer.delivery_observations,
|
||||
});
|
||||
if (left.delivery === right.delivery) {
|
||||
if (
|
||||
left.delivery === right.delivery &&
|
||||
(!Object.hasOwn(PACKET_ARTIFACTS, left.artifact) ||
|
||||
left.packet_selection === right.packet_selection)
|
||||
) {
|
||||
answer.delivery_observations = answer.delivery_observations.map(
|
||||
(observation) =>
|
||||
Object.fromEntries(
|
||||
|
||||
@@ -291,6 +291,58 @@
|
||||
"upstream_expected_digest": null,
|
||||
"derivation": "Unmodified capture published by official Wireshark wiki. Local digest pins downloaded bytes; no independently published upstream digest is claimed."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "wireshark-pgsql-jdbc.pcap",
|
||||
"file": "pgsql-jdbc.pcap",
|
||||
"bytes": 4906316,
|
||||
"sha256": "6e68b8b792a0f4336092f98856abc7de8c54b55a29112854d28d676f9093c16b",
|
||||
"provenance": {
|
||||
"project": "Wireshark public sample captures",
|
||||
"source_page": "https://wiki.wireshark.org/SampleCaptures",
|
||||
"url": "https://wiki.wireshark.org/uploads/__moin_import__/attachments/SampleCaptures/pgsql-jdbc.pcap.gz",
|
||||
"resolved_url": "https://gitlab.com/wireshark/wireshark/-/wikis/uploads/__moin_import__/attachments/SampleCaptures/pgsql-jdbc.pcap.gz",
|
||||
"upstream_expected_digest": null,
|
||||
"license": "not stated in inspected upstream catalogue",
|
||||
"derivation": "Unmodified official saved sample; decompressed gzip where indicated. Local digest pins acquired bytes, not an upstream-published digest.",
|
||||
"compressed_source": {
|
||||
"name": "pgsql-jdbc.pcap.gz",
|
||||
"bytes": 590093,
|
||||
"sha256": "15b51bb4cc197753c1ac6b7dc3cf5697f22a81e3bb5d2e1d24d15de469c3a06b"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "wireshark-dns.cap",
|
||||
"file": "dns.cap",
|
||||
"bytes": 4338,
|
||||
"sha256": "041eeb6f98bb398f1ee8b09651b5b5a84f6a62639f95bf226f9e7b77355d9f28",
|
||||
"provenance": {
|
||||
"project": "Wireshark public sample captures",
|
||||
"source_page": "https://wiki.wireshark.org/SampleCaptures",
|
||||
"url": "https://wiki.wireshark.org/uploads/__moin_import__/attachments/SampleCaptures/dns.cap",
|
||||
"resolved_url": "https://gitlab.com/wireshark/wireshark/-/wikis/uploads/__moin_import__/attachments/SampleCaptures/dns.cap",
|
||||
"upstream_expected_digest": null,
|
||||
"license": "not stated in inspected upstream catalogue",
|
||||
"derivation": "Unmodified official saved sample; decompressed gzip where indicated. Local digest pins acquired bytes, not an upstream-published digest.",
|
||||
"compressed_source": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "wireshark-dhcp.pcap",
|
||||
"file": "dhcp.pcap",
|
||||
"bytes": 1400,
|
||||
"sha256": "2471b5420bdac826eecf8f61a2bbb4a3eb20dbfab7c02ff2be502f349f368214",
|
||||
"provenance": {
|
||||
"project": "Wireshark public sample captures",
|
||||
"source_page": "https://wiki.wireshark.org/SampleCaptures",
|
||||
"url": "https://wiki.wireshark.org/uploads/__moin_import__/attachments/SampleCaptures/dhcp.pcap",
|
||||
"resolved_url": "https://gitlab.com/wireshark/wireshark/-/wikis/uploads/__moin_import__/attachments/SampleCaptures/dhcp.pcap",
|
||||
"upstream_expected_digest": null,
|
||||
"license": "not stated in inspected upstream catalogue",
|
||||
"derivation": "Unmodified official saved sample; decompressed gzip where indicated. Local digest pins acquired bytes, not an upstream-published digest.",
|
||||
"compressed_source": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { PACKET_ARTIFACTS, measurePacketEntry } from "./packet-entry.mjs";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const { values } = parseArgs({
|
||||
@@ -29,6 +30,7 @@ const { values } = parseArgs({
|
||||
"managed-question": { type: "string", default: "inventory" },
|
||||
schemas: { type: "string", default: "default" },
|
||||
"sqlite-selection": { type: "string", default: "all" },
|
||||
"packet-selection": { type: "string", default: "baseline" },
|
||||
},
|
||||
});
|
||||
if (
|
||||
@@ -45,6 +47,15 @@ if (
|
||||
!["inventory", "selected"].includes(values["javascript-question"]) ||
|
||||
!["default", "selected"].includes(values.schemas) ||
|
||||
!["all", "columns"].includes(values["sqlite-selection"]) ||
|
||||
!["baseline", "focused", "discovery", "pages"].includes(
|
||||
values["packet-selection"],
|
||||
) ||
|
||||
(!Object.hasOwn(PACKET_ARTIFACTS, values.artifact) &&
|
||||
values["packet-selection"] !== "baseline") ||
|
||||
(Object.hasOwn(PACKET_ARTIFACTS, values.artifact) &&
|
||||
values.delivery !== "complete") ||
|
||||
(values["packet-selection"] === "pages" &&
|
||||
values.artifact !== "wireshark-pgsql-jdbc.pcap") ||
|
||||
(values.artifact !== "qgis-world-map.gpkg" &&
|
||||
values["sqlite-selection"] !== "all") ||
|
||||
(values.artifact === "qgis-world-map.gpkg" &&
|
||||
@@ -142,6 +153,12 @@ const fingerprints = {
|
||||
native_workflow_source_sha256: await fileSha(
|
||||
fileURLToPath(new URL("./native-entry.mjs", import.meta.url)),
|
||||
),
|
||||
packet_workflow_source_sha256: await fileSha(
|
||||
fileURLToPath(new URL("./packet-entry.mjs", import.meta.url)),
|
||||
),
|
||||
packet_oracle_source_sha256: await fileSha(
|
||||
fileURLToPath(new URL("./packet-oracle.py", import.meta.url)),
|
||||
),
|
||||
runtime_files_sha256: sha(JSON.stringify(runtimeFiles)),
|
||||
runtime_dispatcher_sha256: await fileSha(
|
||||
resolve(values["runtime-root"], "scripts/rea.mjs"),
|
||||
@@ -722,6 +739,12 @@ const inspectPcapTarget = async () => {
|
||||
};
|
||||
|
||||
const workflows = {
|
||||
...Object.fromEntries(
|
||||
Object.keys(PACKET_ARTIFACTS).map((artifact) => [
|
||||
artifact,
|
||||
() => measurePacketEntry({ values, call, inputSha, catalogDefinitions }),
|
||||
]),
|
||||
),
|
||||
"wireshark-http.cap": inspectPcapTarget,
|
||||
"wireshark-http_redirects.pcapng": inspectPcapTarget,
|
||||
"qgis-world-map.gpkg": async () => {
|
||||
@@ -1160,6 +1183,9 @@ const report = {
|
||||
values.artifact === "qgis-world-map.gpkg"
|
||||
? values["sqlite-selection"]
|
||||
: null,
|
||||
packet_selection: Object.hasOwn(PACKET_ARTIFACTS, values.artifact)
|
||||
? values["packet-selection"]
|
||||
: null,
|
||||
provider_settings_sha256: sha(JSON.stringify(selectedProviders)),
|
||||
provider_search_path_sha256: sha(
|
||||
selectedProviders.PATH ?? process.env.PATH ?? "",
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
export const verifyPacketPage = (first, next, oracle) => {
|
||||
if (
|
||||
first.artifact.sha256 !== oracle.sha256 ||
|
||||
next.artifact.sha256 !== oracle.sha256 ||
|
||||
first.output_window.matching_packets !== oracle.matching_packets ||
|
||||
next.output_window.matching_packets !== oracle.matching_packets ||
|
||||
!isDeepStrictEqual(
|
||||
packetFacts("postgres", first).rows,
|
||||
oracle.rows.slice(0, 12),
|
||||
) ||
|
||||
!isDeepStrictEqual(
|
||||
packetFacts("postgres", next).rows,
|
||||
oracle.rows.slice(12, 24),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Packet pages differ from independently parsed original matches",
|
||||
);
|
||||
};
|
||||
|
||||
export const PACKET_ARTIFACTS = {
|
||||
"wireshark-pgsql-jdbc.pcap": {
|
||||
kind: "postgres",
|
||||
fields: ["tcp.srcport", "tcp.dstport", "tcp.flags"],
|
||||
extra: ["tcp.len"],
|
||||
protocol: "tcp",
|
||||
query: "tcp.flags",
|
||||
discovered: "tcp.flags",
|
||||
filter: "tcp.port == 5432",
|
||||
},
|
||||
"wireshark-dns.cap": {
|
||||
kind: "dns",
|
||||
fields: [
|
||||
"dns.id",
|
||||
"dns.flags.response",
|
||||
"dns.flags.rcode",
|
||||
"dns.qry.name",
|
||||
"dns.qry.type",
|
||||
],
|
||||
extra: ["dns.resp.name", "dns.resp.ttl", "udp.srcport", "udp.dstport"],
|
||||
protocol: "dns",
|
||||
query: "dns.flags.rcode",
|
||||
discovered: "dns.flags.rcode",
|
||||
filter: "dns.flags.response == 1 && dns.flags.rcode != 0",
|
||||
},
|
||||
"wireshark-dhcp.pcap": {
|
||||
kind: "dhcp",
|
||||
fields: [
|
||||
"dhcp.id",
|
||||
"dhcp.option.dhcp",
|
||||
"dhcp.ip.your",
|
||||
"dhcp.option.dhcp_server_id",
|
||||
],
|
||||
extra: [
|
||||
"dhcp.type",
|
||||
"dhcp.ip.client",
|
||||
"dhcp.hw.mac_addr",
|
||||
"udp.srcport",
|
||||
"udp.dstport",
|
||||
],
|
||||
protocol: "dhcp",
|
||||
query: "Option 53:",
|
||||
discovered: "dhcp.option.dhcp",
|
||||
filter: "dhcp",
|
||||
},
|
||||
};
|
||||
|
||||
export const packetRequest = (artifact, path, selection) => {
|
||||
const descriptor = PACKET_ARTIFACTS[artifact];
|
||||
if (!descriptor) throw new Error("Unknown pinned packet question");
|
||||
const baseline = selection === "baseline";
|
||||
return {
|
||||
path,
|
||||
fields: baseline
|
||||
? [...descriptor.fields, ...descriptor.extra]
|
||||
: descriptor.fields,
|
||||
display_filter:
|
||||
descriptor.kind === "postgres" && baseline
|
||||
? `${descriptor.filter} && frame.number <= 2000`
|
||||
: descriptor.kind === "dns" && baseline
|
||||
? "dns"
|
||||
: descriptor.filter,
|
||||
...(descriptor.kind === "postgres" && !baseline
|
||||
? { packet_limit: 2000, match_limit: 12 }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const number = (row, field) => {
|
||||
const value = row[field];
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length !== 1 ||
|
||||
!/^(?:[0-9]+|0x[0-9a-f]+)$/iu.test(value[0])
|
||||
)
|
||||
throw new Error(`Expected one recorded integer for ${field}`);
|
||||
const parsed = Number(value[0]);
|
||||
if (!Number.isSafeInteger(parsed))
|
||||
throw new Error(`Unsafe recorded integer for ${field}`);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const packetFacts = (kind, report) => {
|
||||
let packets = report.packets;
|
||||
if (kind === "postgres") packets = packets.slice(0, 12);
|
||||
if (kind === "dns")
|
||||
packets = packets.filter(
|
||||
(row) =>
|
||||
number(row, "dns.flags.response") === 1 &&
|
||||
number(row, "dns.flags.rcode") !== 0,
|
||||
);
|
||||
return {
|
||||
kind,
|
||||
matching_packets:
|
||||
kind === "dns" ? packets.length : report.output_window.matching_packets,
|
||||
rows: packets.map((row) => {
|
||||
const frame = number(row, "frame.number");
|
||||
if (kind === "postgres")
|
||||
return {
|
||||
frame,
|
||||
source_port: number(row, "tcp.srcport"),
|
||||
destination_port: number(row, "tcp.dstport"),
|
||||
flags: number(row, "tcp.flags"),
|
||||
};
|
||||
if (kind === "dns") {
|
||||
const names = row["dns.qry.name"];
|
||||
const types = row["dns.qry.type"];
|
||||
if (
|
||||
!Array.isArray(names) ||
|
||||
names.length === 0 ||
|
||||
!Array.isArray(types) ||
|
||||
types.length !== names.length
|
||||
)
|
||||
throw new Error(
|
||||
"The fixed DNS question requires recorded names and types; missing fields are unknown",
|
||||
);
|
||||
return {
|
||||
frame,
|
||||
id: number(row, "dns.id"),
|
||||
rcode: number(row, "dns.flags.rcode"),
|
||||
names,
|
||||
types: types.map((value) => number({ type: [value] }, "type")),
|
||||
};
|
||||
}
|
||||
const server = row["dhcp.option.dhcp_server_id"];
|
||||
const address = row["dhcp.ip.your"];
|
||||
if (
|
||||
!Array.isArray(address) ||
|
||||
address.length !== 1 ||
|
||||
(server !== undefined &&
|
||||
(!Array.isArray(server) || server.length !== 1))
|
||||
)
|
||||
throw new Error("Expected recorded DHCP address scalar");
|
||||
return {
|
||||
frame,
|
||||
id: number(row, "dhcp.id"),
|
||||
message_type: number(row, "dhcp.option.dhcp"),
|
||||
your_address: address[0],
|
||||
...(server === undefined ? {} : { server_identifier: server[0] }),
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const measurePacketEntry = async ({
|
||||
values,
|
||||
call,
|
||||
inputSha,
|
||||
catalogDefinitions,
|
||||
}) => {
|
||||
const descriptor = PACKET_ARTIFACTS[values.artifact];
|
||||
const { parseEvidence } = await import(
|
||||
pathToFileURL(resolve(values["runtime-root"], "dist/domain/evidence.js"))
|
||||
.href
|
||||
);
|
||||
const { inspectPacketCaptureInputSchema, packetCaptureSchema } = await import(
|
||||
pathToFileURL(
|
||||
resolve(values["runtime-root"], "dist/domain/captures/packetCapture.js"),
|
||||
).href
|
||||
);
|
||||
const { inspectPacketFieldsInputSchema, packetFieldsCatalogSchema } =
|
||||
await import(
|
||||
pathToFileURL(
|
||||
resolve(values["runtime-root"], "dist/domain/captures/packetFields.js"),
|
||||
).href
|
||||
);
|
||||
const observations = [];
|
||||
const selection = values["packet-selection"];
|
||||
const checked = (evidence, operation, input, schema) => {
|
||||
parseEvidence(evidence);
|
||||
const report = schema.parse(evidence.normalized_result);
|
||||
if (
|
||||
evidence.operation !== operation ||
|
||||
!isDeepStrictEqual(evidence.parameters, input) ||
|
||||
!isDeepStrictEqual(report.selection, input)
|
||||
)
|
||||
throw new Error(
|
||||
"Packet Evidence operation or choices differs from the request",
|
||||
);
|
||||
if (
|
||||
operation === "inspect_packet_capture" &&
|
||||
(evidence.subject?.local_path !== values.path ||
|
||||
evidence.subject?.digest?.sha256 !== inputSha ||
|
||||
report.artifact.path !== values.path ||
|
||||
report.artifact.sha256 !== inputSha)
|
||||
)
|
||||
throw new Error("Packet Evidence identifies different capture bytes");
|
||||
if (
|
||||
operation === "inspect_packet_fields" &&
|
||||
(evidence.subject?.local_path !== report.tool.path ||
|
||||
evidence.subject?.digest?.sha256 !== report.tool.sha256)
|
||||
)
|
||||
throw new Error("Field discovery identifies a different executable");
|
||||
observations.push({
|
||||
evidence_id: evidence.evidence_id,
|
||||
operation,
|
||||
selection: report.selection,
|
||||
output_window: report.output_window,
|
||||
read_scope: report.read_scope ?? null,
|
||||
limitations: evidence.limitations,
|
||||
});
|
||||
return report;
|
||||
};
|
||||
if (selection === "discovery") {
|
||||
const input = inspectPacketFieldsInputSchema.parse({
|
||||
kind: "fields",
|
||||
protocol: descriptor.protocol,
|
||||
query: descriptor.query,
|
||||
limit: 1,
|
||||
});
|
||||
const found = checked(
|
||||
await call("inspect_packet_fields", input),
|
||||
"inspect_packet_fields",
|
||||
input,
|
||||
packetFieldsCatalogSchema,
|
||||
);
|
||||
if (
|
||||
!found.records.some((row) => row.abbreviation === descriptor.discovered)
|
||||
)
|
||||
throw new Error(
|
||||
"Selected installation does not advertise the required protocol field",
|
||||
);
|
||||
}
|
||||
const input = inspectPacketCaptureInputSchema.parse(
|
||||
packetRequest(values.artifact, values.path, selection),
|
||||
);
|
||||
for (const name of [
|
||||
"inspect_packet_capture",
|
||||
...(selection === "discovery" ? ["inspect_packet_fields"] : []),
|
||||
])
|
||||
if (!catalogDefinitions.has(name))
|
||||
throw new Error(`Missing live packet contract ${name}`);
|
||||
const report = checked(
|
||||
await call("inspect_packet_capture", input),
|
||||
"inspect_packet_capture",
|
||||
input,
|
||||
packetCaptureSchema,
|
||||
);
|
||||
const facts = packetFacts(descriptor.kind, report);
|
||||
if (selection === "pages") {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
"python3",
|
||||
[
|
||||
fileURLToPath(new URL("./packet-oracle.py", import.meta.url)),
|
||||
"postgres",
|
||||
values.path,
|
||||
"--row-limit",
|
||||
"24",
|
||||
],
|
||||
{ timeout: 30_000, maxBuffer: 128 * 1024 },
|
||||
);
|
||||
const oracle = JSON.parse(stdout);
|
||||
const nextInput = inspectPacketCaptureInputSchema.parse({
|
||||
...input,
|
||||
match_offset: 12,
|
||||
});
|
||||
const next = checked(
|
||||
await call("inspect_packet_capture", nextInput),
|
||||
"inspect_packet_capture",
|
||||
nextInput,
|
||||
packetCaptureSchema,
|
||||
);
|
||||
const repeated = checked(
|
||||
await call("inspect_packet_capture", input),
|
||||
"inspect_packet_capture",
|
||||
input,
|
||||
packetCaptureSchema,
|
||||
);
|
||||
verifyPacketPage(report, next, oracle);
|
||||
if (
|
||||
!isDeepStrictEqual(report.packets, repeated.packets) ||
|
||||
!isDeepStrictEqual(report.output_window, repeated.output_window) ||
|
||||
next.artifact.sha256 !== report.artifact.sha256 ||
|
||||
next.output_window.matching_packets !==
|
||||
report.output_window.matching_packets ||
|
||||
Number(next.packets[0]?.["frame.number"]?.[0]) <=
|
||||
Number(report.packets.at(-1)?.["frame.number"]?.[0])
|
||||
)
|
||||
throw new Error(
|
||||
"Packet page order, digest or repeated observations changed",
|
||||
);
|
||||
}
|
||||
return {
|
||||
artifact_sha256: inputSha,
|
||||
facts,
|
||||
delivery_observations: observations,
|
||||
evidence_ids: [...new Set(observations.map((row) => row.evidence_id))],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
# Larger and non-HTTP packet investigation costs
|
||||
|
||||
This closes the bounded experiment in [#3130](https://github.com/morluto/rea/issues/3130).
|
||||
Existing field, filter and input/output-window controls substantially reduce
|
||||
delivered JSON for the selected questions. Actual Codex total input and elapsed
|
||||
time are less consistent. Requiring field discovery for a known field adds cost.
|
||||
These results support documenting caller-selected controls, while retaining the
|
||||
current API, defaults and uncached provider behavior.
|
||||
|
||||
## Runtime and public corpus
|
||||
|
||||
Measurements use immutable main
|
||||
`ec67aebed02dbd20fa5c7703fcd183ad0b1c343c`, Node 24.18.0, the locked MCP client and
|
||||
server, Linux, TShark/capinfos 4.0.17 and util-linux prlimit. The SDK captures live
|
||||
advertised contracts and passes selected definitions to actual public MCP calls.
|
||||
Runtime-file, dispatcher, lockfile, experiment-source and explicit provider
|
||||
configuration digests remain with the private trial records. Subsequent main
|
||||
integration checks do not relabel these measurements.
|
||||
|
||||
Three additional saved recordings come from the
|
||||
[official Wireshark catalogue](https://wiki.wireshark.org/SampleCaptures).
|
||||
`corpus.json` pins their acquisition URLs, exact bytes and SHA-256 values.
|
||||
The PostgreSQL recording is described upstream as a JDBC test session; it is
|
||||
not a representative production workload. Capture-specific licenses were not
|
||||
stated in the inspected catalogue. No captures or raw trial records are
|
||||
redistributed in Git.
|
||||
|
||||
| Recording | Acquired/analyzed size | Recorded frames | Fixed question |
|
||||
| -------------------- | --------------------------------------------: | --------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `pgsql-jdbc.pcap.gz` | 590,093 compressed / 4,906,316 expanded bytes | 18,472 | Within original frames 1–2,000, report the first twelve TCP packets using port 5432, their source/destination ports and flags, and the matching count. |
|
||||
| `dns.cap` | 4,338 bytes | 38 | Report nonzero-RCODE DNS responses, transaction IDs and question names/types. |
|
||||
| `dhcp.pcap` | 1,400 bytes | 4 | Report message types, transaction IDs, offered address and present server identifiers in frame order. |
|
||||
|
||||
The larger application recording and two small non-HTTP controls expand the
|
||||
question/protocol coverage. Large DNS/DHCP, PCAPNG non-HTTP, USB, Bluetooth,
|
||||
Windows and macOS provider performance remain unverified.
|
||||
|
||||
`packet-oracle.py` independently reads classic PCAP, Ethernet/IPv4 and selected
|
||||
TCP/UDP headers. It does not consume TShark output. Its narrow supported scope
|
||||
fails on truncation, IP fragmentation, DNS compression cycles, duplicate DHCP
|
||||
options or DHCP overload. TCP header facts follow
|
||||
[RFC 9293](https://www.rfc-editor.org/rfc/rfc9293.html), DNS names/header fields
|
||||
[RFC 1035](https://www.rfc-editor.org/rfc/rfc1035.html), and DHCP options
|
||||
[RFC 2132](https://www.rfc-editor.org/rfc/rfc2132.html).
|
||||
SQL interpretation, TCP reassembly and network completeness are outside the
|
||||
oracle. The question and oracle were fixed before measured cohorts.
|
||||
|
||||
The oracle establishes 1,337 selected PostgreSQL TCP packets in the input prefix
|
||||
and six DNS error responses. DHCP records contain Discover/Offer with transaction
|
||||
ID 15645 and Request/ACK with ID 15646. An absent server identifier stays omitted;
|
||||
the recording does not establish a single transaction containing all four steps.
|
||||
|
||||
## Equivalent questions and SDK measurements
|
||||
|
||||
Each arm uses fresh actual MCP sessions; heavy trials run sequentially.
|
||||
Two interleaved repetitions cover each arm and each recording. Discovery, catalog,
|
||||
RPC, schema checking, encoding and cleanup costs are retained. JSON bytes describe
|
||||
serialized SDK replies, not raw transport bytes or model tokens.
|
||||
|
||||
The PostgreSQL baseline selects `tcp.srcport`, `tcp.dstport`, `tcp.flags` and
|
||||
`tcp.len`, retains the default fifty-row output window and reads to EOF with
|
||||
`tcp.port == 5432 && frame.number <= 2000`. The focused arm selects the three
|
||||
required fields, uses `packet_limit: 2000`, filter `tcp.port == 5432` and
|
||||
`match_limit: 12`. Both establish the same requested prefix facts and count.
|
||||
Their source read scopes differ explicitly: to EOF versus an input limit.
|
||||
Neither scope establishes whole-network completeness.
|
||||
|
||||
DNS focuses the nine-field baseline on five requested fields and the error-response
|
||||
filter. DHCP selects four required fields from the nine-field baseline; all four
|
||||
records are still read. The discovery arm adds one narrow installed-field probe
|
||||
before the focused request. It tests the cost of validating an already specified
|
||||
field, rather than proving a benefit for a previously unknown field or guessing
|
||||
that an installed registration appears in traffic.
|
||||
|
||||
| Question | Baseline reply bytes | Focused reply bytes | Focused change | Discovery + focused bytes | Discovery change |
|
||||
| ----------------- | -------------------: | ------------------: | -------------: | ------------------------: | ---------------: |
|
||||
| PostgreSQL prefix | 23,054 | 13,304 | −42.29% | 21,096 | −8.49% |
|
||||
| DNS errors | 29,002 | 13,424 | −53.71% | 21,226 | −26.81% |
|
||||
| DHCP sequence | 13,770 | 12,020 | −12.71% | 19,840 | +44.08% |
|
||||
|
||||
Both repetitions produce the same reply size in each cell and pass the independent
|
||||
fact comparison. Authentication uses the selected runtime's `parseEvidence` and
|
||||
named input/output schemas, checking operation, normalized caller choices,
|
||||
artifact or executable identity, original frame order and omission semantics.
|
||||
|
||||
| Question | Baseline tool-call time, ms | Focused tool-call time, ms | Discovery + focused tool-call time, ms |
|
||||
| ----------------- | --------------------------- | -------------------------- | -------------------------------------- |
|
||||
| PostgreSQL prefix | 1,670 / 2,255 | 1,708 / 933 | 3,549 / 2,674 |
|
||||
| DNS errors | 892 / 786 | 889 / 778 | 2,506 / 2,608 |
|
||||
| DHCP sequence | 763 / 759 | 755 / 731 | 2,502 / 2,502 |
|
||||
|
||||
Whole SDK workflow times, including connection/catalog, vary from 4.23–8.79 seconds
|
||||
for these arms. The PostgreSQL focused tool time improves in one repetition and
|
||||
regresses slightly in the other. Discovery consistently adds tool work. Two
|
||||
repetitions are descriptive observations, not a statistical speed guarantee.
|
||||
|
||||
A separate first-page, next-page and repeated-first-page sequence costs
|
||||
922 / 804 / 1,244 ms and returns 39,918 reply bytes in total. Each call repeats
|
||||
dissection, reports the same 1,337 matching count and capture digest, and preserves
|
||||
the original frame order. Identical repeated observations retain the same
|
||||
content-addressed Evidence ID; distinct pages have distinct IDs. Page delivery
|
||||
does not establish capture or network completeness. This narrow repeat does not
|
||||
justify introducing a generic cache or weakening provider identity/cleanup rules.
|
||||
|
||||
The two original HTTP controls were also replayed with two SDK pairs each on the
|
||||
new pinned runtime. Useful summary/prefix facts remain equivalent. For `http.cap`,
|
||||
complete delivery returns 7,601 bytes and summary plus a two-URI retained page
|
||||
17,540 bytes (+130.76%); for `http_redirects.pcapng`, 7,535 versus 17,240 bytes
|
||||
(+128.80%). These remain negative controls. The original model observations in
|
||||
[the earlier checkpoint](pcap-small-captures.md) keep their original runtime pin.
|
||||
|
||||
## Actual Codex CLI observations
|
||||
|
||||
Six actual Codex CLI 0.161.0 runs use requested alias `gpt-5.6-terra`, reasoning
|
||||
`high`, a disposable configuration and the pinned REA MCP runtime. Resolved
|
||||
server-side model identity is not inferred. Both tool names are enabled in every
|
||||
arm, exact-name discovery guidance is identical, shell/browser/other-agent tools
|
||||
are disabled and copied authentication is removed after each run.
|
||||
The shared question and generated plans contain no oracle answers.
|
||||
|
||||
Order is baseline 1, focused 1, discovery 1, focused 2, baseline 2, discovery 2.
|
||||
The rubric checks authenticated actual tool results, exact header-derived facts,
|
||||
citations and the planned calls. Answer correctness and adherence to the plan
|
||||
are reported separately. Limitation wording is reviewed in transcripts; a string
|
||||
or string array is accepted because the prompt does not require one representation.
|
||||
|
||||
| Trial | Total input tokens | Cached input tokens | Output tokens | Elapsed seconds | Actual MCP calls | Facts / plan |
|
||||
| ----------- | -----------------: | ------------------: | ------------: | --------------: | ---------------: | ---------------------------------- |
|
||||
| Baseline 1 | 40,649 | 27,904 | 1,574 | 27.989 | 1 | pass / pass |
|
||||
| Focused 1 | 36,642 | 19,968 | 943 | 38.542 | 1 | pass / pass |
|
||||
| Discovery 1 | 52,802 | 33,024 | 1,089 | 44.398 | 2 | pass / pass |
|
||||
| Focused 2 | 40,912 | 22,016 | 726 | 32.520 | 1 | pass / pass |
|
||||
| Baseline 2 | 40,181 | 19,968 | 896 | 40.299 | 1 | pass / pass |
|
||||
| Discovery 2 | 48,754 | 30,976 | 1,780 | 76.081 | 1 | pass / **fail: discovery omitted** |
|
||||
|
||||
The two eligible baseline/focused pairs show total-input changes of −9.86% and
|
||||
+1.82%, and elapsed changes of +37.70% and −19.30%. Thus reduced SDK reply size
|
||||
does not establish a stable total-token or speed improvement in these runs.
|
||||
The first complete discovery comparison adds 29.90% input and 58.63% elapsed time.
|
||||
The second discovery attempt is retained as a workflow failure and cannot serve
|
||||
as a successful two-call discovery comparison. Its cost is not discarded or
|
||||
counted as a passing trial. No model-side recovery or retries occurred in these
|
||||
six attempts.
|
||||
|
||||
An earlier configuration preflight rejected the packet tool under Codex's
|
||||
`approval_policy: never`: 36,651 input tokens, 549 output tokens, 21.603 seconds,
|
||||
one rejected call and no packet observations. The experiment runner now explicitly
|
||||
preapproves the two authorized passive packet tools, consistently across all arms.
|
||||
That failed preflight remains separate from the corrected cohort and its cost is
|
||||
reported here; CLI exit zero alone is never a factual or workflow pass.
|
||||
|
||||
## Resource and compatibility scope
|
||||
|
||||
All engine/model workloads use an owned Linux resource scope with one CPU,
|
||||
3 GiB early-stop threshold, 4 GiB hard memory ceiling, no swap and a 192-task cap.
|
||||
Node's heap is additionally limited to 512 MiB. The nineteen non-HTTP SDK trials
|
||||
have a maximum observed payload-exit memory peak of 860,909,568 bytes; the six model
|
||||
attempts peak at 940,806,144 bytes. Payload-exit counters and cleanup were verified
|
||||
with the observer still alive and report no memory-limit/OOM events in that phase.
|
||||
Final whole-scope counters remain unavailable, so no stronger whole-scope claim
|
||||
is made. CPU usage and observed process ownership remain in each private receipt.
|
||||
|
||||
A full PostgreSQL probe with four selected fields and no input prefix exceeds the
|
||||
existing 4 MiB producer-output ceiling even with the default fifty-row window.
|
||||
It correctly fails instead of delivering partial success. The SDK error retains
|
||||
large truncated diagnostics (5,790,979 serialized bytes); this separate product
|
||||
concern is recorded privately for later repair, outside #3130. Paging alone cannot
|
||||
prevent producer expansion. The successful prefix experiment does not prove that
|
||||
the full recording can be inspected with those selections.
|
||||
|
||||
Only experiment sources, corpus provenance, a question template and guidance
|
||||
change. Production schemas, provider limits, CLI/MCP defaults, Evidence identity
|
||||
and session/resource ownership stay as before. Eight parser regressions retain
|
||||
malformed/truncated-input and omission checks. Existing clients need no migration.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Acquire the exact corpus outside the repository and verify the pinned hashes.
|
||||
Use the pinned Node/npm toolchain, locked dependencies, `npm run build:cached` and
|
||||
an external resource guard before invoking actual engines/models. Supply explicit
|
||||
provider settings in a private JSON file. An example SDK arm is:
|
||||
|
||||
```sh
|
||||
node experiments/analysis-efficiency/measure.mjs \
|
||||
--runtime-root /absolute/pinned/runtime --revision PINNED_SHA \
|
||||
--artifact wireshark-pgsql-jdbc.pcap --path /private/pgsql-jdbc.pcap \
|
||||
--providers /private/providers.json --schemas selected \
|
||||
--packet-selection focused --output /private/new-trial
|
||||
```
|
||||
|
||||
Use `baseline`, `focused`, `discovery`, or the PostgreSQL-only `pages` diagnostic.
|
||||
Run `packet-oracle.py postgres PATH` independently and compare `answer.json` facts.
|
||||
Generate each model prompt with `packet-model-prompt.mjs --selection ARM`, then use
|
||||
the existing `agent-trial.py` with those two enabled tools and context capture.
|
||||
Run `review-packet.mjs` against the actual trial, oracle, runtime and selected arm.
|
||||
Its nonzero exit includes a plan failure even when all requested facts are correct.
|
||||
Retain failed attempts, actual usage, digests, coverage, cleanup and resource
|
||||
receipts alongside passing observations. No raw results need to be committed.
|
||||
|
||||
## Review verification
|
||||
|
||||
The comparator now recognizes the three non-HTTP questions: observations remain
|
||||
attached to each run, while different plans compare requested facts. Repeated
|
||||
identical plans still compare stable observation scope. Capture grading authenticates
|
||||
Evidence against the actual call and assesses plan differences separately; an
|
||||
actual 24-row SDK window replay retains a factual pass and a plan failure. This
|
||||
is a grading replay, not an additional model trial. The stricter header oracle
|
||||
preserves all original sample facts and rejects truncated IPv4/TCP options and
|
||||
unsupported Ethernet protocols. The page diagnostic now derives matches 13–24
|
||||
independently before accepting the second page; skipped, reversed and changed-byte
|
||||
pages are rejected. Original cohort measurements retain their recorded source
|
||||
digests and runtime pin; review checks do not relabel them.
|
||||
|
||||
Capture grading keeps authenticated facts by Evidence ID and evaluates the cited
|
||||
observations. Four replays using actual first-page and second-page SDK Evidence
|
||||
verify that later or reordered pages and repeated reads preserve a correct cited
|
||||
answer, while citing only the wrong page fails factual grading. Extra calls still
|
||||
fail the fixed workflow plan. Regrading all six original model records preserves
|
||||
their outcomes and recorded costs; these replays are not new model trials.
|
||||
|
||||
Failed or malformed calls remain workflow failures and never become authenticated
|
||||
Evidence. Nine failure-injection and answer-shape replays using an actual successful
|
||||
capture verify that later recovery preserves supported facts, while failed-only
|
||||
attempts and missing or unobserved citations fail factual grading. Missing
|
||||
limitations and extra answer keys fail the fixed plan without changing correct
|
||||
recorded facts. Successful original-model outcomes remain unchanged.
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { parseArgs } from "node:util";
|
||||
import { packetRequest } from "./packet-entry.mjs";
|
||||
|
||||
const { values } = parseArgs({ options: { selection: { type: "string" } } });
|
||||
if (!["baseline", "focused", "discovery"].includes(values.selection))
|
||||
throw new Error("Select baseline, focused or discovery");
|
||||
const question = await readFile(
|
||||
new URL("./questions/packet-postgres.txt", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const plan = [
|
||||
...(values.selection === "discovery"
|
||||
? [
|
||||
{
|
||||
tool: "inspect_packet_fields",
|
||||
arguments: {
|
||||
kind: "fields",
|
||||
protocol: "tcp",
|
||||
query: "tcp.flags",
|
||||
limit: 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
tool: "inspect_packet_capture",
|
||||
arguments: packetRequest(
|
||||
"wireshark-pgsql-jdbc.pcap",
|
||||
"{{artifact_path}}",
|
||||
values.selection,
|
||||
),
|
||||
},
|
||||
];
|
||||
process.stdout.write(
|
||||
question +
|
||||
"\nExecute this fixed public REA MCP plan in order. These are input selections, not expected answers. Preserve omitted defaults. All calls count toward the comparison.\n" +
|
||||
JSON.stringify(plan, null, 2) +
|
||||
"\n",
|
||||
);
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Independent, narrow oracle for the three pinned Ethernet/IPv4 PCAPs.
|
||||
|
||||
Reads header bytes, not TShark output. No PCAPNG, IP fragmentation or TCP
|
||||
reassembly support is claimed. DNS compression follows RFC 1035; DHCP options
|
||||
53/54 follow RFC 2132. Unsupported selected traffic fails rather than guessing.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def require(condition, reason):
|
||||
if not condition:
|
||||
raise ValueError(reason)
|
||||
|
||||
|
||||
def packets(data):
|
||||
require(len(data) >= 24, 'truncated PCAP header')
|
||||
magic = data[:4]
|
||||
require(magic in (b'\xd4\xc3\xb2\xa1', b'\xa1\xb2\xc3\xd4'),
|
||||
'oracle requires microsecond classic PCAP')
|
||||
endian = '<' if magic == b'\xd4\xc3\xb2\xa1' else '>'
|
||||
require(struct.unpack_from(endian + 'HH', data, 4) == (2, 4),
|
||||
'unsupported PCAP version')
|
||||
require(struct.unpack_from(endian + 'I', data, 20)[0] == 1,
|
||||
'oracle requires Ethernet')
|
||||
offset, frame = 24, 0
|
||||
while offset < len(data):
|
||||
require(offset + 16 <= len(data), 'truncated PCAP record header')
|
||||
captured, original = struct.unpack_from(endian + 'II', data, offset + 8)
|
||||
offset += 16
|
||||
require(captured <= original and offset + captured <= len(data),
|
||||
'invalid captured packet length')
|
||||
frame += 1
|
||||
yield frame, data[offset:offset + captured]
|
||||
offset += captured
|
||||
|
||||
|
||||
def transport(packet):
|
||||
require(len(packet) >= 14, 'truncated Ethernet header')
|
||||
if packet[12:14] == b'\x08\x06':
|
||||
return None # ARP cannot contain the selected TCP/UDP messages.
|
||||
require(packet[12:14] == b'\x08\x00', 'unsupported Ethernet protocol')
|
||||
ip = packet[14:]
|
||||
require(len(ip) >= 20 and ip[0] >> 4 == 4, 'invalid IPv4 header')
|
||||
header = (ip[0] & 15) * 4
|
||||
total = struct.unpack_from('!H', ip, 2)[0]
|
||||
require(20 <= header <= len(ip) and total >= header, 'invalid IPv4 lengths')
|
||||
require(total <= len(ip), 'truncated IPv4 packet')
|
||||
require(struct.unpack_from('!H', ip, 6)[0] & 0x3fff == 0,
|
||||
'fragmentation is outside oracle scope')
|
||||
return ip[9], ip[header:min(total, len(ip))]
|
||||
|
||||
|
||||
def dns_name(message, offset):
|
||||
labels, visited, end = [], set(), None
|
||||
while True:
|
||||
require(offset < len(message) and offset not in visited,
|
||||
'invalid DNS name or compression cycle')
|
||||
visited.add(offset)
|
||||
size = message[offset]
|
||||
if size & 0xc0 == 0xc0:
|
||||
require(offset + 1 < len(message), 'truncated DNS compression pointer')
|
||||
if end is None:
|
||||
end = offset + 2
|
||||
offset = ((size & 0x3f) << 8) | message[offset + 1]
|
||||
continue
|
||||
require(size & 0xc0 == 0 and offset + 1 + size <= len(message),
|
||||
'invalid DNS label')
|
||||
offset += 1
|
||||
if size == 0:
|
||||
return '.'.join(labels), end if end is not None else offset
|
||||
labels.append(message[offset:offset + size].decode('ascii'))
|
||||
require(sum(len(label) + 1 for label in labels) <= 255,
|
||||
'DNS name exceeds protocol length')
|
||||
offset += size
|
||||
|
||||
|
||||
def dhcp_options(payload):
|
||||
require(len(payload) >= 240 and payload[236:240] == b'\x63\x82\x53\x63',
|
||||
'invalid DHCP fixed header or cookie')
|
||||
options, offset = {}, 240
|
||||
while offset < len(payload):
|
||||
kind = payload[offset]
|
||||
offset += 1
|
||||
if kind == 255:
|
||||
return options
|
||||
if kind == 0:
|
||||
continue
|
||||
require(offset < len(payload), 'missing DHCP option length')
|
||||
size = payload[offset]
|
||||
offset += 1
|
||||
require(offset + size <= len(payload), 'truncated DHCP option')
|
||||
require(kind not in options, 'duplicate DHCP options outside oracle scope')
|
||||
options[kind] = payload[offset:offset + size]
|
||||
offset += size
|
||||
raise ValueError('DHCP end option missing')
|
||||
|
||||
|
||||
def analyze(data, kind, row_limit=12):
|
||||
rows, matching, count = [], 0, 0
|
||||
for frame, packet in packets(data):
|
||||
count = frame
|
||||
decoded = transport(packet)
|
||||
if decoded is None:
|
||||
continue
|
||||
protocol, segment = decoded
|
||||
if kind == 'postgres' and protocol == 6 and frame <= 2000:
|
||||
require(len(segment) >= 20, 'truncated TCP header')
|
||||
tcp_header = (segment[12] >> 4) * 4
|
||||
require(20 <= tcp_header <= len(segment), 'invalid or truncated TCP header/options')
|
||||
source, destination = struct.unpack_from('!HH', segment)
|
||||
if 5432 not in (source, destination):
|
||||
continue
|
||||
matching += 1
|
||||
if len(rows) < row_limit:
|
||||
rows.append({'frame': frame, 'source_port': source,
|
||||
'destination_port': destination,
|
||||
'flags': struct.unpack_from('!H', segment, 12)[0] & 0xfff})
|
||||
elif kind in ('dns', 'dhcp') and protocol == 17:
|
||||
require(len(segment) >= 8, 'truncated UDP header')
|
||||
source, destination, length = struct.unpack_from('!HHH', segment)
|
||||
selected = 53 in (source, destination) if kind == 'dns' else (
|
||||
source, destination) in ((67, 68), (68, 67))
|
||||
if not selected:
|
||||
continue
|
||||
require(8 <= length <= len(segment), 'truncated selected UDP message')
|
||||
payload = segment[8:length]
|
||||
if kind == 'dns':
|
||||
require(len(payload) >= 12, 'truncated DNS header')
|
||||
identifier, flags, questions = struct.unpack_from('!HHH', payload)
|
||||
if not flags & 0x8000 or flags & 15 == 0:
|
||||
continue
|
||||
matching += 1
|
||||
offset, names, types = 12, [], []
|
||||
for _ in range(questions):
|
||||
name, offset = dns_name(payload, offset)
|
||||
require(offset + 4 <= len(payload), 'truncated DNS question')
|
||||
types.append(struct.unpack_from('!H', payload, offset)[0])
|
||||
names.append(name)
|
||||
offset += 4
|
||||
rows.append({'frame': frame, 'id': identifier, 'rcode': flags & 15,
|
||||
'names': names, 'types': types})
|
||||
else:
|
||||
options = dhcp_options(payload)
|
||||
require(53 in options and len(options[53]) == 1,
|
||||
'missing DHCP message type')
|
||||
server = options.get(54)
|
||||
require(server is None or len(server) == 4, 'invalid DHCP server ID')
|
||||
require(52 not in options, 'DHCP overload outside oracle scope')
|
||||
matching += 1
|
||||
row = {'frame': frame, 'id': struct.unpack_from('!I', payload, 4)[0],
|
||||
'message_type': options[53][0],
|
||||
'your_address': '.'.join(map(str, payload[16:20]))}
|
||||
if server is not None:
|
||||
row['server_identifier'] = '.'.join(map(str, server))
|
||||
rows.append(row)
|
||||
return {'kind': kind, 'capture_frames': count, 'matching_packets': matching,
|
||||
'question_input_frames': min(2000, count) if kind == 'postgres' else count,
|
||||
'rows': rows, 'scope': 'selected recorded headers; no network completeness claim'}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('kind', choices=['postgres', 'dns', 'dhcp'])
|
||||
parser.add_argument('path', type=Path)
|
||||
parser.add_argument('--row-limit', type=int, choices=[12, 24], default=12,
|
||||
help='PostgreSQL rows; 24 independently checks the second page')
|
||||
args = parser.parse_args()
|
||||
with args.path.open('rb') as source:
|
||||
data = source.read(32 * 1024 * 1024 + 1)
|
||||
require(len(data) <= 32 * 1024 * 1024, 'oracle input budget exceeded')
|
||||
print(json.dumps({'sha256': hashlib.sha256(data).hexdigest(),
|
||||
**analyze(data, args.kind, args.row_limit)}, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,3 @@
|
||||
Inspect the selected saved PostgreSQL capture {{artifact_path}} with REA. Within original capture frames 1 through 2000, return the first 12 TCP packets with source or destination port 5432 in original frame order. For each return integer frame, source_port, destination_port and flags (the numeric TCP flags word, not prose). Also report the matching packet count within this 2000-frame question scope.
|
||||
|
||||
Do not claim whole-capture or network completeness. Return a single JSON object with exactly kind ("postgres"), matching_packets, rows, evidence_ids and limitations. Use actual successful REA Evidence IDs. Include a concise limitation explaining the recorded input scope; no extra claims or tool calls are needed.
|
||||
@@ -0,0 +1,247 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { isDeepStrictEqual, parseArgs } from "node:util";
|
||||
import { packetFacts, packetRequest } from "./packet-entry.mjs";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
trial: { type: "string" },
|
||||
oracle: { type: "string" },
|
||||
"runtime-root": { type: "string" },
|
||||
selection: { type: "string" },
|
||||
},
|
||||
});
|
||||
if (
|
||||
!values.trial ||
|
||||
!values.oracle ||
|
||||
!values["runtime-root"] ||
|
||||
!["baseline", "focused", "discovery"].includes(values.selection)
|
||||
)
|
||||
throw new Error(
|
||||
"Use --trial DIR --oracle JSON --runtime-root DIR --selection baseline|focused|discovery",
|
||||
);
|
||||
|
||||
const root = values["runtime-root"];
|
||||
const { parseEvidence } = await import(
|
||||
pathToFileURL(resolve(root, "dist/domain/evidence.js")).href
|
||||
);
|
||||
const { inspectPacketCaptureInputSchema, packetCaptureSchema } = await import(
|
||||
pathToFileURL(resolve(root, "dist/domain/captures/packetCapture.js")).href
|
||||
);
|
||||
const { inspectPacketFieldsInputSchema, packetFieldsCatalogSchema } =
|
||||
await import(
|
||||
pathToFileURL(resolve(root, "dist/domain/captures/packetFields.js")).href
|
||||
);
|
||||
const oracle = JSON.parse(await readFile(values.oracle, "utf8"));
|
||||
const settings = JSON.parse(
|
||||
await readFile(resolve(values.trial, "trial-settings.json"), "utf8"),
|
||||
);
|
||||
const summary = JSON.parse(
|
||||
await readFile(resolve(values.trial, "summary.json"), "utf8"),
|
||||
);
|
||||
const expected = Object.fromEntries(
|
||||
["kind", "matching_packets", "rows"].map((key) => [key, oracle[key]]),
|
||||
);
|
||||
const calls = (await readFile(resolve(values.trial, "events.jsonl"), "utf8"))
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map(JSON.parse)
|
||||
.filter(
|
||||
(event) =>
|
||||
event.type === "item.completed" && event.item?.type === "mcp_tool_call",
|
||||
)
|
||||
.map((event) => event.item);
|
||||
const reasons = [];
|
||||
const workflowReasons = [];
|
||||
let answer;
|
||||
const captures = new Map();
|
||||
try {
|
||||
if (oracle.kind !== "postgres")
|
||||
throw new Error("This model rubric is the fixed PostgreSQL question only");
|
||||
const expectedCalls =
|
||||
values.selection === "discovery"
|
||||
? ["inspect_packet_fields", "inspect_packet_capture"]
|
||||
: ["inspect_packet_capture"];
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
calls.map((call) => call.tool),
|
||||
expectedCalls,
|
||||
) ||
|
||||
calls.some(
|
||||
(call) =>
|
||||
call.server !== "rea" || call.status !== "completed" || call.error,
|
||||
)
|
||||
)
|
||||
workflowReasons.push(
|
||||
"Completed public REA call sequence differs from the fixed plan",
|
||||
);
|
||||
const ids = new Set();
|
||||
const conflictingIds = new Set();
|
||||
for (const call of calls) {
|
||||
if (call.server !== "rea" || call.status !== "completed" || call.error)
|
||||
continue;
|
||||
try {
|
||||
const result = call.result;
|
||||
if (result?.isError)
|
||||
throw new Error("Tool failure is not observed packet evidence");
|
||||
const text =
|
||||
result?.content?.filter((item) => item.type === "text") ?? [];
|
||||
if (!result?.structuredContent && text.length !== 1)
|
||||
throw new Error("No unambiguous captured Evidence envelope");
|
||||
const evidence = result.structuredContent ?? JSON.parse(text[0].text);
|
||||
parseEvidence(evidence);
|
||||
if (conflictingIds.has(evidence.evidence_id))
|
||||
throw new Error("Previously conflicting Evidence identity");
|
||||
if (call.tool === "inspect_packet_fields") {
|
||||
const input = inspectPacketFieldsInputSchema.parse({
|
||||
kind: "fields",
|
||||
protocol: "tcp",
|
||||
query: "tcp.flags",
|
||||
limit: 1,
|
||||
});
|
||||
const report = packetFieldsCatalogSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
inspectPacketFieldsInputSchema.parse(call.arguments),
|
||||
input,
|
||||
) ||
|
||||
!isDeepStrictEqual(evidence.parameters, input) ||
|
||||
!isDeepStrictEqual(report.selection, input) ||
|
||||
evidence.operation !== call.tool ||
|
||||
!report.records.some((row) => row.abbreviation === "tcp.flags") ||
|
||||
evidence.subject?.local_path !== report.tool.path ||
|
||||
evidence.subject?.digest?.sha256 !== report.tool.sha256
|
||||
)
|
||||
throw new Error(
|
||||
"Discovery Evidence does not establish the requested installed field",
|
||||
);
|
||||
} else if (call.tool === "inspect_packet_capture") {
|
||||
const planned = inspectPacketCaptureInputSchema.parse(
|
||||
packetRequest(
|
||||
"wireshark-pgsql-jdbc.pcap",
|
||||
settings.selected_artifact_path,
|
||||
values.selection,
|
||||
),
|
||||
);
|
||||
const input = inspectPacketCaptureInputSchema.parse(call.arguments);
|
||||
if (!isDeepStrictEqual(input, planned))
|
||||
workflowReasons.push("Capture selection differs from the fixed plan");
|
||||
const report = packetCaptureSchema.parse(evidence.normalized_result);
|
||||
if (
|
||||
!isDeepStrictEqual(evidence.parameters, input) ||
|
||||
!isDeepStrictEqual(report.selection, input) ||
|
||||
evidence.operation !== call.tool ||
|
||||
evidence.subject?.local_path !== input.path ||
|
||||
evidence.subject?.digest?.sha256 !== oracle.sha256 ||
|
||||
report.artifact.path !== input.path ||
|
||||
report.artifact.sha256 !== oracle.sha256
|
||||
)
|
||||
throw new Error(
|
||||
"Capture Evidence differs from the fixed input/byte identity",
|
||||
);
|
||||
const observed = packetFacts("postgres", report);
|
||||
if (
|
||||
captures.has(evidence.evidence_id) &&
|
||||
!isDeepStrictEqual(captures.get(evidence.evidence_id), observed)
|
||||
) {
|
||||
captures.delete(evidence.evidence_id);
|
||||
ids.delete(evidence.evidence_id);
|
||||
conflictingIds.add(evidence.evidence_id);
|
||||
throw new Error("Repeated Evidence identity has inconsistent facts");
|
||||
}
|
||||
captures.set(evidence.evidence_id, observed);
|
||||
} else {
|
||||
throw new Error("Call is not a packet observation tool");
|
||||
}
|
||||
ids.add(evidence.evidence_id);
|
||||
} catch (error) {
|
||||
workflowReasons.push(
|
||||
`Call ${call.tool} did not provide authenticated packet observations: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
answer = JSON.parse(
|
||||
(await readFile(resolve(values.trial, "answer.txt"), "utf8")).trim(),
|
||||
);
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
Object.keys(answer).sort(),
|
||||
[
|
||||
"kind",
|
||||
"matching_packets",
|
||||
"rows",
|
||||
"evidence_ids",
|
||||
"limitations",
|
||||
].sort(),
|
||||
)
|
||||
)
|
||||
workflowReasons.push("Final answer shape differs from the fixed plan");
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
Object.fromEntries(
|
||||
Object.keys(expected).map((key) => [key, answer[key]]),
|
||||
),
|
||||
expected,
|
||||
)
|
||||
)
|
||||
throw new Error("Final answer is not the exact requested recorded facts");
|
||||
if (
|
||||
!Array.isArray(answer.evidence_ids) ||
|
||||
answer.evidence_ids.some((id) => !ids.has(id)) ||
|
||||
new Set(answer.evidence_ids).size !== answer.evidence_ids.length
|
||||
)
|
||||
throw new Error("Final citations lack the authenticated packet Evidence");
|
||||
const supportingFacts = answer.evidence_ids
|
||||
.map((id) => captures.get(id))
|
||||
.find((observed) => isDeepStrictEqual(observed, expected));
|
||||
if (!supportingFacts)
|
||||
throw new Error(
|
||||
"Cited observations differ from independent raw header oracle",
|
||||
);
|
||||
const limitations =
|
||||
typeof answer.limitations === "string"
|
||||
? [answer.limitations]
|
||||
: answer.limitations;
|
||||
if (
|
||||
!Array.isArray(limitations) ||
|
||||
limitations.length === 0 ||
|
||||
limitations.some((item) => typeof item !== "string" || !item.trim())
|
||||
)
|
||||
workflowReasons.push("Final answer omits investigation limitations");
|
||||
if (
|
||||
summary.exit_code !== 0 ||
|
||||
summary.timeout ||
|
||||
summary.selected_artifact_bytes_unchanged !== true ||
|
||||
settings.selected_artifact_sha256_before !== oracle.sha256 ||
|
||||
summary.selected_artifact_sha256_after !== oracle.sha256
|
||||
)
|
||||
workflowReasons.push(
|
||||
"Trial did not complete with the selected artifact unchanged",
|
||||
);
|
||||
} catch (error) {
|
||||
reasons.push(String(error));
|
||||
}
|
||||
const review = {
|
||||
factual_correctness: reasons.length ? "failed" : "passed",
|
||||
scope:
|
||||
"Fixed first-2000-frame PostgreSQL question; authenticated tool observations and independent raw header oracle",
|
||||
workflow_correctness:
|
||||
reasons.length || workflowReasons.length ? "failed" : "passed",
|
||||
reasons,
|
||||
workflow_reasons: workflowReasons,
|
||||
completed_calls: calls.length,
|
||||
wall_seconds: summary.wall_seconds,
|
||||
usage: summary.usage,
|
||||
selected_artifact_sha256: oracle.sha256,
|
||||
limitation_prose:
|
||||
"Presence checked; wording requires transcript review, not heuristic factual certification",
|
||||
};
|
||||
await writeFile(
|
||||
resolve(values.trial, "packet-review.json"),
|
||||
JSON.stringify(review, null, 2) + "\n",
|
||||
);
|
||||
console.log(JSON.stringify(review));
|
||||
if (reasons.length || workflowReasons.length) process.exitCode = 1;
|
||||
@@ -0,0 +1,67 @@
|
||||
import importlib.util
|
||||
import struct
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
spec = importlib.util.spec_from_file_location('packet_oracle', Path(__file__).with_name('packet-oracle.py'))
|
||||
oracle = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(oracle)
|
||||
|
||||
|
||||
class PacketOracleTest(unittest.TestCase):
|
||||
def test_compression_pointer_preserves_question_cursor(self):
|
||||
message = b'\x03www\x07example\x03com\0' + b'\xc0\0' + b'\0\x01\0\x01'
|
||||
self.assertEqual(oracle.dns_name(message, 17), ('www.example.com', 19))
|
||||
|
||||
def test_compression_cycle_and_missing_pointer_byte_fail(self):
|
||||
for message in (b'\xc0\0', b'\xc0', b'\xc0\x02\xc0\0'):
|
||||
with self.subTest(message=message), self.assertRaises(ValueError):
|
||||
oracle.dns_name(message, 0)
|
||||
|
||||
def test_truncated_pcap_record_is_not_exhaustion(self):
|
||||
header = struct.pack('<IHHIIII', 0xa1b2c3d4, 2, 4, 0, 0, 65535, 1)
|
||||
with self.assertRaises(ValueError):
|
||||
list(oracle.packets(header + b'\0'))
|
||||
record = struct.pack('<IIII', 0, 0, 20, 20)
|
||||
with self.assertRaises(ValueError):
|
||||
list(oracle.packets(header + record + b'\0' * 19))
|
||||
|
||||
def test_missing_dhcp_server_id_stays_omitted(self):
|
||||
payload = b'\0' * 236 + b'\x63\x82\x53\x63\x35\x01\x01\xff'
|
||||
self.assertEqual(oracle.dhcp_options(payload), {53: b'\x01'})
|
||||
|
||||
def test_dhcp_duplicates_truncation_and_missing_end_fail(self):
|
||||
prefix = b'\0' * 236 + b'\x63\x82\x53\x63'
|
||||
for suffix in (b'\x35\x01\x01\x35\x01\x02\xff', b'\x36\x04\0', b'\x35\x01\x01'):
|
||||
with self.subTest(suffix=suffix), self.assertRaises(ValueError):
|
||||
oracle.dhcp_options(prefix + suffix)
|
||||
|
||||
def test_fragmented_selected_traffic_cannot_pass_header_oracle(self):
|
||||
ip = bytearray(20)
|
||||
ip[0], ip[9] = 0x45, 17
|
||||
struct.pack_into('!H', ip, 2, 20)
|
||||
struct.pack_into('!H', ip, 6, 0x2000)
|
||||
with self.assertRaises(ValueError):
|
||||
oracle.transport(b'\0' * 12 + b'\x08\0' + ip)
|
||||
|
||||
def test_declared_ip_length_and_tcp_options_cannot_be_truncated(self):
|
||||
ip = bytearray(40)
|
||||
ip[0], ip[9], ip[32] = 0x45, 6, 0xf0
|
||||
struct.pack_into('!H', ip, 2, 40)
|
||||
struct.pack_into('!HH', ip, 20, 12345, 5432)
|
||||
packet = b'\0' * 12 + b'\x08\0' + ip
|
||||
header = struct.pack('<IHHIIII', 0xa1b2c3d4, 2, 4, 0, 0, 65535, 1)
|
||||
record = struct.pack('<IIII', 0, 0, len(packet), len(packet))
|
||||
with self.assertRaisesRegex(ValueError, 'TCP header'):
|
||||
oracle.analyze(header + record + packet, 'postgres')
|
||||
struct.pack_into('!H', ip, 2, 60)
|
||||
with self.assertRaisesRegex(ValueError, 'truncated IPv4'):
|
||||
oracle.transport(b'\0' * 12 + b'\x08\0' + ip)
|
||||
|
||||
def test_unknown_link_protocol_does_not_prove_tcp_absence(self):
|
||||
with self.assertRaisesRegex(ValueError, 'unsupported Ethernet'):
|
||||
oracle.transport(b'\0' * 12 + b'\x86\xdd' + b'\0' * 40)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -2905,11 +2905,11 @@ pure-rand@8.4.2 | development | MIT
|
||||
Package: https://www.npmjs.com/package/pure-rand/v/8.4.2
|
||||
Archive: https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz
|
||||
Integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==
|
||||
qs@6.15.1 | development | BSD-3-Clause
|
||||
qs@6.16.0 | development | BSD-3-Clause
|
||||
Lockfile path: node_modules/qs
|
||||
Package: https://www.npmjs.com/package/qs/v/6.15.1
|
||||
Archive: https://registry.npmjs.org/qs/-/qs-6.15.1.tgz
|
||||
Integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==
|
||||
Package: https://www.npmjs.com/package/qs/v/6.16.0
|
||||
Archive: https://registry.npmjs.org/qs/-/qs-6.16.0.tgz
|
||||
Integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==
|
||||
quick-format-unescaped@4.0.4 | runtime | MIT
|
||||
Lockfile path: node_modules/quick-format-unescaped
|
||||
Package: https://www.npmjs.com/package/quick-format-unescaped/v/4.0.4
|
||||
|
||||
Generated
+5
-4
@@ -8408,13 +8408,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
|
||||
@@ -353,6 +353,9 @@
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"overrides": {
|
||||
"typed-rest-client": {
|
||||
"qs": "6.16.0"
|
||||
},
|
||||
"vitepress": {
|
||||
"vite": "6.4.4"
|
||||
}
|
||||
|
||||
+6
-2
@@ -10,6 +10,7 @@ const HOST_TEST_OWNERS = ["apple", "package", "sqlite"];
|
||||
// workflows' release, publication and self-hosted runner safeguards.
|
||||
const GUARDED_WORKFLOWS = new Set([
|
||||
".github/workflows/pr-title.yml",
|
||||
".github/workflows/pr-readiness.yml",
|
||||
".github/workflows/ci-full-pr.yml",
|
||||
".github/workflows/release.yml",
|
||||
".github/workflows/real-ghidra-windows.yml",
|
||||
@@ -175,8 +176,11 @@ if (tier === "reused") {
|
||||
file.startsWith(".github/ISSUE_TEMPLATE/")
|
||||
) {
|
||||
selected.docs = true;
|
||||
// Authored documentation code also belongs to the static checkers.
|
||||
selected.code ||= /^docs\/.*\.(?:[cm]?[jt]s|[jt]sx)$/su.test(file);
|
||||
// Authored code and its Node package scopes belong to static checkers.
|
||||
selected.code ||=
|
||||
file.startsWith("docs/") &&
|
||||
(/\.(?:[cm]?[jt]s|[jt]sx)$/u.test(file) ||
|
||||
file.endsWith("/package.json"));
|
||||
selected.website ||= matchesAny(file, SCOPES.website);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
export interface ReadinessPullRequest {
|
||||
readonly number: number;
|
||||
readonly state: "open" | "closed";
|
||||
readonly draft: boolean;
|
||||
readonly body: string | null;
|
||||
readonly head: { readonly sha: string };
|
||||
readonly base: { readonly ref: string };
|
||||
}
|
||||
export interface ReadinessResult {
|
||||
readonly state: "success" | "failure" | "pending";
|
||||
readonly description: string;
|
||||
}
|
||||
export interface ReadinessGitHub {
|
||||
readonly rest: {
|
||||
readonly pulls: {
|
||||
get(input: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
pull_number: number;
|
||||
}): Promise<{ data: ReadinessPullRequest }>;
|
||||
};
|
||||
readonly repos: {
|
||||
createCommitStatus(
|
||||
input: ReadinessResult & {
|
||||
owner: string;
|
||||
repo: string;
|
||||
sha: string;
|
||||
context: string;
|
||||
target_url: string;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
readonly listPullRequestsAssociatedWithCommit: unknown;
|
||||
};
|
||||
};
|
||||
paginate(
|
||||
method: unknown,
|
||||
input: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
commit_sha: string;
|
||||
per_page: number;
|
||||
},
|
||||
): Promise<
|
||||
readonly Pick<ReadinessPullRequest, "number" | "state" | "head">[]
|
||||
>;
|
||||
}
|
||||
export function evaluateReadiness(
|
||||
pull: Pick<ReadinessPullRequest, "body" | "draft">,
|
||||
template: string,
|
||||
): ReadinessResult;
|
||||
export function runReadinessCheck(input: {
|
||||
github: ReadinessGitHub;
|
||||
context: {
|
||||
repo: { owner: string; repo: string };
|
||||
payload: {
|
||||
pull_request?: { number: number };
|
||||
inputs?: { pull_request_number?: string };
|
||||
};
|
||||
serverUrl: string;
|
||||
runId: number;
|
||||
};
|
||||
core: { info(message: string): void; setFailed(message: string): void };
|
||||
}): Promise<ReadinessResult | undefined>;
|
||||
@@ -0,0 +1,173 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const result = (state, description) => ({ state, description });
|
||||
|
||||
function readinessLines(markdown) {
|
||||
const lines = markdown.split(/\r?\n/u);
|
||||
const visible = [];
|
||||
let fence;
|
||||
let comment = false;
|
||||
for (const source of lines) {
|
||||
if (fence) {
|
||||
if (
|
||||
new RegExp(
|
||||
`^ {0,3}${fence.character}{${fence.length},}\\s*$`,
|
||||
"u",
|
||||
).test(source)
|
||||
)
|
||||
fence = undefined;
|
||||
continue;
|
||||
}
|
||||
const opening = !comment && /^ {0,3}(`{3,}|~{3,})/u.exec(source);
|
||||
if (opening) {
|
||||
fence = { character: opening[1][0], length: opening[1].length };
|
||||
continue;
|
||||
}
|
||||
let line = "";
|
||||
let offset = 0;
|
||||
while (offset < source.length) {
|
||||
const marker = comment ? "-->" : "<!--";
|
||||
const index = source.indexOf(marker, offset);
|
||||
if (!comment)
|
||||
line += source.slice(offset, index === -1 ? source.length : index);
|
||||
if (index === -1) break;
|
||||
offset = index + marker.length;
|
||||
comment = !comment;
|
||||
}
|
||||
visible.push(line);
|
||||
}
|
||||
const starts = visible.flatMap((line, index) =>
|
||||
/^## Author readiness\s*$/u.test(line) ? [index] : [],
|
||||
);
|
||||
if (starts.length !== 1) return [];
|
||||
const remaining = visible.slice(starts[0] + 1);
|
||||
const end = remaining.findIndex((line) => /^#{1,2}\s/u.test(line));
|
||||
return (end === -1 ? remaining : remaining.slice(0, end)).map((line) =>
|
||||
line.trimEnd(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Check the initial declaration against the trusted repository template. */
|
||||
export function evaluateReadiness(pull, template) {
|
||||
if (pull.draft)
|
||||
return result(
|
||||
"pending",
|
||||
"Draft PR: complete Author readiness before requesting review.",
|
||||
);
|
||||
const expected = readinessLines(template).flatMap((line) => {
|
||||
const match = /^- \[ \] (.+)$/u.exec(line);
|
||||
return match ? [match[1]] : [];
|
||||
});
|
||||
if (expected.length !== 3)
|
||||
throw new Error(
|
||||
"Trusted PR template must contain three readiness declarations.",
|
||||
);
|
||||
const lines = readinessLines(pull.body ?? "");
|
||||
const owners = lines.filter((line) => line.startsWith("Human owner:"));
|
||||
if (
|
||||
owners.length !== 1 ||
|
||||
!/^Human owner: @[a-z\d](?:[a-z\d-]*[a-z\d])?$/iu.test(owners[0])
|
||||
)
|
||||
return result(
|
||||
"failure",
|
||||
"Fill Human owner with one @GitHub-handle in the Author readiness section.",
|
||||
);
|
||||
const declarations = lines.flatMap((line) => {
|
||||
const match = /^- \[([ xX])\] (.+)$/u.exec(line);
|
||||
return match
|
||||
? [{ checked: match[1].toLowerCase() === "x", text: match[2] }]
|
||||
: [];
|
||||
});
|
||||
if (
|
||||
expected.some((text) => {
|
||||
const matches = declarations.filter((item) => item.text === text);
|
||||
return matches.length !== 1 || !matches[0].checked;
|
||||
})
|
||||
)
|
||||
return result(
|
||||
"failure",
|
||||
"Complete all three Author readiness declarations from the current PR template.",
|
||||
);
|
||||
return result(
|
||||
"success",
|
||||
"Human owner and all three initial readiness declarations are filled.",
|
||||
);
|
||||
}
|
||||
|
||||
/** Publish readiness on the PR head without running contributor code. */
|
||||
export async function runReadinessCheck({ github, context, core }) {
|
||||
const number =
|
||||
context.payload.pull_request?.number ??
|
||||
Number(context.payload.inputs?.pull_request_number);
|
||||
if (!Number.isSafeInteger(number) || number <= 0)
|
||||
throw new Error("A positive pull request number is required.");
|
||||
const repository = context.repo;
|
||||
const getPull = async () =>
|
||||
(await github.rest.pulls.get({ ...repository, pull_number: number })).data;
|
||||
const pull = await getPull();
|
||||
if (pull.state !== "open" || !/^(?:main|release\/.+)$/u.test(pull.base.ref))
|
||||
return;
|
||||
const publish = (sha, verdict) =>
|
||||
github.rest.repos.createCommitStatus({
|
||||
...repository,
|
||||
sha,
|
||||
context: "PR readiness",
|
||||
...verdict,
|
||||
target_url: `${context.serverUrl}/${repository.owner}/${repository.repo}/actions/runs/${context.runId}`,
|
||||
});
|
||||
await publish(
|
||||
pull.head.sha,
|
||||
result("pending", "Checking the initial author readiness declaration."),
|
||||
);
|
||||
const template = await readFile(
|
||||
new URL("../../.github/pull_request_template.md", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
let verdict = evaluateReadiness(pull, template);
|
||||
if (verdict.state === "success") {
|
||||
const associated = await github.paginate(
|
||||
github.rest.repos.listPullRequestsAssociatedWithCommit,
|
||||
{ ...repository, commit_sha: pull.head.sha, per_page: 100 },
|
||||
);
|
||||
if (
|
||||
!associated.some(
|
||||
(other) =>
|
||||
other.number === number &&
|
||||
other.state === "open" &&
|
||||
other.head.sha === pull.head.sha,
|
||||
)
|
||||
)
|
||||
verdict = result(
|
||||
"pending",
|
||||
"GitHub has not confirmed this PR's head association; rerun PR readiness.",
|
||||
);
|
||||
else if (
|
||||
associated.some(
|
||||
(other) =>
|
||||
other.state === "open" &&
|
||||
other.number !== number &&
|
||||
other.head.sha === pull.head.sha,
|
||||
)
|
||||
)
|
||||
verdict = result(
|
||||
"failure",
|
||||
"Open PRs must have distinct head commits because GitHub shares commit statuses.",
|
||||
);
|
||||
}
|
||||
const latest = await getPull();
|
||||
if (latest.state !== "open") return;
|
||||
if (
|
||||
latest.head.sha !== pull.head.sha ||
|
||||
latest.body !== pull.body ||
|
||||
latest.draft !== pull.draft ||
|
||||
latest.base.ref !== pull.base.ref
|
||||
)
|
||||
verdict = result(
|
||||
"pending",
|
||||
"PR changed during the check; wait for the latest run or rerun PR readiness.",
|
||||
);
|
||||
await publish(latest.head.sha, verdict);
|
||||
core.info(verdict.description);
|
||||
if (verdict.state === "failure") core.setFailed(verdict.description);
|
||||
return verdict;
|
||||
}
|
||||
+12
-3
@@ -314,12 +314,21 @@ class WebsiteTranslationChecks(unittest.TestCase):
|
||||
|
||||
def test_broken_placeholders_and_inline_markup_fail_before_replacing_preview(self):
|
||||
with self.translated_project() as root:
|
||||
page = root / "website/public/index.html"
|
||||
page.write_text(page.read_text().replace(
|
||||
"</main>",
|
||||
'<p>Translation test <a href="get-started/">read more</a>.</p></main>',
|
||||
))
|
||||
extract_catalogs(root)
|
||||
path = root / "website/locales/zh-CN/messages.po"
|
||||
catalog = polib.pofile(path)
|
||||
entry = catalog.find("Translation test {ph0}read more{ph1}.", msgctxt="page:index.html")
|
||||
self.assertIsNotNone(entry)
|
||||
entry.msgstr = "翻译测试:{ph0}继续阅读{ph1}。"
|
||||
catalog.save(path)
|
||||
site = root / "website/_site"
|
||||
build_translations(root, site)
|
||||
before = (site / "zh-CN/index.html").read_bytes()
|
||||
path = root / "website/locales/zh-CN/messages.po"
|
||||
catalog = polib.pofile(path)
|
||||
entry = catalog.find("Already know RE? {ph0}Skip to analysis guides →{ph1}", msgctxt="page:index.html")
|
||||
original = entry.msgstr
|
||||
for bad, reason in (("丢失链接", "changed placeholders"), ("{ph1}错误的链接{ph0}", "invalid inline markup")):
|
||||
with self.subTest(reason=reason):
|
||||
|
||||
@@ -55,4 +55,12 @@ describe("parseAdbVersionOutput", () => {
|
||||
installedPath: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not accept arbitrary successful output as an adb version", () => {
|
||||
expect(
|
||||
parseAdbVersionOutput(
|
||||
"Android Debug Bridge version tomorrow\nVersion unknown\n",
|
||||
),
|
||||
).toMatchObject({ version: null });
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,9 @@ export interface AdbClientIdentity {
|
||||
* `Version ` the build version (no colon), and `Installed as` the binary path.
|
||||
*/
|
||||
export const parseAdbVersionOutput = (stdout: string): AdbClientIdentity => {
|
||||
const build = /^Version\s+(\S.*)$/mu.exec(stdout);
|
||||
const build = /^Version\s+(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\s*$/mu.exec(
|
||||
stdout,
|
||||
);
|
||||
const installed = /^Installed as\s+(.+)$/mu.exec(stdout);
|
||||
return {
|
||||
version: build === null ? null : build[1]!.trim(),
|
||||
@@ -84,14 +86,34 @@ export const inspectAdbClient = async (
|
||||
signal?: AbortSignal,
|
||||
): Promise<AdbClientIdentity> => {
|
||||
try {
|
||||
const { stdout } = await execFileOutput(selection.binary, ["version"], {
|
||||
timeout: VERSION_TIMEOUT_MS,
|
||||
stopSignal: "SIGTERM",
|
||||
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
return parseAdbVersionOutput(stdout);
|
||||
const { stdout, stderr } = await execFileOutput(
|
||||
selection.binary,
|
||||
["version"],
|
||||
{
|
||||
timeout: VERSION_TIMEOUT_MS,
|
||||
stopSignal: "SIGTERM",
|
||||
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
);
|
||||
const identity = parseAdbVersionOutput(stdout);
|
||||
const hasRecognizedProtocolVersion =
|
||||
/^Android Debug Bridge version\s+\d+\.\d+\.\d+\s*$/mu.test(stdout);
|
||||
if (identity.version === null && !hasRecognizedProtocolVersion)
|
||||
throw new AdbConfigurationFailure(
|
||||
"version_unresolved",
|
||||
"The selected adb binary exited successfully but did not report a recognizable protocol or build version",
|
||||
{
|
||||
selected_binary: selection.binary,
|
||||
path_source: selection.pathSource,
|
||||
version_probe: "unrecognized_output",
|
||||
stdout,
|
||||
stderr,
|
||||
},
|
||||
);
|
||||
return identity;
|
||||
} catch (cause) {
|
||||
if (cause instanceof AdbConfigurationFailure) throw cause;
|
||||
const failure = execFileOutputFailure(cause);
|
||||
if (signal?.aborted || failure?.code === "ABORT_ERR")
|
||||
throw new AnalysisCancelledError("inspect_adb_client", { cause });
|
||||
|
||||
@@ -56,7 +56,9 @@ describe("parseAdbDevicesOutput", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ADB daemon startup notices", () => {
|
||||
it("recognizes daemon startup notices instead of failing the listing", () => {
|
||||
const output = [
|
||||
"* daemon not running; starting now at tcp:5037",
|
||||
@@ -70,6 +72,67 @@ describe("parseAdbDevicesOutput", () => {
|
||||
expect(devices).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("recognizes daemon startup notices emitted on stderr", () => {
|
||||
const stdout = [
|
||||
"List of devices attached",
|
||||
"emulator-5554 device transport_id:1",
|
||||
"",
|
||||
].join("\n");
|
||||
const stderr = [
|
||||
"* daemon not running; starting now at tcp:5037",
|
||||
"* daemon started successfully",
|
||||
"",
|
||||
].join("\n");
|
||||
const { devices, daemonStarted, unparsedLines } = parseAdbDevicesOutput(
|
||||
stdout,
|
||||
stderr,
|
||||
);
|
||||
expect(daemonStarted).toBe(true);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
expect(devices).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("recognizes the legacy daemon notice with its trailing marker and CRLF", () => {
|
||||
const { daemonStarted, unparsedLines } = parseAdbDevicesOutput(
|
||||
"List of devices attached\r\n",
|
||||
"* daemon started successfully *\r\n",
|
||||
);
|
||||
expect(daemonStarted).toBe(true);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat a daemon startup error containing the success phrase as success", () => {
|
||||
const { daemonStarted, unparsedLines } = parseAdbDevicesOutput(
|
||||
"List of devices attached\r\n",
|
||||
"* daemon not started successfully\r\n",
|
||||
);
|
||||
expect(daemonStarted).toBe(false);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores non-startup lines and device strings on stderr", () => {
|
||||
const stdout = [
|
||||
"List of devices attached",
|
||||
"emulator-5554 device transport_id:1",
|
||||
"",
|
||||
].join("\n");
|
||||
const stderr = [
|
||||
"adb: warning: some transport warning",
|
||||
"emulator-5556 device transport_id:2",
|
||||
"",
|
||||
].join("\n");
|
||||
const { devices, daemonStarted, unparsedLines } = parseAdbDevicesOutput(
|
||||
stdout,
|
||||
stderr,
|
||||
);
|
||||
expect(daemonStarted).toBe(false);
|
||||
expect(unparsedLines).toEqual([]);
|
||||
expect(devices).toHaveLength(1);
|
||||
expect(devices[0]?.serial).toBe("emulator-5554");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ADB device states", () => {
|
||||
it("keeps non-device states such as unauthorized and offline", () => {
|
||||
const output = [
|
||||
"List of devices attached",
|
||||
|
||||
@@ -30,6 +30,12 @@ export interface AdbDeviceEntry {
|
||||
|
||||
const DEVICE_LINE = /^(\S+)\s+(.*)$/u;
|
||||
|
||||
const normalizeAdbOutputLine = (rawLine: string): string => rawLine.trimEnd();
|
||||
|
||||
const isSuccessfulDaemonStartNotice = (line: string): boolean =>
|
||||
line === "* daemon started successfully" ||
|
||||
line === "* daemon started successfully *";
|
||||
|
||||
const metadataValue = (remainder: string, key: string): string | null => {
|
||||
const match = new RegExp(`(?:^|\\s)${key}:(\\S+)`, "u").exec(remainder);
|
||||
return match === null ? null : match[1]!;
|
||||
@@ -42,6 +48,7 @@ const metadataValue = (remainder: string, key: string): string | null => {
|
||||
*/
|
||||
export const parseAdbDevicesOutput = (
|
||||
stdout: string,
|
||||
stderr = "",
|
||||
): {
|
||||
devices: readonly AdbDeviceEntry[];
|
||||
unparsedLines: readonly string[];
|
||||
@@ -51,12 +58,13 @@ export const parseAdbDevicesOutput = (
|
||||
const unparsedLines: string[] = [];
|
||||
let daemonStarted = false;
|
||||
for (const rawLine of stdout.split(/\r?\n/u)) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (line === "" || line.startsWith("List of devices attached")) continue;
|
||||
if (line.startsWith("* daemon")) {
|
||||
if (line.includes("started successfully")) daemonStarted = true;
|
||||
const line = normalizeAdbOutputLine(rawLine);
|
||||
if (isSuccessfulDaemonStartNotice(line)) {
|
||||
daemonStarted = true;
|
||||
continue;
|
||||
}
|
||||
if (line === "" || line.startsWith("List of devices attached")) continue;
|
||||
if (line.startsWith("* daemon")) continue;
|
||||
const match = DEVICE_LINE.exec(line);
|
||||
const state = ADB_DEVICE_STATES.find((candidate) => {
|
||||
const remainder = match?.[2];
|
||||
@@ -105,6 +113,15 @@ export const parseAdbDevicesOutput = (
|
||||
: "none",
|
||||
});
|
||||
}
|
||||
if (!daemonStarted && stderr !== "") {
|
||||
for (const rawLine of stderr.split(/\r?\n/u)) {
|
||||
const line = normalizeAdbOutputLine(rawLine);
|
||||
if (isSuccessfulDaemonStartNotice(line)) {
|
||||
daemonStarted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { devices, unparsedLines, daemonStarted };
|
||||
};
|
||||
|
||||
|
||||
@@ -91,6 +91,36 @@ it("inspects the adb client without contacting the server", async () => {
|
||||
expect(result.contacted_adb_server).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed when adb exits successfully without a recognized version", async () => {
|
||||
const stdout = `unexpected adb output ${"x".repeat(700)}\n`;
|
||||
const stderr = `probe stderr ${"y".repeat(700)}\n`;
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `printf '%s' '${stdout}'; printf '%s' '${stderr}' >&2; exit 0`,
|
||||
);
|
||||
await expect(
|
||||
providerOver(binary).inspectAvailability(),
|
||||
).resolves.toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "version_unresolved",
|
||||
diagnostics: {
|
||||
version_probe: "unrecognized_output",
|
||||
stdout,
|
||||
stderr,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts adb's recognized protocol version when the build version is absent", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `printf 'Android Debug Bridge version 1.0.41\\n'`,
|
||||
);
|
||||
await expect(
|
||||
providerOver(binary).inspectAvailability(),
|
||||
).resolves.toMatchObject({
|
||||
status: "available",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists devices with inferred kinds and observed daemon startup", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
@@ -121,6 +151,36 @@ fi`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("observes daemon startup when notices are printed to stderr", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "devices" ]; then
|
||||
printf '* daemon not running; starting now at tcp:5037\\n* daemon started successfully\\n' >&2
|
||||
printf 'List of devices attached\\n'
|
||||
printf 'emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1\\n'
|
||||
exit 0
|
||||
fi`,
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "list_adb_devices",
|
||||
input: {},
|
||||
});
|
||||
expect(result.may_have_started_adb_server).toBe(true);
|
||||
expect(result.devices).toEqual([
|
||||
{
|
||||
serial: "emulator-5554",
|
||||
state: "device",
|
||||
transport: "tcp",
|
||||
product: "sdk_gphone64_x86_64",
|
||||
model: "sdk_gphone64_x86_64",
|
||||
device: "emu64xa",
|
||||
transport_id: 1,
|
||||
kind: "emulator",
|
||||
kind_basis: "emulator_serial_prefix",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses device listings with an invalid state token", async () => {
|
||||
const malformedLine = "serial deviceXYZ ";
|
||||
const { binary } = await writeAdbStub(
|
||||
@@ -1162,15 +1222,21 @@ exit 1`,
|
||||
const apk = join(root, "app.apk");
|
||||
await writeFile(apk, "apk");
|
||||
const digest = createHash("sha256").update("apk").digest("hex");
|
||||
const installed = await evidenceResult(providerOver(binary), {
|
||||
const execution = await providerOver(binary).execute({
|
||||
operation: "install_adb_package",
|
||||
input: { serial: "emulator-5554", apk_path: apk, replace: false },
|
||||
});
|
||||
expect(installed).toMatchObject({
|
||||
expect(execution.ok).toBe(true);
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.result).toMatchObject({
|
||||
bytes: 3,
|
||||
sha256: digest,
|
||||
replaced: false,
|
||||
replace_requested: false,
|
||||
replaced: null,
|
||||
});
|
||||
expect(execution.value.limitations).toContain(
|
||||
"Whether an existing package was replaced is unknown; replace_requested records only the adb install mode",
|
||||
);
|
||||
|
||||
const { binary: failing } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
|
||||
@@ -755,6 +755,7 @@ export class AdbProvider {
|
||||
if (!ran.ok) return ran;
|
||||
const { devices, unparsedLines, daemonStarted } = parseAdbDevicesOutput(
|
||||
ran.value.stdout,
|
||||
ran.value.stderr,
|
||||
);
|
||||
if (unparsedLines.length > 0)
|
||||
return err(
|
||||
@@ -1883,12 +1884,14 @@ export class AdbProvider {
|
||||
apk_path: apkPath,
|
||||
bytes: digest.bytes,
|
||||
sha256: digest.sha256,
|
||||
replaced: input.replace,
|
||||
replace_requested: input.replace,
|
||||
replaced: null,
|
||||
},
|
||||
provider,
|
||||
{
|
||||
limitations: [
|
||||
"Install changes device state; the installed APK's digest is the host file's digest",
|
||||
"Whether an existing package was replaced is unknown; replace_requested records only the adb install mode",
|
||||
],
|
||||
},
|
||||
),
|
||||
|
||||
@@ -58,7 +58,28 @@ describe("parseApktoolVersionOutput", () => {
|
||||
});
|
||||
|
||||
it("reports null when the launcher prints nothing usable", () => {
|
||||
expect(parseApktoolVersionOutput(" \n")).toEqual({ version: null });
|
||||
expect(parseApktoolVersionOutput(" \n")).toEqual({
|
||||
version: null,
|
||||
reportedVersion: null,
|
||||
});
|
||||
expect(parseApktoolVersionOutput("successful but not a version\n")).toEqual(
|
||||
{
|
||||
version: null,
|
||||
reportedVersion: "successful but not a version",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("parses semantic prerelease versions without claiming they meet the minimum", () => {
|
||||
expect(parseApktoolVersionOutput("2.7.0-beta.1\n")).toEqual({
|
||||
version: "2.7.0-beta.1",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates normalized semantic versions while preserving caller spelling", () => {
|
||||
expect(parseApktoolVersionOutput("v2.7.0\n")).toEqual({
|
||||
version: "v2.7.0",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { gte, valid } from "semver";
|
||||
import {
|
||||
isWindowsBatchFile,
|
||||
windowsBatchCommand,
|
||||
@@ -148,7 +149,8 @@ export type ApktoolConfigurationFailureCode =
|
||||
| "command_missing"
|
||||
| "command_unresolved"
|
||||
| "launcher_unsupported"
|
||||
| "version_unresolved";
|
||||
| "version_unresolved"
|
||||
| "unsupported_version";
|
||||
|
||||
export class ApktoolConfigurationFailure extends Error {
|
||||
constructor(
|
||||
@@ -161,9 +163,9 @@ export class ApktoolConfigurationFailure extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApktoolClientIdentity {
|
||||
readonly version: string | null;
|
||||
}
|
||||
export type ApktoolClientIdentity =
|
||||
| { readonly version: string; readonly reportedVersion?: never }
|
||||
| { readonly version: null; readonly reportedVersion: string | null };
|
||||
|
||||
/** Process creation for one apktool run on the selected host. */
|
||||
export interface ApktoolInvocation {
|
||||
@@ -199,7 +201,7 @@ export const apktoolInvocation = (
|
||||
windowsVerbatimArguments: false,
|
||||
};
|
||||
|
||||
/** Read the version from `apktool --version` output (first non-empty line). */
|
||||
/** Read the version from Apktool's single-line `--version` response. */
|
||||
export const parseApktoolVersionOutput = (
|
||||
stdout: string,
|
||||
): ApktoolClientIdentity => {
|
||||
@@ -207,7 +209,17 @@ export const parseApktoolVersionOutput = (
|
||||
.split(/\r?\n/u)
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry !== "");
|
||||
return { version: line === undefined ? null : line };
|
||||
if (line === undefined || valid(line) === null)
|
||||
return { version: null, reportedVersion: line ?? null };
|
||||
return { version: line };
|
||||
};
|
||||
|
||||
const supportsDocumentedApktoolVersion = (version: string): boolean => {
|
||||
// Debian's verified 2.7.0-dirty build is a downstream build marker, not a
|
||||
// prerelease. Treat only that known marker as build metadata for the floor.
|
||||
const comparisonVersion = version.replace(/-dirty$/u, "+dirty");
|
||||
const parsedVersion = valid(comparisonVersion);
|
||||
return parsedVersion !== null && gte(parsedVersion, "2.7.0");
|
||||
};
|
||||
|
||||
/** Probe the selected apktool launcher without decoding anything. */
|
||||
@@ -239,8 +251,10 @@ export const inspectApktoolClient = async (
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
let stdout: string;
|
||||
let stderr: string;
|
||||
try {
|
||||
const { stdout } = await execFileOutput(
|
||||
({ stdout, stderr } = await execFileOutput(
|
||||
invocation.command,
|
||||
invocation.arguments,
|
||||
{
|
||||
@@ -251,8 +265,7 @@ export const inspectApktoolClient = async (
|
||||
env: hostEnvironment,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
);
|
||||
return parseApktoolVersionOutput(stdout);
|
||||
));
|
||||
} catch (cause) {
|
||||
const failure = execFileOutputFailure(cause);
|
||||
if (signal?.aborted || failure?.code === "ABORT_ERR")
|
||||
@@ -283,6 +296,33 @@ export const inspectApktoolClient = async (
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
const identity = parseApktoolVersionOutput(stdout);
|
||||
if (identity.version === null)
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"version_unresolved",
|
||||
"The selected apktool launcher exited successfully but did not report a recognizable version",
|
||||
{
|
||||
selected_command: selection.command,
|
||||
command_source: selection.commandSource,
|
||||
version_probe: "unrecognized_output",
|
||||
reported_version: identity.reportedVersion,
|
||||
stdout,
|
||||
stderr,
|
||||
},
|
||||
);
|
||||
if (!supportsDocumentedApktoolVersion(identity.version))
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"unsupported_version",
|
||||
`The selected Apktool version ${identity.version} is older than the supported minimum 2.7.0`,
|
||||
{
|
||||
selected_command: selection.command,
|
||||
command_source: selection.commandSource,
|
||||
reported_version: identity.version,
|
||||
minimum_version: "2.7.0",
|
||||
remediation: `Use Apktool 2.7.0 or newer; see docs/apktool-resource-analysis.md.`,
|
||||
},
|
||||
);
|
||||
return identity;
|
||||
};
|
||||
|
||||
/** Availability in the shape shared by every provider. */
|
||||
@@ -295,7 +335,9 @@ const rejectionCodeOf = (
|
||||
code === "launcher_unsupported" ||
|
||||
code === "command_unresolved"
|
||||
? "not_configured"
|
||||
: "version_unresolved";
|
||||
: code === "unsupported_version"
|
||||
? "unsupported_version"
|
||||
: "version_unresolved";
|
||||
|
||||
export const apktoolConfigurationAvailability = (
|
||||
cause: ApktoolConfigurationFailure,
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.j
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
const VERSION_BLOCK = "printf '2.7.0-stub\\n'";
|
||||
const VERSION_BLOCK = "printf '2.7.0-dirty\\n'";
|
||||
|
||||
const REAL_YML = [
|
||||
"!!brut.androlib.meta.MetaInfo",
|
||||
@@ -172,7 +172,7 @@ it("inspects the apktool launcher", async () => {
|
||||
expect(result.client).toEqual({
|
||||
command,
|
||||
command_source: "environment",
|
||||
apktool_version: "2.7.0-stub",
|
||||
apktool_version: "2.7.0-dirty",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -355,6 +355,39 @@ it("reports a missing launcher as a capability gap with recovery", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for unrecognized and unsupported Apktool versions", async () => {
|
||||
const invalidOutput = `unexpected Apktool output ${"x".repeat(700)}\n`;
|
||||
const unrecognized = await writeApktoolStub(
|
||||
() => `printf '%s' '${invalidOutput}'; exit 0`,
|
||||
);
|
||||
await expect(
|
||||
providerOver(unrecognized.command).inspectAvailability(),
|
||||
).resolves.toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "version_unresolved",
|
||||
diagnostics: {
|
||||
reported_version: invalidOutput.trim(),
|
||||
stdout: invalidOutput,
|
||||
},
|
||||
});
|
||||
|
||||
const old = await writeApktoolStub(() => `printf '2.6.3\\n'`);
|
||||
await expect(
|
||||
providerOver(old.command).inspectAvailability(),
|
||||
).resolves.toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "unsupported_version",
|
||||
});
|
||||
|
||||
const prerelease = await writeApktoolStub(() => `printf '2.7.0-beta.1\\n'`);
|
||||
await expect(
|
||||
providerOver(prerelease.command).inspectAvailability(),
|
||||
).resolves.toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "unsupported_version",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a target that is not a readable file", async () => {
|
||||
const { command, root } = await writeApktoolStub(decodeStub());
|
||||
const projected = await refusedBy(providerOver(command), {
|
||||
|
||||
@@ -97,6 +97,53 @@ const entry = (
|
||||
return found;
|
||||
};
|
||||
|
||||
describe("ADB and Apktool host availability", () => {
|
||||
it("uses only their selected owner probes", () => {
|
||||
const ready = {
|
||||
status: "available" as const,
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics: {},
|
||||
};
|
||||
const configuredButInvalid = {
|
||||
status: "unavailable" as const,
|
||||
code: "not_configured" as const,
|
||||
reason: "REA_ADB_PATH is invalid",
|
||||
diagnostics: {},
|
||||
};
|
||||
const readyInventory = buildCapabilityInventory(status(), {
|
||||
...enabledPolicy,
|
||||
adbAnalysisAvailability: ready,
|
||||
apktoolAnalysisAvailability: ready,
|
||||
});
|
||||
expect(
|
||||
readyInventory.find(({ name }) => name === "inspect_adb_client"),
|
||||
).toMatchObject({ available: true, reason: "available" });
|
||||
expect(
|
||||
readyInventory.find(({ name }) => name === "inspect_apktool_client"),
|
||||
).toMatchObject({ available: true, reason: "available" });
|
||||
|
||||
const unavailableInventory = buildCapabilityInventory(status(), {
|
||||
...enabledPolicy,
|
||||
adbAnalysisAvailability: configuredButInvalid,
|
||||
apktoolAnalysisAvailability: {
|
||||
status: "unavailable",
|
||||
code: "unsupported_version",
|
||||
reason: "Apktool 2.6.3 is older than 2.7.0",
|
||||
diagnostics: {},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
unavailableInventory.find(({ name }) => name === "inspect_adb_client"),
|
||||
).toMatchObject({ available: false, reason: "provider_missing" });
|
||||
expect(
|
||||
unavailableInventory.find(
|
||||
({ name }) => name === "decode_android_resources",
|
||||
),
|
||||
).toMatchObject({ available: false, reason: "provider_unavailable" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability failure reasons", () => {
|
||||
it("keeps an unavailable Android runtime distinct from a missing provider", () => {
|
||||
const inventory = buildCapabilityInventory(status(), {
|
||||
|
||||
@@ -54,6 +54,8 @@ export type AvailabilityPolicy = {
|
||||
readonly firmwareInspectionEnabled?: boolean;
|
||||
readonly firmwareExtractionEnabled?: boolean;
|
||||
readonly androidAnalysisAvailability?: ProviderAvailability;
|
||||
readonly adbAnalysisAvailability?: ProviderAvailability;
|
||||
readonly apktoolAnalysisAvailability?: ProviderAvailability;
|
||||
readonly jebAnalysisAvailability?: ProviderAvailability;
|
||||
readonly javascriptRecoveryEnabled?: boolean;
|
||||
readonly webModuleResolutionEnabled?: boolean;
|
||||
@@ -473,6 +475,16 @@ const workflowAvailabilityFor = ({
|
||||
policy.androidAnalysisAvailability?.reason ??
|
||||
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
|
||||
};
|
||||
if (kind === "adb-provider")
|
||||
return ownerProbeAvailability(
|
||||
policy.adbAnalysisAvailability,
|
||||
"Set REA_ADB_PATH to a caller-supplied adb executable or make adb available on PATH. This checks the client version only; device and serial availability depend on the request.",
|
||||
);
|
||||
if (kind === "apktool-provider")
|
||||
return ownerProbeAvailability(
|
||||
policy.apktoolAnalysisAvailability,
|
||||
"Set REA_APKTOOL_COMMAND to a caller-supplied Apktool executable or make Apktool 2.7.0 or newer available on PATH.",
|
||||
);
|
||||
if (kind === "jeb-provider")
|
||||
return policy.jebAnalysisAvailability?.status === "available"
|
||||
? { reason: "available", remediation: null }
|
||||
@@ -506,6 +518,25 @@ const workflowAvailabilityFor = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
const ownerProbeAvailability = (
|
||||
observation: ProviderAvailability | undefined,
|
||||
missingRemediation: string,
|
||||
): Availability => {
|
||||
if (observation === undefined)
|
||||
return { reason: "provider_missing", remediation: missingRemediation };
|
||||
if (observation.status === "available")
|
||||
return { reason: "available", remediation: null };
|
||||
return {
|
||||
reason:
|
||||
observation.code === "unsupported_host"
|
||||
? "unsupported_host"
|
||||
: observation.code === "not_configured"
|
||||
? "provider_missing"
|
||||
: "provider_unavailable",
|
||||
remediation: observation.reason,
|
||||
};
|
||||
};
|
||||
|
||||
const electronProviderAvailability = (
|
||||
name: string,
|
||||
kind: ToolKind,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { basename, isAbsolute, resolve } from "node:path";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import { valid } from "semver";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
@@ -112,13 +112,56 @@ const isOwnedPackageReference = (packageReference: string): boolean => {
|
||||
};
|
||||
|
||||
/** Check that one parsed command points to REA's MCP entry point. */
|
||||
export type ClientRegistrationCommandIdentity = "owned" | "foreign" | "unknown";
|
||||
|
||||
/** Classify only what the command and current executable identity establish. */
|
||||
export const classifyClientRegistrationCommand = (
|
||||
command: readonly string[],
|
||||
currentCommandPath: string | undefined,
|
||||
): ClientRegistrationCommandIdentity => {
|
||||
const packageReference = clientRegistrationPackageReference(command);
|
||||
if (packageReference !== undefined)
|
||||
return isOwnedClientRegistrationCommand(command, currentCommandPath)
|
||||
? "owned"
|
||||
: "foreign";
|
||||
const currentPathAvailable =
|
||||
currentCommandPath !== undefined && currentCommandPath.length > 0;
|
||||
const nodeScript = command.length === 3 && command[2] === "mcp";
|
||||
if (nodeScript) {
|
||||
if (
|
||||
!isAbsolute(command[0] ?? "") ||
|
||||
!isAbsolute(command[1] ?? "") ||
|
||||
!currentPathAvailable
|
||||
)
|
||||
return "unknown";
|
||||
return isOwnedClientRegistrationCommand(command, currentCommandPath)
|
||||
? "owned"
|
||||
: "foreign";
|
||||
}
|
||||
if (command.length === 2 && command[1] === "mcp") {
|
||||
const executable = command[0] ?? "";
|
||||
if (executable === PRODUCT_IDENTITY.cliBinary) return "owned";
|
||||
const relativePathLike =
|
||||
!isAbsolute(executable) && /[./\\]/u.test(executable);
|
||||
if (relativePathLike || (isAbsolute(executable) && !currentPathAvailable))
|
||||
return "unknown";
|
||||
}
|
||||
if (isOwnedClientRegistrationCommand(command, currentCommandPath))
|
||||
return "owned";
|
||||
return "foreign";
|
||||
};
|
||||
|
||||
export const isOwnedClientRegistrationCommand = (
|
||||
command: readonly string[],
|
||||
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
|
||||
currentCommandPath: string | undefined,
|
||||
): boolean => {
|
||||
if (
|
||||
currentCommandPath !== undefined &&
|
||||
currentCommandPath.length > 0 &&
|
||||
command.length === 3 &&
|
||||
command[2] === "mcp" &&
|
||||
isAbsolute(command[0] ?? "") &&
|
||||
isAbsolute(command[1] ?? "") &&
|
||||
resolve(command[0] ?? "") === resolve(process.execPath) &&
|
||||
resolve(command[1] ?? "") === currentCommandPath
|
||||
)
|
||||
@@ -129,8 +172,9 @@ export const isOwnedClientRegistrationCommand = (
|
||||
if (
|
||||
executable === PRODUCT_IDENTITY.cliBinary ||
|
||||
(isAbsolute(executable) &&
|
||||
basename(executable) === PRODUCT_IDENTITY.cliBinary) ||
|
||||
resolve(executable) === currentCommandPath
|
||||
currentCommandPath !== undefined &&
|
||||
currentCommandPath.length > 0 &&
|
||||
resolve(executable) === currentCommandPath)
|
||||
)
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7,16 +7,17 @@ import {
|
||||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { access } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
import { valid } from "semver";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||
import {
|
||||
clientRegistrationPackageReference,
|
||||
isOwnedClientRegistrationCommand,
|
||||
classifyClientRegistrationCommand,
|
||||
packageRunnerRegistrationCommand,
|
||||
} from "./ClientRegistrationIdentity.js";
|
||||
import {
|
||||
@@ -50,6 +51,11 @@ export type ClientRegistrationStatus = ClientRegistrationStatusBase &
|
||||
readonly state: "stale";
|
||||
readonly remediation: string;
|
||||
}
|
||||
| {
|
||||
readonly command: RegistrationCommand;
|
||||
readonly state: "unknown";
|
||||
readonly remediation: string;
|
||||
}
|
||||
| {
|
||||
readonly command: readonly [];
|
||||
readonly state: "missing" | "invalid" | "manual";
|
||||
@@ -93,7 +99,7 @@ export interface ClientRegistrationInspection {
|
||||
/** Project diagnostics from the same inspection used to select maintenance. */
|
||||
export const readClientRegistrationStatuses = async (
|
||||
home: string,
|
||||
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
|
||||
currentCommandPath: string | undefined = CURRENT_ENTRYPOINT_PATH,
|
||||
options: ClientRegistrationReadOptions = {},
|
||||
): Promise<readonly ClientRegistrationStatus[]> =>
|
||||
(
|
||||
@@ -103,7 +109,7 @@ export const readClientRegistrationStatuses = async (
|
||||
/** Parse effective registrations and their client-specific activation once. */
|
||||
export const readClientRegistrationInspections = async (
|
||||
home: string,
|
||||
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
|
||||
currentCommandPath: string | undefined = CURRENT_ENTRYPOINT_PATH,
|
||||
options: ClientRegistrationReadOptions = {},
|
||||
): Promise<readonly ClientRegistrationInspection[]> => {
|
||||
const inspections: ClientRegistrationInspection[] = [];
|
||||
@@ -219,26 +225,32 @@ export const readClientRegistrationInspections = async (
|
||||
const packageVersionDrift =
|
||||
packageVersion !== undefined &&
|
||||
packageVersion !== PRODUCT_IDENTITY.packageVersion;
|
||||
const alignment = registrationAligned(
|
||||
registration,
|
||||
effectiveClient,
|
||||
currentCommandPath,
|
||||
options.platform ?? process.platform,
|
||||
);
|
||||
record(
|
||||
configuredStatus(
|
||||
effectiveClient,
|
||||
command,
|
||||
activation === "enabled" &&
|
||||
!packageVersionDrift &&
|
||||
registrationAligned(
|
||||
registration,
|
||||
effectiveClient,
|
||||
currentCommandPath,
|
||||
options.platform ?? process.platform,
|
||||
) &&
|
||||
(parsed.dialect !== "dsh" || dshDocument(parsed.document).ready) &&
|
||||
policyBlock === undefined
|
||||
? "aligned"
|
||||
: "stale",
|
||||
alignment === "unknown"
|
||||
? "unknown"
|
||||
: activation === "enabled" &&
|
||||
!packageVersionDrift &&
|
||||
alignment === "aligned" &&
|
||||
(parsed.dialect !== "dsh" ||
|
||||
dshDocument(parsed.document).ready) &&
|
||||
policyBlock === undefined
|
||||
? "aligned"
|
||||
: "stale",
|
||||
policyBlock ??
|
||||
(packageVersionDrift
|
||||
? packageVersionRemediation(packageReference ?? "", client)
|
||||
: undefined),
|
||||
: alignment === "unknown"
|
||||
? unknownEntryPointRemediation()
|
||||
: undefined),
|
||||
packageReference,
|
||||
),
|
||||
activation,
|
||||
@@ -275,25 +287,29 @@ const registrationActivation = (
|
||||
const registrationAligned = (
|
||||
registration: z.output<typeof registrationSchema>,
|
||||
client: SetupClient,
|
||||
currentCommandPath: string,
|
||||
currentCommandPath: string | undefined,
|
||||
platform: NodeJS.Platform,
|
||||
): boolean => {
|
||||
): "aligned" | "stale" | "unknown" => {
|
||||
const command = [registration.command, ...registration.args];
|
||||
if (!isOwnedClientRegistrationCommand(command, currentCommandPath))
|
||||
return false;
|
||||
const identity = classifyClientRegistrationCommand(
|
||||
command,
|
||||
currentCommandPath,
|
||||
);
|
||||
if (identity === "unknown") return "unknown";
|
||||
if (identity === "foreign") return "stale";
|
||||
if (
|
||||
(client.name === "codex" || client.name === "grok_build") &&
|
||||
registration.startup_timeout_sec !==
|
||||
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds
|
||||
)
|
||||
return false;
|
||||
return "stale";
|
||||
if (
|
||||
client.name === "kimi" &&
|
||||
(registration.startupTimeoutMs !==
|
||||
MCP_STARTUP_POLICY.kimiStartupTimeoutMs ||
|
||||
registration.toolTimeoutMs !== MCP_STARTUP_POLICY.kimiToolTimeoutMs)
|
||||
)
|
||||
return false;
|
||||
return "stale";
|
||||
if (
|
||||
(client.format === "kimi" ||
|
||||
client.format === "omp" ||
|
||||
@@ -301,45 +317,55 @@ const registrationAligned = (
|
||||
registration.type !== undefined &&
|
||||
registration.type !== "stdio"
|
||||
)
|
||||
return false;
|
||||
if (client.format === "vscode" && registration.type !== "stdio") return false;
|
||||
return "stale";
|
||||
if (client.format === "vscode" && registration.type !== "stdio")
|
||||
return "stale";
|
||||
// A Harness registration only connects as the rea stdio server; another
|
||||
// server name or transport leaves the session without REA tools.
|
||||
if (
|
||||
client.format === "dsh" &&
|
||||
(registration.serverName !== "rea" || registration.transport !== "stdio")
|
||||
)
|
||||
return false;
|
||||
return "stale";
|
||||
if (
|
||||
client.format === "copilot_cli" &&
|
||||
(registration.type !== "stdio" ||
|
||||
JSON.stringify(registration.tools) !== JSON.stringify(["*"]))
|
||||
)
|
||||
return false;
|
||||
return "stale";
|
||||
if (client.format === "commandcode" && registration.transport !== "stdio")
|
||||
return false;
|
||||
if (
|
||||
command.length === 3 &&
|
||||
command[2] === "mcp" &&
|
||||
resolve(command[0] ?? "") === resolve(process.execPath) &&
|
||||
resolve(command[1] ?? "") === currentCommandPath
|
||||
)
|
||||
return true;
|
||||
return "stale";
|
||||
if (
|
||||
(["npx", "bunx"] as const).some((runner) => {
|
||||
const runnerCommand = packageRunnerRegistrationCommand(runner, platform);
|
||||
const expected = packageRunnerRegistrationCommand(runner, platform);
|
||||
return (
|
||||
command.length === runnerCommand.length &&
|
||||
command.every((argument, index) => argument === runnerCommand[index])
|
||||
command.length === expected.length &&
|
||||
command.every((argument, index) => argument === expected[index])
|
||||
);
|
||||
})
|
||||
)
|
||||
return true;
|
||||
return (
|
||||
return "aligned";
|
||||
if (
|
||||
currentCommandPath !== undefined &&
|
||||
currentCommandPath.length > 0 &&
|
||||
command.length === 3 &&
|
||||
command[2] === "mcp" &&
|
||||
isAbsolute(command[0] ?? "") &&
|
||||
isAbsolute(command[1] ?? "") &&
|
||||
resolve(command[0] ?? "") === resolve(process.execPath) &&
|
||||
resolve(command[1] ?? "") === currentCommandPath
|
||||
)
|
||||
return "aligned";
|
||||
if (
|
||||
currentCommandPath !== undefined &&
|
||||
currentCommandPath.length > 0 &&
|
||||
command.length === 2 &&
|
||||
command[1] === "mcp" &&
|
||||
isAbsolute(command[0] ?? "") &&
|
||||
resolve(command[0] ?? "") === currentCommandPath
|
||||
);
|
||||
)
|
||||
return "aligned";
|
||||
return "stale";
|
||||
};
|
||||
|
||||
const parseRegistration = (
|
||||
@@ -400,10 +426,13 @@ const remediation = (client: SetupClient): string => {
|
||||
return `Quit ${name} fully, run rea setup to refresh this registration, then start ${name} again. A running ${name} can rewrite its configuration from memory and drop the registration.`;
|
||||
};
|
||||
|
||||
const unknownEntryPointRemediation = (): string =>
|
||||
"The existing registration was retained because its executable identity cannot be verified. Run the installed REA CLI or a supported package runner, then rerun rea doctor; no registration was changed.";
|
||||
|
||||
const configuredStatus = (
|
||||
client: SetupClient,
|
||||
command: RegistrationCommand,
|
||||
state: "aligned" | "stale",
|
||||
state: "aligned" | "stale" | "unknown",
|
||||
policyRemediation?: string,
|
||||
packageReference?: string,
|
||||
): ClientRegistrationStatus =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js
|
||||
import { providerCapabilitySet } from "../contracts/toolOutputSchemaPrimitives.js";
|
||||
import { providerCapabilitySets } from "./binary/BinarySessionStatus.js";
|
||||
import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
|
||||
/** Read target-free provider status and operation availability. */
|
||||
export const runSessionStatus = async (
|
||||
@@ -31,6 +32,7 @@ export const runSessionStatus = async (
|
||||
],
|
||||
server_identity: createServerIdentity({
|
||||
startedAt: new Date().toISOString(),
|
||||
commandPath: CURRENT_ENTRYPOINT_PATH,
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -330,7 +330,7 @@ describe("doctor scoped readiness", () => {
|
||||
client: "cursor",
|
||||
config_path: "/home/user/.cursor/mcp.json",
|
||||
command: ["/old/rea", "mcp"],
|
||||
state: "stale",
|
||||
state: "unknown",
|
||||
remediation: "Run rea setup to refresh this registration.",
|
||||
},
|
||||
];
|
||||
@@ -349,7 +349,11 @@ describe("doctor scoped readiness", () => {
|
||||
expect.objectContaining({ name: "registration:codex", ok: true }),
|
||||
);
|
||||
expect(scoped.informational_checks).toContainEqual(
|
||||
expect.objectContaining({ name: "registration:cursor", ok: false }),
|
||||
expect.objectContaining({
|
||||
name: "registration:cursor",
|
||||
ok: false,
|
||||
classification: "unknown",
|
||||
}),
|
||||
);
|
||||
|
||||
const staleSelected = await runDoctor(
|
||||
@@ -591,6 +595,28 @@ describe("doctor Linux and Hopper discovery", () => {
|
||||
});
|
||||
|
||||
describe("doctor installed Hopper discovery", () => {
|
||||
it("checks the canonical user install after the system launcher", async () => {
|
||||
const userPath =
|
||||
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
const checked: string[] = [];
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
homeDirectory: "/fixture/home",
|
||||
executable: (candidate) => {
|
||||
checked.push(candidate);
|
||||
return Promise.resolve(candidate === userPath);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.hopperPath).toBe(userPath);
|
||||
expect(checked.slice(0, 2)).toEqual([
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||
userPath,
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects a Homebrew cask installed outside /Applications", async () => {
|
||||
const path =
|
||||
"/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
|
||||
+68
-26
@@ -14,6 +14,10 @@ import {
|
||||
type JavaScriptRuntime,
|
||||
} from "../domain/runtimeVersion.js";
|
||||
import { installedCaskAppdir, probeHomebrew } from "./homebrew.js";
|
||||
import {
|
||||
macHopperApplicationRoots,
|
||||
macHopperRenamedLauncherCandidates,
|
||||
} from "./MacHopperPaths.js";
|
||||
import {
|
||||
linuxHopperBinarySupported,
|
||||
linuxSharedLibrariesAvailable,
|
||||
@@ -23,6 +27,7 @@ import {
|
||||
import {
|
||||
readInstalledSkillIdentity,
|
||||
type InstalledSkillIdentity,
|
||||
type SkillPathIssue,
|
||||
} from "./SetupSkill.js";
|
||||
import { CATALOG_IDENTITY } from "../catalogIdentity.js";
|
||||
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
|
||||
@@ -52,6 +57,7 @@ export interface DoctorCheck {
|
||||
| "unsupported_host"
|
||||
| "unsupported_target"
|
||||
| "missing_analysis_engine"
|
||||
| "unknown"
|
||||
| "config_drift";
|
||||
readonly detail?: string;
|
||||
readonly details?: Readonly<Record<string, JsonValue>>;
|
||||
@@ -132,6 +138,13 @@ export interface DoctorScope {
|
||||
}
|
||||
|
||||
/** Installed-package, skill, client, and runtime identity observations. */
|
||||
export type DoctorSkillState =
|
||||
| "aligned"
|
||||
| "stale"
|
||||
| "missing"
|
||||
| "unmanaged"
|
||||
| "unknown";
|
||||
|
||||
interface DoctorIdentity {
|
||||
readonly cli_package_version: string;
|
||||
readonly expected_skill_version: string;
|
||||
@@ -148,7 +161,8 @@ interface DoctorIdentity {
|
||||
readonly skill: {
|
||||
readonly installed_version: string | null;
|
||||
readonly installed_tool_count: number | null;
|
||||
readonly state: "aligned" | "stale" | "missing";
|
||||
readonly state: DoctorSkillState;
|
||||
readonly issues?: readonly SkillPathIssue[];
|
||||
readonly remediation: string | null;
|
||||
};
|
||||
readonly registrations: readonly ClientRegistrationStatus[];
|
||||
@@ -311,15 +325,13 @@ const collectDoctorIdentity = async (
|
||||
skill: {
|
||||
installed_version: installedSkillIdentity?.version ?? null,
|
||||
installed_tool_count: installedSkillIdentity?.toolCount ?? null,
|
||||
state:
|
||||
installedSkillIdentity === undefined
|
||||
? "missing"
|
||||
: skillAligned
|
||||
? "aligned"
|
||||
: "stale",
|
||||
state: skillIdentityState(installedSkillIdentity),
|
||||
...(installedSkillIdentity?.issues === undefined
|
||||
? {}
|
||||
: { issues: installedSkillIdentity.issues }),
|
||||
remediation: skillAligned
|
||||
? null
|
||||
: "Run rea setup to update the installed REA skill.",
|
||||
: skillIdentityRemediation(installedSkillIdentity),
|
||||
},
|
||||
registrations,
|
||||
},
|
||||
@@ -333,25 +345,52 @@ const skillIdentityAligned = (
|
||||
identity.toolCount === CATALOG_IDENTITY.counts.mcp_tools &&
|
||||
identity.canonical;
|
||||
|
||||
const skillIdentityState = (
|
||||
identity: InstalledSkillIdentity | undefined,
|
||||
): DoctorSkillState => {
|
||||
if (identity?.issues?.some(({ kind }) => kind === "unreadable"))
|
||||
return "unknown";
|
||||
if ((identity?.issues?.length ?? 0) > 0) return "unmanaged";
|
||||
return identity === undefined
|
||||
? "missing"
|
||||
: skillIdentityAligned(identity)
|
||||
? "aligned"
|
||||
: "stale";
|
||||
};
|
||||
|
||||
const skillIdentityRemediation = (
|
||||
identity: InstalledSkillIdentity | undefined,
|
||||
): string => {
|
||||
const issues = identity?.issues;
|
||||
if (issues !== undefined && issues.length > 0)
|
||||
return `${issues.map(({ path, detail }) => `${detail} (${path})`).join(" ")} Verify the reported paths and use regular skill directories, or run rea setup with --skill=false to configure the client without installing the skill.`;
|
||||
return "Run rea setup to update the installed REA skill.";
|
||||
};
|
||||
|
||||
const skillIdentityCheck = (
|
||||
identity: InstalledSkillIdentity | undefined,
|
||||
): DoctorCheck => ({
|
||||
name: "skill:identity",
|
||||
ok: false,
|
||||
classification: "config_drift",
|
||||
detail:
|
||||
identity === undefined
|
||||
? "Installed REA skill identity is missing."
|
||||
: "Installed REA skill identity is stale.",
|
||||
remediation: "Run rea setup to update the installed REA skill.",
|
||||
});
|
||||
): DoctorCheck => {
|
||||
const issues = identity?.issues;
|
||||
return {
|
||||
name: "skill:identity",
|
||||
ok: false,
|
||||
classification: "config_drift",
|
||||
detail:
|
||||
issues !== undefined && issues.length > 0
|
||||
? `Selected skill identity is ${skillIdentityState(identity)}: ${issues.map(({ path }) => path).join(", ")}.`
|
||||
: identity === undefined
|
||||
? "Installed REA skill identity is missing."
|
||||
: "Installed REA skill identity is stale.",
|
||||
remediation: skillIdentityRemediation(identity),
|
||||
};
|
||||
};
|
||||
|
||||
const registrationCheck = (
|
||||
registration: UnhealthyClientRegistrationStatus,
|
||||
): DoctorCheck => ({
|
||||
name: `registration:${registration.client}`,
|
||||
ok: false,
|
||||
classification: "config_drift",
|
||||
classification: registration.state === "unknown" ? "unknown" : "config_drift",
|
||||
detail: registration.config_path,
|
||||
remediation: registration.remediation,
|
||||
});
|
||||
@@ -587,19 +626,22 @@ const uncomposedLinuxDemoRuntimeCheck = (): Promise<DoctorCheck> =>
|
||||
});
|
||||
|
||||
const manualHopperPaths = async (home: string): Promise<readonly string[]> => {
|
||||
const paths: string[] = [];
|
||||
for (const root of ["/Applications", join(home, "Applications")]) {
|
||||
const roots = macHopperApplicationRoots(home);
|
||||
const bundleNamesByRoot = new Map<string, readonly string[]>();
|
||||
for (const root of roots) {
|
||||
try {
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && /^Hopper.*\.app$/i.test(entry.name))
|
||||
paths.push(join(root, entry.name, "Contents/MacOS/hopper"));
|
||||
}
|
||||
bundleNamesByRoot.set(
|
||||
root,
|
||||
(await readdir(root, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map(({ name }) => name),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// A missing or unreadable optional application directory is not fatal.
|
||||
void cause;
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
return macHopperRenamedLauncherCandidates(home, bundleNamesByRoot);
|
||||
};
|
||||
|
||||
const uniqueLines = (value: string): string[] => [
|
||||
|
||||
@@ -7,15 +7,16 @@ import {
|
||||
systemLinuxHopperLauncherCandidates,
|
||||
supportsReaHost,
|
||||
} from "./LinuxHopper.js";
|
||||
import {
|
||||
MAC_HOPPER_SYSTEM_LAUNCHER,
|
||||
macHopperCanonicalLauncherCandidates,
|
||||
} from "./MacHopperPaths.js";
|
||||
import type {
|
||||
DoctorCheck,
|
||||
DoctorHost,
|
||||
DoctorProviderInspection,
|
||||
} from "./Doctor.js";
|
||||
|
||||
const DEFAULT_HOPPER =
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
|
||||
/** Ordered environment diagnostics consumed by the Doctor report assembler. */
|
||||
export interface DoctorDiagnostics {
|
||||
readonly checks: readonly DoctorCheck[];
|
||||
@@ -150,10 +151,17 @@ const findHopper = async (host: DoctorHost): Promise<string | undefined> => {
|
||||
const candidates =
|
||||
host.configuredHopperPath === undefined
|
||||
? [
|
||||
DEFAULT_HOPPER,
|
||||
...(host.platform === "darwin"
|
||||
? macHopperCanonicalLauncherCandidates(host.homeDirectory)
|
||||
: [MAC_HOPPER_SYSTEM_LAUNCHER]),
|
||||
...(host.platform === "linux"
|
||||
? linuxHopperLauncherCandidates(host.homeDirectory)
|
||||
: [...systemLinuxHopperLauncherCandidates]),
|
||||
...(host.platform === "darwin"
|
||||
? []
|
||||
: macHopperCanonicalLauncherCandidates(host.homeDirectory).slice(
|
||||
1,
|
||||
)),
|
||||
...(await host.manualHopperPaths()),
|
||||
]
|
||||
: [host.configuredHopperPath];
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
DoctorCheck,
|
||||
DoctorProviderInspection,
|
||||
DoctorScope,
|
||||
DoctorSkillState,
|
||||
} from "./Doctor.js";
|
||||
|
||||
/** Normalized readiness boundary included in every doctor response. */
|
||||
@@ -29,7 +30,7 @@ interface DoctorProviderState {
|
||||
interface DoctorScopeInput {
|
||||
readonly checks: readonly DoctorCheck[];
|
||||
readonly registrations: readonly ClientRegistrationStatus[];
|
||||
readonly skillState: "aligned" | "stale" | "missing";
|
||||
readonly skillState: DoctorSkillState;
|
||||
readonly providers: DoctorProviderState;
|
||||
readonly scope: DoctorScopeReport;
|
||||
}
|
||||
@@ -173,7 +174,7 @@ const registrationCheck = (
|
||||
): DoctorCheck => ({
|
||||
name: `registration:${status.client}`,
|
||||
ok: false,
|
||||
classification: "config_drift",
|
||||
classification: status.state === "unknown" ? "unknown" : "config_drift",
|
||||
detail: status.config_path,
|
||||
remediation: status.remediation,
|
||||
});
|
||||
@@ -188,7 +189,14 @@ const selectedSkillCheck = (
|
||||
classification: state === "aligned" ? "healthy" : "config_drift",
|
||||
...(state === "aligned"
|
||||
? { detail: "Installed REA skill identity is aligned." }
|
||||
: { remediation: "Run rea setup to update the installed REA skill." }),
|
||||
: {
|
||||
remediation:
|
||||
state === "unmanaged"
|
||||
? "Use regular skill directories, or run rea setup with --skill=false to configure the client without installing the skill."
|
||||
: state === "unknown"
|
||||
? "Verify access to the reported skill files before retrying."
|
||||
: "Run rea setup to update the installed REA skill.",
|
||||
}),
|
||||
};
|
||||
|
||||
const doctorProviderCheckName = (providerId: string, name: string): boolean =>
|
||||
|
||||
@@ -47,6 +47,49 @@ import { resolveProcedureAddress } from "./ProcedureAddressResolution.js";
|
||||
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
|
||||
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
|
||||
|
||||
type BatchProcedureIdentity =
|
||||
| {
|
||||
readonly status: "resolved";
|
||||
readonly address: string;
|
||||
readonly name: string | null;
|
||||
readonly name_error?: ReturnType<typeof projectAnalysisError>;
|
||||
}
|
||||
| {
|
||||
readonly status: "unknown";
|
||||
readonly error: ReturnType<typeof projectAnalysisError>;
|
||||
};
|
||||
|
||||
type BatchResolution = {
|
||||
readonly address: string;
|
||||
readonly result: Result<string, AnalysisError>;
|
||||
};
|
||||
|
||||
type BatchReadyItem = {
|
||||
readonly address: string;
|
||||
readonly procedure: BatchProcedureIdentity;
|
||||
readonly providerProcedure: string;
|
||||
};
|
||||
|
||||
type BatchDecompileItem = {
|
||||
readonly address: string;
|
||||
readonly procedure: BatchProcedureIdentity;
|
||||
readonly status: "ok" | "error";
|
||||
readonly pseudocode?: string;
|
||||
readonly error?: ReturnType<typeof projectAnalysisError>;
|
||||
};
|
||||
|
||||
const isAnalysisCancellation = (
|
||||
error: AnalysisError,
|
||||
): error is AnalysisCancelledError => error._tag === "AnalysisCancelledError";
|
||||
|
||||
const firstAnalysisCancellation = <Value>(
|
||||
results: readonly Result<Value, AnalysisError>[],
|
||||
): AnalysisCancelledError | undefined => {
|
||||
for (const result of results)
|
||||
if (!result.ok && isAnalysisCancellation(result.error)) return result.error;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Composes direct provider operations into bounded reverse-engineering tools.
|
||||
* Direct callers use `execute`; adapters that own validation use
|
||||
@@ -259,52 +302,34 @@ export class EnhancedTools {
|
||||
addresses: readonly string[],
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
const items = await Promise.all(
|
||||
const resolutions = await Promise.all(
|
||||
addresses.map(async (address) => {
|
||||
const resolved = await resolveProcedureAddress(
|
||||
const result = await resolveProcedureAddress(
|
||||
this.#call.bind(this),
|
||||
address,
|
||||
signal,
|
||||
);
|
||||
const procedure = resolved.ok
|
||||
? await this.#batchProcedureIdentity(resolved.value, signal)
|
||||
: {
|
||||
status: "unknown" as const,
|
||||
error: projectAnalysisError(resolved.error),
|
||||
};
|
||||
const result = await this.#call(
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: resolved.ok ? resolved.value : address },
|
||||
signal,
|
||||
resolved.ok ? "provider" : undefined,
|
||||
);
|
||||
if (!result.ok)
|
||||
return {
|
||||
address,
|
||||
procedure,
|
||||
status: "error" as const,
|
||||
error: projectAnalysisError(result.error),
|
||||
};
|
||||
if (typeof result.value !== "string" || result.value.length === 0)
|
||||
return {
|
||||
address,
|
||||
procedure,
|
||||
status: "error" as const,
|
||||
error: projectAnalysisError(
|
||||
new AnalysisOutputError(
|
||||
"procedure_pseudo_code",
|
||||
"provider returned empty pseudocode",
|
||||
),
|
||||
),
|
||||
};
|
||||
return {
|
||||
address,
|
||||
procedure,
|
||||
status: "ok" as const,
|
||||
pseudocode: result.value,
|
||||
};
|
||||
return { address, result };
|
||||
}),
|
||||
);
|
||||
const resolutionCancellation = firstAnalysisCancellation(
|
||||
resolutions.map(({ result }) => result),
|
||||
);
|
||||
if (resolutionCancellation !== undefined)
|
||||
return err(resolutionCancellation);
|
||||
|
||||
const identities = await this.#batchIdentities(resolutions, signal);
|
||||
const ready: BatchReadyItem[] = [];
|
||||
for (const identity of identities) {
|
||||
if (!identity.ok) return err(identity.error);
|
||||
ready.push(identity.value);
|
||||
}
|
||||
const decompilations = await this.#batchPseudocode(ready, signal);
|
||||
const items: BatchDecompileItem[] = [];
|
||||
for (const decompilation of decompilations) {
|
||||
if (!decompilation.ok) return err(decompilation.error);
|
||||
items.push(decompilation.value);
|
||||
}
|
||||
const succeeded = items.filter(({ status }) => status === "ok").length;
|
||||
return ok({
|
||||
items,
|
||||
@@ -314,12 +339,91 @@ export class EnhancedTools {
|
||||
});
|
||||
}
|
||||
|
||||
async #batchProcedureIdentity(address: string, signal?: AbortSignal) {
|
||||
async #batchIdentities(
|
||||
resolutions: readonly BatchResolution[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<BatchReadyItem, AnalysisCancelledError>[]> {
|
||||
return Promise.all(
|
||||
resolutions.map(async ({ address, result }) => {
|
||||
if (!result.ok)
|
||||
return ok({
|
||||
address,
|
||||
providerProcedure: address,
|
||||
procedure: {
|
||||
status: "unknown" as const,
|
||||
error: projectAnalysisError(result.error),
|
||||
},
|
||||
});
|
||||
const identity = await this.#batchProcedureIdentity(
|
||||
result.value,
|
||||
signal,
|
||||
);
|
||||
return identity.ok
|
||||
? ok({
|
||||
address,
|
||||
providerProcedure: result.value,
|
||||
procedure: identity.value,
|
||||
})
|
||||
: err(identity.error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async #batchPseudocode(
|
||||
ready: readonly BatchReadyItem[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<BatchDecompileItem, AnalysisCancelledError>[]> {
|
||||
return Promise.all(
|
||||
ready.map(async ({ address, procedure, providerProcedure }) => {
|
||||
const result = await this.#call(
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: providerProcedure },
|
||||
signal,
|
||||
procedure.status === "resolved" ? "provider" : undefined,
|
||||
);
|
||||
if (!result.ok && isAnalysisCancellation(result.error))
|
||||
return err(result.error);
|
||||
const item: BatchDecompileItem = !result.ok
|
||||
? {
|
||||
address,
|
||||
procedure,
|
||||
status: "error",
|
||||
error: projectAnalysisError(result.error),
|
||||
}
|
||||
: typeof result.value !== "string" || result.value.length === 0
|
||||
? {
|
||||
address,
|
||||
procedure,
|
||||
status: "error",
|
||||
error: projectAnalysisError(
|
||||
new AnalysisOutputError(
|
||||
"procedure_pseudo_code",
|
||||
"provider returned empty pseudocode",
|
||||
),
|
||||
),
|
||||
}
|
||||
: {
|
||||
address,
|
||||
procedure,
|
||||
status: "ok",
|
||||
pseudocode: result.value,
|
||||
};
|
||||
return ok(item);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async #batchProcedureIdentity(
|
||||
address: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<BatchProcedureIdentity, AnalysisCancelledError>> {
|
||||
const observed = await this.#call("address_name", { address }, signal);
|
||||
if (!observed.ok && isAnalysisCancellation(observed.error))
|
||||
return err(observed.error);
|
||||
const named =
|
||||
observed.ok &&
|
||||
(typeof observed.value === "string" || observed.value === null);
|
||||
return {
|
||||
return ok({
|
||||
status: "resolved" as const,
|
||||
address,
|
||||
name: named ? observed.value : null,
|
||||
@@ -335,7 +439,7 @@ export class EnhancedTools {
|
||||
: observed.error,
|
||||
),
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async #callGraph(
|
||||
@@ -385,6 +489,7 @@ export class EnhancedTools {
|
||||
"provider",
|
||||
);
|
||||
if (!result.ok) {
|
||||
if (isAnalysisCancellation(result.error)) return result;
|
||||
graph[level].push({
|
||||
address: current.address,
|
||||
status: "error",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { z } from "zod";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
|
||||
import { macDeveloperIdRequirement } from "../process/MacCodeSignature.js";
|
||||
import { macHopperUserApplicationPath } from "./MacHopperPaths.js";
|
||||
import {
|
||||
downloadHopperPackage,
|
||||
HOPPER_DOWNLOAD_BYTES,
|
||||
@@ -320,8 +321,7 @@ export const systemMacHopperInstallHost = (
|
||||
}
|
||||
return failure;
|
||||
},
|
||||
destination: () =>
|
||||
join(homeDirectory, "Applications/Hopper Disassembler.app"),
|
||||
destination: () => macHopperUserApplicationPath(homeDirectory),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
macHopperCanonicalLauncherCandidates,
|
||||
macHopperLauncherCandidates,
|
||||
macHopperRenamedLauncherCandidates,
|
||||
} from "./MacHopperPaths.js";
|
||||
|
||||
describe("macOS Hopper launcher paths", () => {
|
||||
const home = "/fixture/home";
|
||||
|
||||
it("keeps canonical system and user candidates ahead of renamed bundles", () => {
|
||||
expect(macHopperCanonicalLauncherCandidates(home)).toEqual([
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||
]);
|
||||
expect(
|
||||
macHopperRenamedLauncherCandidates(
|
||||
home,
|
||||
new Map([
|
||||
[
|
||||
"/Applications",
|
||||
["Hopper z.app", "Hopper a.app", "Hopper Disassembler.app"],
|
||||
],
|
||||
["/fixture/home/Applications", ["Hopper User.app"]],
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
"/Applications/Hopper a.app/Contents/MacOS/hopper",
|
||||
"/Applications/Hopper z.app/Contents/MacOS/hopper",
|
||||
"/fixture/home/Applications/Hopper User.app/Contents/MacOS/hopper",
|
||||
]);
|
||||
});
|
||||
|
||||
it("always includes the installer's canonical user destination", () => {
|
||||
expect(macHopperLauncherCandidates(home)).toContain(
|
||||
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||
);
|
||||
});
|
||||
|
||||
it("finds renamed user bundles through the runtime filesystem scan", async () => {
|
||||
const temporary = await mkdtemp(join(tmpdir(), "rea-hopper-paths-"));
|
||||
const appDirectory = join(temporary, "Applications", "Hopper Preview.app");
|
||||
try {
|
||||
await mkdir(appDirectory, { recursive: true });
|
||||
expect([...macHopperLauncherCandidates(temporary)]).toContain(
|
||||
join(appDirectory, "Contents/MacOS/hopper"),
|
||||
);
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const MAC_HOPPER_APPLICATION_DIRECTORY = "/Applications";
|
||||
export const MAC_HOPPER_APPLICATION_NAME = "Hopper Disassembler.app";
|
||||
export const MAC_HOPPER_USER_APPLICATIONS_DIRECTORY = "Applications";
|
||||
|
||||
export const MAC_HOPPER_SYSTEM_LAUNCHER = join(
|
||||
MAC_HOPPER_APPLICATION_DIRECTORY,
|
||||
MAC_HOPPER_APPLICATION_NAME,
|
||||
"Contents/MacOS/hopper",
|
||||
);
|
||||
|
||||
export const macHopperUserApplicationPath = (homeDirectory: string): string =>
|
||||
join(
|
||||
homeDirectory,
|
||||
MAC_HOPPER_USER_APPLICATIONS_DIRECTORY,
|
||||
MAC_HOPPER_APPLICATION_NAME,
|
||||
);
|
||||
|
||||
export const macHopperApplicationRoots = (
|
||||
homeDirectory: string,
|
||||
): readonly string[] => [
|
||||
MAC_HOPPER_APPLICATION_DIRECTORY,
|
||||
join(homeDirectory, MAC_HOPPER_USER_APPLICATIONS_DIRECTORY),
|
||||
];
|
||||
|
||||
export const macHopperCanonicalLauncherCandidates = (
|
||||
homeDirectory: string,
|
||||
): readonly string[] => [
|
||||
MAC_HOPPER_SYSTEM_LAUNCHER,
|
||||
join(macHopperUserApplicationPath(homeDirectory), "Contents/MacOS/hopper"),
|
||||
];
|
||||
|
||||
/** Build deterministic candidates from the discovered app bundle names. */
|
||||
export const macHopperRenamedLauncherCandidates = (
|
||||
homeDirectory: string,
|
||||
bundleNamesByRoot: ReadonlyMap<string, readonly string[]>,
|
||||
): readonly string[] =>
|
||||
macHopperApplicationRoots(homeDirectory).flatMap((root) =>
|
||||
[...new Set(bundleNamesByRoot.get(root) ?? [])]
|
||||
.filter(
|
||||
(name) =>
|
||||
/^Hopper.*\.app$/iu.test(name) &&
|
||||
name.toLocaleLowerCase("en-US") !==
|
||||
MAC_HOPPER_APPLICATION_NAME.toLocaleLowerCase("en-US"),
|
||||
)
|
||||
.sort()
|
||||
.map((name) => join(root, name, "Contents/MacOS/hopper")),
|
||||
);
|
||||
|
||||
const localBundleNames = (root: string): readonly string[] => {
|
||||
try {
|
||||
return readdirSync(root, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name),
|
||||
)
|
||||
.map(({ name }) => name);
|
||||
} catch (cause: unknown) {
|
||||
// Missing or unreadable optional app directories contain no candidates.
|
||||
void cause;
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Yield canonical paths first; enumerate renamed bundles only if needed. */
|
||||
export function* macHopperLauncherCandidates(
|
||||
homeDirectory: string,
|
||||
): Generator<string> {
|
||||
yield* macHopperCanonicalLauncherCandidates(homeDirectory);
|
||||
for (const root of macHopperApplicationRoots(homeDirectory))
|
||||
yield* macHopperRenamedLauncherCandidates(
|
||||
homeDirectory,
|
||||
new Map([[root, localBundleNames(root)]]),
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,64 @@ import { parseReferenceSourceEntries } from "./ReferenceSourceImportEntries.js";
|
||||
import { historicalSourceLanguages } from "../domain/referenceSourceGraph.js";
|
||||
import type { ReferenceSourceRead } from "../reference/ReferenceSourceReaderTypes.js";
|
||||
|
||||
it("projects an unsupported non-regular entry as unknown kind instead of file", () => {
|
||||
const read: ReferenceSourceRead = {
|
||||
root: "/selected/source",
|
||||
entries: [
|
||||
{
|
||||
status: "failed",
|
||||
kind: "other",
|
||||
path: "src/pipe",
|
||||
code: "unsupported",
|
||||
message: "Entry is not a regular file",
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
bytesRead: 0,
|
||||
limitations: [],
|
||||
};
|
||||
const parsed = parseReferenceSourceEntries(read, new Set());
|
||||
expect(parsed.entries).toContainEqual({
|
||||
path: "src/pipe",
|
||||
kind: "unknown",
|
||||
classifications: ["unknown"],
|
||||
limitations: [
|
||||
"Entry is not a regular file Exclude this entry or replace it with a regular file.",
|
||||
],
|
||||
});
|
||||
expect(parsed.entries.some((entry) => entry.kind === "file")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a failed regular file as file kind", () => {
|
||||
const read: ReferenceSourceRead = {
|
||||
root: "/selected/source",
|
||||
entries: [
|
||||
{
|
||||
status: "failed",
|
||||
kind: "file",
|
||||
path: "src/broken.ts",
|
||||
code: "io",
|
||||
message: "Entry could not be read",
|
||||
},
|
||||
],
|
||||
bytesRead: 0,
|
||||
limitations: [],
|
||||
};
|
||||
const parsed = parseReferenceSourceEntries(read, new Set());
|
||||
expect(parsed.entries).toContainEqual({
|
||||
path: "src/broken.ts",
|
||||
kind: "file",
|
||||
sha256: null,
|
||||
size: null,
|
||||
language: null,
|
||||
classifications: ["source"],
|
||||
content_state: "unreadable",
|
||||
limitations: [
|
||||
"Entry could not be read This file could not be read. Check its permissions, then try again.",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains Kotlin source identity and language across the inventory projection", () => {
|
||||
const files = [
|
||||
[
|
||||
|
||||
@@ -75,7 +75,7 @@ const failedEntry = (
|
||||
classifications,
|
||||
limitations: [limitation],
|
||||
};
|
||||
if (entry.kind === "unknown")
|
||||
if (entry.kind === "unknown" || entry.kind === "other")
|
||||
return {
|
||||
path: entry.path,
|
||||
kind: "unknown",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { join, resolve } from "node:path";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
||||
import { runSetup } from "./Setup.js";
|
||||
import { setupRegistrationCommand } from "./SetupHost.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import { discoverSetupState } from "./SetupPlan.js";
|
||||
import type { MsixPackageView } from "./MsixPackageStorage.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
@@ -333,13 +334,13 @@ describe("setup workflow action selection", () => {
|
||||
|
||||
expect(result.status).toBe("ready");
|
||||
expect(host.configuredCommands).toEqual([
|
||||
setupRegistrationCommand("win32", undefined),
|
||||
]);
|
||||
expect(setupRegistrationCommand("win32", undefined)).toEqual([
|
||||
process.execPath,
|
||||
resolve(process.argv[1] ?? "rea"),
|
||||
"mcp",
|
||||
setupRegistrationCommand("win32", undefined, CURRENT_ENTRYPOINT_PATH),
|
||||
]);
|
||||
expect(
|
||||
setupRegistrationCommand("win32", undefined, CURRENT_ENTRYPOINT_PATH),
|
||||
).toEqual([process.execPath, CURRENT_ENTRYPOINT_PATH, "mcp"]);
|
||||
expect(setupRegistrationCommand("win32", undefined, undefined)).toEqual([]);
|
||||
expect(setupRegistrationCommand("win32", "npx", undefined)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type { SetupHopperInstallResult } from "./SetupInstallFailure.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { SetupSkillInstallError } from "./SetupSkill.js";
|
||||
import type { DoctorCheck, DoctorReport, DoctorScope } from "./Doctor.js";
|
||||
import type { LinuxDistribution } from "./LinuxHopper.js";
|
||||
@@ -16,6 +16,7 @@ import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
|
||||
import { CATALOG_IDENTITY } from "../catalogIdentity.js";
|
||||
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
|
||||
import { setupRegistrationCommand } from "./SetupHost.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
|
||||
/** Recording setup host for service-level planning and recovery tests. */
|
||||
export class FakeSetupHost implements SetupHost {
|
||||
@@ -24,7 +25,7 @@ export class FakeSetupHost implements SetupHost {
|
||||
skillDestinations(clientIds: readonly string[]) {
|
||||
return skillDestinations(this.homeDirectory, clientIds);
|
||||
}
|
||||
readonly registrationCommand: readonly string[];
|
||||
registrationCommand: readonly string[];
|
||||
registrationCommandBlocker?: string;
|
||||
nodeVersion = "24.18.0";
|
||||
version: string | undefined = "14.5";
|
||||
@@ -43,6 +44,7 @@ export class FakeSetupHost implements SetupHost {
|
||||
hopperReplaceRequests: boolean[] = [];
|
||||
configurations = 0;
|
||||
skillInstalls = 0;
|
||||
skillDirectoryValidations: Array<readonly string[]> = [];
|
||||
doctorCalls = 0;
|
||||
doctorScopes: Array<DoctorScope | undefined> = [];
|
||||
checkedHopperPaths: Array<string | undefined> = [];
|
||||
@@ -56,7 +58,11 @@ export class FakeSetupHost implements SetupHost {
|
||||
|
||||
constructor(platform: NodeJS.Platform = "darwin") {
|
||||
this.platform = platform;
|
||||
this.registrationCommand = setupRegistrationCommand(platform, undefined);
|
||||
this.registrationCommand = setupRegistrationCommand(
|
||||
platform,
|
||||
undefined,
|
||||
CURRENT_ENTRYPOINT_PATH,
|
||||
);
|
||||
}
|
||||
|
||||
macosVersion = (): Promise<string | undefined> =>
|
||||
@@ -129,6 +135,12 @@ export class FakeSetupHost implements SetupHost {
|
||||
);
|
||||
skillNeedsInstall = (): Promise<boolean> =>
|
||||
Promise.resolve(this.skill !== "unchanged");
|
||||
validateSkillDirectories = (
|
||||
clientIds: readonly string[],
|
||||
): Promise<Result<void, SetupSkillInstallError>> => {
|
||||
this.skillDirectoryValidations.push(clientIds);
|
||||
return Promise.resolve(ok(undefined));
|
||||
};
|
||||
installSkill = () => {
|
||||
this.skillInstalls += 1;
|
||||
return Promise.resolve(
|
||||
|
||||
@@ -3,6 +3,97 @@ import { describe, expect, it } from "vitest";
|
||||
import { runSetup } from "./Setup.js";
|
||||
import type { SetupProgressEvent } from "./SetupTypes.js";
|
||||
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
||||
import { SetupSkillInstallError } from "./SetupSkill.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
|
||||
const linkedSkillFailure = () =>
|
||||
new SetupSkillInstallError({
|
||||
path: "/fixture/home/.claude/skills/reverse-engineer-anything",
|
||||
stage: "prepare",
|
||||
reason: "Skill directory is a symbolic link",
|
||||
rollbackFailures: [],
|
||||
recoveryBackupPaths: [],
|
||||
cause: undefined,
|
||||
});
|
||||
|
||||
describe("selected skill admission", () => {
|
||||
it.each(["codex", "shared"] as const)(
|
||||
"keeps %s choices when an unselected client is linked",
|
||||
async (choice) => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [
|
||||
{ name: "claude_code", configPath: "/claude.json" },
|
||||
{ name: "codex", configPath: "/codex.toml", format: "toml" },
|
||||
];
|
||||
host.validateSkillDirectories = async (ids) => {
|
||||
host.skillDirectoryValidations.push(ids);
|
||||
return ids.includes("claude_code")
|
||||
? err(linkedSkillFailure())
|
||||
: ok(undefined);
|
||||
};
|
||||
const result = await runSetup(
|
||||
{ ...options(false), structured: false },
|
||||
host,
|
||||
async (actions, context) => {
|
||||
if (context?.stage === "select") {
|
||||
expect(actions.some(({ kind }) => kind === "install_skill")).toBe(
|
||||
true,
|
||||
);
|
||||
return {
|
||||
approved: false,
|
||||
selectedActionIds:
|
||||
choice === "codex"
|
||||
? ["configure_client:codex"]
|
||||
: ["install_skill"],
|
||||
};
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe("ready");
|
||||
expect(host.skillInstalls).toBe(1);
|
||||
expect(host.configurations).toBe(choice === "codex" ? 1 : 0);
|
||||
expect(host.skillDirectoryValidations).toEqual(
|
||||
choice === "codex" ? [["codex"], ["codex"]] : [[], []],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rechecks directory admission after approval before changing clients or Hopper", async () => {
|
||||
const host = new FakeSetupHost("linux");
|
||||
host.distribution = {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: true,
|
||||
};
|
||||
host.clients = [{ name: "claude_code", configPath: "/claude.json" }];
|
||||
let linked = false;
|
||||
host.validateSkillDirectories = async () =>
|
||||
linked ? err(linkedSkillFailure()) : ok(undefined);
|
||||
const result = await runSetup(
|
||||
{
|
||||
...options(false, true),
|
||||
clientIds: ["claude_code"],
|
||||
structured: false,
|
||||
},
|
||||
host,
|
||||
async () => {
|
||||
linked = true;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: "needs_human",
|
||||
code: "skill_install_failed",
|
||||
appliedActions: [],
|
||||
});
|
||||
expect(host.configurations).toBe(0);
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.skillInstalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill plan destinations", () => {
|
||||
it("plans a Claude-only skill in its personal location", async () => {
|
||||
@@ -45,6 +136,55 @@ describe("skill plan destinations", () => {
|
||||
});
|
||||
|
||||
describe("setup workflow", () => {
|
||||
it("blocks client registration when no executable entrypoint is available", async () => {
|
||||
const host = new FakeSetupHost("win32");
|
||||
host.registrationCommand = [];
|
||||
host.registrationCommandBlocker =
|
||||
"REA could not determine the current executable entrypoint.";
|
||||
host.clients = [{ name: "codex", configPath: "C:\\config\\codex.toml" }];
|
||||
|
||||
const result = await runSetup(
|
||||
{ ...options(true), clientIds: ["codex"], installSkill: false },
|
||||
host,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.plannedActions).toEqual([]);
|
||||
expect(result.remediation).toBe(host.registrationCommandBlocker);
|
||||
expect(host.configurations).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a relative Node registration out of setup defaults when cwd happens to match", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
||||
host.productRegistrations = [
|
||||
{
|
||||
client: "cursor",
|
||||
config_path: "/cursor.json",
|
||||
command: [process.execPath, "./rea.mjs", "mcp"],
|
||||
state: "stale",
|
||||
remediation: "Run rea setup to refresh this registration.",
|
||||
},
|
||||
];
|
||||
|
||||
const result = await runSetup(
|
||||
{ ...options(false), clientIds: [], installSkill: false },
|
||||
host,
|
||||
);
|
||||
|
||||
expect(result.clientStates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({ name: "cursor" }),
|
||||
status: "unknown",
|
||||
configured: false,
|
||||
}),
|
||||
);
|
||||
expect(result.plannedActions).toEqual([]);
|
||||
expect(host.configurations).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup plan application", () => {
|
||||
it("returns a complete plan without mutation", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
||||
|
||||
@@ -99,6 +99,12 @@ const runSetupWorkflow = async (
|
||||
[host, plannedActions, appliedActions, clients, clientStates],
|
||||
readinessScope,
|
||||
);
|
||||
const failSkill = async (
|
||||
error: SetupSkillInstallError,
|
||||
): Promise<SetupResult> => ({
|
||||
...(await fail(error.remediation)),
|
||||
code: "skill_install_failed",
|
||||
});
|
||||
const unsupported = await hostRemediation(host, false);
|
||||
if (unsupported !== undefined) return fail(unsupported);
|
||||
const initialState = await host.initialSetupState(options.readinessScope);
|
||||
@@ -235,6 +241,12 @@ const runSetupWorkflow = async (
|
||||
);
|
||||
|
||||
const manualRemediation = manualClientsRemediation(planned.selectedClients);
|
||||
if (skillSelected) {
|
||||
const admission = await host.validateSkillDirectories(
|
||||
selectedSkillClientIds,
|
||||
);
|
||||
if (!admission.ok) return failSkill(admission.error);
|
||||
}
|
||||
if (options.dryRun === true)
|
||||
return {
|
||||
status: "planned",
|
||||
@@ -285,6 +297,13 @@ const runSetupWorkflow = async (
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Approval may pause while a skill manager changes a selected directory.
|
||||
if (planSelection.skillSelected) {
|
||||
const admission = await host.validateSkillDirectories(
|
||||
selectedSkillClientIds,
|
||||
);
|
||||
if (!admission.ok) return failSkill(admission.error);
|
||||
}
|
||||
let selectedClients: readonly SetupClient[] =
|
||||
planSelection.clientsToConfigure;
|
||||
if (
|
||||
@@ -325,10 +344,7 @@ const runSetupWorkflow = async (
|
||||
selectedSkillClientIds,
|
||||
);
|
||||
if (skillFailure !== undefined) {
|
||||
return {
|
||||
...(await fail(skillFailure.remediation)),
|
||||
code: "skill_install_failed",
|
||||
};
|
||||
return failSkill(skillFailure);
|
||||
}
|
||||
}
|
||||
const doctor = summarizeDoctor(await host.doctor(readinessScope));
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
skillDestinations,
|
||||
canonicalSkillNeedsInstall,
|
||||
installCanonicalSkill,
|
||||
validateCanonicalSkillDirectories,
|
||||
} from "./SetupSkill.js";
|
||||
import {
|
||||
clientConfigurationAligned,
|
||||
@@ -55,21 +56,21 @@ import type {
|
||||
SetupProviderEnvironment,
|
||||
} from "./SetupTypes.js";
|
||||
import type { DoctorScope } from "./Doctor.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
|
||||
/** Resolve the executable and arguments used in a managed MCP registration. */
|
||||
export const setupRegistrationCommand = (
|
||||
platform: NodeJS.Platform,
|
||||
packageRunner: PackageRunner | undefined,
|
||||
): readonly string[] =>
|
||||
packageRunner !== undefined
|
||||
? packageRunnerRegistrationCommand(packageRunner, platform)
|
||||
: platform === "win32"
|
||||
? [
|
||||
process.execPath,
|
||||
resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary),
|
||||
"mcp",
|
||||
]
|
||||
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
|
||||
entrypointPath: string | undefined,
|
||||
): readonly string[] => {
|
||||
if (packageRunner !== undefined)
|
||||
return packageRunnerRegistrationCommand(packageRunner, platform);
|
||||
if (entrypointPath === undefined || entrypointPath.length === 0) return [];
|
||||
return platform === "win32"
|
||||
? [process.execPath, entrypointPath, "mcp"]
|
||||
: [entrypointPath, "mcp"];
|
||||
};
|
||||
|
||||
/**
|
||||
* The launcher setup registers, and why it must not be registered when it
|
||||
@@ -83,14 +84,17 @@ const setupRegistration = (
|
||||
const registrationCommand = setupRegistrationCommand(
|
||||
platform,
|
||||
invokingPackageRunner(environment, platform),
|
||||
CURRENT_ENTRYPOINT_PATH,
|
||||
);
|
||||
const registrationCommandBlocker =
|
||||
platform === "win32"
|
||||
? packagePrivateLauncherBlocker(
|
||||
registrationCommand,
|
||||
msixPackagesDirectory(homeDirectory, environment),
|
||||
)
|
||||
: undefined;
|
||||
registrationCommand.length === 0
|
||||
? "REA could not determine the current executable entrypoint. Run setup through the installed REA CLI or a supported package runner before configuring clients."
|
||||
: platform === "win32"
|
||||
? packagePrivateLauncherBlocker(
|
||||
registrationCommand,
|
||||
msixPackagesDirectory(homeDirectory, environment),
|
||||
)
|
||||
: undefined;
|
||||
return registrationCommandBlocker === undefined
|
||||
? { registrationCommand }
|
||||
: { registrationCommand, registrationCommandBlocker };
|
||||
@@ -224,6 +228,13 @@ export const systemSetupHost = (
|
||||
platform,
|
||||
resources,
|
||||
),
|
||||
validateSkillDirectories: (clientIds) =>
|
||||
validateCanonicalSkillDirectories(
|
||||
homeDirectory,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
),
|
||||
installSkill: (clientIds) =>
|
||||
installCanonicalSkill(
|
||||
homeDirectory,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import { classifyClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import {
|
||||
linuxHopperInstallDisclosure,
|
||||
type LinuxPackageFamily,
|
||||
} from "./LinuxHopper.js";
|
||||
import type { SkillDestination } from "./SetupSkill.js";
|
||||
import { macHopperInstallDisclosure } from "./MacHopper.js";
|
||||
import { macHopperUserApplicationPath } from "./MacHopperPaths.js";
|
||||
import type {
|
||||
ClientConfigurationInspection,
|
||||
SetupAction,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
import { redirectedWriteBlocker } from "./MsixPackageStorage.js";
|
||||
import type { DoctorReport } from "./Doctor.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
|
||||
/** Read-only setup facts shared by client selection and preflight. */
|
||||
export interface SetupDiscovery {
|
||||
@@ -78,20 +78,29 @@ export const discoverSetupState = async (input: {
|
||||
);
|
||||
const clientStates = clients.map((client): SetupClientState => {
|
||||
const registration = registrationByClient.get(client.name);
|
||||
const commandIdentity =
|
||||
registration === undefined
|
||||
? undefined
|
||||
: classifyClientRegistrationCommand(
|
||||
registration.command,
|
||||
CURRENT_ENTRYPOINT_PATH,
|
||||
);
|
||||
const ownedRegistration =
|
||||
(registration?.state === "aligned" || registration?.state === "stale") &&
|
||||
isOwnedClientRegistrationCommand(registration.command);
|
||||
commandIdentity === "owned";
|
||||
const configured = ownedRegistration;
|
||||
const status: SetupClientState["status"] =
|
||||
registration?.state === "invalid"
|
||||
? "invalid"
|
||||
: ownedRegistration && registration.state === "aligned"
|
||||
? "configured"
|
||||
: ownedRegistration && registration.state === "stale"
|
||||
? "needs_configuration"
|
||||
: detectedIds.has(client.name)
|
||||
: registration?.state === "unknown" || commandIdentity === "unknown"
|
||||
? "unknown"
|
||||
: ownedRegistration && registration.state === "aligned"
|
||||
? "configured"
|
||||
: ownedRegistration && registration.state === "stale"
|
||||
? "needs_configuration"
|
||||
: "missing";
|
||||
: detectedIds.has(client.name)
|
||||
? "needs_configuration"
|
||||
: "missing";
|
||||
return {
|
||||
client,
|
||||
detected: detectedIds.has(client.name),
|
||||
@@ -103,7 +112,10 @@ export const discoverSetupState = async (input: {
|
||||
.filter(
|
||||
(registration) =>
|
||||
(registration.state === "aligned" || registration.state === "stale") &&
|
||||
isOwnedClientRegistrationCommand(registration.command),
|
||||
classifyClientRegistrationCommand(
|
||||
registration.command,
|
||||
CURRENT_ENTRYPOINT_PATH,
|
||||
) === "owned",
|
||||
)
|
||||
.map(({ client }) => client);
|
||||
return {
|
||||
@@ -150,6 +162,16 @@ export const planSetupActions = async (input: {
|
||||
const client = byId.get(id);
|
||||
return client === undefined ? [] : [client];
|
||||
});
|
||||
if (
|
||||
selectedClients.length > 0 &&
|
||||
input.host.registrationCommandBlocker !== undefined
|
||||
)
|
||||
return {
|
||||
plannedActions: [],
|
||||
selectedClients,
|
||||
clientsToConfigure: [],
|
||||
blocker: input.host.registrationCommandBlocker,
|
||||
};
|
||||
const inspections = await Promise.all(
|
||||
selectedClients.map(async (client) => ({
|
||||
client,
|
||||
@@ -264,10 +286,7 @@ const setupPlan = (input: {
|
||||
label: "Hopper deep-analysis provider",
|
||||
target:
|
||||
input.platform === "darwin"
|
||||
? join(
|
||||
input.homeDirectory,
|
||||
"Applications/Hopper Disassembler.app",
|
||||
)
|
||||
? macHopperUserApplicationPath(input.homeDirectory)
|
||||
: "system package manager",
|
||||
detail:
|
||||
input.platform === "linux"
|
||||
|
||||
+193
-61
@@ -178,6 +178,14 @@ export interface InstalledSkillIdentity {
|
||||
readonly version: string | null;
|
||||
readonly toolCount: number | null;
|
||||
readonly canonical: boolean;
|
||||
readonly issues?: readonly SkillPathIssue[];
|
||||
}
|
||||
|
||||
/** Known management blockers and unresolved filesystem observations. */
|
||||
export interface SkillPathIssue {
|
||||
readonly path: string;
|
||||
readonly kind: "symbolic_link" | "not_directory" | "unreadable";
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
/** Resolve the skill roots required by a client selection, or all owned roots. */
|
||||
@@ -209,8 +217,10 @@ export const existingSkillDestinations = async (
|
||||
platform,
|
||||
)) {
|
||||
try {
|
||||
const root = await lstat(destination.path);
|
||||
if (root.isSymbolicLink() || !root.isDirectory()) continue;
|
||||
const issues = await skillDirectoryIssues([destination]);
|
||||
if (issues.some(({ kind }) => kind === "unreadable"))
|
||||
throw skillDirectoryFailure(issues);
|
||||
if (issues.length > 0) continue;
|
||||
const manifest = join(destination.path, "SKILL.md");
|
||||
const content = await readSkillFile(manifest, resources);
|
||||
if (isManagedSkillManifest(content)) existing.push(destination);
|
||||
@@ -242,49 +252,187 @@ const readOptionalSkillState = async (
|
||||
}
|
||||
};
|
||||
|
||||
const inspectSkillDirectory = async (
|
||||
path: string,
|
||||
): Promise<Result<void, SkillPathIssue>> => {
|
||||
try {
|
||||
const directory = await lstat(path);
|
||||
if (directory.isSymbolicLink())
|
||||
return err({
|
||||
path,
|
||||
kind: "symbolic_link",
|
||||
detail: "Skill directory is a symbolic link.",
|
||||
});
|
||||
if (!directory.isDirectory())
|
||||
return err({
|
||||
path,
|
||||
kind: "not_directory",
|
||||
detail: "Skill path is not a directory.",
|
||||
});
|
||||
return ok(undefined);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||
return ok(undefined);
|
||||
return err({
|
||||
path,
|
||||
kind: "unreadable",
|
||||
detail: cause instanceof Error ? cause.message : String(cause),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const skillDirectoryIssues = async (
|
||||
destinations: readonly SkillDestination[],
|
||||
): Promise<readonly SkillPathIssue[]> => {
|
||||
const issues: SkillPathIssue[] = [];
|
||||
for (const { path } of destinations) {
|
||||
const root = await inspectSkillDirectory(path);
|
||||
if (!root.ok) {
|
||||
issues.push(root.error);
|
||||
continue;
|
||||
}
|
||||
const references = await inspectSkillDirectory(join(path, "references"));
|
||||
if (!references.ok) issues.push(references.error);
|
||||
}
|
||||
return issues;
|
||||
};
|
||||
|
||||
const skillDirectoryFailure = (
|
||||
issues: readonly SkillPathIssue[],
|
||||
): SetupSkillInstallError =>
|
||||
new SetupSkillInstallError({
|
||||
path: issues[0]?.path ?? "skill bundle",
|
||||
stage: "prepare",
|
||||
reason: `${issues.map(({ path, detail }) => `${detail} (${path})`).join(" ")} Use a regular skill directory, or run setup with --skill=false to configure the client without installing the skill`,
|
||||
rollbackFailures: [],
|
||||
recoveryBackupPaths: [],
|
||||
cause: issues,
|
||||
});
|
||||
|
||||
/** Validate only final selected roots; permission to install is separate from freshness. */
|
||||
export const validateCanonicalSkillDirectories = async (
|
||||
home: string,
|
||||
clientIds: readonly string[],
|
||||
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<Result<void, SetupSkillInstallError>> => {
|
||||
const issues = await skillDirectoryIssues(
|
||||
skillDestinations(home, clientIds, environment, platform),
|
||||
);
|
||||
return issues.length === 0
|
||||
? ok(undefined)
|
||||
: err(skillDirectoryFailure(issues));
|
||||
};
|
||||
|
||||
const canonicalSkillFiles = async (
|
||||
home: string,
|
||||
clientIds: readonly string[],
|
||||
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<readonly CanonicalSkillFile[]> =>
|
||||
Promise.all(
|
||||
skillDestinations(home, clientIds, environment, platform).flatMap(
|
||||
({ path: root }) =>
|
||||
MANAGED_SKILL_FILES.map(async (relativePath) => {
|
||||
const destination = join(root, relativePath);
|
||||
return {
|
||||
destination,
|
||||
content: await canonicalSkillContent(relativePath),
|
||||
original: await readOptionalSkillState(destination, resources),
|
||||
};
|
||||
}),
|
||||
): Promise<readonly CanonicalSkillFile[]> => {
|
||||
const destinations = skillDestinations(
|
||||
home,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
);
|
||||
const admission = await validateCanonicalSkillDirectories(
|
||||
home,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
);
|
||||
if (!admission.ok) throw admission.error;
|
||||
return Promise.all(
|
||||
destinations.flatMap(({ path: root }) =>
|
||||
MANAGED_SKILL_FILES.map(async (relativePath) => {
|
||||
const destination = join(root, relativePath);
|
||||
return {
|
||||
destination,
|
||||
content: await canonicalSkillContent(relativePath),
|
||||
original: await readOptionalSkillState(destination, resources),
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const canonicalSkillNeedsInstallByReading = async (
|
||||
interface CanonicalSkillInspection {
|
||||
readonly canonical: boolean;
|
||||
readonly manifest: string | undefined;
|
||||
}
|
||||
|
||||
interface SkillInspectionFailure {
|
||||
readonly _tag: "SkillInspectionFailure";
|
||||
readonly issues: readonly SkillPathIssue[];
|
||||
readonly cause: unknown;
|
||||
readonly manifest: string | undefined;
|
||||
}
|
||||
|
||||
const inspectCanonicalSkill = async (
|
||||
home: string,
|
||||
clientIds: readonly string[],
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<boolean> => {
|
||||
for (const { path: root } of skillDestinations(
|
||||
): Promise<Result<CanonicalSkillInspection, SkillInspectionFailure>> => {
|
||||
const destinations = skillDestinations(
|
||||
home,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
)) {
|
||||
);
|
||||
const issues = [...(await skillDirectoryIssues(destinations))];
|
||||
if (issues.length > 0)
|
||||
return err({
|
||||
_tag: "SkillInspectionFailure",
|
||||
issues,
|
||||
cause: issues,
|
||||
manifest: undefined,
|
||||
});
|
||||
let canonical = true;
|
||||
let manifest: string | undefined;
|
||||
let failureCause: unknown;
|
||||
for (const { path: root } of destinations) {
|
||||
for (const relativePath of MANAGED_SKILL_FILES) {
|
||||
const destination = join(root, relativePath);
|
||||
const content = await canonicalSkillContent(relativePath);
|
||||
const original = await readOptionalSkillState(destination, resources);
|
||||
if (original?.bytes.equals(Buffer.from(content, "utf8")) !== true)
|
||||
return true;
|
||||
let original: SkillFileState | undefined;
|
||||
try {
|
||||
original = await readOptionalSkillState(destination, resources);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof RegularFileCleanupFailure ||
|
||||
cause instanceof ArtifactReaderFailure
|
||||
)
|
||||
throw cause;
|
||||
issues.push({
|
||||
path: destination,
|
||||
kind: "unreadable",
|
||||
detail: causeReason(cause),
|
||||
});
|
||||
if (issues.length === 1) failureCause = cause;
|
||||
canonical = false;
|
||||
continue;
|
||||
}
|
||||
if (relativePath === "SKILL.md" && manifest === undefined)
|
||||
manifest = original?.bytes.toString("utf8");
|
||||
// A mismatch settles freshness, but not coverage of later read failures.
|
||||
if (original === undefined) canonical = false;
|
||||
else if (canonical) {
|
||||
const content = await canonicalSkillContent(relativePath);
|
||||
canonical = original.bytes.equals(Buffer.from(content, "utf8"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return issues.length === 0
|
||||
? ok({ canonical, manifest })
|
||||
: err({
|
||||
_tag: "SkillInspectionFailure",
|
||||
issues,
|
||||
cause: failureCause,
|
||||
manifest,
|
||||
});
|
||||
};
|
||||
|
||||
/** Report whether setup would change any file in the managed REA skill bundle. */
|
||||
@@ -296,20 +444,21 @@ export const canonicalSkillNeedsInstall = async (
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
return await canonicalSkillNeedsInstallByReading(
|
||||
const inspection = await inspectCanonicalSkill(
|
||||
home,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
resources,
|
||||
);
|
||||
return !inspection.ok || !inspection.value.canonical;
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof RegularFileCleanupFailure ||
|
||||
cause instanceof ArtifactReaderFailure
|
||||
)
|
||||
throw cause;
|
||||
// Unreadable skill state fails open to install so setup can repair it.
|
||||
// A copy needs attention; selected-root admission decides whether it may be installed.
|
||||
void cause;
|
||||
return true;
|
||||
}
|
||||
@@ -431,6 +580,8 @@ export const installCanonicalSkill = async (
|
||||
}
|
||||
return ok("installed");
|
||||
} catch (cause: unknown) {
|
||||
if (stage === "prepare" && cause instanceof SetupSkillInstallError)
|
||||
return err(cause);
|
||||
failingPath = causePath(cause) ?? failingPath;
|
||||
const rollbackFailures = await restoreSkillFiles(attempted, operations);
|
||||
return err(
|
||||
@@ -458,48 +609,29 @@ export const readInstalledSkillIdentity = async (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<InstalledSkillIdentity | undefined> => {
|
||||
const destinations = skillDestinations(
|
||||
const inspection = await inspectCanonicalSkill(
|
||||
home,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
resources,
|
||||
);
|
||||
let content: string | undefined;
|
||||
for (const destination of destinations) {
|
||||
try {
|
||||
content = await readSkillFile(
|
||||
join(destination.path, "SKILL.md"),
|
||||
resources,
|
||||
);
|
||||
break;
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof RegularFileCleanupFailure ||
|
||||
cause instanceof ArtifactReaderFailure
|
||||
)
|
||||
throw cause;
|
||||
// Another selected copy may provide metadata; canonical validation below
|
||||
// still marks missing or unreadable copies as stale.
|
||||
}
|
||||
}
|
||||
if (content === undefined) return undefined;
|
||||
const toolCount = /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1];
|
||||
let canonical: boolean;
|
||||
try {
|
||||
canonical = !(await canonicalSkillNeedsInstall(
|
||||
home,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
resources,
|
||||
));
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof ArtifactReaderFailure)) throw cause;
|
||||
canonical = false;
|
||||
}
|
||||
const content = inspection.ok
|
||||
? inspection.value.manifest
|
||||
: inspection.error.manifest;
|
||||
const issues = inspection.ok ? [] : inspection.error.issues;
|
||||
if (content === undefined && issues.length === 0) return undefined;
|
||||
const toolCount =
|
||||
content === undefined
|
||||
? undefined
|
||||
: /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1];
|
||||
return {
|
||||
canonical,
|
||||
version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null,
|
||||
canonical: inspection.ok && inspection.value.canonical,
|
||||
version:
|
||||
content === undefined
|
||||
? null
|
||||
: (/^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null),
|
||||
toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10),
|
||||
...(issues.length === 0 ? {} : { issues }),
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
|
||||
import type { LinuxDistribution } from "./LinuxHopper.js";
|
||||
import type { SkillDestination, SkillInstallResult } from "./SetupSkill.js";
|
||||
import type {
|
||||
SkillDestination,
|
||||
SkillInstallResult,
|
||||
SetupSkillInstallError,
|
||||
} from "./SetupSkill.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type {
|
||||
SetupFailureCode,
|
||||
@@ -62,7 +67,7 @@ export interface SetupHost {
|
||||
readonly homeDirectory: string;
|
||||
skillDestinations(clientIds: readonly string[]): readonly SkillDestination[];
|
||||
readonly registrationCommand: readonly string[];
|
||||
/** Why no client may be registered with `registrationCommand` from this host. */
|
||||
/** Why client registration cannot proceed from this host. */
|
||||
readonly registrationCommandBlocker?: string;
|
||||
readonly nodeVersion: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
@@ -89,6 +94,9 @@ export interface SetupHost {
|
||||
command: readonly string[],
|
||||
): Promise<ClientConfigurationInspection>;
|
||||
skillNeedsInstall(clientIds: readonly string[]): Promise<boolean>;
|
||||
validateSkillDirectories(
|
||||
clientIds: readonly string[],
|
||||
): Promise<Result<void, SetupSkillInstallError>>;
|
||||
installSkill(clientIds: readonly string[]): Promise<SkillInstallResult>;
|
||||
doctor(scope?: DoctorScope): Promise<Awaited<ReturnType<typeof runDoctor>>>;
|
||||
}
|
||||
@@ -149,7 +157,12 @@ export interface SetupClientState {
|
||||
readonly client: SetupClient;
|
||||
readonly detected: boolean;
|
||||
readonly configured: boolean;
|
||||
readonly status: "configured" | "missing" | "needs_configuration" | "invalid";
|
||||
readonly status:
|
||||
| "configured"
|
||||
| "missing"
|
||||
| "needs_configuration"
|
||||
| "invalid"
|
||||
| "unknown";
|
||||
}
|
||||
|
||||
/** One concrete setup mutation disclosed before approval. */
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
skillDestinations,
|
||||
} from "./SetupSkill.js";
|
||||
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import { dshDocument } from "./DshCordisPatch.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import {
|
||||
@@ -562,12 +563,15 @@ const isOwnedRegistration = (value: unknown, client: SetupClient): boolean => {
|
||||
.safeParse(value);
|
||||
if (!parsed.success) return false;
|
||||
const command = parsed.data.command;
|
||||
return isOwnedClientRegistrationCommand(command);
|
||||
return isOwnedClientRegistrationCommand(command, CURRENT_ENTRYPOINT_PATH);
|
||||
}
|
||||
const parsed = registrationSchema.safeParse(value);
|
||||
if (!parsed.success) return false;
|
||||
const { command, args } = parsed.data;
|
||||
return isOwnedClientRegistrationCommand([command, ...args]);
|
||||
return isOwnedClientRegistrationCommand(
|
||||
[command, ...args],
|
||||
CURRENT_ENTRYPOINT_PATH,
|
||||
);
|
||||
};
|
||||
|
||||
const purgeManagedData = async (
|
||||
@@ -768,6 +772,12 @@ const removeManagedSkill = async (
|
||||
const failed = results.find(({ status }) => status === "failed");
|
||||
if (failed !== undefined)
|
||||
return item(name, "failed", results.map(({ detail }) => detail).join(" "));
|
||||
if (results.some(({ status }) => status === "retained"))
|
||||
return item(
|
||||
name,
|
||||
"retained",
|
||||
[...new Set(results.map(({ detail }) => detail))].join(" "),
|
||||
);
|
||||
const removed = results.some(({ status }) => status === "removed");
|
||||
return item(
|
||||
name,
|
||||
|
||||
@@ -137,6 +137,7 @@ class FakeDevice implements Device {
|
||||
readonly process = { pid: 512, name: "fixture" } satisfies Process;
|
||||
readonly session = new FakeSession();
|
||||
resumed: number[] = [];
|
||||
attachedTargets: Array<number | string> = [];
|
||||
resumeFailureCount = 0;
|
||||
readonly spawned: string[] = [];
|
||||
|
||||
@@ -161,7 +162,8 @@ class FakeDevice implements Device {
|
||||
this.resumed.push(pid);
|
||||
}
|
||||
|
||||
async attach(): Promise<Session> {
|
||||
async attach(target: number | string): Promise<Session> {
|
||||
this.attachedTargets.push(target);
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
@@ -207,6 +209,110 @@ const startLocalAttachedSession = async (
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- related manager lifecycle cases share the same focused fake provider.
|
||||
describe("FridaInstrumentationManager", () => {
|
||||
it("attaches by PID when Frida omits the target from process lookup", async () => {
|
||||
const bindings = new FakeDeviceManager();
|
||||
bindings.device.getProcessByPid = async () => {
|
||||
throw new Error("Process not found");
|
||||
};
|
||||
const manager = new FridaInstrumentationManager({
|
||||
deviceManager: bindings,
|
||||
});
|
||||
const started = await manager.startSession({
|
||||
mode: "attach",
|
||||
deviceId: "local",
|
||||
pid: 512,
|
||||
});
|
||||
expect(started.ok).toBe(true);
|
||||
if (!started.ok) return;
|
||||
expect(started.value.pid).toBe(512);
|
||||
expect(bindings.device.attachedTargets).toEqual([512]);
|
||||
expect(started.value.target).toBe("pid 512");
|
||||
expect(bindings.device.session.isDetached()).toBe(false);
|
||||
await manager.closeAll();
|
||||
expect(bindings.device.session.isDetached()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the caller PID when Frida resolves a different process", async () => {
|
||||
const bindings = new FakeDeviceManager();
|
||||
bindings.device.getProcessByPid = async () => ({
|
||||
...bindings.device.process,
|
||||
pid: 513,
|
||||
});
|
||||
const manager = new FridaInstrumentationManager({
|
||||
deviceManager: bindings,
|
||||
});
|
||||
const started = await manager.startSession({
|
||||
mode: "attach",
|
||||
deviceId: "local",
|
||||
pid: 512,
|
||||
});
|
||||
expect(started.ok).toBe(true);
|
||||
if (!started.ok) return;
|
||||
expect(started.value.pid).toBe(512);
|
||||
expect(started.value.target).toBe("fixture (pid 512)");
|
||||
expect(bindings.device.attachedTargets).toEqual([512]);
|
||||
await manager.closeSession(started.value.sessionId);
|
||||
});
|
||||
|
||||
it("records a session already detached during process metadata lookup", async () => {
|
||||
const bindings = new FakeDeviceManager();
|
||||
let lookupStarted!: () => void;
|
||||
const lookupEntered = new Promise<void>((resolve) => {
|
||||
lookupStarted = resolve;
|
||||
});
|
||||
let finishLookup!: () => void;
|
||||
const lookupGate = new Promise<void>((resolve) => {
|
||||
finishLookup = resolve;
|
||||
});
|
||||
bindings.device.getProcessByPid = async () => {
|
||||
lookupStarted();
|
||||
await lookupGate;
|
||||
return bindings.device.process;
|
||||
};
|
||||
const manager = new FridaInstrumentationManager({
|
||||
deviceManager: bindings,
|
||||
});
|
||||
const starting = manager.startSession({
|
||||
mode: "attach",
|
||||
deviceId: "local",
|
||||
pid: 512,
|
||||
});
|
||||
await lookupEntered;
|
||||
bindings.device.session.markDetached();
|
||||
finishLookup();
|
||||
|
||||
const started = await starting;
|
||||
expect(started.ok).toBe(true);
|
||||
if (!started.ok) return;
|
||||
expect(started.value.pid).toBe(512);
|
||||
expect(bindings.device.attachedTargets).toEqual([512]);
|
||||
expect(started.value.state).toBe("detached");
|
||||
expect(manager.status(started.value.sessionId)?.state).toBe("detached");
|
||||
await manager.closeSession(started.value.sessionId);
|
||||
});
|
||||
|
||||
it("still reports a real attach failure when process lookup is unavailable", async () => {
|
||||
const bindings = new FakeDeviceManager();
|
||||
bindings.device.getProcessByPid = async () => {
|
||||
throw new Error("Process not found");
|
||||
};
|
||||
bindings.device.attach = async () => {
|
||||
throw new Error("attach denied");
|
||||
};
|
||||
const manager = new FridaInstrumentationManager({
|
||||
deviceManager: bindings,
|
||||
});
|
||||
const started = await manager.startSession({
|
||||
mode: "attach",
|
||||
deviceId: "local",
|
||||
pid: 512,
|
||||
});
|
||||
expect(started.ok).toBe(false);
|
||||
if (started.ok) return;
|
||||
expect(JSON.stringify(started.error)).toContain("attach denied");
|
||||
await manager.closeAll();
|
||||
});
|
||||
|
||||
it("retains a failed startup's spawn and remote lease until cleanup succeeds", async () => {
|
||||
const bindings = new FakeDeviceManager();
|
||||
bindings.device.attach = async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ it("retains batched TraceMap-resolved paths and null source positions", () => {
|
||||
text: { included: true, value: sourceMapText },
|
||||
},
|
||||
],
|
||||
unavailable_relevant_files: [],
|
||||
containers: [],
|
||||
text_bytes_read: Buffer.byteLength(sourceMapText),
|
||||
invalid_utf8_files: 0,
|
||||
|
||||
@@ -178,6 +178,7 @@ const graphCoverage = (context: JavaScriptArtifactGraphContext) => {
|
||||
);
|
||||
const unknownGap =
|
||||
(context.fileSet.source_selection?.unselected_files ?? 0) > 0 ||
|
||||
context.fileSet.unavailable_relevant_files.length > 0 ||
|
||||
context.analysis.files.some(
|
||||
({ analysis_failure, application_projection_failure }) =>
|
||||
analysis_failure !== undefined ||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||
import { htmlInlineScriptLimitation } from "./JavaScriptArtifactAnalysis.js";
|
||||
import { completeApplicationCoverage } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
|
||||
import {
|
||||
completeApplicationCoverage,
|
||||
partialApplicationCoverage,
|
||||
} from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
|
||||
import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { freezeJsonSnapshot } from "../../domain/immutableJson.js";
|
||||
@@ -300,8 +303,133 @@ export const addJavaScriptArtifactFiles = (
|
||||
},
|
||||
});
|
||||
}
|
||||
addUnavailableAsarEntries(context);
|
||||
};
|
||||
|
||||
const addUnavailableAsarEntries = (
|
||||
context: JavaScriptArtifactGraphContext,
|
||||
): void => {
|
||||
for (const file of context.fileSet.unavailable_relevant_files) {
|
||||
if (!isAsarContainer(context, file.container_sha256)) continue;
|
||||
const limitations = [
|
||||
...(file.limitations ?? []),
|
||||
`The inventoried ${file.kind ?? "relevant"} entry has unavailable bytes; no content artifact identity can be established.`,
|
||||
];
|
||||
const entry = context.accumulator.addNode({
|
||||
kind: "asar-entry",
|
||||
identity: {
|
||||
strategy: "canonical-path",
|
||||
stability: "artifact-version",
|
||||
artifact_sha256: file.container_sha256,
|
||||
path: file.path,
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: file.path,
|
||||
properties: {
|
||||
entry_sha256: file.sha256,
|
||||
bytes: file.bytes,
|
||||
inventory_artifact_id: file.inventory_artifact_id,
|
||||
...(file.inventory_occurrence_id === undefined
|
||||
? {}
|
||||
: { inventory_occurrence_id: file.inventory_occurrence_id }),
|
||||
hash_status: file.hash_status,
|
||||
artifact_kind: file.artifact_kind,
|
||||
...(file.kind === undefined ? {} : { kind: file.kind }),
|
||||
content_available: false,
|
||||
},
|
||||
evidence: artifactObservationEvidence({
|
||||
sha256: file.container_sha256,
|
||||
path: file.path,
|
||||
operation: "inventory-asar-unavailable-entry",
|
||||
coverage: partialApplicationCoverage([], null),
|
||||
limitations,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
const parent =
|
||||
context.containerNodes.get(file.container_sha256) ?? context.root;
|
||||
context.accumulator.addEdge({
|
||||
source_node_id: parent.node_id,
|
||||
target_node_id: entry.node_id,
|
||||
relation: "contains",
|
||||
properties: {
|
||||
path: file.path,
|
||||
content_available: false,
|
||||
hash_status: file.hash_status,
|
||||
},
|
||||
evidence: artifactObservationEvidence({
|
||||
sha256: file.container_sha256,
|
||||
path: file.path,
|
||||
operation: "inventory-asar-unavailable-entry",
|
||||
coverage: partialApplicationCoverage([], null),
|
||||
limitations,
|
||||
}),
|
||||
});
|
||||
if (file.kind === "native-addon" || file.artifact_kind === "native-addon") {
|
||||
const addon = context.accumulator.addNode({
|
||||
kind: "native-addon",
|
||||
identity: {
|
||||
strategy: "canonical-path",
|
||||
stability: "artifact-version",
|
||||
artifact_sha256: file.container_sha256,
|
||||
path: file.path,
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: file.path,
|
||||
properties: {
|
||||
availability: "unavailable",
|
||||
...(file.hash_status === undefined
|
||||
? {}
|
||||
: { hash_status: file.hash_status }),
|
||||
...(file.artifact_kind === undefined
|
||||
? {}
|
||||
: { artifact_kind: file.artifact_kind }),
|
||||
bytes: file.bytes,
|
||||
inventory_artifact_id: file.inventory_artifact_id,
|
||||
...(file.inventory_occurrence_id === undefined
|
||||
? {}
|
||||
: { inventory_occurrence_id: file.inventory_occurrence_id }),
|
||||
},
|
||||
evidence: artifactObservationEvidence({
|
||||
sha256: file.container_sha256,
|
||||
path: file.path,
|
||||
operation: "classify-unavailable-native-addon",
|
||||
coverage: partialApplicationCoverage([], null),
|
||||
limitations,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
context.accumulator.addEdge({
|
||||
source_node_id: entry.node_id,
|
||||
target_node_id: addon.node_id,
|
||||
relation: "contains",
|
||||
properties: {
|
||||
classification: "native-addon",
|
||||
content_available: false,
|
||||
},
|
||||
evidence: artifactObservationEvidence({
|
||||
sha256: file.container_sha256,
|
||||
path: file.path,
|
||||
operation: "classify-unavailable-native-addon",
|
||||
coverage: partialApplicationCoverage([], null),
|
||||
limitations,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isAsarContainer = (
|
||||
context: JavaScriptArtifactGraphContext,
|
||||
containerSha256: string,
|
||||
): boolean =>
|
||||
context.snapshot.manifest.root_format === "asar" ||
|
||||
containerSha256 !== context.snapshot.manifest.root_sha256;
|
||||
|
||||
const sourceFileRange = (
|
||||
file: JavaScriptArtifactFile,
|
||||
): JavaScriptSourceRange => {
|
||||
@@ -464,10 +592,7 @@ const createAsarEntry = (
|
||||
context: JavaScriptArtifactGraphContext,
|
||||
file: JavaScriptArtifactFile,
|
||||
): ApplicationNode | undefined => {
|
||||
const isAsar =
|
||||
context.snapshot.manifest.root_format === "asar" ||
|
||||
file.container_sha256 !== context.snapshot.manifest.root_sha256;
|
||||
if (!isAsar) return undefined;
|
||||
if (!isAsarContainer(context, file.container_sha256)) return undefined;
|
||||
return context.accumulator.addNode({
|
||||
kind: "asar-entry",
|
||||
identity: {
|
||||
|
||||
@@ -68,6 +68,56 @@ describe("artifact-local graph identity", () => {
|
||||
});
|
||||
|
||||
describe("loader-owned path lookup", () => {
|
||||
it("applies URL whitespace handling only to ESM lookup and retains declarations", () => {
|
||||
const tab = "\t";
|
||||
const files = fileMap([
|
||||
file("app/entry.mjs", "root"),
|
||||
file("app/feature.js", "root"),
|
||||
file(`app/tab${tab}name.js`, "root"),
|
||||
]);
|
||||
for (const whitespace of ["\t", "\n", "\r"]) {
|
||||
const raw = `./fea${whitespace}ture.js`;
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: raw,
|
||||
sourcePath: "app/entry.mjs",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({ declared_path: raw, resolved_path: "app/feature.js" });
|
||||
}
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: `./tab${tab}name.js`,
|
||||
sourcePath: "app/entry.mjs",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({ resolved_path: null });
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: `./tab${tab}name.js`,
|
||||
sourcePath: "app/entry.cjs",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({ resolved_path: "app/tab\tname.js" });
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "./tab%09name.js",
|
||||
sourcePath: "app/entry.mjs",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({ resolved_path: "app/tab\tname.js" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("loader-specific module suffix lookup", () => {
|
||||
it("keeps relative ESM imports exact", () => {
|
||||
const files = fileMap([
|
||||
file("app/entry.mjs", "root"),
|
||||
@@ -186,6 +236,183 @@ describe("loader-owned path lookup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("unavailable inventoried native add-ons", () => {
|
||||
it("retains an inventoried native add-on whose bytes are unavailable", () => {
|
||||
const unavailable = {
|
||||
path: "app/addon.node",
|
||||
container_sha256: file("app/entry.cjs", "root").container_sha256,
|
||||
sha256: null,
|
||||
bytes: 8192,
|
||||
inventory_artifact_id: null,
|
||||
inventory_occurrence_id: "missing-addon-occurrence",
|
||||
kind: "native-addon" as const,
|
||||
hash_status: "unavailable" as const,
|
||||
limitations: ["unpacked companion is missing"],
|
||||
};
|
||||
const result = resolve({
|
||||
declaredPath: "./addon.node",
|
||||
sourcePath: "app/entry.cjs",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files: fileMap([file("app/entry.cjs", "root")]),
|
||||
unanalyzedFiles: {
|
||||
get: (path) => (path === unavailable.path ? unavailable : undefined),
|
||||
hasDirectory: () => false,
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
declared_path: "./addon.node",
|
||||
resolution_status: "unavailable",
|
||||
candidate_path: "app/addon.node",
|
||||
limitations: [
|
||||
"unpacked companion is missing",
|
||||
expect.stringContaining("inventoried native-addon file"),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unavailable package metadata", () => {
|
||||
it("blocks directory fallback until same-container manifests are readable", () => {
|
||||
const manifest = {
|
||||
path: "app/relative/package.json",
|
||||
container_sha256: file("app/main.cjs", "root").container_sha256,
|
||||
sha256: null,
|
||||
bytes: 28,
|
||||
inventory_artifact_id: null,
|
||||
inventory_occurrence_id: "missing-relative-manifest",
|
||||
kind: "package-json" as const,
|
||||
hash_status: "unavailable" as const,
|
||||
limitations: ["unpacked manifest companion is missing"],
|
||||
};
|
||||
const files = fileMap([
|
||||
file("app/main.cjs", "root"),
|
||||
file("app/main.mjs", "root"),
|
||||
file("app/relative/index.js", "root"),
|
||||
]);
|
||||
const requestedPaths: string[] = [];
|
||||
const lookup = {
|
||||
get: (path: string) => {
|
||||
requestedPaths.push(path);
|
||||
return path === manifest.path ? manifest : undefined;
|
||||
},
|
||||
hasDirectory: () => false,
|
||||
};
|
||||
const relativeRequire = resolve({
|
||||
declaredPath: "./relative",
|
||||
sourcePath: "app/main.cjs",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
unanalyzedFiles: lookup,
|
||||
});
|
||||
expect(requestedPaths).toContain(manifest.path);
|
||||
expect(relativeRequire).toMatchObject({
|
||||
resolution_status: "unavailable",
|
||||
resolved_path: null,
|
||||
limitations: ["unpacked manifest companion is missing"],
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "./relative",
|
||||
sourcePath: "app/main.mjs",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
unanalyzedFiles: lookup,
|
||||
}),
|
||||
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
|
||||
|
||||
const restoredFiles = fileMap([
|
||||
...files.values(),
|
||||
file("app/relative/package.json", "root", '{"main":"public.js"}'),
|
||||
file("app/relative/public.js", "root"),
|
||||
]);
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "./relative",
|
||||
sourcePath: "app/main.cjs",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files: restoredFiles,
|
||||
unanalyzedFiles: lookup,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "app/relative/public.js",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps bare package exports unknown while its manifest is unavailable", () => {
|
||||
const containerSha256 = file("app/main.mjs", "root").container_sha256;
|
||||
const manifest = {
|
||||
path: "app/node_modules/fixture/package.json",
|
||||
container_sha256: containerSha256,
|
||||
sha256: null,
|
||||
bytes: 47,
|
||||
inventory_artifact_id: null,
|
||||
kind: "package-json" as const,
|
||||
hash_status: "unavailable" as const,
|
||||
limitations: ["unpacked exports manifest is missing"],
|
||||
};
|
||||
const files = fileMap([
|
||||
file("app/main.cjs", "root"),
|
||||
file("app/main.mjs", "root"),
|
||||
file("app/node_modules/fixture/index.js", "root"),
|
||||
file("app/node_modules/fixture/import.js", "root"),
|
||||
file("app/node_modules/fixture/require.js", "root"),
|
||||
]);
|
||||
const lookup = {
|
||||
get: (path: string) => (path === manifest.path ? manifest : undefined),
|
||||
hasDirectory: () => false,
|
||||
};
|
||||
for (const [sourcePath, moduleKind] of [
|
||||
["app/main.cjs", "require"],
|
||||
["app/main.mjs", "import"],
|
||||
] as const)
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "fixture",
|
||||
sourcePath,
|
||||
context: "module-specifier",
|
||||
moduleKind,
|
||||
files,
|
||||
unanalyzedFiles: lookup,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "unavailable",
|
||||
resolved_path: null,
|
||||
limitations: ["unpacked exports manifest is missing"],
|
||||
});
|
||||
|
||||
const restoredFiles = fileMap([
|
||||
...files.values(),
|
||||
file(
|
||||
manifest.path,
|
||||
"root",
|
||||
'{"exports":{"import":"./import.js","require":"./require.js"}}',
|
||||
),
|
||||
]);
|
||||
for (const [sourcePath, moduleKind, target] of [
|
||||
["app/main.cjs", "require", "app/node_modules/fixture/require.js"],
|
||||
["app/main.mjs", "import", "app/node_modules/fixture/import.js"],
|
||||
] as const)
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "fixture",
|
||||
sourcePath,
|
||||
context: "module-specifier",
|
||||
moduleKind,
|
||||
files: restoredFiles,
|
||||
unanalyzedFiles: lookup,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: target,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("bare package root file lookup", () => {
|
||||
it("uses loader-specific bare package roots when only a sibling .js file exists", () => {
|
||||
const absentDirectory = fileMap([
|
||||
|
||||
@@ -499,6 +499,7 @@ const contextualCandidate = (
|
||||
}
|
||||
const hasUrlPathSemantics =
|
||||
context === "module-specifier" && input.moduleKind === "import";
|
||||
if (hasUrlPathSemantics) declared = htmlUrlText(declared);
|
||||
const rejected = rejectDeclaration(
|
||||
declared,
|
||||
declaresLiteralPercent(input) || hasUrlPathSemantics,
|
||||
@@ -1143,10 +1144,21 @@ const readPackageMetadata = (
|
||||
const path = posix.join(directory, "package.json");
|
||||
const file = input.files.get(path);
|
||||
const source = input.files.get(input.sourcePath);
|
||||
if (
|
||||
file === undefined ||
|
||||
(source !== undefined && file.container_sha256 !== source.container_sha256)
|
||||
)
|
||||
if (file === undefined) {
|
||||
const unavailable = input.unanalyzedFiles?.get(path);
|
||||
if (
|
||||
source !== undefined &&
|
||||
unavailable?.container_sha256 === source.container_sha256
|
||||
)
|
||||
return {
|
||||
kind: "unavailable",
|
||||
limitation:
|
||||
unavailable.limitations?.[0] ??
|
||||
`Directory package metadata ${path} was inventoried but its bytes are unavailable.`,
|
||||
};
|
||||
return { kind: "absent" };
|
||||
}
|
||||
if (source !== undefined && file.container_sha256 !== source.container_sha256)
|
||||
return { kind: "absent" };
|
||||
if (!file.text.included)
|
||||
return {
|
||||
@@ -1400,7 +1412,9 @@ const findFileCandidate = (
|
||||
candidatePath: path,
|
||||
limitations: [
|
||||
...(unanalyzed.limitations ?? []),
|
||||
`${path} exists in the inventoried artifact container (sha256 ${unanalyzed.sha256 ?? "unknown"}, ${unanalyzed.bytes === null ? "unknown size" : `${String(unanalyzed.bytes)} bytes`}) but is not a JavaScript, JSON, HTML, source map, or native add-on file; its contents were not analyzed.`,
|
||||
unanalyzed.kind === undefined
|
||||
? `${path} exists in the inventoried artifact container (sha256 ${unanalyzed.sha256 ?? "unknown"}, ${unanalyzed.bytes === null ? "unknown size" : `${String(unanalyzed.bytes)} bytes`}) but is not a JavaScript, JSON, HTML, source map, or native add-on file; its contents were not analyzed.`
|
||||
: `${path} is an inventoried ${unanalyzed.kind} file, but its bytes are unavailable (hash status ${unanalyzed.hash_status ?? "unknown"}); resolution cannot be confirmed.`,
|
||||
],
|
||||
};
|
||||
return null;
|
||||
|
||||
@@ -261,7 +261,8 @@ export const reconstructJavaScriptArtifact = async (
|
||||
),
|
||||
),
|
||||
statistics: {
|
||||
relevant_files: files.files.length,
|
||||
relevant_files:
|
||||
files.files.length + files.unavailable_relevant_files.length,
|
||||
nested_asar_containers: files.containers.length,
|
||||
text_bytes_read: files.text_bytes_read,
|
||||
invalid_utf8_files: files.invalid_utf8_files,
|
||||
|
||||
@@ -25,6 +25,43 @@ import { semanticCoverageResourceLimits } from "../../domain/javascript/javascri
|
||||
|
||||
const SHA256 = "a".repeat(64);
|
||||
|
||||
it.each([
|
||||
["plain export", "const answer = 42; export { answer };", 0],
|
||||
["aliased export", "const answer = 42; export { answer as result };", 0],
|
||||
["declaration only", "const answer = 42;", 0],
|
||||
["genuine assignment", "let answer = 0; answer = 1;", 1],
|
||||
])(
|
||||
"projects writes only for actual writes: %s",
|
||||
(_name, source, writeCount) => {
|
||||
const graph = graphFor(source);
|
||||
const binding = graph.nodes.find(
|
||||
({ kind, label }) => kind === "binding" && label === "answer",
|
||||
);
|
||||
if (binding === undefined) throw new Error("Expected answer binding");
|
||||
const writes = graph.relations.filter(
|
||||
({ relation, target_node_id }) =>
|
||||
relation === "writes" && target_node_id === binding.node_id,
|
||||
);
|
||||
expect(writes).toHaveLength(writeCount);
|
||||
const exportReference = graph.nodes.find(
|
||||
({ identity }) => identity.role_key === "reference:0:export:answer",
|
||||
);
|
||||
if (source.includes("export {")) expect(exportReference).toBeDefined();
|
||||
|
||||
const trace = queryJavaScriptSemanticGraph(graph, {
|
||||
seed: { kind: "semantic-node", node_id: binding.node_id },
|
||||
direction: "backward-provenance",
|
||||
allowed_relations: ["writes"],
|
||||
});
|
||||
expect(trace.status).toBe("found");
|
||||
expect(trace.summary).toMatchObject({
|
||||
total_seed_matches: 1,
|
||||
traversed_relations: writeCount,
|
||||
});
|
||||
expect(trace.relations).toHaveLength(writeCount);
|
||||
},
|
||||
);
|
||||
|
||||
it("projects closure and direct interprocedural flow without execution", () => {
|
||||
const graph = graphFor(`
|
||||
const outer = 2;
|
||||
|
||||
@@ -648,7 +648,7 @@ function* projectDefinitionsAndReferences(
|
||||
resolution: "resolved",
|
||||
evidence: inferredSemanticEvidenceAt(context.file, reference.location),
|
||||
});
|
||||
else
|
||||
else if (reference.role === "write")
|
||||
addRelation(context.state, {
|
||||
source: expression,
|
||||
target: binding,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ArtifactPathRegistry,
|
||||
annotateEnglishUnicodeCaseCollisions,
|
||||
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
|
||||
} from "./ArtifactPaths.js";
|
||||
@@ -26,6 +27,15 @@ const annotate = (paths: readonly string[]) => {
|
||||
};
|
||||
|
||||
describe("English Unicode case collisions", () => {
|
||||
it("retains repeated same-kind entries while rejecting file-directory aliasing", () => {
|
||||
const registry = new ArtifactPathRegistry();
|
||||
registry.add("duplicate", "file");
|
||||
expect(() => registry.add("duplicate", "file")).not.toThrow();
|
||||
expect(() => registry.add("duplicate", "directory")).toThrow(
|
||||
"Artifact path collision",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the other spelling of a colliding file", () => {
|
||||
const { limitation, byPath } = annotate([".", "Main.js", "main.js", "x"]);
|
||||
expect(limitation).toBe(ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION);
|
||||
|
||||
@@ -61,7 +61,7 @@ export class ArtifactPathRegistry {
|
||||
}
|
||||
node = child;
|
||||
if (index === parts.length - 1) {
|
||||
if (node.kind !== undefined)
|
||||
if (node.kind !== undefined && node.kind !== kind)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Artifact path collision: ${path}`,
|
||||
|
||||
@@ -226,3 +226,128 @@ describe("MachOSliceArtifactReader", () => {
|
||||
expect(Buffer.concat(verifiedChunks)).toEqual(arm);
|
||||
});
|
||||
});
|
||||
|
||||
describe("universal slices outside the structural index", () => {
|
||||
const PPC = { type: 18, subtype: 0 } as const;
|
||||
/** A minimal big-endian 32-bit Mach-O header, as a PowerPC slice stores it. */
|
||||
const bigEndianImage = (): Uint8Array => {
|
||||
const bytes = Buffer.alloc(32);
|
||||
bytes.writeUInt32BE(0xfeed_face, 0);
|
||||
bytes.writeUInt32BE(PPC.type, 4);
|
||||
bytes.writeUInt32BE(1, 12);
|
||||
return bytes;
|
||||
};
|
||||
/** An arm64 image whose load commands claim more bytes than the slice holds. */
|
||||
const malformedImage = (): Uint8Array => {
|
||||
const bytes = machoImage({ cpu: CPU.arm64 });
|
||||
new DataView(bytes.buffer).setUint32(20, 4096, true);
|
||||
return bytes;
|
||||
};
|
||||
const lipoRunner = (
|
||||
binary: string,
|
||||
slices: readonly {
|
||||
readonly name: string;
|
||||
readonly cpu: { readonly type: number; readonly subtype: number };
|
||||
readonly offset: number;
|
||||
readonly size: number;
|
||||
}[],
|
||||
withRanges = true,
|
||||
): NativeCommandRunner => ({
|
||||
run: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
tool: "lipo",
|
||||
executable: "/usr/bin/lipo",
|
||||
executableSha256: "1".repeat(64),
|
||||
toolVersion: null,
|
||||
versionReason: "fixture",
|
||||
arguments: ["-detailed_info", binary],
|
||||
stdout: slices
|
||||
.map(
|
||||
({ name, cpu, offset, size }) =>
|
||||
`architecture ${name}\n cputype ${String(cpu.type)}\n cpusubtype ${String(cpu.subtype)}\n${withRanges ? ` offset ${String(offset)}\n size ${String(size)}\n` : ""} align 2^12 (4096)\n`,
|
||||
)
|
||||
.join(""),
|
||||
stderr: "",
|
||||
stdoutBytes: 1,
|
||||
stderrBytes: 0,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const readAll = async (
|
||||
slices: readonly {
|
||||
readonly name: string;
|
||||
readonly cpu: { readonly type: number; readonly subtype: number };
|
||||
readonly bytes: Uint8Array;
|
||||
}[],
|
||||
withRanges = true,
|
||||
) => {
|
||||
const root = await createTestTempDirectory("rea-slices-unsupported-");
|
||||
const binary = join(root, "fat");
|
||||
await writeFile(binary, fatImage(slices));
|
||||
const reader = new MachOSliceArtifactReader(
|
||||
binary,
|
||||
{},
|
||||
lipoRunner(
|
||||
binary,
|
||||
slices.map(({ name, cpu, bytes }, index) => ({
|
||||
name,
|
||||
cpu,
|
||||
offset: 4096 * (index + 1),
|
||||
size: bytes.length,
|
||||
})),
|
||||
withRanges,
|
||||
),
|
||||
);
|
||||
onTestFinished(() => reader.close());
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
return entries;
|
||||
};
|
||||
|
||||
it("inventories every lipo range beside a big-endian slice", async () => {
|
||||
const entries = await readAll([
|
||||
{ name: "ppc", cpu: PPC, bytes: bigEndianImage() },
|
||||
{ name: "arm64", cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) },
|
||||
]);
|
||||
expect(entries.map(({ path, byteOffset }) => [path, byteOffset])).toEqual([
|
||||
["slices/ppc", 4096],
|
||||
["slices/arm64", 8192],
|
||||
]);
|
||||
expect(entries[0]?.limitations).toContain(
|
||||
"Structural Mach-O facts were unsupported (big-endian Mach-O slices are not supported); slice range is based on lipo output.",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports missing lipo ranges for an unindexed slice as unavailable support", async () => {
|
||||
await expect(
|
||||
readAll(
|
||||
[
|
||||
{ name: "ppc", cpu: PPC, bytes: bigEndianImage() },
|
||||
{
|
||||
name: "arm64",
|
||||
cpu: CPU.arm64,
|
||||
bytes: machoImage({ cpu: CPU.arm64 }),
|
||||
},
|
||||
],
|
||||
false,
|
||||
),
|
||||
).rejects.toMatchObject({ reason: "unavailable" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["before", true],
|
||||
["after", false],
|
||||
])(
|
||||
"rejects a malformed slice %s a big-endian slice",
|
||||
async (_order, malformedFirst) => {
|
||||
const ppc = { name: "ppc", cpu: PPC, bytes: bigEndianImage() };
|
||||
const broken = { name: "arm64", cpu: CPU.arm64, bytes: malformedImage() };
|
||||
await expect(
|
||||
readAll(malformedFirst ? [broken, ppc] : [ppc, broken]),
|
||||
).rejects.toMatchObject({ reason: "integrity" });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -122,9 +122,16 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
||||
"integrity",
|
||||
`Mach-O structural slice index is malformed: ${structural.reason}`,
|
||||
);
|
||||
if (structural.status === "unsupported")
|
||||
// A valid universal binary can hold slices REA cannot index, such as
|
||||
// big-endian PowerPC. lipo's own ranges still inventory every slice.
|
||||
if (
|
||||
structural.status === "unsupported" &&
|
||||
architectures.some(
|
||||
({ file_offset, size }) => file_offset === null || size === null,
|
||||
)
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"unavailable",
|
||||
`Mach-O structural slice index is unsupported: ${structural.reason}`,
|
||||
);
|
||||
const slices = structural.status === "parsed" ? structural.slices : null;
|
||||
@@ -185,7 +192,11 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
||||
? [
|
||||
"Structural Mach-O facts were unavailable; slice range is based on lipo output.",
|
||||
]
|
||||
: []),
|
||||
: structural.status === "unsupported"
|
||||
? [
|
||||
`Structural Mach-O facts were unsupported (${structural.reason}); slice range is based on lipo output.`,
|
||||
]
|
||||
: []),
|
||||
...(architecture.file_offset === null || architecture.size === null
|
||||
? [
|
||||
"Slice range was derived from structural Mach-O facts because lipo omitted it.",
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { consumeWithOrderedReads } from "./OrderedReads.js";
|
||||
|
||||
/** A read the test settles explicitly. */
|
||||
const controlled = <Value>() => {
|
||||
let resolve!: (value: Value) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
let settled = false;
|
||||
const promise = new Promise<Value>((onResolve, onReject) => {
|
||||
resolve = (value) => {
|
||||
settled = true;
|
||||
onResolve(value);
|
||||
};
|
||||
reject = (error) => {
|
||||
settled = true;
|
||||
onReject(error);
|
||||
};
|
||||
});
|
||||
return { promise, resolve, reject, settled: () => settled };
|
||||
};
|
||||
|
||||
/** Let every queued continuation run. */
|
||||
const drain = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
/** Yield the items, then optionally fail; records whether it was closed early. */
|
||||
const enumeration = (
|
||||
items: readonly string[],
|
||||
failure?: Error,
|
||||
): {
|
||||
readonly items: AsyncIterable<string>;
|
||||
readonly closed: () => boolean;
|
||||
} => {
|
||||
let closed = false;
|
||||
return {
|
||||
items: (async function* () {
|
||||
try {
|
||||
yield* items;
|
||||
if (failure !== undefined) throw failure;
|
||||
} finally {
|
||||
closed = true;
|
||||
}
|
||||
})(),
|
||||
closed: () => closed,
|
||||
};
|
||||
};
|
||||
|
||||
describe("consumeWithOrderedReads", () => {
|
||||
it("consumes in enumeration order when a later read finishes first", async () => {
|
||||
const reads = new Map(
|
||||
["a", "b", "c"].map((item) => [item, controlled<string>()]),
|
||||
);
|
||||
const consumed: string[] = [];
|
||||
const done = consumeWithOrderedReads(
|
||||
enumeration(["a", "b", "c"]).items,
|
||||
8,
|
||||
(item) => reads.get(item)!.promise,
|
||||
async (item, value) => {
|
||||
consumed.push(`${item}=${value}`);
|
||||
},
|
||||
);
|
||||
await drain();
|
||||
reads.get("c")!.resolve("3");
|
||||
reads.get("b")!.resolve("2");
|
||||
await drain();
|
||||
expect(consumed).toEqual([]);
|
||||
reads.get("a")!.resolve("1");
|
||||
await done;
|
||||
expect(consumed).toEqual(["a=1", "b=2", "c=3"]);
|
||||
});
|
||||
|
||||
it("keeps at most width reads in flight and skips undefined reads", async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
const consumed: string[] = [];
|
||||
await consumeWithOrderedReads(
|
||||
enumeration(["a", "skip", "b", "c", "d", "e"]).items,
|
||||
2,
|
||||
(item) => {
|
||||
if (item === "skip") return undefined;
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
return drain().then(() => {
|
||||
inFlight -= 1;
|
||||
return item;
|
||||
});
|
||||
},
|
||||
async (item) => {
|
||||
consumed.push(item);
|
||||
},
|
||||
);
|
||||
expect(maxInFlight).toBe(2);
|
||||
expect(consumed).toEqual(["a", "b", "c", "d", "e"]);
|
||||
});
|
||||
|
||||
it("reports an earlier read failure before a later one", async () => {
|
||||
const reads = new Map(["a", "b"].map((item) => [item, controlled<void>()]));
|
||||
const done = consumeWithOrderedReads(
|
||||
enumeration(["a", "b"]).items,
|
||||
8,
|
||||
(item) => reads.get(item)!.promise,
|
||||
async () => undefined,
|
||||
);
|
||||
await drain();
|
||||
reads.get("b")!.reject(new Error("b failed"));
|
||||
await drain();
|
||||
reads.get("a")!.reject(new Error("a failed"));
|
||||
await expect(done).rejects.toThrow("a failed");
|
||||
});
|
||||
|
||||
it("consumes earlier items before reporting an enumeration failure", async () => {
|
||||
const reads = new Map(["a", "b"].map((item) => [item, controlled<void>()]));
|
||||
const consumed: string[] = [];
|
||||
const done = consumeWithOrderedReads(
|
||||
enumeration(["a", "b"], new Error("walk failed")).items,
|
||||
8,
|
||||
(item) => reads.get(item)!.promise,
|
||||
async (item) => {
|
||||
consumed.push(item);
|
||||
},
|
||||
);
|
||||
await drain();
|
||||
reads.get("a")!.resolve();
|
||||
reads.get("b")!.reject(new Error("b failed"));
|
||||
await expect(done).rejects.toThrow("b failed");
|
||||
expect(consumed).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("reports the enumeration failure once earlier items succeed", async () => {
|
||||
const consumed: string[] = [];
|
||||
await expect(
|
||||
consumeWithOrderedReads(
|
||||
enumeration(["a"], new Error("walk failed")).items,
|
||||
8,
|
||||
async (item) => item,
|
||||
async (item) => {
|
||||
consumed.push(item);
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("walk failed");
|
||||
expect(consumed).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("closes the enumeration and waits for started reads after a failure", async () => {
|
||||
const later = controlled<void>();
|
||||
const source = enumeration(["a", "b", "c"]);
|
||||
let settledBeforeReturn = false;
|
||||
const done = consumeWithOrderedReads(
|
||||
source.items,
|
||||
2,
|
||||
(item) =>
|
||||
item === "a" ? Promise.reject(new Error("a failed")) : later.promise,
|
||||
async () => undefined,
|
||||
).catch((error: unknown) => {
|
||||
settledBeforeReturn = later.settled();
|
||||
throw error;
|
||||
});
|
||||
await drain();
|
||||
expect(source.closed()).toBe(true);
|
||||
later.resolve();
|
||||
await expect(done).rejects.toThrow("a failed");
|
||||
expect(settledBeforeReturn).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a consume failure when closing the enumeration also fails", async () => {
|
||||
const items: AsyncIterable<string> = {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => ({ done: false, value: "a" }),
|
||||
return: async () => {
|
||||
throw new Error("close failed");
|
||||
},
|
||||
}),
|
||||
};
|
||||
await expect(
|
||||
consumeWithOrderedReads(
|
||||
items,
|
||||
1,
|
||||
async () => undefined,
|
||||
async () => {
|
||||
throw new Error("consume failed");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("consume failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
type Settled<Value> =
|
||||
| { readonly ok: true; readonly value: Value }
|
||||
| { readonly ok: false; readonly error: unknown };
|
||||
|
||||
/**
|
||||
* Overlap independent reads with an enumeration while consuming their results
|
||||
* in enumeration order. `read` returns undefined for items it skips. At most
|
||||
* `width` reads are in flight. A failure is reported as a one-at-a-time loop
|
||||
* would report it: an earlier item's read or consume failure wins over a
|
||||
* later read failure or an enumeration failure. Every started read settles
|
||||
* before this returns or throws, and an enumeration left early is closed as
|
||||
* `for await` would close it.
|
||||
*/
|
||||
export const consumeWithOrderedReads = async <Item, Value>(
|
||||
items: AsyncIterable<Item>,
|
||||
width: number,
|
||||
read: (item: Item) => Promise<Value> | undefined,
|
||||
consume: (item: Item, value: Value) => Promise<void>,
|
||||
): Promise<void> => {
|
||||
const pending: {
|
||||
readonly item: Item;
|
||||
readonly result: Promise<Settled<Value>>;
|
||||
}[] = [];
|
||||
const consumeNext = async (): Promise<void> => {
|
||||
const next = pending.shift();
|
||||
if (next === undefined) return;
|
||||
const result = await next.result;
|
||||
if (!result.ok) throw result.error;
|
||||
await consume(next.item, result.value);
|
||||
};
|
||||
const iterator = items[Symbol.asyncIterator]();
|
||||
let iterating = true;
|
||||
try {
|
||||
while (iterating) {
|
||||
let step: IteratorResult<Item>;
|
||||
try {
|
||||
step = await iterator.next();
|
||||
} catch (cause: unknown) {
|
||||
iterating = false;
|
||||
while (pending.length > 0) await consumeNext();
|
||||
throw cause;
|
||||
}
|
||||
if (step.done === true) {
|
||||
iterating = false;
|
||||
break;
|
||||
}
|
||||
const started = read(step.value);
|
||||
if (started === undefined) continue;
|
||||
pending.push({
|
||||
item: step.value,
|
||||
result: started.then(
|
||||
(value) => ({ ok: true, value }) as const,
|
||||
(error: unknown) => ({ ok: false, error }) as const,
|
||||
),
|
||||
});
|
||||
if (pending.length >= width) await consumeNext();
|
||||
}
|
||||
while (pending.length > 0) await consumeNext();
|
||||
} finally {
|
||||
if (iterating) await iterator.return?.().catch(() => undefined);
|
||||
await Promise.all(pending.map(({ result }) => result));
|
||||
}
|
||||
};
|
||||
@@ -171,6 +171,9 @@ export class ZipArtifactReader implements ArtifactReader {
|
||||
this.#reader = new ZipReader(this.#source, {
|
||||
checkSignature: true,
|
||||
checkOverlappingEntry: true,
|
||||
// Unflagged names use the ZIP legacy encoding (CP437); the UTF-8 flag
|
||||
// and validated Unicode Path extra fields remain authoritative.
|
||||
filenameEncoding: "cp437",
|
||||
// REA applies its own provider-neutral path validation in scanReader and
|
||||
// must preserve that typed failure instead of zip.js rejecting first.
|
||||
filenameValidation: "tolerant",
|
||||
|
||||
@@ -2,10 +2,11 @@ import { createHash } from "node:crypto";
|
||||
import { mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { encodeNibArchiveFixture } from "./NibArchive.fixture.js";
|
||||
import type { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import {
|
||||
analyzeAppleAssetCatalogs,
|
||||
@@ -206,3 +207,90 @@ it("keeps recognized image fields on NIBArchive controls out of resource joins",
|
||||
});
|
||||
expect(result.resource_key_matches).toEqual([]);
|
||||
});
|
||||
|
||||
describe("Apple asset catalog and Interface Builder concurrency", () => {
|
||||
const catalogBundle = async () => {
|
||||
const bundle = await createTestTempDirectory("rea-asset-catalog-ib-");
|
||||
const resources = join(bundle, "Contents", "Resources");
|
||||
await mkdir(resources, { recursive: true });
|
||||
await writeFile(join(resources, "Assets.car"), "catalog");
|
||||
return bundle;
|
||||
};
|
||||
const emptyGraph = {
|
||||
graph: { nodes: [], truncated: false },
|
||||
} as unknown as Awaited<ReturnType<typeof analyzeInterfaceBuilderBundle>>;
|
||||
|
||||
it("keeps the Interface Builder analysis running while catalogs are inspected", async () => {
|
||||
// The Interface Builder analysis can finish only after catalog inspection
|
||||
// starts, so a sequential implementation would never complete.
|
||||
let catalogStarted!: () => void;
|
||||
const catalogStart = new Promise<void>((resolve) => {
|
||||
catalogStarted = resolve;
|
||||
});
|
||||
const result = await analyzeAppleAssetCatalogs({
|
||||
environment: {},
|
||||
bundlePath: await catalogBundle(),
|
||||
targetSha256: "a".repeat(64),
|
||||
analyzeInterfaceBuilder: async () => {
|
||||
await catalogStart;
|
||||
return emptyGraph;
|
||||
},
|
||||
runAssetUtil: async () => {
|
||||
catalogStarted();
|
||||
return "[]";
|
||||
},
|
||||
});
|
||||
expect(result.catalogs).toHaveLength(1);
|
||||
}, 5_000);
|
||||
|
||||
it("cancels the Interface Builder analysis and reports a catalog failure", async () => {
|
||||
let cancelled = false;
|
||||
let settled = false;
|
||||
const rejected = await analyzeAppleAssetCatalogs({
|
||||
environment: {},
|
||||
bundlePath: await catalogBundle(),
|
||||
targetSha256: "a".repeat(64),
|
||||
analyzeInterfaceBuilder: ({ signal }) =>
|
||||
new Promise((_, reject) => {
|
||||
signal?.addEventListener("abort", () => {
|
||||
cancelled = true;
|
||||
setImmediate(() => {
|
||||
settled = true;
|
||||
reject(new Error("interface builder cancelled"));
|
||||
});
|
||||
});
|
||||
}),
|
||||
runAssetUtil: () => Promise.reject(new Error("assetutil failed")),
|
||||
}).catch((cause: unknown) => cause);
|
||||
expect(rejected).toMatchObject({ message: "assetutil failed" });
|
||||
expect(cancelled).toBe(true);
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an early Interface Builder failure only after catalogs are inspected", async () => {
|
||||
let finishCatalog!: (output: string) => void;
|
||||
let catalogStarted = false;
|
||||
let settled = false;
|
||||
const analysis = analyzeAppleAssetCatalogs({
|
||||
environment: {},
|
||||
bundlePath: await catalogBundle(),
|
||||
targetSha256: "a".repeat(64),
|
||||
analyzeInterfaceBuilder: () =>
|
||||
Promise.reject(new Error("interface builder failed")),
|
||||
runAssetUtil: () => {
|
||||
catalogStarted = true;
|
||||
return new Promise((resolve) => {
|
||||
finishCatalog = resolve;
|
||||
});
|
||||
},
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
analysis.catch(() => undefined);
|
||||
await vi.waitFor(() => expect(catalogStarted).toBe(true));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
finishCatalog("[]");
|
||||
await expect(analysis).rejects.toThrow("interface builder failed");
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<string>;
|
||||
readonly analyzeInterfaceBuilder?: typeof analyzeInterfaceBuilderBundle;
|
||||
}) => {
|
||||
if (process.platform !== "darwin" && input.runAssetUtil === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
@@ -36,6 +37,81 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
"Apple asset catalog inspection requires macOS assetutil",
|
||||
);
|
||||
const page = appleAssetCatalogInputSchema.parse(input.page ?? {});
|
||||
// The Interface Builder graph only supplies resource keys for the join. Its
|
||||
// bundle walk is independent of the catalog walk, so both run together; a
|
||||
// catalog failure cancels it and is reported as the sequential order would.
|
||||
const interfaceBuilderCancel = new AbortController();
|
||||
const interfaceBuilderAnalysis = (
|
||||
input.analyzeInterfaceBuilder ?? analyzeInterfaceBuilderBundle
|
||||
)({
|
||||
bundlePath: input.bundlePath,
|
||||
targetSha256: input.targetSha256,
|
||||
signal:
|
||||
input.signal === undefined
|
||||
? interfaceBuilderCancel.signal
|
||||
: AbortSignal.any([input.signal, interfaceBuilderCancel.signal]),
|
||||
}).then(
|
||||
(value) => ({ ok: true, value }) as const,
|
||||
(error: unknown) => ({ ok: false, error }) as const,
|
||||
);
|
||||
let catalogs: Awaited<ReturnType<typeof collectAssetCatalogs>>["catalogs"];
|
||||
let uninspected: readonly string[];
|
||||
try {
|
||||
({ catalogs, uninspected } = await collectAssetCatalogs(input));
|
||||
} catch (cause: unknown) {
|
||||
interfaceBuilderCancel.abort();
|
||||
await interfaceBuilderAnalysis;
|
||||
throw cause;
|
||||
}
|
||||
const analyzed = await interfaceBuilderAnalysis;
|
||||
if (!analyzed.ok) throw analyzed.error;
|
||||
const interfaceBuilder = analyzed.value;
|
||||
const resourceReferences = collectInterfaceBuilderResourceKeys(
|
||||
interfaceBuilder.graph.nodes,
|
||||
);
|
||||
const result = projectAppleAssetCatalogPage({
|
||||
targetSha256: input.targetSha256,
|
||||
catalogs,
|
||||
offset: page.offset,
|
||||
limit: page.limit,
|
||||
resourceReferences,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
limitations: [
|
||||
...result.limitations,
|
||||
// An app without a compiled catalog is an observed empty inventory, not
|
||||
// a missing host tool.
|
||||
...(catalogs.length === 0
|
||||
? [
|
||||
"No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.",
|
||||
]
|
||||
: []),
|
||||
...(uninspected.length === 0
|
||||
? []
|
||||
: [
|
||||
`Asset catalog entries that are not regular files were not inspected, so resource keys reported as unmatched may resolve there: ${uninspected.join(", ")}`,
|
||||
]),
|
||||
"Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred.",
|
||||
...(interfaceBuilder.graph.truncated
|
||||
? [
|
||||
"Interface Builder resource-key joins may be incomplete because the decoded UI graph was truncated.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/** Inspect every regular Assets.car in the bundle; non-file entries are listed. */
|
||||
const collectAssetCatalogs = async (input: {
|
||||
readonly bundlePath: string;
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly runAssetUtil?: (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<string>;
|
||||
}) => {
|
||||
const reader = new DirectoryArtifactReader(input.bundlePath);
|
||||
const catalogs: {
|
||||
path: string;
|
||||
@@ -77,45 +153,7 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
"path",
|
||||
`Asset catalog entries are not regular files and were not inspected: ${uninspected.join(", ")}`,
|
||||
);
|
||||
const interfaceBuilder = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: input.bundlePath,
|
||||
targetSha256: input.targetSha256,
|
||||
...(input.signal === undefined ? {} : { signal: input.signal }),
|
||||
});
|
||||
const resourceReferences = collectInterfaceBuilderResourceKeys(
|
||||
interfaceBuilder.graph.nodes,
|
||||
);
|
||||
const result = projectAppleAssetCatalogPage({
|
||||
targetSha256: input.targetSha256,
|
||||
catalogs,
|
||||
offset: page.offset,
|
||||
limit: page.limit,
|
||||
resourceReferences,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
limitations: [
|
||||
...result.limitations,
|
||||
// An app without a compiled catalog is an observed empty inventory, not
|
||||
// a missing host tool.
|
||||
...(catalogs.length === 0
|
||||
? [
|
||||
"No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.",
|
||||
]
|
||||
: []),
|
||||
...(uninspected.length === 0
|
||||
? []
|
||||
: [
|
||||
`Asset catalog entries that are not regular files were not inspected, so resource keys reported as unmatched may resolve there: ${uninspected.join(", ")}`,
|
||||
]),
|
||||
"Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred.",
|
||||
...(interfaceBuilder.graph.truncated
|
||||
? [
|
||||
"Interface Builder resource-key joins may be incomplete because the decoded UI graph was truncated.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
return { catalogs, uninspected };
|
||||
};
|
||||
|
||||
const inspectAssetCatalogEntry = async (input: {
|
||||
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { consumeWithOrderedReads } from "../OrderedReads.js";
|
||||
import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
|
||||
|
||||
const OPERATION = "trace_dylib_resolution";
|
||||
const HASH_CHUNK_BYTES = 1024 * 1024;
|
||||
/** Concurrent four-byte magic reads while enumerating bundle roots. */
|
||||
const MAGIC_READ_CONCURRENCY = 8;
|
||||
|
||||
/** Device, inode, size and change times of the file whose bytes were parsed. */
|
||||
type FileIdentity = string;
|
||||
@@ -184,21 +187,27 @@ const executableRoots = async (
|
||||
): Promise<{ readonly roots: string[]; readonly unclassified: string[] }> => {
|
||||
const roots: string[] = [];
|
||||
const unclassified: string[] = [];
|
||||
for await (const entry of new DirectoryArtifactReader(root).entries(signal)) {
|
||||
if (
|
||||
entry.kind !== "file" ||
|
||||
!(await startsWithMachoMagic(entry.adapterKey))
|
||||
)
|
||||
continue;
|
||||
const facts = await view.image(entry.path);
|
||||
if (facts.status === "malformed" || facts.status === "unsupported")
|
||||
unclassified.push(entry.path);
|
||||
else if (
|
||||
facts.status === "parsed" &&
|
||||
facts.slices.some(({ file_type: type }) => type === "execute")
|
||||
)
|
||||
roots.push(entry.path);
|
||||
}
|
||||
// Bundles hold tens of thousands of files and few Mach-O images, so the
|
||||
// magic reads overlap the walk while results are classified in walk order.
|
||||
await consumeWithOrderedReads(
|
||||
new DirectoryArtifactReader(root).entries(signal),
|
||||
MAGIC_READ_CONCURRENCY,
|
||||
(entry) =>
|
||||
entry.kind === "file"
|
||||
? startsWithMachoMagic(entry.adapterKey)
|
||||
: undefined,
|
||||
async (entry, macho) => {
|
||||
if (!macho) return;
|
||||
const facts = await view.image(entry.path);
|
||||
if (facts.status === "malformed" || facts.status === "unsupported")
|
||||
unclassified.push(entry.path);
|
||||
else if (
|
||||
facts.status === "parsed" &&
|
||||
facts.slices.some(({ file_type: type }) => type === "execute")
|
||||
)
|
||||
roots.push(entry.path);
|
||||
},
|
||||
);
|
||||
return {
|
||||
roots: roots.sort(compare),
|
||||
unclassified: unclassified.sort(compare),
|
||||
|
||||
@@ -198,13 +198,27 @@ const readFatSlices = async (
|
||||
declarations: readonly FatSliceDeclaration[],
|
||||
): Promise<MachoSlice[]> => {
|
||||
const slices: MachoSlice[] = [];
|
||||
// Keep validating after an unsupported slice so a later malformed slice is
|
||||
// reported as malformed whatever the slice order.
|
||||
let unsupported: MachoFormatIssue | undefined;
|
||||
for (const declaration of declarations) {
|
||||
const { offset, size: length, cpuType, cpuSubtype } = declaration;
|
||||
const slice = await readSlice(readAt, offset, length, {
|
||||
fatCpuType: cpuType,
|
||||
fatCpuSubtype: cpuSubtype,
|
||||
alignmentExponent: declaration.alignmentExponent,
|
||||
});
|
||||
let slice: MachoSlice;
|
||||
try {
|
||||
slice = await readSlice(readAt, offset, length, {
|
||||
fatCpuType: cpuType,
|
||||
fatCpuSubtype: cpuSubtype,
|
||||
alignmentExponent: declaration.alignmentExponent,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
!(cause instanceof MachoFormatIssue) ||
|
||||
cause.status !== "unsupported"
|
||||
)
|
||||
throw cause;
|
||||
unsupported ??= cause;
|
||||
continue;
|
||||
}
|
||||
const identityIssue = validateFatSliceIdentity(declaration, {
|
||||
cpuType: slice.cpu_type,
|
||||
cpuSubtype: slice.cpu_subtype,
|
||||
@@ -213,6 +227,7 @@ const readFatSlices = async (
|
||||
throw new MachoFormatIssue("malformed", identityIssue);
|
||||
slices.push(slice);
|
||||
}
|
||||
if (unsupported !== undefined) throw unsupported;
|
||||
return slices;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CPU, fatImage, machoImage } from "../apple/MachoImage.fixture.js";
|
||||
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
|
||||
import {
|
||||
classifyArtifactContent,
|
||||
createArtifactEdges,
|
||||
createOccurrence,
|
||||
indexOccurrencesByPath,
|
||||
resolveOccurrenceParents,
|
||||
} from "./ArtifactGraphConstruction.js";
|
||||
import { artifactIdForContent } from "../../domain/artifactIdentity.js";
|
||||
import type { ArtifactEntry } from "../ArtifactReader.js";
|
||||
|
||||
const entry = (path: string, kind: ArtifactEntry["kind"]): ArtifactEntry => ({
|
||||
path,
|
||||
kind,
|
||||
declaredSize: null,
|
||||
compressedSize: null,
|
||||
executable: false,
|
||||
encrypted: false,
|
||||
byteOffset: null,
|
||||
declaredSha256: null,
|
||||
unpacked: false,
|
||||
limitations: [],
|
||||
adapterKey: path,
|
||||
});
|
||||
|
||||
describe("occurrence parent ownership", () => {
|
||||
it("selects the nearest unique nested parent", () => {
|
||||
const outer = createOccurrence(entry("a", "directory"), "a", null);
|
||||
const inner = createOccurrence(entry("a/b", "directory"), "a/b", null);
|
||||
const file = createOccurrence(entry("a/b/file", "file"), "a/b/file", null);
|
||||
const occurrences = [outer, inner, file];
|
||||
|
||||
resolveOccurrenceParents(
|
||||
occurrences,
|
||||
indexOccurrencesByPath(occurrences),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
expect(file.parent_occurrence_id).toBe(inner.occurrence_id);
|
||||
});
|
||||
|
||||
it("leaves a child unparented when duplicate directories make ownership ambiguous", () => {
|
||||
const first = createOccurrence(entry("a", "directory"), "a", null, 0);
|
||||
const second = createOccurrence(entry("a", "directory"), "a", null, 1);
|
||||
const file = createOccurrence(entry("a/file", "file"), "a/file", null);
|
||||
const occurrences = [first, second, file];
|
||||
|
||||
const resolution = resolveOccurrenceParents(
|
||||
occurrences,
|
||||
indexOccurrencesByPath(occurrences),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
expect(file.parent_occurrence_id).toBeNull();
|
||||
expect(resolution.unknownParentOccurrences.has(file)).toBe(true);
|
||||
expect(resolution.limitations).toContain(
|
||||
'The parent occurrence for "a/file" is unknown because multiple observed directory or container occurrences share "a".',
|
||||
);
|
||||
expect([first, second].map(({ hash_status }) => hash_status)).toEqual([
|
||||
"unavailable",
|
||||
"unavailable",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("source-map artifact relationships", () => {
|
||||
const contentId = (digit: string): string =>
|
||||
artifactIdForContent(digit.repeat(64));
|
||||
|
||||
it.each([
|
||||
["missing target", [] as string[], "contains"],
|
||||
["unread target", ["unread"] as string[], "contains"],
|
||||
["unique identified target", ["identified"] as string[], "maps-source"],
|
||||
["duplicate targets", ["identified", "identified"] as string[], "contains"],
|
||||
] as const)(
|
||||
"uses a truthful edge for a %s",
|
||||
(_label, targetIdentities, relation) => {
|
||||
const directory = createOccurrence(
|
||||
entry("app", "directory"),
|
||||
"app",
|
||||
null,
|
||||
);
|
||||
directory.artifact_id = contentId("1");
|
||||
const targets = targetIdentities.map((identity, index) => {
|
||||
const target = createOccurrence(
|
||||
entry("app/main.js", "file"),
|
||||
"app/main.js",
|
||||
directory.occurrence_id,
|
||||
index,
|
||||
);
|
||||
target.artifact_id = identity === "unread" ? null : contentId("2");
|
||||
return target;
|
||||
});
|
||||
const sourceMap = createOccurrence(
|
||||
entry("app/main.js.map", "file"),
|
||||
"app/main.js.map",
|
||||
directory.occurrence_id,
|
||||
);
|
||||
sourceMap.artifact_id = contentId("3");
|
||||
|
||||
const result = createArtifactEdges(contentId("4"), [
|
||||
directory,
|
||||
...targets,
|
||||
sourceMap,
|
||||
]);
|
||||
const edge = result.edges.find(
|
||||
({ occurrence_id }) => occurrence_id === sourceMap.occurrence_id,
|
||||
);
|
||||
|
||||
expect(edge).toMatchObject({
|
||||
relation,
|
||||
parent_artifact_id:
|
||||
relation === "maps-source" ? contentId("2") : contentId("1"),
|
||||
occurrence_id: sourceMap.occurrence_id,
|
||||
});
|
||||
expect(result.limitations.length > 0).toBe(
|
||||
(targetIdentities.length === 1 && targetIdentities[0] === "unread") ||
|
||||
targetIdentities.length > 1,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const fat = (wide = false): Buffer =>
|
||||
Buffer.from(fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }], wide));
|
||||
|
||||
@@ -50,14 +50,17 @@ export interface MutableOccurrence {
|
||||
limitations: string[];
|
||||
}
|
||||
|
||||
/** Index the first occurrence for each canonical logical path. */
|
||||
/** Index every occurrence by canonical logical path without choosing an owner. */
|
||||
export const indexOccurrencesByPath = (
|
||||
occurrences: readonly MutableOccurrence[],
|
||||
): ReadonlyMap<string, MutableOccurrence> => {
|
||||
const byPath = new Map<string, MutableOccurrence>();
|
||||
for (const occurrence of occurrences)
|
||||
if (!byPath.has(occurrence.logical_path))
|
||||
byPath.set(occurrence.logical_path, occurrence);
|
||||
): ReadonlyMap<string, readonly MutableOccurrence[]> => {
|
||||
const byPath = new Map<string, MutableOccurrence[]>();
|
||||
for (const occurrence of occurrences) {
|
||||
const matches = byPath.get(occurrence.logical_path);
|
||||
if (matches === undefined)
|
||||
byPath.set(occurrence.logical_path, [occurrence]);
|
||||
else matches.push(occurrence);
|
||||
}
|
||||
return byPath;
|
||||
};
|
||||
|
||||
@@ -70,6 +73,7 @@ export const createOccurrence = (
|
||||
entry: ArtifactEntry,
|
||||
path: string,
|
||||
parent: string | null,
|
||||
duplicateOrdinal = 0,
|
||||
): MutableOccurrence => {
|
||||
const role =
|
||||
entry.kind === "directory"
|
||||
@@ -83,7 +87,12 @@ export const createOccurrence = (
|
||||
? classifyArtifactPath(path)
|
||||
: ({ kind: "unknown", format: "unknown" } as const);
|
||||
return {
|
||||
occurrence_id: `occ_${digestCanonicalValue({ path, kind: entry.kind }, "Artifact")}`,
|
||||
occurrence_id: `occ_${digestCanonicalValue(
|
||||
duplicateOrdinal === 0
|
||||
? { path, kind: entry.kind }
|
||||
: { path, kind: entry.kind, duplicate_ordinal: duplicateOrdinal },
|
||||
"Artifact",
|
||||
)}`,
|
||||
artifact_id: null,
|
||||
parent_occurrence_id: parent,
|
||||
logical_path: path,
|
||||
@@ -128,7 +137,10 @@ export const materializeDirectoryNodes = (
|
||||
nodes: Map<string, ArtifactNode>,
|
||||
): void => {
|
||||
const directories = occurrences
|
||||
.filter(({ entry_kind: kind }) => kind === "directory")
|
||||
.filter(
|
||||
({ entry_kind: kind, hash_status }) =>
|
||||
kind === "directory" && hash_status !== "unavailable",
|
||||
)
|
||||
.sort(
|
||||
(left, right) => depth(right.logical_path) - depth(left.logical_path),
|
||||
);
|
||||
@@ -249,15 +261,20 @@ export const rekeyOccurrences = (
|
||||
occurrences: MutableOccurrence[],
|
||||
): void => {
|
||||
const replacements = new Map<string, string>();
|
||||
for (const occurrence of occurrences)
|
||||
const ordinalByPath = new Map<string, number>();
|
||||
for (const occurrence of occurrences) {
|
||||
const ordinal = ordinalByPath.get(occurrence.logical_path) ?? 0;
|
||||
ordinalByPath.set(occurrence.logical_path, ordinal + 1);
|
||||
replacements.set(
|
||||
occurrence.occurrence_id,
|
||||
occurrenceIdForLocation({
|
||||
rootArtifactId,
|
||||
logicalPath: occurrence.logical_path,
|
||||
entryKind: occurrence.entry_kind,
|
||||
...(ordinal === 0 ? {} : { duplicateOrdinal: ordinal }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const occurrence of occurrences) {
|
||||
occurrence.occurrence_id =
|
||||
replacements.get(occurrence.occurrence_id) ?? occurrence.occurrence_id;
|
||||
@@ -272,19 +289,45 @@ export const createArtifactEdges = (
|
||||
rootArtifactId: string,
|
||||
occurrences: MutableOccurrence[],
|
||||
producer?: ArtifactCommand,
|
||||
): ArtifactEdge[] => {
|
||||
): { readonly edges: ArtifactEdge[]; readonly limitations: string[] } => {
|
||||
const byId = new Map(occurrences.map((item) => [item.occurrence_id, item]));
|
||||
const byPath = indexOccurrencesByPath(occurrences);
|
||||
const edges: ArtifactEdge[] = [];
|
||||
const limitations: string[] = [];
|
||||
for (const occurrence of occurrences) {
|
||||
const isSourceMap = occurrence.logical_path.toLowerCase().endsWith(".map");
|
||||
const sourcePath = isSourceMap
|
||||
? occurrence.logical_path.slice(0, -".map".length)
|
||||
: undefined;
|
||||
const sourceCandidates =
|
||||
sourcePath === undefined ? [] : (byPath.get(sourcePath) ?? []);
|
||||
const mappedSource =
|
||||
sourceCandidates.length === 1 && sourceCandidates[0]?.artifact_id !== null
|
||||
? sourceCandidates[0]
|
||||
: undefined;
|
||||
if (sourcePath !== undefined) {
|
||||
const unknownMapping =
|
||||
sourceCandidates.length > 1
|
||||
? {
|
||||
occurrence: `Source-map target is unknown because multiple occurrences share ${JSON.stringify(sourcePath)}.`,
|
||||
coverage: `Source-map relationships are unknown because multiple occurrences share ${JSON.stringify(sourcePath)}.`,
|
||||
}
|
||||
: sourceCandidates.length === 1 && mappedSource === undefined
|
||||
? {
|
||||
occurrence: `Source-map target identity is unknown because the matching occurrence at ${JSON.stringify(sourcePath)} has no content identity.`,
|
||||
coverage: `Source-map relationships are unknown because the matching occurrence at ${JSON.stringify(sourcePath)} has no content identity.`,
|
||||
}
|
||||
: undefined;
|
||||
if (unknownMapping !== undefined) {
|
||||
occurrence.limitations.push(unknownMapping.occurrence);
|
||||
limitations.push(unknownMapping.coverage);
|
||||
}
|
||||
}
|
||||
if (
|
||||
occurrence.parent_occurrence_id === null ||
|
||||
occurrence.artifact_id === null
|
||||
)
|
||||
continue;
|
||||
const mappedSource = occurrence.logical_path.toLowerCase().endsWith(".map")
|
||||
? byPath.get(occurrence.logical_path.slice(0, -".map".length))
|
||||
: undefined;
|
||||
const parentArtifactId =
|
||||
mappedSource?.artifact_id ??
|
||||
byId.get(occurrence.parent_occurrence_id)?.artifact_id ??
|
||||
@@ -294,7 +337,9 @@ export const createArtifactEdges = (
|
||||
relation:
|
||||
occurrence.entry_kind === "slice"
|
||||
? ("slice-of" as const)
|
||||
: relationFor(occurrence.logical_path),
|
||||
: isSourceMap && mappedSource === undefined
|
||||
? ("contains" as const)
|
||||
: relationFor(occurrence.logical_path),
|
||||
occurrence_id: occurrence.occurrence_id,
|
||||
};
|
||||
edges.push({
|
||||
@@ -304,29 +349,44 @@ export const createArtifactEdges = (
|
||||
ordinal: edges.length,
|
||||
});
|
||||
}
|
||||
return edges;
|
||||
return { edges, limitations };
|
||||
};
|
||||
|
||||
interface ArtifactParentIndex {
|
||||
readonly children: Map<string, ArtifactParentIndex>;
|
||||
occurrence?: MutableOccurrence;
|
||||
occurrences?: readonly MutableOccurrence[];
|
||||
}
|
||||
|
||||
/** Resolve strict observed ancestors without rebuilding every path prefix. */
|
||||
export const resolveOccurrenceParents = (
|
||||
occurrences: readonly MutableOccurrence[],
|
||||
byPath: ReadonlyMap<string, MutableOccurrence>,
|
||||
byPath: ReadonlyMap<string, readonly MutableOccurrence[]>,
|
||||
expandedContainerIds: ReadonlySet<string>,
|
||||
): void => {
|
||||
): {
|
||||
readonly limitations: readonly string[];
|
||||
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
|
||||
} => {
|
||||
const root: ArtifactParentIndex = { children: new Map() };
|
||||
for (const occurrence of byPath.values()) {
|
||||
if (
|
||||
occurrence.entry_kind !== "directory" &&
|
||||
!expandedContainerIds.has(occurrence.occurrence_id)
|
||||
)
|
||||
continue;
|
||||
for (const matches of byPath.values()) {
|
||||
const parents = matches.filter(
|
||||
(occurrence) =>
|
||||
occurrence.entry_kind === "directory" ||
|
||||
expandedContainerIds.has(occurrence.occurrence_id),
|
||||
);
|
||||
if (parents.length === 0) continue;
|
||||
if (parents.length > 1)
|
||||
for (const parent of parents)
|
||||
if (parent.entry_kind === "directory") {
|
||||
parent.artifact_id = null;
|
||||
parent.hash_status = "unavailable";
|
||||
parent.limitations.push(
|
||||
"This directory occurrence cannot be materialized because multiple directory entries share its logical path.",
|
||||
);
|
||||
}
|
||||
const path = matches[0]?.logical_path;
|
||||
if (path === undefined) continue;
|
||||
let branch = root;
|
||||
for (const segment of occurrence.logical_path.split("/")) {
|
||||
for (const segment of path.split("/")) {
|
||||
let child = branch.children.get(segment);
|
||||
if (child === undefined) {
|
||||
child = { children: new Map() };
|
||||
@@ -334,12 +394,16 @@ export const resolveOccurrenceParents = (
|
||||
}
|
||||
branch = child;
|
||||
}
|
||||
branch.occurrence = occurrence;
|
||||
branch.occurrences = parents;
|
||||
}
|
||||
const limitations = new Set<string>();
|
||||
const unknownParentOccurrences = new Set<MutableOccurrence>();
|
||||
for (const occurrence of occurrences) {
|
||||
const path = occurrence.logical_path;
|
||||
let branch = root;
|
||||
let parent: MutableOccurrence | undefined;
|
||||
let ambiguousParentPath: string | undefined;
|
||||
let prefix = "";
|
||||
// Walk parent segments only. Stop scanning the path once no observed
|
||||
// directory or expanded container can be an ancestor.
|
||||
for (let start = 0; ;) {
|
||||
@@ -349,11 +413,29 @@ export const resolveOccurrenceParents = (
|
||||
const child = branch.children.get(segment);
|
||||
if (child === undefined) break;
|
||||
branch = child;
|
||||
parent = branch.occurrence ?? parent;
|
||||
prefix = prefix.length === 0 ? segment : `${prefix}/${segment}`;
|
||||
const candidates = branch.occurrences ?? [];
|
||||
if (candidates.length === 1) {
|
||||
parent = candidates[0];
|
||||
ambiguousParentPath = undefined;
|
||||
} else if (candidates.length > 1) {
|
||||
parent = undefined;
|
||||
ambiguousParentPath = prefix;
|
||||
}
|
||||
start = slash + 1;
|
||||
}
|
||||
occurrence.parent_occurrence_id = parent?.occurrence_id ?? null;
|
||||
if (ambiguousParentPath !== undefined) {
|
||||
const limitation = `The parent occurrence for ${JSON.stringify(path)} is unknown because multiple observed directory or container occurrences share ${JSON.stringify(ambiguousParentPath)}.`;
|
||||
occurrence.limitations.push(limitation);
|
||||
limitations.add(limitation);
|
||||
unknownParentOccurrences.add(occurrence);
|
||||
}
|
||||
}
|
||||
return {
|
||||
limitations: [...limitations].sort(compareUnicodeCodePoints),
|
||||
unknownParentOccurrences,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import {
|
||||
createArtifactEdges,
|
||||
createRootNode,
|
||||
indexOccurrencesByPath,
|
||||
materializeDirectoryNodes,
|
||||
rekeyOccurrences,
|
||||
rootOccurrenceFor,
|
||||
@@ -100,6 +99,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation,
|
||||
unknownParentOccurrences,
|
||||
traversal,
|
||||
} = await scanReader(reader, ownedReaders, options.signal, integrity);
|
||||
outcome = {
|
||||
@@ -117,6 +117,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation,
|
||||
unknownParentOccurrences,
|
||||
traversal,
|
||||
}),
|
||||
};
|
||||
@@ -199,6 +200,7 @@ interface SnapshotBuildInput {
|
||||
readonly occurrences: MutableOccurrence[];
|
||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||
readonly caseCollisionLimitation: string | undefined;
|
||||
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
|
||||
readonly traversal: ArtifactReaderTraversal;
|
||||
}
|
||||
|
||||
@@ -221,7 +223,10 @@ const buildInventorySnapshot = async (
|
||||
path,
|
||||
});
|
||||
for (const occurrence of input.occurrences)
|
||||
if (occurrence.parent_occurrence_id === null)
|
||||
if (
|
||||
occurrence.parent_occurrence_id === null &&
|
||||
!input.unknownParentOccurrences.has(occurrence)
|
||||
)
|
||||
occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
|
||||
input.occurrences.unshift(rootOccurrence);
|
||||
|
||||
@@ -231,18 +236,17 @@ const buildInventorySnapshot = async (
|
||||
occurrence,
|
||||
]),
|
||||
);
|
||||
const occurrenceByPath = indexOccurrencesByPath(input.occurrences);
|
||||
const integrityContradictions = buildIntegrityContradictions(
|
||||
input.pendingContradictions,
|
||||
occurrenceByPath,
|
||||
occurrenceById,
|
||||
rootNode,
|
||||
);
|
||||
const edges = createArtifactEdges(
|
||||
const edgeResult = createArtifactEdges(
|
||||
rootNode.artifact_id,
|
||||
input.occurrences,
|
||||
reader?.provenance()[0],
|
||||
);
|
||||
const { edges } = edgeResult;
|
||||
const orderedNodes = sortNodes([...input.nodes.values()]);
|
||||
const orderedOccurrences = sortOccurrences(input.occurrences);
|
||||
const orderedEdges = sortEdges(edges).map((edge, ordinal) => ({
|
||||
@@ -273,6 +277,10 @@ const buildInventorySnapshot = async (
|
||||
orderedEdges,
|
||||
});
|
||||
const traversal = inventoryCoverage(rootFormat, reader, input.traversal);
|
||||
const coverage =
|
||||
edgeResult.limitations.length === 0
|
||||
? traversal.coverage
|
||||
: { ...traversal.coverage, status: "unknown" as const };
|
||||
return {
|
||||
manifest,
|
||||
nodes: orderedNodes,
|
||||
@@ -280,11 +288,10 @@ const buildInventorySnapshot = async (
|
||||
edges: orderedEdges,
|
||||
provenance: reader?.provenance() ?? [],
|
||||
integrity_contradictions: integrityContradictions,
|
||||
coverage: traversal.coverage,
|
||||
coverage_status:
|
||||
traversal.coverage.status === "complete" ? "complete" : "partial",
|
||||
coverage,
|
||||
coverage_status: coverage.status === "complete" ? "complete" : "partial",
|
||||
limitations: buildLimitations(
|
||||
traversal.limitations,
|
||||
[...traversal.limitations, ...edgeResult.limitations],
|
||||
integrityContradictions,
|
||||
input.caseCollisionLimitation,
|
||||
),
|
||||
@@ -293,17 +300,11 @@ const buildInventorySnapshot = async (
|
||||
|
||||
const buildIntegrityContradictions = (
|
||||
pending: readonly PendingIntegrityContradiction[],
|
||||
occurrenceByPath: ReadonlyMap<string, MutableOccurrence>,
|
||||
occurrenceById: ReadonlyMap<string, MutableOccurrence>,
|
||||
rootNode: ArtifactNode,
|
||||
): IntegrityContradiction[] =>
|
||||
pending.map((contradiction): IntegrityContradiction => {
|
||||
const occurrence = occurrenceByPath.get(contradiction.logicalPath);
|
||||
if (occurrence === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Integrity contradiction lost its graph occurrence",
|
||||
);
|
||||
const occurrence = contradiction.occurrence;
|
||||
const parent =
|
||||
occurrence.parent_occurrence_id === null
|
||||
? undefined
|
||||
@@ -312,13 +313,13 @@ const buildIntegrityContradictions = (
|
||||
return {
|
||||
contradiction_id: artifactContradictionId({
|
||||
rootArtifactId: rootNode.artifact_id,
|
||||
logicalPath: contradiction.logicalPath,
|
||||
logicalPath: occurrence.logical_path,
|
||||
declaredSha256: contradiction.declaredSha256,
|
||||
observedSha256: contradiction.observedSha256,
|
||||
}),
|
||||
occurrence_id: occurrence.occurrence_id,
|
||||
parent_artifact_id: parentArtifactId,
|
||||
logical_path: contradiction.logicalPath,
|
||||
logical_path: occurrence.logical_path,
|
||||
declared_sha256: contradiction.declaredSha256,
|
||||
observed_sha256: contradiction.observedSha256,
|
||||
entry_kind: contradiction.entryKind,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
createArtifactNode,
|
||||
createOccurrence,
|
||||
noteUnreadContainerSuffix,
|
||||
indexOccurrencesByPath,
|
||||
resolveOccurrenceParents,
|
||||
type MutableOccurrence,
|
||||
} from "./ArtifactGraphConstruction.js";
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
import { hashReadable } from "../ArtifactHash.js";
|
||||
|
||||
export interface PendingIntegrityContradiction {
|
||||
readonly logicalPath: string;
|
||||
readonly occurrence: MutableOccurrence;
|
||||
readonly declaredSha256: string;
|
||||
readonly observedSha256: string;
|
||||
readonly entryKind: "file" | "slice";
|
||||
@@ -54,7 +55,7 @@ interface ScanContext {
|
||||
readonly nodes: Map<string, ArtifactNode>;
|
||||
readonly occurrences: MutableOccurrence[];
|
||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||
readonly occurrenceByPath: Map<string, MutableOccurrence>;
|
||||
readonly occurrenceCountByPath: Map<string, number>;
|
||||
readonly registry: ArtifactPathRegistry;
|
||||
readonly expandedContainerIds: Set<string>;
|
||||
readonly ownedReaders: ArtifactReader[];
|
||||
@@ -71,6 +72,7 @@ export const scanReader = async (
|
||||
readonly occurrences: MutableOccurrence[];
|
||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||
readonly caseCollisionLimitation: string | undefined;
|
||||
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
|
||||
readonly traversal: ArtifactReaderTraversal;
|
||||
}> => {
|
||||
const { nodes, occurrences, pendingContradictions } = emptyScan();
|
||||
@@ -80,6 +82,7 @@ export const scanReader = async (
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation: undefined,
|
||||
unknownParentOccurrences: new Set(),
|
||||
traversal: { status: "complete", limitations: [] },
|
||||
};
|
||||
const context: ScanContext = {
|
||||
@@ -89,7 +92,7 @@ export const scanReader = async (
|
||||
nodes,
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
occurrenceByPath: new Map<string, MutableOccurrence>(),
|
||||
occurrenceCountByPath: new Map<string, number>(),
|
||||
registry: new ArtifactPathRegistry(),
|
||||
expandedContainerIds: new Set<string>(),
|
||||
ownedReaders,
|
||||
@@ -98,25 +101,32 @@ export const scanReader = async (
|
||||
await visitArtifactEntries(context, reader, "");
|
||||
// Archive directories may appear after their children. Resolve containment
|
||||
// against the complete index before directory identities are materialized.
|
||||
resolveOccurrenceParents(
|
||||
const byPath = indexOccurrencesByPath(occurrences);
|
||||
const parentResolution = resolveOccurrenceParents(
|
||||
occurrences,
|
||||
context.occurrenceByPath,
|
||||
byPath,
|
||||
context.expandedContainerIds,
|
||||
);
|
||||
const structuralLimitations = [...parentResolution.limitations];
|
||||
return {
|
||||
nodes,
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation: annotateEnglishUnicodeCaseCollisions(occurrences),
|
||||
unknownParentOccurrences: parentResolution.unknownParentOccurrences,
|
||||
traversal: {
|
||||
status: context.omissions.length === 0 ? "complete" : "unknown",
|
||||
status:
|
||||
context.omissions.length === 0 && structuralLimitations.length === 0
|
||||
? "complete"
|
||||
: "unknown",
|
||||
limitations: [
|
||||
...new Set(
|
||||
context.omissions.map(
|
||||
...new Set([
|
||||
...context.omissions.map(
|
||||
({ path, kind }) =>
|
||||
`Artifact traversal omitted ${kind} at ${JSON.stringify(path)}.`,
|
||||
),
|
||||
),
|
||||
...structuralLimitations,
|
||||
]),
|
||||
].sort(compareUnicodeCodePoints),
|
||||
},
|
||||
};
|
||||
@@ -176,7 +186,15 @@ const visitArtifactEntries = async (
|
||||
logicalPath,
|
||||
expandableAsar ? "directory" : entry.kind,
|
||||
);
|
||||
const occurrence = createOccurrence(entry, logicalPath, null);
|
||||
const duplicateOrdinal =
|
||||
context.occurrenceCountByPath.get(logicalPath) ?? 0;
|
||||
context.occurrenceCountByPath.set(logicalPath, duplicateOrdinal + 1);
|
||||
const occurrence = createOccurrence(
|
||||
entry,
|
||||
logicalPath,
|
||||
null,
|
||||
duplicateOrdinal,
|
||||
);
|
||||
let digested:
|
||||
| {
|
||||
readonly node: ArtifactNode;
|
||||
@@ -190,6 +208,7 @@ const visitArtifactEntries = async (
|
||||
frame.reader,
|
||||
entry,
|
||||
logicalPath,
|
||||
occurrence,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (!isUnavailableUnpackedEntry(cause, entry)) throw cause;
|
||||
@@ -211,7 +230,6 @@ const visitArtifactEntries = async (
|
||||
);
|
||||
}
|
||||
context.occurrences.push(occurrence);
|
||||
context.occurrenceByPath.set(logicalPath, occurrence);
|
||||
if (
|
||||
expandableAsar &&
|
||||
digested !== undefined &&
|
||||
@@ -258,6 +276,7 @@ const digestArtifactEntry = async (
|
||||
currentReader: ArtifactReader,
|
||||
entry: ArtifactEntry,
|
||||
logicalPath: string,
|
||||
occurrence: MutableOccurrence,
|
||||
): Promise<
|
||||
| {
|
||||
readonly node: ArtifactNode;
|
||||
@@ -288,7 +307,7 @@ const digestArtifactEntry = async (
|
||||
},
|
||||
);
|
||||
context.pendingContradictions.push({
|
||||
logicalPath,
|
||||
occurrence,
|
||||
declaredSha256: entry.declaredSha256,
|
||||
observedSha256: digest.sha256,
|
||||
entryKind: entry.kind,
|
||||
|
||||
@@ -140,13 +140,21 @@ export const readJavaScriptArtifactFiles = async (
|
||||
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
|
||||
return compareUnicodeCodePoints(left.path, right.path);
|
||||
});
|
||||
const unanalyzedFiles = createUnanalyzedArtifactLookup(
|
||||
snapshot,
|
||||
context.containers,
|
||||
);
|
||||
const unavailableRelevantFiles = [...inventory.unavailableFiles]
|
||||
.sort(compareUnicodeCodePoints)
|
||||
.flatMap((path) => {
|
||||
const unavailable = unanalyzedFiles.get(path);
|
||||
return unavailable === undefined ? [] : [unavailable];
|
||||
});
|
||||
assertExpectedFilesWereVisited(expected, files);
|
||||
return {
|
||||
files,
|
||||
unanalyzed_files: createUnanalyzedArtifactLookup(
|
||||
snapshot,
|
||||
context.containers,
|
||||
),
|
||||
unanalyzed_files: unanalyzedFiles,
|
||||
unavailable_relevant_files: unavailableRelevantFiles,
|
||||
containers: context.containers.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.path, right.path),
|
||||
),
|
||||
@@ -488,6 +496,10 @@ const createUnanalyzedArtifactLookup = (
|
||||
const isOpaque = (occurrence: ArtifactOccurrence): boolean =>
|
||||
occurrence.entry_kind === "file" &&
|
||||
relevantKind(occurrence.logical_path) === undefined;
|
||||
const isUnavailableRelevant = (occurrence: ArtifactOccurrence): boolean =>
|
||||
occurrence.entry_kind === "file" &&
|
||||
occurrence.artifact_id === null &&
|
||||
relevantKind(occurrence.logical_path) !== undefined;
|
||||
return {
|
||||
get(path) {
|
||||
if (requested.has(path)) return requested.get(path);
|
||||
@@ -495,8 +507,10 @@ const createUnanalyzedArtifactLookup = (
|
||||
snapshot.occurrences[
|
||||
lowerBound(snapshot.occurrences, path, (entry) => entry.logical_path)
|
||||
];
|
||||
const kind = relevantKind(path);
|
||||
const id =
|
||||
occurrence?.logical_path === path && isOpaque(occurrence)
|
||||
occurrence?.logical_path === path &&
|
||||
(isOpaque(occurrence) || isUnavailableRelevant(occurrence))
|
||||
? occurrence.artifact_id
|
||||
: undefined;
|
||||
const candidate =
|
||||
@@ -507,7 +521,8 @@ const createUnanalyzedArtifactLookup = (
|
||||
];
|
||||
const node = candidate?.artifact_id === id ? candidate : undefined;
|
||||
const file =
|
||||
occurrence?.logical_path !== path || !isOpaque(occurrence)
|
||||
occurrence?.logical_path !== path ||
|
||||
(!isOpaque(occurrence) && !isUnavailableRelevant(occurrence))
|
||||
? undefined
|
||||
: {
|
||||
path,
|
||||
@@ -515,6 +530,10 @@ const createUnanalyzedArtifactLookup = (
|
||||
sha256: node?.sha256 ?? null,
|
||||
bytes: node?.size ?? occurrence.declared_size,
|
||||
inventory_artifact_id: occurrence.artifact_id,
|
||||
inventory_occurrence_id: occurrence.occurrence_id,
|
||||
...(kind === undefined ? {} : { kind }),
|
||||
artifact_kind: occurrence.artifact_kind,
|
||||
hash_status: occurrence.hash_status,
|
||||
limitations: occurrence.limitations,
|
||||
};
|
||||
requested.set(path, file);
|
||||
@@ -539,7 +558,7 @@ const createUnanalyzedArtifactLookup = (
|
||||
if (entry === undefined || !entry.logical_path.startsWith(prefix))
|
||||
break;
|
||||
if (
|
||||
isOpaque(entry) &&
|
||||
(isOpaque(entry) || isUnavailableRelevant(entry)) &&
|
||||
(containerSha256 === undefined ||
|
||||
containerFor(entry.logical_path) === containerSha256)
|
||||
) {
|
||||
|
||||
@@ -268,7 +268,9 @@ describe("CDP frame capture", () => {
|
||||
it("walks deeply nested frame trees and returns every frame and resource", () => {
|
||||
let frameTree: Record<string, unknown> = {
|
||||
frame: { id: "frame-0", url: `${origin}/0` },
|
||||
resources: [{ url: `${origin}/0.js`, type: "Script" }],
|
||||
resources: [
|
||||
{ url: `${origin}/0.js`, type: "Script", mimeType: "text/javascript" },
|
||||
],
|
||||
};
|
||||
for (let index = 1; index < 2_000; index += 1) {
|
||||
frameTree = {
|
||||
@@ -276,7 +278,13 @@ describe("CDP frame capture", () => {
|
||||
id: `frame-${String(index)}`,
|
||||
url: `${origin}/${String(index)}`,
|
||||
},
|
||||
resources: [{ url: `${origin}/${String(index)}.js`, type: "Script" }],
|
||||
resources: [
|
||||
{
|
||||
url: `${origin}/${String(index)}.js`,
|
||||
type: "Script",
|
||||
mimeType: "text/javascript",
|
||||
},
|
||||
],
|
||||
childFrames: [frameTree],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
cdpSnapshotString,
|
||||
cdpSnapshotValueString,
|
||||
} from "./CdpSnapshotStrings.js";
|
||||
import type { BrowserExclusionReason } from "../domain/browserCompleteness.js";
|
||||
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
|
||||
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import {
|
||||
@@ -43,8 +44,8 @@ export const captureFrames = (
|
||||
return { items: [] };
|
||||
}
|
||||
const items: WebPageInspection["frames"] = [];
|
||||
for (const tree of walkFrameTrees(root, () =>
|
||||
completeness?.exclude("frames", "invalid_protocol_value"),
|
||||
for (const tree of walkFrameTrees(root, (omittedCount) =>
|
||||
completeness?.exclude("frames", "invalid_protocol_value", omittedCount),
|
||||
)) {
|
||||
const frame = recordValue(tree.frame);
|
||||
const frameId = cdpStringValue(frame?.id);
|
||||
@@ -61,9 +62,16 @@ export const captureFrames = (
|
||||
continue;
|
||||
}
|
||||
if (maximum !== undefined && items.length >= maximum) continue;
|
||||
const parentFrameId = cdpStringValue(frame.parentId);
|
||||
// Only the enclosing tree's root may legitimately omit parent evidence.
|
||||
if (
|
||||
(tree !== root || Object.hasOwn(frame, "parentId")) &&
|
||||
(parentFrameId === undefined || parentFrameId.length === 0)
|
||||
)
|
||||
completeness?.exclude("frames", "invalid_protocol_value");
|
||||
items.push({
|
||||
frame_id: frameId,
|
||||
parent_frame_id: cdpStringValue(frame.parentId) ?? null,
|
||||
parent_frame_id: parentFrameId ?? null,
|
||||
url: sanitized.url,
|
||||
origin: sanitized.origin,
|
||||
});
|
||||
@@ -91,10 +99,19 @@ export const captureResources = (
|
||||
return { items: [] };
|
||||
}
|
||||
const items: CapturedResource[] = [];
|
||||
for (const tree of walkFrameTrees(root, () =>
|
||||
completeness?.exclude("resources", "invalid_protocol_value"),
|
||||
for (const tree of walkFrameTrees(root, (omittedCount) =>
|
||||
completeness?.exclude("resources", "invalid_protocol_value", omittedCount),
|
||||
)) {
|
||||
for (const resource of recordsValue(tree.resources)) {
|
||||
if (!Array.isArray(tree.resources)) {
|
||||
completeness?.exclude("resources", "invalid_protocol_value", null);
|
||||
continue;
|
||||
}
|
||||
for (const entry of tree.resources) {
|
||||
const resource = recordValue(entry);
|
||||
if (resource === undefined) {
|
||||
completeness?.exclude("resources", "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
const url = allowedSanitizedUrl(resource.url, allowedOrigins);
|
||||
if (url === undefined || url.origin === null) {
|
||||
completeness?.exclude(
|
||||
@@ -103,15 +120,26 @@ export const captureResources = (
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const contentSize = numberValue(resource.contentSize);
|
||||
const type = cdpStringValue(resource.type);
|
||||
const mimeType = cdpStringValue(resource.mimeType);
|
||||
if (type === undefined || type.length === 0 || mimeType === undefined) {
|
||||
completeness?.exclude("resources", "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
const rawContentSize = numberValue(resource.contentSize);
|
||||
const contentSize =
|
||||
rawContentSize !== undefined && rawContentSize >= 0
|
||||
? rawContentSize
|
||||
: null;
|
||||
if (resource.contentSize !== undefined && contentSize === null)
|
||||
completeness?.exclude("resources", "invalid_protocol_value");
|
||||
items.push({
|
||||
rawUrl: cdpStringValue(resource.url) ?? "",
|
||||
url: url.url,
|
||||
origin: url.origin,
|
||||
type: cdpStringValue(resource.type) ?? "Other",
|
||||
mime_type: cdpStringValue(resource.mimeType) ?? "",
|
||||
content_size:
|
||||
contentSize === undefined ? null : Math.max(0, contentSize),
|
||||
type,
|
||||
mime_type: mimeType,
|
||||
content_size: contentSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -120,7 +148,7 @@ export const captureResources = (
|
||||
|
||||
export const walkFrameTrees = function* (
|
||||
root: UnknownRecord,
|
||||
onMalformedChild?: () => void,
|
||||
onMalformedChild?: (omittedCount: number | null) => void,
|
||||
): Generator<UnknownRecord> {
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
@@ -128,11 +156,11 @@ export const walkFrameTrees = function* (
|
||||
if (tree === undefined) return;
|
||||
const rawChildren = tree.childFrames;
|
||||
if (rawChildren !== undefined && !Array.isArray(rawChildren))
|
||||
onMalformedChild?.();
|
||||
onMalformedChild?.(null);
|
||||
const children = Array.isArray(rawChildren)
|
||||
? rawChildren.flatMap((child) => {
|
||||
const parsed = recordValue(child);
|
||||
if (parsed === undefined) onMalformedChild?.();
|
||||
if (parsed === undefined) onMalformedChild?.(null);
|
||||
return parsed === undefined ? [] : [parsed];
|
||||
})
|
||||
: [];
|
||||
@@ -187,14 +215,16 @@ export function* captureDom(
|
||||
let excludedUrls = 0;
|
||||
let total = 0;
|
||||
let unresolvedSvgNodes = 0;
|
||||
for (const document of recordsValue(root.documents)) {
|
||||
if (!Array.isArray(root.strings))
|
||||
excludeDocumentEvidence(completeness, "invalid_protocol_value", null);
|
||||
for (const document of snapshotDocuments(root.documents, completeness)) {
|
||||
const documentUrl = cdpSnapshotString(strings, document.documentURL);
|
||||
if (documentUrl === undefined) {
|
||||
completeness?.exclude("dom", "invalid_protocol_value");
|
||||
excludeDocumentEvidence(completeness, "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) {
|
||||
completeness?.exclude("dom", exclusionReasonForUrl(documentUrl));
|
||||
excludeDocumentEvidence(completeness, exclusionReasonForUrl(documentUrl));
|
||||
continue;
|
||||
}
|
||||
const baseUrlValue =
|
||||
@@ -202,12 +232,15 @@ export function* captureDom(
|
||||
? documentUrl
|
||||
: cdpSnapshotValueString(strings, document.baseURL);
|
||||
if (baseUrlValue === undefined) {
|
||||
completeness?.exclude("dom", "invalid_protocol_value");
|
||||
excludeDocumentEvidence(completeness, "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
const baseUrl = baseUrlValue || documentUrl;
|
||||
const documentNodes = recordValue(document.nodes);
|
||||
if (documentNodes === undefined) continue;
|
||||
if (documentNodes === undefined) {
|
||||
excludeDocumentEvidence(completeness, "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
const rawNodeTypes = numberArray(documentNodes.nodeType);
|
||||
const rawNodeNames = numberArray(documentNodes.nodeName);
|
||||
const rawNodeValues = numberArray(documentNodes.nodeValue);
|
||||
@@ -225,12 +258,12 @@ export function* captureDom(
|
||||
rawNodeValues.length !== rawNodeTypes.length ||
|
||||
rawParents.length !== rawNodeTypes.length
|
||||
) {
|
||||
completeness?.exclude("dom", "invalid_protocol_value");
|
||||
excludeDocumentEvidence(completeness, "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
if (rawAttributes.length > rawNodeTypes.length)
|
||||
completeness?.exclude(
|
||||
"dom",
|
||||
excludeDocumentEvidence(
|
||||
completeness,
|
||||
"invalid_protocol_value",
|
||||
rawAttributes.length - rawNodeTypes.length,
|
||||
);
|
||||
@@ -266,7 +299,7 @@ export function* captureDom(
|
||||
attributeIndexes === undefined ||
|
||||
!cdpSnapshotAttributesResolve(strings, attributeIndexes)
|
||||
) {
|
||||
completeness?.exclude("dom", "invalid_protocol_value");
|
||||
excludeDocumentEvidence(completeness, "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
const attributes = attributeIndexes as readonly number[];
|
||||
@@ -346,6 +379,32 @@ export function* captureDom(
|
||||
};
|
||||
}
|
||||
|
||||
/** Skipped document facts also make their derived URL and hint inventory incomplete. */
|
||||
const excludeDocumentEvidence = (
|
||||
completeness: CdpCaptureCompleteness | undefined,
|
||||
reason: BrowserExclusionReason,
|
||||
count: number | null = 1,
|
||||
): void => {
|
||||
completeness?.exclude("dom", reason, count);
|
||||
completeness?.exclude("metadata", reason, count);
|
||||
};
|
||||
|
||||
const snapshotDocuments = (
|
||||
value: unknown,
|
||||
completeness: CdpCaptureCompleteness | undefined,
|
||||
): readonly UnknownRecord[] => {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
excludeDocumentEvidence(completeness, "invalid_protocol_value", null);
|
||||
return [];
|
||||
}
|
||||
return value.flatMap((item) => {
|
||||
const document = recordValue(item);
|
||||
if (document === undefined)
|
||||
excludeDocumentEvidence(completeness, "invalid_protocol_value");
|
||||
return document === undefined ? [] : [document];
|
||||
});
|
||||
};
|
||||
|
||||
function* captureDomMetadata(
|
||||
pending: readonly PendingDomMetadata[],
|
||||
completeness: CdpCaptureCompleteness | undefined,
|
||||
|
||||
@@ -480,6 +480,71 @@ it("preserves observed SaveHar null post-data without broadening arbitrary HAR p
|
||||
).toThrow("HAR schema validation failed");
|
||||
});
|
||||
|
||||
it.each(["[2001:db8::1]", "[::1]", "[::ffff:192.0.2.1]"])(
|
||||
"retains Playwright 1.58.2 bracketed IPv6 spelling in reported Evidence: %s",
|
||||
(address) => {
|
||||
const fixture = historicalHar();
|
||||
const first = fixture.log.entries[0];
|
||||
if (first === undefined) throw new Error("fixture missing");
|
||||
const input = {
|
||||
log: {
|
||||
...fixture.log,
|
||||
creator: { name: "Playwright", version: "1.58.2" },
|
||||
entries: [
|
||||
{
|
||||
...first,
|
||||
serverIPAddress: address,
|
||||
_extension: { serverIPAddress: address },
|
||||
},
|
||||
structuredClone(first),
|
||||
],
|
||||
},
|
||||
};
|
||||
const decoded = decodeHarCapture(JSON.stringify(input), []);
|
||||
expect(decoded.records[0]).toMatchObject({
|
||||
reported: {
|
||||
serverIPAddress: address,
|
||||
_extension: { serverIPAddress: address },
|
||||
},
|
||||
});
|
||||
expect(decoded.records[0]?.limitations).toContain(
|
||||
"This capture declares Playwright 1.58.2 and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.",
|
||||
);
|
||||
expect(
|
||||
decoded.records[1]?.limitations.some((limitation) =>
|
||||
limitation.includes("bracketed IPv6 serverIPAddress"),
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ name: "other", version: "1.58.2", address: "[2001:db8::1]" },
|
||||
{ name: "Playwright", version: "1.58.1", address: "[2001:db8::1]" },
|
||||
{ name: "Playwright", version: "1.58.2", address: "[192.0.2.1]" },
|
||||
{ name: "Playwright", version: "1.58.2", address: "[2001:db8::1]:443" },
|
||||
{ name: "Playwright", version: "1.58.2", address: "[fe80::1%eth0]" },
|
||||
])(
|
||||
"rejects unrecognized bracketed HAR address input: $name $version $address",
|
||||
({ name, version, address }) => {
|
||||
const fixture = historicalHar();
|
||||
const first = fixture.log.entries[0];
|
||||
if (first === undefined) throw new Error("fixture missing");
|
||||
expect(() =>
|
||||
decodeHarCapture(
|
||||
JSON.stringify({
|
||||
log: {
|
||||
...fixture.log,
|
||||
creator: { name, version },
|
||||
entries: [{ ...first, serverIPAddress: address }],
|
||||
},
|
||||
}),
|
||||
[],
|
||||
),
|
||||
).toThrow("HAR schema validation failed");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"c2VjcmV0",
|
||||
"c2Vj",
|
||||
|
||||
@@ -25,14 +25,16 @@ type Projection = Pick<
|
||||
"reported" | "numeric_literals" | "binary_fields" | "redactions"
|
||||
>;
|
||||
|
||||
/** Decode a retained HAR with exact numeric lexemes and unchanged upstream schema validation. */
|
||||
/** Decode a retained HAR, validating a disposable projection of source facts. */
|
||||
export const decodeHarCapture = (
|
||||
text: string,
|
||||
sensitiveValues: readonly string[],
|
||||
): DecodedCapture => {
|
||||
const raw = parseCaptureJson(text, "HAR");
|
||||
const validation = projectHar(raw, [], false);
|
||||
createHarValidator()(validation.reported);
|
||||
const adaptedPlaywrightEntries = new Set(
|
||||
createHarValidator()(validation.reported),
|
||||
);
|
||||
if (
|
||||
!captureObject(raw) ||
|
||||
!captureObject(raw.log) ||
|
||||
@@ -73,6 +75,11 @@ export const decodeHarCapture = (
|
||||
...projection,
|
||||
limitations: [
|
||||
"HAR text is producer-decoded Unicode. Original body bytes remain unknown unless content explicitly uses valid base64; declared sizes are preserved independently of retained bytes. mitmproxy 12.2.3 SaveHar may report null optional postData.text; that null is retained and omitted only from the upstream validation copy.",
|
||||
...(adaptedPlaywrightEntries.has(ordinal)
|
||||
? [
|
||||
"This capture declares Playwright 1.58.2 and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.",
|
||||
]
|
||||
: []),
|
||||
...(recordsExcluded
|
||||
? [
|
||||
"This record's payload and sidecars are excluded because its structural ancestor property was explicitly marked sensitive.",
|
||||
|
||||
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user