Compare commits

...
Author SHA1 Message Date
morluto 64228f8144 test(browser): verify typed worker refusals across navigations 2026-10-15 03:37:16 +08:00
morluto 576abe196f test(process): use owned host-aware settlement fixtures 2026-10-15 03:37:16 +08:00
morluto b2d2e43068 fix(mcp): authenticate inventory facts before prompt completion 2026-10-15 03:37:15 +08:00
morluto acae8a5382 refactor(artifacts): share inspection validation and prune legacy fixtures 2026-10-15 03:31:06 +08:00
morluto 1deb6e87bf test: isolate host discovery and update current capture fixtures 2026-10-15 03:25:16 +08:00
morluto fd3d29bc8d fix(artifacts): bind inspection provenance to every retained inventory 2026-10-15 03:25:16 +08:00
morluto 27faf8b983 fix(javascript): keep source-module semantics independent of sibling bytes 2026-10-15 03:25:16 +08:00
morluto 3a1aa3fd03 fix(evidence)!: reject missing recorded inventory and capture facts
BREAKING CHANGE: Inventory Evidence must explicitly report integrity_contradictions, and browser capture Evidence must report every resolved budget. Caller input defaults remain supported; stored observations are never filled with inferred defaults.
2026-10-15 03:11:37 +08:00
morluto 3b272dc8e2 style(apple): normalize class parser module formatting 2026-10-15 03:08:26 +08:00
morluto 5bea037552 refactor(apple): remove unused class identity re-export 2026-10-15 03:07:39 +08:00
morluto 2c36f8d8e5 style(test): format coverage regression fixtures 2026-10-15 03:06:59 +08:00
morluto 054be92874 docs: align workflow recovery and current Evidence contracts 2026-10-15 03:06:26 +08:00
morluto 67f4b79b32 fix(browser)!: expose capture budgets and queue admitted content reads
BREAKING CHANGE: Browser capture results require resolved limits, event families, and omission facts. Caller-selected budgets replace hidden retention ceilings; admitted content reads queue behind bounded concurrency.
2026-10-15 03:06:26 +08:00
morluto b01db67586 test(hopper): verify external exit recovery and retained ownership 2026-10-15 03:06:26 +08:00
morluto 3c4bd35180 fix(jeb): probe live operations with owned discovery deadlines 2026-10-15 03:06:26 +08:00
morluto b647e4a4e8 fix(cli): preserve Cutter choices and reject invalid runtime arguments 2026-10-15 03:06:26 +08:00
morluto 7f2f0b78c4 fix(ghidra): preserve MIPS overrides and filter entry metadata 2026-10-15 03:06:26 +08:00
morluto 61141032f7 fix(windows): explain missing native artifacts and verify output drain 2026-10-15 03:06:26 +08:00
morluto 303dc2aa73 fix(setup): own Hopper replacement staging and committed outcomes 2026-10-15 03:06:26 +08:00
morluto 98c3c527d8 fix(apple): distinguish JVM resources and UIKit event connections 2026-10-15 03:06:26 +08:00
morluto 5f986d4869 fix(process): attribute finite trace omissions and cleanup states 2026-10-15 03:06:26 +08:00
morluto b9539e9c1c fix(functions): preserve coverage diagnostics separately from blockers 2026-10-15 03:06:26 +08:00
morluto 92e17ccfce fix(javascript): preserve module identity and boundary fields 2026-10-15 03:06:26 +08:00
morluto 9392590f2f fix(artifacts)!: authenticate complete inspection and summary inputs
BREAKING CHANGE: Artifact inventories require scoped coverage. Obsolete coverage_status and prose-based coverage inference are removed. Inspection carriers authenticate every retained inventory substep.
2026-10-15 03:06:26 +08:00
morluto ef0c8005bc refactor(evidence)!: require current coverage commitments
BREAKING CHANGE: Evidence and analysis snapshots require explicit nullable relationship coverage and include it in their identity commitments. Historical records without the field are rejected; saved files are not migrated.
2026-10-15 03:06:26 +08:00
152 changed files with 6305 additions and 1300 deletions

No files matched your search

+1
View File
@@ -741,6 +741,7 @@ jobs:
tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts
tests/boundary/filesystem/webScriptManifestPaths.test.ts
tests/boundary/process/ProcessEvidence.host.test.ts
tests/boundary/process/windowsOutputCapture.test.ts
tests/acceptance/webModuleCli.test.ts
tests/acceptance/webSourceLocationCli.test.ts
tests/boundary/cli/referenceSourceImportStatus.test.ts
+2 -1
View File
@@ -1340,7 +1340,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
ReferenceIterator incomingIterator = currentProgram.getReferenceManager().getReferencesTo(address);
while (incomingIterator.hasNext()) {
monitor.checkCancelled();
addReference(incoming, incomingIterator.next());
Reference reference = incomingIterator.next();
if (!reference.isEntryPointReference()) addReference(incoming, reference);
}
List<Reference> orderedIncoming = new ArrayList<>(incoming.values());
orderedIncoming.sort(REFERENCE_ORDER);
+56 -19
View File
@@ -23,9 +23,9 @@ that rejection does not roll back browser effects. REA removes its owned
future-document scripts, restores live frame methods, then disconnects.
Add secret declarations, storage seeds, and additional artifact or event
capture only when needed. Action, secret, and storage counts are not capped.
Duration, action, and navigation
timeouts remain fixed provider-owned liveness limits; the request does not
accept a caller-controlled `limits` object.
Duration, action, and navigation timeouts remain fixed provider-owned liveness
limits. `capture.limits` controls retained event and network-content budgets;
it does not change those liveness deadlines.
## Minimal launch example
@@ -179,12 +179,44 @@ declared action. Each step reports action status, elapsed time, sanitized URLs,
event bounds, and independently typed capture states for screenshot, DOM,
accessibility, URL, history, and storage. Console, page-error, network,
WebSocket, frame, worker, popup, and cancelled-download events are attributed to
steps and all observed events and requested artifacts are returned inline.
There are no event, frame, worker, popup, WebSocket, DOM-node,
accessibility-node, storage-entry, screenshot-count, or cumulative
metadata-byte caps. Requested artifacts and observed events are returned
inline without application-defined size ceilings. Other missing sections
remain explicit and make the capture ineligible for equality claims.
steps. Retained events and requested artifacts are returned inline; omitted
events are counted by kind and identify affected network transactions.
Event and selected network-content retention have caller-selected limits under
`capture.limits`. Omitted values use the defaults below; every supplied value
must be a positive safe integer, and callers can raise or lower a default.
Byte limits are conservative allocation-cost estimates for retained event
values, including UTF-16 storage and JSON expansion, rather than serialized
bytes or a promise about exact process memory. `events.omissions.by_kind`
reports counts and reasons for omitted event kinds. Its
`network_transactions` rows identify affected transaction IDs and omitted
event kinds; `network_transaction_range` identifies the contiguous IDs beyond
the transaction limit. Omission metadata is bounded by the selected
transaction limit. Other missing sections remain explicit and make the capture
ineligible for equality claims.
The default limits are 10,000 events, 4 MiB estimated allocation for one
event, 16 MiB estimated allocation across retained events, 1,024 network
transactions, 64 KiB of body bytes per transaction phase, 1 MiB estimated
allocation for body redaction/base64, 1 MiB for retained header values, and
1 MiB for network metadata. The same limits are included in the capture
Evidence. Playwright materializes response bodies before REA can inspect their
byte length; `max_network_body_bytes` bounds subsequent retained copies and
does not bound the browser API read itself. A body that exceeds its selected
limit has an explicit `body-size-limit` or `body-allocation-limit` unavailable
state with the observed and allowed values.
Additional implementation bounds are 16 simultaneous network-content reads,
a 5-second deadline for each provider API read, and 2,048 live event-listener
owners. These are not caller-selectable. The concurrency bound queues admitted
content jobs and does not itself omit observations; a provider read timeout
produces a typed unavailable state, while exceeding listener capacity produces
event-kind omission counts. A preflight avoids body redaction and base64 copies
when the currently available event budget already rejects the body.
Concurrent reads may each pass that check before their events are retained;
their retained-copy work is bounded by 16 times the selected per-body
allocation limit. The Playwright response Buffer itself is materialized before
REA can apply that limit. Requested artifacts and retained events are returned
inline within the declared limits.
Attach-mode captures also declare the unavoidable pre-attach event gap.
Selected event families are observed on the root page and discovered descendant
@@ -288,16 +320,21 @@ retained representation after redaction.
Only completed responses are read. At the end of the final step, observation
stops; requests still in flight emit `request-unfinished`, and their observed
responses have explicitly unavailable bodies. This cutoff does not wait for
long-lived streaming connections. Already-started content reads settle before
browser cleanup, with a provider-owned five-second timeout per read and
cancellation support. Failed or unexposed reads retain per-content states and make selected
event coverage incomplete. Body events can arrive during later steps or final
drain; event bounds describe receipt during the step, while the source event
reference establishes the transaction phase. Comparison attributes content, like
every other event of its transaction, to the step that sent the request (the
receipt step when that request was not observed) and excludes the sequence
reference from normalization.
responses have explicitly unavailable bodies. Already-admitted content reads
drain before browser cleanup. At most 16 reads run at once; queued work is
bounded by the selected network-transaction limit and by successful admission
of its request or response event. Each provider API read has its own five-second
timeout once it starts, so queue wait adds to total drain and cleanup time; this
is not a five-second total capture cutoff. Cancellation interrupts active reads
and queued jobs produce cancelled states without calling their provider APIs.
This drain does not wait for long-lived unfinished streaming connections and
does not refetch unfinished response bodies. Failed or unexposed reads retain
per-content states and make selected event coverage incomplete. Body events can
arrive during later steps or final drain; event bounds describe receipt during
the step, while the source event reference establishes the transaction phase.
Comparison attributes content, like every other event of its transaction, to
the step that sent the request (the receipt step when that request was not
observed) and excludes the sequence reference from normalization.
Playwright scenario capture does not supply initiator stacks; passive CDP
observation retains its own initiator evidence.
+38 -5
View File
@@ -319,11 +319,44 @@ redirected, which keeps large results smaller for agents and scripts; pipe
through `jq .` for indented files. Output selection and formatting do not change
operation status.
| Status | Meaning |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The operation completed. Its result may include partial evidence, warnings or unresolved questions. |
| `1` | The operation could not complete. Structured output identifies invalid input, permissions, cancellation, timeouts or another failure when available. |
| `128 + N` | Signal `N` ended the process, where the shell or runtime preserves the conventional signal-derived status. |
For `cutter-command`, `--json-output` requests command-specific JSON from
Cutter when available. This is separate from `--format json`, which selects the
CLI result representation; the global `--json` flag selects that same CLI
representation.
Cutter still accepts deprecated `--json=true` and `--json=false` compatibility
values. When both forms are present, `--json-output` takes precedence in either
argument order. Bare `--json` retains its global meaning of selecting JSON CLI
output formatting.
| Status | Meaning |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The operation completed. Its result may include partial evidence, warnings or unresolved questions. |
| `1` | The operation could not complete. Structured output identifies invalid input, permissions, cancellation, timeouts or another failure when available. |
| `128 + N` | Signal-derived status. An unhandled signal may have ended the process, with the shell or runtime preserving the conventional status; a command may instead have handled the signal, waited for its operation and cleanup to settle, and assigned the same status (`130` for SIGINT, `143` for SIGTERM). In the handled case, it may also have printed a structured `cancelled` result. |
A `cancelled` result does not by itself determine the exit status; it depends on
the command's signal handling. Commands routed through the shared
`withCommandCancellation` handler in the CLI set status `130` for SIGINT or
`143` for SIGTERM after their operation settles. Current source registrations
use that handler for process capture and comparison (`capture-process`,
`compare-process-captures`), JavaScript application analysis (`analyze` when it
selects the JavaScript lane, and `analyze-javascript-application`), selected
browser, web and Electron commands (including `analyze-web-bundle`,
`observe-web-execution`, `capture-browser-scenario` and
`capture-electron-scenario`), and the platform and provider command families
for Android/ADB, Apktool/JEB/Flutter, firmware, PCAP and signal analysis,
WASM/EVM, Go/Rust and binary diagnostics, Frida instrumentation, the Cutter
command, objdump, Rizin and GDB. In these paths, a returned structured
`cancelled` result can be printed before the wrapper assigns the
signal-derived status. This describes the current source routing; it is not a
claim that every command or provider runtime has been independently exercised.
`inspect-artifact` also returns a structured cancelled result, but its direct
analysis cancellation path does not set a signal-derived exit status, so it
exits `1`. Setup has its own contract: cancellation exits `0`. Treat the
structured result and process status as separate signals when scripting
cancellation.
`setup --dry-run` returns `planned` and exits `0`; a cancelled setup also exits
`0`. Setup returns `1` for `needs_confirmation` or `needs_human`.
+2
View File
@@ -60,6 +60,8 @@ provider implicitly.
`trace_javascript_semantics` queries the authenticated companion graph without
rereading mutable files. A seed may identify a semantic or application node,
literal, function fingerprint, property, endpoint, event, or boundary field.
For a recovered parse, coercion, or validation boundary, its field value is the
static operation's method name (for example, `Number`).
The query declares backward provenance, forward influence, callers, or
ownership and can restrict admitted relation kinds. REA returns every matching
seed and the complete reachable subgraph; callers do not choose node, relation,
+6
View File
@@ -16,6 +16,12 @@ URL after validating its syntax. That validation does not establish that the
JEB service is reachable or that a project is open; use the existing
`inspect-jeb-client` command for a read-only service query.
MCP `binary_session` probes the live JEB service and its advertised tool catalog.
Availability is reported per operation: a reachable headless client can support
inspection and decompilation while refusing `open_jeb_project`. Catalog discovery
uses one 60-second deadline across connection and pagination; a responsive server
cannot keep discovery running indefinitely by returning more pages.
Verified against JEB 5.48.0 (Linux x64, licensed build) serving `jeb-mcp-server` 1.3.0 over streamable HTTP.
## Operations
+4 -7
View File
@@ -38,13 +38,10 @@ socket and device entries are not opened. Case-distinct archive members remain
separate observations. Their case-insensitive materialization diagnostic does
not reduce traversal coverage when both spellings were read.
Current output also retains `coverage_status` (`complete` or `partial`) for existing clients, derived from the scoped traversal status. The owning parser normalizes retained `coverage_status` records to scoped coverage and rejects conflicting declarations.
The coverage field is optional when reading older retained Evidence. The owning
inventory parser recognizes historical REA scope notices for compatibility;
unfamiliar legacy limitations remain conservative. Typed records do not derive
coverage from limitation text. Pages from one inventory must carry the same
coverage declaration.
Current inventory Evidence requires scoped `coverage` with a `complete` or
`unknown` status. Consumers use this producer-owned fact and reject records
without it; limitations are explanatory and do not supply or infer coverage.
Pages from one inventory must carry the same coverage declaration.
Function dossiers can declare per-facet coverage in the optional
`normalized_result.facet_availability` map. Each supplied facet is either
+3 -2
View File
@@ -481,8 +481,9 @@ user's permissions and may change files, show UI or use the network.
Debugging must be allowed for the user (Developer Tools access). A
hardened-runtime target without `com.apple.security.get-task-allow` cannot be
debugged; `inspect_signature` reports this as the debugger-attach facet, and
the tool fails with a `debugger-attach-denied` reason.
debugged; `inspect_signature` reports the signature's `hardened_runtime` value
and its `entitlements`, including `com.apple.security.get-task-allow` when it
is present. A denied trace reports the `debugger-attach-denied` reason.
## Provider and verification boundaries
+23
View File
@@ -26,6 +26,29 @@ its manifest. Release CI builds both, and `prepublishOnly` rejects a missing,
stale, mismatched, or wrong-architecture artifact. A development tarball built
without this lane reports Windows native controls as unavailable.
## Restore the artifact in a source checkout
The source tree omits `native/windows/build/` from Git. To run Windows
provider-backed workflows from a checkout without the MinGW build lane, extract
the two files from the published `rea-agents` tarball whose version exactly
matches the checkout's `package.json` version. Do not use `latest` or copy files
from another REA version; the loader verifies the manifest version, ABI,
architecture, and addon digest before it grants authority.
From the checkout root, replace `<matching-version>` with the `version` in
`package.json`:
```sh
npm pack rea-agents@<matching-version>
tar -xzf rea-agents-<matching-version>.tgz
mkdir -p native/windows/build
cp package/native/windows/build/manifest.json native/windows/build/
cp package/native/windows/build/rea-windows-x64.node native/windows/build/
```
This is a manual recovery step. REA does not fetch or install the artifact
automatically. Alternatively, build it using the Linux/MinGW lane above.
## Boundaries
- Opaque Node objects carry a native type tag, resource kind, and closed state.
+1
View File
@@ -138,6 +138,7 @@ export const SCOPES = {
"src/domain/javascript/javascriptRuntimeStaticCandidates.ts",
"tests/boundary/filesystem/webScriptManifestPaths.test.ts",
"tests/boundary/process/ProcessEvidence.host.test.ts",
"tests/boundary/process/windowsOutputCapture.test.ts",
"tests/acceptance/webModuleCli.test.ts",
"tests/acceptance/webSourceLocationCli.test.ts",
"tests/boundary/cli/referenceSourceImportStatus.test.ts",
@@ -144,14 +144,19 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
true,
);
};
const assertRejected = async (scenario) => {
const assertRejected = async (scenario, expectedReason) => {
const result = await provider.captureScenario(scenario);
assert.equal(
result.ok,
false,
"Worker-influenced capture unexpectedly succeeded",
);
assert.equal(result.error.reason, "target_not_allowed");
assert.equal(result.error.reason, expectedReason);
if (expectedReason === "service_worker_blocked")
assert.match(
result.error.userMessage,
/environment\.service_workers to allow/,
);
assert.equal(
result.error.partialObservation.capture.browser.cleanup,
"disconnected-external",
@@ -229,7 +234,7 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
"worker",
);
await page.goto("about:blank");
await assertRejected(scenarioFor(second.origin));
await assertRejected(scenarioFor(second.origin), "service_worker_blocked");
await page.goto("about:blank");
await assertRejected(
scenarioFor(first.origin, [
@@ -240,6 +245,7 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
wait_until: "load",
},
]),
"service_worker_blocked",
);
await page.goto("about:blank");
await assertRejected(
@@ -251,9 +257,13 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
},
{ step_id: "loaded", action: "wait_for_timeout", duration_ms: 100 },
]),
"service_worker_blocked",
);
await page.goto("about:blank");
await assertRejected(scenarioFor(`${first.origin}/frame`));
await assertRejected(
scenarioFor(`${first.origin}/frame`),
"service_worker_blocked",
);
const allowed = await provider.captureScenario(
scenarioFor(second.origin, [], "allow"),
+18 -10
View File
@@ -22,6 +22,7 @@ import type { ToolKind } from "../contracts/toolContractTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { isMachOFormat } from "../domain/binaryTargetTypes.js";
import type { ProviderAvailability } from "./AnalysisProvider.js";
import type { JebAnalysisAvailability } from "./jeb/JebAnalysisPort.js";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js";
import {
@@ -56,7 +57,7 @@ export type AvailabilityPolicy = {
readonly androidAnalysisAvailability?: ProviderAvailability;
readonly adbAnalysisAvailability?: ProviderAvailability;
readonly apktoolAnalysisAvailability?: ProviderAvailability;
readonly jebAnalysisAvailability?: ProviderAvailability;
readonly jebAnalysisAvailability?: JebAnalysisAvailability;
readonly javascriptRecoveryEnabled?: boolean;
readonly webModuleResolutionEnabled?: boolean;
readonly browserObservationEnabled?: boolean;
@@ -485,15 +486,22 @@ const workflowAvailabilityFor = ({
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 }
: {
reason: "provider_missing",
remediation:
policy.jebAnalysisAvailability?.reason ??
"Start a JEB client serving MCP (GUI client, or headless via its programmatic launcher) and point REA_JEB_MCP_URL at it (default http://127.0.0.1:8425/mcp). REA does not install or launch JEB.",
};
if (kind === "jeb-provider") {
const availability = policy.jebAnalysisAvailability;
if (availability?.status === "available")
return availability.supportedOperations.some(
(operation) => operation === name,
)
? { reason: "available", remediation: null }
: {
reason: "provider_unavailable",
remediation: `The running JEB endpoint does not advertise support for ${name}. Use a JEB client build that provides this operation.`,
};
return ownerProbeAvailability(
availability,
"Start a JEB client serving MCP (GUI client, or headless via its programmatic launcher) and point REA_JEB_MCP_URL at it (default http://127.0.0.1:8425/mcp). REA does not install or launch JEB.",
);
}
const browser = browserProviderAvailability(name, kind, policy);
if (browser !== null) return browser;
if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
+78 -6
View File
@@ -4,7 +4,9 @@ import { describe, expect, it } from "vitest";
import {
installMacHopper,
installMacHopperBundle,
type MacHopperBundleVerification,
type MacHopperBundleInstallResult,
type MacHopperInstallHost,
} from "./MacHopper.js";
@@ -15,7 +17,8 @@ class FakeMacHopperHost implements MacHopperInstallHost {
existing = false;
mountSucceeds = true;
copySucceeds = true;
launcherSucceeds = true;
mountFailure: Error | undefined;
verificationFailure: Error | undefined;
badHash = false;
unmounts = 0;
cleanups = 0;
@@ -46,25 +49,37 @@ class FakeMacHopperHost implements MacHopperInstallHost {
createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test");
createMountDirectory = () => Promise.resolve();
writePackage = () => Promise.resolve();
mount = () => Promise.resolve(this.mountSucceeds);
mount = () =>
this.mountFailure === undefined
? Promise.resolve(this.mountSucceeds)
: Promise.reject(this.mountFailure);
unmount = () => {
this.unmounts += 1;
return Promise.resolve();
};
appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app");
validBundle = () => Promise.resolve(this.verification);
validBundle = () =>
this.verificationFailure === undefined
? Promise.resolve(this.verification)
: Promise.reject(this.verificationFailure);
destinationExists = () => Promise.resolve(this.existing);
installBundle = (
_source: string,
_destination: string,
_stage: string,
replaceExisting: boolean,
) => {
this.installs += 1;
this.replaces = replaceExisting;
return Promise.resolve(this.copySucceeds);
return Promise.resolve<MacHopperBundleInstallResult>(
this.copySucceeds
? { status: "installed" }
: {
status: "failed",
reason: "copy",
diagnostic: "ditto: destination is read-only",
},
);
};
launcherReady = () => Promise.resolve(this.launcherSucceeds);
openApplication = () => {
this.opens += 1;
return Promise.resolve();
@@ -89,6 +104,22 @@ describe("macOS Hopper installation", () => {
expect(host.cleanups).toBe(1);
});
it("preserves installation when macOS cannot open the installed app", async () => {
const host = new FakeMacHopperHost();
host.openApplication = () =>
Promise.reject(new Error("LaunchServices refused"));
await expect(installMacHopper({}, host)).resolves.toEqual({
status: "installed",
launcherPath:
"/home/user/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
openFailure: "LaunchServices refused",
});
expect(host.installs).toBe(1);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
it("rejects an integrity mismatch before mounting", async () => {
const host = new FakeMacHopperHost();
host.badHash = true;
@@ -130,6 +161,33 @@ describe("macOS Hopper installation", () => {
expect(host.cleanups).toBe(1);
});
it("preserves a thrown mount-stage diagnostic and cleans the temporary image", async () => {
const host = new FakeMacHopperHost();
host.mountFailure = new Error("hdiutil attach: resource busy");
await expect(installMacHopper({}, host)).resolves.toMatchObject({
status: "failed",
reason: "mount",
diagnostic: "hdiutil attach: resource busy",
});
expect(host.unmounts).toBe(0);
expect(host.cleanups).toBe(1);
});
it("preserves the copy diagnostic from the owned install operation", async () => {
const host = new FakeMacHopperHost();
host.copySucceeds = false;
await expect(installMacHopper({}, host)).resolves.toMatchObject({
status: "failed",
reason: "copy",
diagnostic: "ditto: destination is read-only",
});
expect(host.installs).toBe(1);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
it("reports a publisher mismatch without copying or launching the bundle", async () => {
const host = new FakeMacHopperHost();
host.verification = "unverified_publisher";
@@ -142,4 +200,18 @@ describe("macOS Hopper installation", () => {
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
it("preserves a thrown signature-check diagnostic as a bundle failure", async () => {
const host = new FakeMacHopperHost();
host.verificationFailure = new Error("codesign: helper unavailable");
await expect(installMacHopper({}, host)).resolves.toMatchObject({
status: "failed",
reason: "bundle",
diagnostic: "codesign: helper unavailable",
});
expect(host.installs).toBe(0);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
});
+240 -90
View File
@@ -10,7 +10,7 @@ import {
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
@@ -22,6 +22,7 @@ import {
downloadHopperPackage,
HOPPER_DOWNLOAD_BYTES,
} from "./HopperDownload.js";
import type { HopperInstallFailureReason } from "./SetupInstallFailure.js";
const execFileAsync = promisify(execFile);
const RELEASES_URL =
@@ -38,6 +39,38 @@ export type MacHopperBundleVerification =
| "invalid_signature"
| "unverified_publisher";
type MacHopperInstallFailureReason = Extract<
HopperInstallFailureReason,
| "release_metadata"
| "download"
| "integrity"
| "launcher_missing"
| "destination_exists"
| "mount"
| "bundle"
| "publisher_verification_failed"
| "copy"
>;
export type MacHopperBundleInstallResult =
| { readonly status: "installed"; readonly cleanupFailure?: string }
| {
readonly status: "failed";
readonly reason: "copy" | "launcher_missing";
readonly diagnostic: string;
readonly cleanupFailure?: string;
};
/** Filesystem effects for one installer-owned bundle replacement transaction. */
export interface MacHopperBundleInstallOperations {
createStagingRoot(parent: string): Promise<string>;
copyBundle(source: string, stage: string): Promise<void>;
requireLauncher(path: string): Promise<void>;
pathExists(path: string): Promise<boolean>;
rename(source: string, destination: string): Promise<void>;
removeStagingRoot(path: string): Promise<void>;
}
/** Stable network and command effects disclosed before macOS Hopper setup. */
export const macHopperInstallDisclosure = {
networkOrigins: [RELEASES_URL, DOWNLOAD_PREFIX] as const,
@@ -56,19 +89,12 @@ export type MacHopperInstallResult =
readonly status: "installed";
readonly launcherPath: string;
readonly cleanupFailure?: string;
readonly openFailure?: string;
}
| {
readonly status: "failed";
readonly reason:
| "release_metadata"
| "download"
| "integrity"
| "mount"
| "bundle"
| "publisher_verification_failed"
| "destination_exists"
| "copy"
| "launcher_missing";
readonly reason: MacHopperInstallFailureReason;
readonly diagnostic?: string;
readonly cleanupFailure?: string;
};
@@ -88,10 +114,8 @@ export interface MacHopperInstallHost {
installBundle(
source: string,
destination: string,
stage: string,
replaceExisting: boolean,
): Promise<boolean>;
launcherReady(path: string): Promise<boolean>;
): Promise<MacHopperBundleInstallResult>;
openApplication(path: string): Promise<void>;
cleanup(path: string): Promise<void>;
/** Retain the cleanup capability until the owning setup host retries it. */
@@ -109,6 +133,8 @@ export const installMacHopper = async (
): Promise<MacHopperInstallResult> => {
let temporary: string | undefined;
let mounted = false;
let bundleCleanupFailure: string | undefined;
let installStage: MacHopperInstallFailureReason = "release_metadata";
let result: MacHopperInstallResult;
try {
result = await (async (): Promise<MacHopperInstallResult> => {
@@ -120,15 +146,19 @@ export const installMacHopper = async (
const release = parsed.data["OS X"];
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
return { status: "failed", reason: "release_metadata" };
installStage = "download";
const archive = await host.download(release.filename);
if (!archive.ok) return { status: "failed", reason: "download" };
installStage = "integrity";
if (!integrityMatches(archive.bytes, release))
return { status: "failed", reason: "integrity" };
installStage = "destination_exists";
const destination = host.destination();
const destinationExists = await host.destinationExists(destination);
if (destinationExists && options.replaceExisting !== true)
return { status: "failed", reason: "destination_exists" };
installStage = "mount";
temporary = await host.createTemporaryDirectory();
const packagePath = join(temporary, "hopper.dmg");
const mountPath = join(temporary, "mounted");
@@ -137,6 +167,7 @@ export const installMacHopper = async (
if (!(await host.mount(packagePath, mountPath)))
return { status: "failed", reason: "mount" };
mounted = true;
installStage = "bundle";
const source = await host.appBundle(mountPath);
if (source === undefined) return { status: "failed", reason: "bundle" };
const verification = await host.validBundle(source);
@@ -144,26 +175,41 @@ export const installMacHopper = async (
return { status: "failed", reason: "bundle" };
if (verification === "unverified_publisher")
return { status: "failed", reason: "publisher_verification_failed" };
const stage = `${destination}.rea-stage`;
if (
!(await host.installBundle(
source,
destination,
stage,
destinationExists,
))
)
return { status: "failed", reason: "copy" };
installStage = "copy";
const installation = await host.installBundle(
source,
destination,
destinationExists,
);
if (installation.status === "failed")
return {
status: "failed",
reason: installation.reason,
diagnostic: installation.diagnostic,
...(installation.cleanupFailure === undefined
? {}
: { cleanupFailure: installation.cleanupFailure }),
};
bundleCleanupFailure = installation.cleanupFailure;
const launcherPath = join(destination, "Contents/MacOS/hopper");
if (!(await host.launcherReady(launcherPath)))
return { status: "failed", reason: "launcher_missing" };
await host.openApplication(destination);
return { status: "installed", launcherPath };
let openFailure: string | undefined;
try {
await host.openApplication(destination);
} catch (cause: unknown) {
openFailure = failureMessage(cause);
}
return {
status: "installed",
launcherPath,
...(openFailure === undefined ? {} : { openFailure }),
};
})();
} catch (cause: unknown) {
// Install-stage failures share the download reason; the stage is cleaned below.
void cause;
result = { status: "failed", reason: "download" };
result = {
status: "failed",
reason: installStage,
diagnostic: failureMessage(cause),
};
}
let cleanupFailure: string | undefined;
@@ -185,7 +231,106 @@ export const installMacHopper = async (
}
}
return cleanupFailure === undefined ? result : { ...result, cleanupFailure };
const retainedCleanupFailure = [
bundleCleanupFailure,
cleanupFailure,
result.cleanupFailure,
]
.filter((failure): failure is string => failure !== undefined)
.join(" ");
return retainedCleanupFailure === ""
? result
: { ...result, cleanupFailure: retainedCleanupFailure };
};
/** Replace a bundle through a fresh same-volume root owned by this transaction. */
export const installMacHopperBundle = async (
source: string,
destination: string,
replaceExisting: boolean,
operations: MacHopperBundleInstallOperations,
retainCleanup: (cleanup: () => Promise<void>) => void,
): Promise<MacHopperBundleInstallResult> => {
const stagingRoot = await operations.createStagingRoot(dirname(destination));
const stagedBundle = join(stagingRoot, "new.app");
const previousBundle = join(stagingRoot, "previous.app");
let originalBackedUp = false;
let newCommitted = false;
let failureReason: "copy" | "launcher_missing" = "copy";
try {
await operations.copyBundle(source, stagedBundle);
failureReason = "launcher_missing";
await operations.requireLauncher(
join(stagedBundle, "Contents/MacOS/hopper"),
);
failureReason = "copy";
if (replaceExisting) {
await operations.rename(destination, previousBundle);
originalBackedUp = true;
}
await operations.rename(stagedBundle, destination);
newCommitted = true;
} catch (cause: unknown) {
const diagnostics = [failureMessage(cause)];
if (originalBackedUp && !newCommitted) {
try {
if (await operations.pathExists(destination))
throw new Error(
"destination now contains another installation; preserving the previous bundle in its owned staging root",
);
await operations.rename(previousBundle, destination);
originalBackedUp = false;
} catch (rollbackCause: unknown) {
diagnostics.push(`rollback failed: ${failureMessage(rollbackCause)}`);
const cleanupFailure = `The previous Hopper bundle remains owned at ${previousBundle}; cleanup will retry restoration only if the destination is empty.`;
retainCleanup(async () => {
if (originalBackedUp) {
if (await operations.pathExists(destination))
throw new Error(
`Hopper destination is occupied; preserving owned backup ${previousBundle}`,
);
await operations.rename(previousBundle, destination);
originalBackedUp = false;
}
await operations.removeStagingRoot(stagingRoot);
});
return {
status: "failed",
reason: failureReason,
diagnostic: diagnostics.join("; "),
cleanupFailure,
};
}
}
try {
await operations.removeStagingRoot(stagingRoot);
} catch (cleanupCause: unknown) {
const cleanupFailure = `Hopper staging root cleanup failed: ${failureMessage(cleanupCause)}`;
retainCleanup(() => operations.removeStagingRoot(stagingRoot));
diagnostics.push(cleanupFailure);
return {
status: "failed",
reason: failureReason,
diagnostic: diagnostics.join("; "),
cleanupFailure,
};
}
return {
status: "failed",
reason: failureReason,
diagnostic: diagnostics.join("; "),
};
}
try {
await operations.removeStagingRoot(stagingRoot);
return { status: "installed" };
} catch (cause: unknown) {
const cleanupFailure = `Hopper staging root cleanup failed after installation committed: ${failureMessage(cause)}`;
retainCleanup(() => operations.removeStagingRoot(stagingRoot));
return { status: "installed", cleanupFailure };
}
};
/** Compose installer paths and subprocesses from one selected caller context. */
@@ -200,7 +345,7 @@ export const systemMacHopperInstallHost = (
createMountDirectory: (path) => mkdir(path),
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
async mount(packagePath, mountPath) {
return commandSucceeds(
await execFileAsync(
"hdiutil",
[
"attach",
@@ -210,20 +355,14 @@ export const systemMacHopperInstallHost = (
mountPath,
packagePath,
],
environment,
{ env: environment },
);
return true;
},
async unmount(mountPath) {
if (
!(await commandSucceeds(
"hdiutil",
["detach", mountPath, "-force"],
environment,
))
)
throw new Error(
`hdiutil detach failed for ${JSON.stringify(mountPath)}`,
);
await execFileAsync("hdiutil", ["detach", mountPath, "-force"], {
env: environment,
});
},
async appBundle(mountPath) {
const entries = await readdir(mountPath, { withFileTypes: true });
@@ -261,49 +400,32 @@ export const systemMacHopperInstallHost = (
await access(path);
return true;
} catch (cause: unknown) {
// best-effort cleanup: optional existence probing; absence means missing.
void cause;
return false;
}
},
async installBundle(source, destination, stage, replaceExisting) {
const previous = `${stage}-previous`;
try {
await mkdir(join(homeDirectory, "Applications"), { recursive: true });
await rm(stage, { recursive: true, force: true });
await rm(previous, { recursive: true, force: true });
await execFileAsync("ditto", [source, stage], { env: environment });
await access(join(stage, "Contents/MacOS/hopper"));
if (replaceExisting) await rename(destination, previous);
await rename(stage, destination);
if (replaceExisting)
await rm(previous, { recursive: true, force: true });
return true;
} catch (cause: unknown) {
// Install failure rolls back below; the false return reports it.
void cause;
await rm(stage, { recursive: true, force: true });
if (replaceExisting) {
try {
await rename(previous, destination);
} catch (rollbackCause: unknown) {
// The original destination was not moved, or rollback is impossible.
void rollbackCause;
}
}
return false;
}
},
async launcherReady(path) {
try {
await access(path);
return true;
} catch (cause: unknown) {
// best-effort cleanup: optional launcher probing; absence means not ready.
void cause;
return false;
if (isMissingPath(cause)) return false;
throw cause;
}
},
installBundle: (source, destination, replaceExisting) =>
installMacHopperBundle(
source,
destination,
replaceExisting,
{
async createStagingRoot(parent) {
await mkdir(parent, { recursive: true });
return mkdtemp(join(parent, ".rea-hopper-stage-"));
},
copyBundle: async (bundle, staged) => {
await execFileAsync("ditto", [bundle, staged], {
env: environment,
});
},
requireLauncher: (path) => access(path).then(() => undefined),
pathExists,
rename,
removeStagingRoot: (path) => rm(path, { recursive: true }),
},
(cleanup) => pendingCleanup.add(cleanup),
),
async openApplication(path) {
await execFileAsync("open", [path], { env: environment });
},
@@ -316,7 +438,7 @@ export const systemMacHopperInstallHost = (
await cleanup();
pendingCleanup.delete(cleanup);
} catch (cause: unknown) {
failure ??= `Hopper DMG cleanup remains incomplete: ${failureMessage(cause)}`;
failure ??= `Hopper installer cleanup remains incomplete: ${failureMessage(cause)}`;
}
}
return failure;
@@ -343,13 +465,41 @@ const commandSucceeds = async (
await execFileAsync(command, args, { env: environment });
return true;
} catch (cause: unknown) {
// best-effort cleanup: optional command probing; failure means unavailable.
void cause;
return false;
if (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
typeof cause.code === "number"
)
return false;
throw cause;
}
};
const failureMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
cause instanceof Error
? [
cause.message,
"stderr" in cause && typeof cause.stderr === "string"
? cause.stderr.trim()
: "",
]
.filter((value) => value !== "")
.join(": ")
: String(cause);
const isMissingPath = (cause: unknown): boolean =>
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT";
const pathExists = async (path: string): Promise<boolean> => {
try {
await access(path);
return true;
} catch (cause: unknown) {
if (isMissingPath(cause)) return false;
throw cause;
}
};
const releaseSchema = z.object({
filename: z.string().url(),
file_length: z.string().regex(/^\d+$/u),
+19
View File
@@ -311,6 +311,25 @@ describe("setup plan application", () => {
expect(result.remediation).toBeUndefined();
});
it("records an installed Hopper when opening the app needs manual recovery", async () => {
const host = new FakeSetupHost("darwin");
host.installHopper = () =>
Promise.resolve({
status: "installed",
launcherPath: "/Applications/Hopper.app/Contents/MacOS/hopper",
warning:
"Hopper was installed, but macOS could not open it. Open it manually.",
});
const result = await runSetup(options(true, true), host);
expect(result).toMatchObject({
status: "needs_human",
appliedActions: ["installed_hopper"],
remediation: expect.stringContaining("Open it manually"),
});
});
it("reuses existing Hopper without selecting every detected client", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
+15 -4
View File
@@ -306,6 +306,7 @@ const runSetupWorkflow = async (
}
let selectedClients: readonly SetupClient[] =
planSelection.clientsToConfigure;
let hopperWarning: string | undefined;
if (
planSelection.installHopper &&
(interactiveApproval || options.installHopper)
@@ -322,7 +323,12 @@ const runSetupWorkflow = async (
clientStates,
});
if ("failure" in install) return install.failure;
({ hopperPath, providerEnvironment, selectedClients } = install);
({
hopperPath,
providerEnvironment,
selectedClients,
warning: hopperWarning,
} = install);
}
const clientFailure = await configureDetectedClients({
host,
@@ -355,6 +361,7 @@ const runSetupWorkflow = async (
doctor.healthy,
hopperPath,
),
hopperWarning,
manualRemediation,
);
return {
@@ -407,14 +414,17 @@ const installHopperAction = async (input: {
};
}
input.appliedActions.push("installed_hopper");
const warning = [installed.warning, installed.cleanupFailure]
.filter((value): value is string => value !== undefined)
.join(" ");
emitProgress(input.options, {
actionId: "install_hopper",
label,
state: installed.cleanupFailure === undefined ? "completed" : "warning",
state: warning === "" ? "completed" : "warning",
detail:
installed.cleanupFailure === undefined
warning === ""
? installed.launcherPath
: `${installed.launcherPath}; ${installed.cleanupFailure}`,
: `${installed.launcherPath}; ${warning}`,
});
const providerEnvironment = {
...input.providerEnvironment,
@@ -423,6 +433,7 @@ const installHopperAction = async (input: {
return {
hopperPath: installed.launcherPath,
providerEnvironment,
...(warning === "" ? {} : { warning }),
selectedClients: await filterClientsNeedingConfigure(
input.host,
input.selectedClients,
+33 -7
View File
@@ -203,18 +203,29 @@ export const systemSetupHost = (
return {
status: "installed",
launcherPath: result.launcherPath,
...(result.openFailure === undefined
? {}
: {
warning: `Hopper was installed, but macOS could not open it: ${result.openFailure}. Open ${result.launcherPath} manually to complete first-run setup.`,
}),
...(result.cleanupFailure === undefined
? {}
: { cleanupFailure: result.cleanupFailure }),
};
const failure = setupInstallFailure(result.reason);
if (result.cleanupFailure === undefined) return failure;
return failure.status === "failed"
? {
...failure,
remediation: `${failure.remediation} ${result.cleanupFailure}`,
}
: failure;
const diagnostic = setupInstallerDiagnostic(result.diagnostic);
return {
...failure,
remediation: [
failure.remediation,
...(diagnostic === undefined
? []
: [`Installer detail: ${diagnostic}`]),
...(result.cleanupFailure === undefined
? []
: [result.cleanupFailure]),
].join(" "),
};
},
detectedClients: () => detectClients(homeDirectory, platform, environment),
supportedClients: () =>
@@ -248,6 +259,21 @@ export const systemSetupHost = (
};
};
const setupInstallerDiagnostic = (
value: string | undefined,
): string | undefined => {
if (value === undefined) return undefined;
const normalized = value
.replace(/\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\([0-9A-Za-z]|[@-Z\\^_])/gu, "")
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
.replace(/\s+/gu, " ")
.trim();
if (normalized === "") return undefined;
return normalized.length <= 400
? normalized
: `${normalized.slice(0, 397).trimEnd()}...`;
};
const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
configureClient: (
client: SetupClient,
+7 -1
View File
@@ -23,6 +23,7 @@ export type SetupHopperInstallResult =
readonly status: "installed";
readonly launcherPath: string;
readonly cleanupFailure?: string;
readonly warning?: string;
}
| {
readonly status: "failed";
@@ -30,6 +31,11 @@ export type SetupHopperInstallResult =
readonly remediation: string;
};
export type SetupHopperInstallFailure = Extract<
SetupHopperInstallResult,
{ readonly status: "failed" }
>;
export type HopperInstallFailureReason =
| "unsupported_host"
| "release_metadata"
@@ -93,7 +99,7 @@ const packageManagerRemediation = (
export const setupInstallFailure = (
reason: HopperInstallFailureReason,
packageFailure?: LinuxPackageInstallFailure,
): SetupHopperInstallResult => {
): SetupHopperInstallFailure => {
switch (reason) {
case "unsupported_host":
return {
@@ -0,0 +1,166 @@
import { expect, it } from "vitest";
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js";
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../domain/evidence.js";
import {
EvidenceIntegrityError,
EvidenceReferenceError,
} from "../../domain/evidenceErrors.js";
import { resolveArtifactComparisonEvidence } from "./ArtifactComparisonEvidence.js";
const artifactSummary = () => {
const parent = ARTIFACT_COMPARISON_EXAMPLE.left;
const summary = inspectAnalysisView(
{
source: { kind: "inline", evidence: parent },
view: { kind: "summary" },
},
(evidenceId) => (evidenceId === parent.evidence_id ? parent : undefined),
);
if (!summary.ok) throw summary.error;
return { parent, summary: summary.value };
};
const recreateSummary = (
summary: Evidence,
normalizedResult: Evidence["normalized_result"],
parameters: Evidence["parameters"] = summary.parameters,
): Evidence => {
if (summary.subject === null)
throw new Error("artifact summary fixture must identify its subject");
return createEvidence(
{
path: summary.subject.local_path,
sha256: summary.subject.digest.sha256,
format: summary.subject.format,
...(summary.subject.architecture === null
? {}
: { architecture: summary.subject.architecture }),
},
summary.provider,
{
predicateType: summary.predicate_type,
operation: summary.operation,
parameters,
result: normalizedResult,
...(summary.analysis_profile === null
? {}
: { analysisProfile: summary.analysis_profile }),
rawResult: summary.raw_result,
confidence: summary.confidence,
authority: summary.authority,
environment: summary.environment,
limitations: summary.limitations,
...(summary.relationship_coverage === null
? {}
: { relationshipCoverage: summary.relationship_coverage }),
locations: summary.locations,
evidenceLinks: summary.evidence_links,
},
);
};
it("reports a missing retained parent instead of treating summary facts as inventory", () => {
const { summary } = artifactSummary();
const resolved = resolveArtifactComparisonEvidence(summary, undefined);
expect(resolved.ok).toBe(false);
if (!resolved.ok) {
expect(resolved.error).toBeInstanceOf(EvidenceReferenceError);
expect(resolved.error).toMatchObject({ reason: "missing" });
}
});
it("uses retained Evidence resolution to reject a mismatched parent identity", () => {
const { summary } = artifactSummary();
const otherParent = ARTIFACT_COMPARISON_EXAMPLE.right;
const resolved = resolveArtifactComparisonEvidence(
summary,
() => otherParent,
);
expect(resolved.ok).toBe(false);
if (!resolved.ok) {
expect(resolved.error).toBeInstanceOf(EvidenceReferenceError);
expect(resolved.error).toMatchObject({ reason: "identity_mismatch" });
}
});
it("rejects a summary that declares the wrong parent operation", () => {
const { parent, summary } = artifactSummary();
const summaryResult = artifactInventorySummaryViewResultSchema.parse(
summary.normalized_result,
);
const normalizedResult = {
...summaryResult,
parent_operation: "inspect_artifact",
};
const forged = recreateSummary(summary, normalizedResult);
const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) =>
evidenceId === parent.evidence_id ? parent : undefined,
);
expect(resolved.ok).toBe(false);
if (!resolved.ok)
expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError);
});
it("rejects summary parameters that do not identify the projected parent", () => {
const { parent, summary } = artifactSummary();
const forged = recreateSummary(summary, summary.normalized_result, {
...summary.parameters,
parent_evidence_id: `ev_${"f".repeat(64)}`,
});
const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) =>
evidenceId === parent.evidence_id ? parent : undefined,
);
expect(resolved.ok).toBe(false);
if (!resolved.ok)
expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError);
});
it("rejects summary facts that do not reproject from the complete parent", () => {
const { parent, summary } = artifactSummary();
const summaryResult = artifactInventorySummaryViewResultSchema.parse(
summary.normalized_result,
);
const normalizedResult = {
...summaryResult,
summary: {
...summaryResult.summary,
available_counts: {
...summaryResult.summary.available_counts,
nodes: summaryResult.summary.available_counts.nodes + 1,
},
},
};
const forged = recreateSummary(summary, normalizedResult);
const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) =>
evidenceId === parent.evidence_id ? parent : undefined,
);
expect(resolved.ok).toBe(false);
if (!resolved.ok)
expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError);
});
it("accepts display-only subject changes excluded from Evidence identity", () => {
const { parent, summary } = artifactSummary();
if (summary.subject === null)
throw new Error("artifact summary fixture must identify its subject");
const displayOnly = parseEvidence({
...summary,
subject: {
...summary.subject,
name: "renamed.app",
local_path: "/display-only/renamed.app",
},
});
const resolved = resolveArtifactComparisonEvidence(
displayOnly,
(evidenceId) => (evidenceId === parent.evidence_id ? parent : undefined),
);
expect(resolved).toEqual({ ok: true, value: parent });
});
@@ -0,0 +1,61 @@
import type { EvidenceLookup } from "../EvidenceInputResolver.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewService.js";
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
import {
EvidenceIntegrityError,
EvidenceReferenceError,
} from "../../domain/evidenceErrors.js";
import type { Evidence } from "../../domain/evidence.js";
import { err, ok, type Result } from "../../domain/result.js";
/** Resolve only an artifact summary's exact retained owner before comparison. */
export const resolveArtifactComparisonEvidence = (
evidence: Evidence,
evidenceById: EvidenceLookup | undefined,
): Result<Evidence, AnalysisError> => {
if (evidence.operation !== "inspect_analysis_view") return ok(evidence);
const summary = artifactInventorySummaryViewResultSchema.safeParse(
evidence.normalized_result,
);
if (!summary.success)
return err(
new AnalysisInputError("compare_artifacts", undefined, [
{
path: ["evidence"],
reason: "invalid_value",
message:
"Artifact comparison accepts only an artifact inventory summary from inspect_analysis_view.",
},
]),
);
const parentId = summary.data.parent_evidence_id;
const parent = evidenceById?.(parentId);
if (parent === undefined)
return err(
new EvidenceReferenceError(
parentId,
"missing",
"retained complete artifact inventory Evidence",
null,
),
);
const reprojected = inspectAnalysisViewValidated(
{
source: { kind: "retained-evidence", evidence_id: parentId },
view: { kind: "summary" },
},
(evidenceId) => (evidenceId === parentId ? parent : undefined),
);
if (!reprojected.ok) return reprojected;
if (reprojected.value.evidence_id !== evidence.evidence_id)
return err(
new EvidenceIntegrityError(
"Artifact summary does not match its retained parent inventory.",
),
);
return ok(parent);
};
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import type { RelationshipCoverage } from "../../domain/relationshipCoverage.js";
import {
ANALYSIS_SNAPSHOT_PROFILE,
ANALYSIS_SNAPSHOT_PROVIDER,
@@ -502,57 +503,58 @@ describe("analysis snapshot cache partitioning", () => {
});
});
describe("relationship coverage snapshot replay", () => {
it("authenticates relationship coverage and replays legacy reads as unknown", () => {
const cache = new AnalysisSnapshotCache();
const operation = "procedure_callees" as const;
const parameters = { procedure: "0x1000", document: "fixture" };
const coverage = {
status: "complete" as const,
diagnostics: ["The provider retained an unresolved call target."],
};
const execution = createAnalysisExecution(
["0x2000"],
ANALYSIS_SNAPSHOT_PROVIDER,
{
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
rawResult: ["0x2000"],
relationshipCoverage: coverage,
subject: {
path: ANALYSIS_SNAPSHOT_TARGET.path,
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
format: ANALYSIS_SNAPSHOT_TARGET.format,
...(ANALYSIS_SNAPSHOT_TARGET.architecture === undefined
? {}
: { architecture: ANALYSIS_SNAPSHOT_TARGET.architecture }),
},
},
);
cache.record({
target: ANALYSIS_SNAPSHOT_TARGET,
profile: ANALYSIS_SNAPSHOT_PROFILE,
const createRelationshipCoverageSnapshot = (
relationshipCoverage?: RelationshipCoverage,
) => {
const operation = "procedure_callees" as const;
const parameters = { procedure: "0x1000", document: "fixture" };
const cache = new AnalysisSnapshotCache();
const execution = createAnalysisExecution(
["0x2000"],
ANALYSIS_SNAPSHOT_PROVIDER,
{
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
rawResult: ["0x2000"],
subject: ANALYSIS_SNAPSHOT_TARGET,
...(relationshipCoverage === undefined ? {} : { relationshipCoverage }),
},
);
cache.record({
target: ANALYSIS_SNAPSHOT_TARGET,
profile: ANALYSIS_SNAPSHOT_PROFILE,
operation,
parameters,
execution,
});
const evidence = createEvidence(
ANALYSIS_SNAPSHOT_TARGET,
ANALYSIS_SNAPSHOT_PROVIDER,
{
operation,
parameters,
execution,
});
const evidence = createEvidence(
ANALYSIS_SNAPSHOT_TARGET,
ANALYSIS_SNAPSHOT_PROVIDER,
{
operation,
parameters,
result: ["0x2000"],
rawResult: ["0x2000"],
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
relationshipCoverage: coverage,
},
);
const exported = cache.export(
ANALYSIS_SNAPSHOT_TARGET,
ANALYSIS_SNAPSHOT_PROFILE,
createEvidenceBundle([evidence]),
);
if (!exported.ok) throw exported.error;
result: ["0x2000"],
rawResult: ["0x2000"],
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
...(relationshipCoverage === undefined ? {} : { relationshipCoverage }),
},
);
const exported = cache.export(
ANALYSIS_SNAPSHOT_TARGET,
ANALYSIS_SNAPSHOT_PROFILE,
createEvidenceBundle([evidence]),
);
if (!exported.ok) throw exported.error;
return { cache, operation, parameters, snapshot: exported.value };
};
describe("relationship coverage snapshot replay", () => {
it("authenticates coverage and rejects missing serialized fields", () => {
const coverage: RelationshipCoverage = {
status: "complete",
diagnostics: ["The provider retained an unresolved call target."],
};
const { cache, operation, parameters, snapshot } =
createRelationshipCoverageSnapshot(coverage);
expect(
cache.lookup(
ANALYSIS_SNAPSHOT_TARGET,
@@ -562,7 +564,7 @@ describe("relationship coverage snapshot replay", () => {
)?.relationshipCoverage,
).toEqual(coverage);
const tampered = structuredClone(exported.value);
const tampered = structuredClone(snapshot);
const tamperedEntry = tampered.entries[0];
if (tamperedEntry === undefined) throw new Error("snapshot entry missing");
tamperedEntry.execution.relationship_coverage = {
@@ -573,26 +575,36 @@ describe("relationship coverage snapshot replay", () => {
new AnalysisSnapshotCache().import(tampered, undefined, () => ok(0)).ok,
).toBe(false);
const legacy = structuredClone(exported.value);
const legacyEntry = legacy.entries[0];
if (legacyEntry === undefined) throw new Error("snapshot entry missing");
Reflect.deleteProperty(legacyEntry.execution, "relationship_coverage");
const legacyEvidence = createEvidence(
ANALYSIS_SNAPSHOT_TARGET,
ANALYSIS_SNAPSHOT_PROVIDER,
{
operation,
parameters,
result: ["0x2000"],
rawResult: ["0x2000"],
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
},
const missingCoverage = structuredClone(snapshot);
const missingCoverageEntry = missingCoverage.entries[0];
if (missingCoverageEntry === undefined)
throw new Error("snapshot entry missing");
Reflect.deleteProperty(
missingCoverageEntry.execution,
"relationship_coverage",
);
Reflect.deleteProperty(legacyEvidence, "relationship_coverage");
legacy.evidence_bundle = createEvidenceBundle([legacyEvidence]);
parseAnalysisSnapshot(legacy);
const missingCoverageEvidence = missingCoverage.evidence_bundle.records[0];
if (missingCoverageEvidence === undefined)
throw new Error("snapshot Evidence missing");
Reflect.deleteProperty(missingCoverageEvidence, "relationship_coverage");
expect(() => parseAnalysisSnapshot(missingCoverage)).toThrow();
expect(
new AnalysisSnapshotCache().import(missingCoverage, undefined, () =>
ok(0),
).ok,
).toBe(false);
});
it("replays absent coverage as explicitly unknown", () => {
const { operation, parameters, snapshot } =
createRelationshipCoverageSnapshot();
expect(snapshot.entries[0]?.execution.relationship_coverage).toBeNull();
expect(
snapshot.evidence_bundle.records[0]?.relationship_coverage,
).toBeNull();
const replayCache = new AnalysisSnapshotCache();
expect(replayCache.import(legacy, undefined, () => ok(0)).ok).toBe(true);
expect(replayCache.import(snapshot, undefined, () => ok(0)).ok).toBe(true);
expect(
replayCache.lookup(
ANALYSIS_SNAPSHOT_TARGET,
@@ -105,8 +105,8 @@ export const addJavaScriptSourceModules = (
label: file.path,
properties: {
semantic_role: "source-module",
source_sha256: file.sha256,
path: file.path,
container_sha256: file.container_sha256,
logical_module_key: file.path,
module_format: moduleFormat(file.path, semantic.ir),
import_relationships: semantic.ir.moduleLinks.filter(
@@ -421,6 +421,33 @@ it("keeps duplicate function fingerprints ambiguous", () => {
expect(query.summary.total_seed_matches).toBe(2);
});
it("selects analyzer-produced boundaries by their method field", () => {
const graph = graphFor("const port = Number(process.env.PORT);");
const boundary = graph.nodes.find(({ kind }) => kind === "boundary");
if (boundary === undefined) throw new Error("Expected a boundary node");
expect(boundary.properties).toMatchObject({
boundary_kind: "coerce",
field: "Number",
method: "Number",
});
expect(
queryJavaScriptSemanticGraph(graph, {
seed: { kind: "boundary-field", field: "Number" },
direction: "forward-influence",
}),
).toMatchObject({
seed_node_ids: [boundary.node_id],
summary: { total_seed_matches: 1 },
});
expect(
queryJavaScriptSemanticGraph(graph, {
seed: { kind: "boundary-field", field: "missing-field" },
direction: "forward-influence",
}).summary.total_seed_matches,
).toBe(0);
});
const semanticNodeFor = (roleKey: string) => {
const file: JavaScriptArtifactFile = {
path: "budget.js",
@@ -263,6 +263,7 @@ const addBoundaryNode = (
null),
properties: {
boundary_kind: operation.kind,
field: operation.method,
method: operation.method,
},
});
+9 -2
View File
@@ -3,17 +3,24 @@ import type {
ExecutionOptions,
ProviderAvailability,
} from "../AnalysisProvider.js";
import type { JebRequest } from "../../domain/jeb/jebAnalysis.js";
import type { JebOperation, JebRequest } from "../../domain/jeb/jebAnalysis.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Result } from "../../domain/result.js";
/** Live engine readiness and the operations advertised by that engine. */
export type JebAnalysisAvailability =
| (Extract<ProviderAvailability, { status: "available" }> & {
readonly supportedOperations: readonly JebOperation[];
})
| Extract<ProviderAvailability, { status: "unavailable" }>;
/**
* JEB inspection boundary served by a caller-supplied running engine client.
* Implementation details belong to the provider adapter.
*/
export interface JebAnalysisPort {
/** Probe the running JEB client without opening a project. */
inspectAvailability(signal?: AbortSignal): Promise<ProviderAvailability>;
inspectAvailability(signal?: AbortSignal): Promise<JebAnalysisAvailability>;
/** Release engine connection state; the JEB client itself keeps running. */
close(): Promise<void>;
execute(
@@ -27,6 +27,12 @@ const fakePort = (
code: null,
reason: null,
diagnostics: {},
supportedOperations: [
"inspect_jeb_client",
"list_jeb_units",
"open_jeb_project",
"decompile_jeb_item",
],
};
},
async close() {},
@@ -460,12 +460,26 @@ describe("Mach-O load command reader inputs", () => {
"i386",
);
// A Java class file shares the FAT magic; version fields are not a slice count.
const javaClasses = [javaClassFile(), javaClassFile(4000)];
expect(javaClasses.map(({ length }) => length)).toEqual([57, 4060]);
const javaClasses = [
javaClassFile(),
javaClassFile(4000),
javaClassFile(4000, true),
];
for (const javaClass of javaClasses) {
expect(hasMachoMagic(javaClass)).toBe(true);
expect(await read(javaClass)).toEqual({ status: "not-mach-o" });
}
const malformedFat = Buffer.alloc(28);
malformedFat.writeUInt32BE(0xcafebabe, 0);
malformedFat.writeUInt32BE(1, 4);
malformedFat.writeUInt32BE(0x0100000c, 8);
malformedFat.writeUInt32BE(4096, 16);
malformedFat.writeUInt32BE(32, 20);
malformedFat.writeUInt32BE(12, 24);
expect(await read(malformedFat)).toMatchObject({
status: "malformed",
reason: expect.stringContaining("slice range extends beyond the file"),
});
expect(await read(new TextEncoder().encode("#!/bin/sh\n"))).toEqual({
status: "not-mach-o",
});
@@ -477,6 +491,35 @@ describe("Mach-O load command reader inputs", () => {
await read(Uint8Array.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00])),
).toEqual({ status: "malformed", reason: "Mach-O header is truncated" });
});
it("keeps oversized CAFEBABE identity unknown when class payload is sparse", async () => {
const classPrefix = Buffer.from(javaClassFile(4000, true));
const payloadBytes = MAX_LOAD_COMMAND_BYTES + 1;
const attributeHeader = Buffer.alloc(6);
attributeHeader.writeUInt16BE(5, 0);
attributeHeader.writeUInt32BE(payloadBytes, 2);
classPrefix.writeUInt16BE(1, classPrefix.length - 2);
const prefix = Buffer.concat([classPrefix, attributeHeader]);
const size = prefix.length + payloadBytes;
const sparseReadAt = async (offset: number, length: number) => {
const result = Buffer.alloc(Math.min(length, size - offset));
if (offset < prefix.length)
prefix.copy(
result,
0,
offset,
Math.min(prefix.length, offset + length),
);
return result;
};
expect(await readMachoImage(sparseReadAt, size)).toEqual({
status: "unsupported",
reason: expect.stringContaining(
"class identity scan exceeds the 16 MiB limit",
),
});
});
});
describe("Mach-O load command reader failures", () => {
+18 -2
View File
@@ -141,7 +141,14 @@ export const readMachoImage = async (
: { status: "not-mach-o" };
const view = viewOf(head);
const magic = view.getUint32(0, false);
let header = readFatContainerHeader(head, size);
let header: ReturnType<typeof readFatContainerHeader>;
if (magic === FAT_MAGIC && size <= MAX_LOAD_COMMAND_BYTES) {
const complete = await readAt(0, size);
header =
complete.byteLength === size
? readFatContainerHeader(complete, size)
: readFatContainerHeader(head, size);
} else header = readFatContainerHeader(head, size);
if (header.state !== "not-fat") {
if (header.state === "needs-data") {
header = readFatContainerHeader(
@@ -169,8 +176,17 @@ export const readMachoImage = async (
);
}
if (identity.state === "not-mach-o") return { status: "not-mach-o" };
if (identity.state === "malformed")
if (identity.state === "malformed") {
// A large CAFEBABE input whose bounded prefix resembles an invalid
// known-CPU FAT table remains unknown until its JVM identity can be
// checked; do not turn the resource limit into a malformed claim.
if (magic === FAT_MAGIC && size > MAX_LOAD_COMMAND_BYTES)
throw new MachoFormatIssue(
"unsupported",
"FAT/JVM identity is unresolved because the class identity scan exceeds the 16 MiB limit",
);
throw new MachoFormatIssue(identity.category, identity.reason);
}
if (identity.state === "needs-slice-header")
return { status: "not-mach-o" };
return {
+4 -4
View File
@@ -1,8 +1,8 @@
import { realpath } from "node:fs/promises";
import {
artifactInventoryOutputSchema,
type ArtifactInventoryOutput,
artifactInventoryResultSchema,
type ArtifactInventoryResult,
} from "../../domain/artifactGraph.js";
import { abortIfNeeded } from "../ArtifactHash.js";
import { scanCanonicalArtifactInventoryInScope } from "./scanCanonical.js";
@@ -13,9 +13,9 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySn
export const inventoryArtifact = async (
inputPath: string,
options: ArtifactInventoryOptions,
): Promise<ArtifactInventoryOutput> => {
): Promise<ArtifactInventoryResult> => {
const snapshot = await scanArtifactInventory(inputPath, options);
return artifactInventoryOutputSchema.parse(snapshot);
return artifactInventoryResultSchema.parse(snapshot);
};
/** Scan an artifact once and retain the complete immutable graph for projection. */
-1
View File
@@ -289,7 +289,6 @@ const buildInventorySnapshot = async (
provenance: reader?.provenance() ?? [],
integrity_contradictions: integrityContradictions,
coverage,
coverage_status: coverage.status === "complete" ? "complete" : "partial",
limitations: buildLimitations(
[...traversal.limitations, ...edgeResult.limitations],
integrityContradictions,
+5 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js";
const secret = (secret_id: string) => ({ source: "secret", secret_id });
const literal = (value: string) => ({
@@ -136,6 +137,7 @@ describe("browserScenarioSchema", () => {
service_workers: "block",
});
expect(parsed.capture).toEqual({
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
network: {
request_body: false,
response_body: false,
@@ -145,7 +147,9 @@ describe("browserScenarioSchema", () => {
at_end: ["dom", "storage"],
events: ["console", "page-errors", "network", "websockets"],
});
expect(parsed).not.toHaveProperty("limits");
expect(parsed.capture.limits).toEqual(
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
);
});
it("allows callers to choose visible browsers and service workers", () => {
@@ -90,6 +90,7 @@ export const browserScenarioCaptureData = (input: {
.sort(),
network_content: input.scenario.capture.network,
event_families: [...new Set(input.scenario.capture.events)].sort(),
limits: input.scenario.capture.limits,
},
duration_ms: Math.max(0, Math.round(performance.now() - input.startedAt)),
steps: [...input.steps],
@@ -2,6 +2,7 @@ import type { BrowserScenario } from "../domain/browserScenario.js";
import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js";
import type {
BrowserScenarioEvent,
BrowserScenarioEventOmissions,
BrowserStepArtifacts,
} from "../domain/browserScenarioCaptureValues.js";
import type { SanitizedBrowserUrl } from "../domain/browserObservation.js";
@@ -23,6 +24,7 @@ export interface BrowserScenarioSessionPort {
readonly retained: number;
readonly dropped: number;
readonly items: readonly BrowserScenarioEvent[];
readonly omissions: BrowserScenarioEventOmissions;
};
/** Gaps in selected event families that prevent complete capture claims. */
eventLimitations?(): readonly string[];
+7 -1
View File
@@ -73,7 +73,13 @@ import { comparePngScreenshots } from "./PngVisualDiff.js";
import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const;
const CLEANUP_DOMAINS = [
"Network",
"Debugger",
"Runtime",
"Page",
"WebMCP",
] as const;
/** Passive browser observation through a selected user-owned CDP endpoint. */
export class CdpBrowserProvider implements BrowserObservationPort {
@@ -145,15 +145,16 @@ class FakeSession implements BrowserScenarioSessionPort {
return 0;
}
events(): {
readonly retained: number;
readonly dropped: number;
readonly items: readonly BrowserScenarioEvent[];
} {
events(): ReturnType<BrowserScenarioSessionPort["events"]> {
return {
retained: this.eventItems.length,
dropped: 0,
items: this.eventItems,
omissions: {
by_kind: [],
network_transactions: [],
network_transaction_range: null,
},
};
}
@@ -559,9 +560,16 @@ describe("PlaywrightBrowserScenarioProvider operation failures", () => {
},
steps: [{ step_id: "scenario_start" }],
events: {
retained: 1,
dropped: 0,
items: [
expect.objectContaining({ text: "navigation started" }),
],
omissions: {
by_kind: [],
network_transactions: [],
network_transaction_range: null,
},
},
},
},
+118 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { Page } from "playwright-core";
import type { BrowserContext, Page, Request, Response } from "playwright-core";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
@@ -76,7 +76,124 @@ describe("declared secrets in scenario events", () => {
});
});
const captureNetworkBudgetFixture = async (
maxTotalEventAllocationBytes?: number,
) => {
const pageListeners = new Map<string, (value: unknown) => void>();
const contextListeners = new Map<string, (value: unknown) => void>();
const context = {
on: (name: string, listener: (value: unknown) => void) =>
contextListeners.set(name, listener),
off: vi.fn(),
} as unknown as BrowserContext;
const page = {
url: () => "http://127.0.0.1/fixture",
context: () => context,
on: (name: string, listener: (value: unknown) => void) =>
pageListeners.set(name, listener),
off: vi.fn(),
} as unknown as Page;
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: "/fixture/chrome" },
start_url: { url: "http://127.0.0.1/fixture" },
actions: [{ step_id: "ready", action: "wait_for_timeout", duration_ms: 1 }],
capture: {
events: ["network"],
network: { response_body: true },
...(maxTotalEventAllocationBytes === undefined
? {}
: {
limits: {
max_total_event_allocation_bytes: maxTotalEventAllocationBytes,
},
}),
},
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
if (secrets === undefined) throw new Error("Expected resolved secrets");
const redactBytes = vi.spyOn(secrets, "redactBytes");
const capture = new PlaywrightScenarioEvents({
page,
context,
ownsContext: true,
enabled: new Set(["network"]),
network: scenario.capture.network,
limits: scenario.capture.limits,
secrets,
});
for (let index = 0; index < 43; index += 1) {
const post = index >= 40;
const body = post
? Buffer.from('{"ok":true}')
: Buffer.from("x".repeat(60_000));
const request = {
method: () => (post ? "POST" : "GET"),
url: () => `http://127.0.0.1/${post ? "final" : "data"}/${String(index)}`,
resourceType: () => "fetch",
postDataBuffer: () => (post ? Buffer.from("{}") : null),
redirectedFrom: () => null,
failure: () => null,
headers: () => ({ "content-type": "application/json" }),
headersArray: async () => [
{ name: "content-type", value: "application/json" },
],
} as unknown as Request;
const response = {
request: () => request,
url: () => request.url(),
status: () => 200,
headers: () => ({ "content-type": "application/json" }),
headersArray: async () => [
{ name: "content-type", value: "application/json" },
],
body: async () => body,
} as unknown as Response;
contextListeners.get("request")?.(request);
contextListeners.get("response")?.(response);
contextListeners.get("requestfinished")?.(request);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
await capture.finish();
return {
result: capture.result(),
redactedByteValues: redactBytes.mock.calls.length,
};
};
describe("PlaywrightScenarioEvents", () => {
it("identifies omitted network transaction content and honors a raised budget", async () => {
const baseline = await captureNetworkBudgetFixture();
const raised = await captureNetworkBudgetFixture(32 * 1024 * 1024);
expect(baseline.result.dropped).toBeGreaterThan(0);
expect(
baseline.result.omissions.by_kind.find(
({ kind }) => kind === "network-content",
),
).toMatchObject({
count: expect.any(Number),
reasons: expect.arrayContaining(["max-total-event-allocation-bytes"]),
});
expect(
baseline.result.omissions.network_transactions.length,
).toBeGreaterThan(0);
expect(baseline.redactedByteValues).toBe(
2 *
baseline.result.items.filter(
(event) => event.kind === "network-content",
).length,
);
expect(raised.result).toMatchObject({
dropped: 0,
omissions: { network_transactions: [] },
});
expect(
raised.result.items.filter(
(event) =>
event.kind === "network-content" && event.phase === "response",
),
).toHaveLength(43);
});
it("observes requested popup page errors independently of popup lifecycle events", () => {
const listeners = new Map<string, (value: unknown) => void>();
const popupListeners = new Map<string, (value: unknown) => void>();
+210 -50
View File
@@ -17,6 +17,14 @@ import {
} from "../domain/browserScenarioCaptureValues.js";
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import type { BrowserNetworkContentSelection } from "../domain/browserNetworkEvidence.js";
import type { BrowserNetworkHeaders } from "../domain/browserNetworkEvidence.js";
import type { BrowserScenarioCaptureLimits } from "../domain/browserScenarioCaptureLimits.js";
import type {
BrowserScenarioEventAdmission,
BrowserScenarioEventBudgetReason,
BrowserScenarioEventOmissions,
} from "../domain/browserScenarioCaptureValues.js";
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js";
import { PlaywrightScenarioNetwork } from "./network/PlaywrightScenarioNetwork.js";
type EventName =
@@ -43,14 +51,12 @@ interface EventCaptureOptions {
readonly enabled: ReadonlySet<EventName>;
readonly secrets: BrowserScenarioSecrets;
readonly network?: BrowserNetworkContentSelection;
readonly limits?: BrowserScenarioCaptureLimits;
readonly signal?: AbortSignal;
}
// Conservative allocation accounting includes UTF-16 storage and worst-case
// JSON escaping. Reserve before schema cloning; never serialize to measure.
const MAX_EVENT_COST = 4 * 1024 * 1024;
const MAX_CAPTURE_COST = 16 * 1024 * 1024;
const MAX_EVENTS = 10_000;
// JSON escaping. Check before schema cloning; never serialize to measure.
const MAX_LISTENER_OWNERS = 2048;
const allocationCost = (value: unknown, remaining: number): number => {
if (typeof value === "string") return value.length * 8 + 2;
@@ -65,15 +71,28 @@ const allocationCost = (value: unknown, remaining: number): number => {
return cost;
};
type EventKind = BrowserScenarioEvent["kind"];
/** Arrival-ordered Playwright event capture with current-step attribution. */
export class PlaywrightScenarioEvents {
private readonly items: BrowserScenarioEvent[] = [];
private retainedCost = 0;
private dropped = 0;
private readonly omittedKinds = new Map<
EventKind,
{ count: number; reasons: Set<BrowserScenarioEventBudgetReason> }
>();
private readonly omittedNetwork = new Map<
string,
{
readonly kinds: Set<EventKind>;
readonly reasons: Set<BrowserScenarioEventBudgetReason>;
}
>();
private stepIndex = 0;
private sequence = 0;
private readonly enabled: ReadonlySet<EventName>;
private readonly secrets: BrowserScenarioSecrets;
private readonly limits: BrowserScenarioCaptureLimits;
private readonly ownsContext: boolean;
private pages = new WeakSet<Page>();
private readonly incompleteFamilies = new Set<EventName>();
@@ -92,6 +111,7 @@ export class PlaywrightScenarioEvents {
if (network.request_body || network.response_body || network.header_values)
this.enabled = new Set([...this.enabled, "network"]);
this.secrets = options.secrets;
this.limits = options.limits ?? DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS;
this.ownsContext = options.ownsContext === true;
this.pages.add(options.page);
if (this.ownsContext && this.enabled.size > 0) {
@@ -104,9 +124,13 @@ export class PlaywrightScenarioEvents {
const context = options.context ?? options.page.context();
const collector = new PlaywrightScenarioNetwork({
selection: network,
limits: this.limits,
secrets: options.secrets,
emit: (event) => this.push(event),
admitText: (values) => this.admitProducerText(values),
admitText: (kind, values) => this.admitProducerText(kind, values, true),
admitContentBody: (input) => this.admitContentBody(input),
omitNetwork: (id, kind, reason) => this.omitNetwork(id, kind, reason),
omitEvent: (kind, reason) => this.omitEvent(kind, reason),
...(options.signal === undefined ? {} : { signal: options.signal }),
});
this.network = collector;
@@ -181,7 +205,7 @@ export class PlaywrightScenarioEvents {
const gaps = [...popupGaps, ...(this.network?.limitations() ?? [])];
if (this.dropped > 0)
gaps.push(
"Browser event collection reached its resource budget; omitted events and descendant observations are unknown.",
"Browser event collection reached a selected resource budget; per-kind omissions and affected network transaction IDs are listed in events.omissions, and event coverage is incomplete.",
);
return this.enabled.has("network") && !this.ownsContext
? [
@@ -195,25 +219,55 @@ export class PlaywrightScenarioEvents {
readonly retained: number;
readonly dropped: number;
readonly items: readonly BrowserScenarioEvent[];
readonly omissions: BrowserScenarioEventOmissions;
} {
const omittedKinds = new Map(
[...this.omittedKinds].map(
([kind, omission]) =>
[
kind,
{ count: omission.count, reasons: new Set(omission.reasons) },
] as const,
),
);
return {
retained: this.items.length,
dropped: this.dropped,
items: this.items,
omissions: {
by_kind: [...omittedKinds]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([kind, omission]) => ({
kind,
count: omission.count,
reasons: [...omission.reasons].sort(),
})),
network_transactions: [...this.omittedNetwork]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([transaction_id, omission]) => ({
transaction_id,
omitted_kinds: [...omission.kinds].sort(),
reasons: [...omission.reasons].sort(),
})),
network_transaction_range:
this.network?.omittedTransactionRange() ?? null,
},
};
}
private push(event: UnindexedEvent): number {
if (this.sealed) return 0;
private push(event: UnindexedEvent): BrowserScenarioEventAdmission {
this.sequence += 1;
if (this.items.length >= MAX_EVENTS) {
this.dropped += 1;
return 0;
}
const cost = allocationCost(event, MAX_EVENT_COST) + 128;
if (cost > MAX_EVENT_COST || this.retainedCost + cost > MAX_CAPTURE_COST) {
this.dropped += 1;
return 0;
const cost =
allocationCost(event, this.limits.max_event_allocation_bytes) + 128;
const reason = this.admissionReason(cost);
if (this.sealed || reason !== undefined) {
const finalReason = reason ?? "max-events";
this.omitEvent(event.kind, finalReason);
return {
status: "omitted",
sequence: this.sequence,
reason: finalReason,
};
}
this.retainedCost += cost;
const parsed = browserScenarioEventSchema.parse({
@@ -222,44 +276,134 @@ export class PlaywrightScenarioEvents {
step_index: this.stepIndex,
});
this.items.push(parsed);
return this.sequence;
return { status: "retained", sequence: this.sequence };
}
private admitText(...values: readonly string[]): boolean {
return this.admitProducerText(values);
private admitText(kind: EventKind, ...values: readonly string[]): boolean {
return this.admitProducerText(kind, values, false).status === "retained";
}
/** Admit conservative output cost before running redaction or cloning. */
private admitProducerText(values: Iterable<string>): boolean {
if (this.sealed) return false;
private admitProducerText(
kind: EventKind,
values: Iterable<string>,
networkMetadata: boolean,
): BrowserScenarioEventAdmission {
let cost = 4096;
for (const value of values) {
cost += this.secrets.redactionAllocationCost(value.length);
if (
cost > MAX_EVENT_COST ||
cost > MAX_CAPTURE_COST - this.retainedCost
) {
this.sequence += 1;
this.dropped += 1;
return false;
}
}
if (
this.items.length >= MAX_EVENTS ||
cost > MAX_EVENT_COST ||
cost > MAX_CAPTURE_COST - this.retainedCost
) {
const reason =
networkMetadata &&
cost > this.limits.max_network_metadata_allocation_bytes
? "max-network-metadata-allocation-bytes"
: this.admissionReason(cost);
if (reason !== undefined) {
this.sequence += 1;
this.dropped += 1;
return false;
this.omitEvent(kind, reason);
return { status: "omitted", sequence: this.sequence, reason };
}
return true;
return { status: "retained", sequence: this.sequence + 1 };
}
private admitOwner(): boolean {
if (this.sealed) return false;
if (this.stopListeners.size < MAX_LISTENER_OWNERS) return true;
private admissionReason(
cost: number,
): BrowserScenarioEventBudgetReason | undefined {
if (this.sealed || this.items.length >= this.limits.max_events)
return "max-events";
if (cost > this.limits.max_event_allocation_bytes)
return "max-event-allocation-bytes";
if (cost > this.limits.max_total_event_allocation_bytes - this.retainedCost)
return "max-total-event-allocation-bytes";
return undefined;
}
private admitContentBody(input: {
readonly transactionId: string;
readonly sourceEventSequence: number;
readonly phase: "request" | "response";
readonly headers: BrowserNetworkHeaders;
readonly observedBytes: number;
readonly allocationBytes: number;
readonly mediaType: string | undefined;
}): BrowserScenarioEventAdmission {
const kind = "network-content";
const eventCost =
allocationCost(
{
kind,
sequence: this.sequence + 1,
step_index: this.stepIndex,
transaction_id: input.transactionId,
source_event_sequence: input.sourceEventSequence,
phase: input.phase,
headers: input.headers,
body: {
state: "captured",
representation:
input.phase === "request"
? "browser-exposed-request-bytes"
: "playwright-response-body-bytes",
encoding: "base64",
content: null,
bytes: input.observedBytes,
sha256: "0".repeat(64),
media_type: null,
redacted: false,
},
},
this.limits.max_event_allocation_bytes,
) -
64 +
input.allocationBytes +
(input.mediaType === undefined
? 0
: Math.max(
input.mediaType.length * 8 + 2,
this.secrets.redactionAllocationCost(input.mediaType.length),
)) +
128;
const reason = this.admissionReason(eventCost);
if (reason !== undefined) {
this.sequence += 1;
this.omitEvent(kind, reason);
return { status: "omitted", sequence: this.sequence, reason };
}
return { status: "retained", sequence: this.sequence + 1 };
}
private omitEvent(
kind: EventKind,
reason: BrowserScenarioEventBudgetReason,
): void {
this.dropped += 1;
let omission = this.omittedKinds.get(kind);
if (omission === undefined) {
omission = { count: 0, reasons: new Set() };
this.omittedKinds.set(kind, omission);
}
omission.count += 1;
omission.reasons.add(reason);
}
private omitNetwork(
transactionId: string,
kind: EventKind,
reason: BrowserScenarioEventBudgetReason,
): void {
let omission = this.omittedNetwork.get(transactionId);
if (omission === undefined) {
omission = { kinds: new Set(), reasons: new Set() };
this.omittedNetwork.set(transactionId, omission);
}
omission.kinds.add(kind);
omission.reasons.add(reason);
}
private admitOwner(kind: EventKind): boolean {
if (this.stopListeners.size < MAX_LISTENER_OWNERS) return true;
this.sequence += 1;
this.omitEvent(kind, "max-listener-owners");
return false;
}
@@ -294,7 +438,8 @@ export class PlaywrightScenarioEvents {
}
if (this.enabled.has("page-errors")) {
const onError = (error: Error) => {
if (!this.admitText(error.message, error.stack ?? "")) return;
if (!this.admitText("page-error", error.message, error.stack ?? ""))
return;
this.push({
kind: "page-error",
message: this.secrets.redact(error.message),
@@ -348,7 +493,8 @@ export class PlaywrightScenarioEvents {
private console(message: ConsoleMessage): void {
const rawText = message.text();
const location = message.location();
if (!this.admitText(rawText, location.url, message.type())) return;
if (!this.admitText("console", rawText, location.url, message.type()))
return;
this.push({
kind: "console",
level: this.secrets.redact(message.type()),
@@ -358,8 +504,8 @@ export class PlaywrightScenarioEvents {
}
private webSocket(socket: WebSocket): void {
if (!this.admitOwner()) return;
if (!this.admitText(socket.url())) return;
if (!this.admitOwner("websocket-opened")) return;
if (!this.admitText("websocket-opened", socket.url())) return;
const url = this.safeUrl(socket.url());
this.push({ kind: "websocket-opened", url });
const onSent = ({ payload }: { payload: string | Buffer }) =>
@@ -396,7 +542,7 @@ export class PlaywrightScenarioEvents {
});
return;
}
if (!this.admitText(payload)) return;
if (!this.admitText(kind, payload)) return;
const text = this.secrets.redact(payload);
this.push({
kind,
@@ -412,7 +558,7 @@ export class PlaywrightScenarioEvents {
kind: "frame-attached" | "frame-detached" | "frame-navigated",
frame: Frame,
): void {
if (!this.admitText(frame.url(), frame.name())) return;
if (!this.admitText(kind, frame.url(), frame.name())) return;
this.push({
kind,
url: frame.url() === "" ? null : this.safeUrl(frame.url()),
@@ -421,7 +567,11 @@ export class PlaywrightScenarioEvents {
}
private worker(worker: Worker): void {
if (!this.admitOwner() || !this.admitText(worker.url())) return;
if (
!this.admitOwner("worker-created") ||
!this.admitText("worker-created", worker.url())
)
return;
const details = {
url: this.safeUrl(worker.url()),
name: null,
@@ -437,7 +587,11 @@ export class PlaywrightScenarioEvents {
private popup(page: Page): void {
if (this.pages.has(page)) return;
if (!this.admitOwner() || !this.admitText(page.url())) return;
if (
!this.admitOwner("popup-opened") ||
!this.admitText("popup-opened", page.url())
)
return;
this.pages.add(page);
for (const family of [
"frames",
@@ -453,7 +607,7 @@ export class PlaywrightScenarioEvents {
if (this.enabled.has("popups")) {
this.push({ kind: "popup-opened", ...opened });
const onClose = () => {
if (!this.admitText(page.url())) return;
if (!this.admitText("popup-closed", page.url())) return;
this.push({
kind: "popup-closed",
url: page.url() === "" ? null : this.safeUrl(page.url()),
@@ -467,7 +621,13 @@ export class PlaywrightScenarioEvents {
}
private download(download: Download): void {
if (this.admitText(download.suggestedFilename(), download.url()))
if (
this.admitText(
"download-cancelled",
download.suggestedFilename(),
download.url(),
)
)
this.push({
kind: "download-cancelled",
suggested_filename: this.secrets.redact(download.suggestedFilename()),
@@ -207,23 +207,29 @@ export class PlaywrightScenarioServiceWorkers {
async assertBlocked(): Promise<void> {
if (this.#session !== undefined) await this.settle();
for (const frame of this.page.frames()) {
let violatesPolicy: unknown;
let failure: unknown;
try {
violatesPolicy = await frame.evaluate(`(async () => {
if (!("serviceWorker" in navigator)) return false;
failure = await frame.evaluate(`(async () => {
if (!("serviceWorker" in navigator)) return null;
const serviceWorker = navigator.serviceWorker;
if (${this.#session !== undefined} &&
(!Object.hasOwn(serviceWorker.register, Symbol.for(${this.marker})) ||
typeof globalThis[${this.restoreKey}] !== "function")) return true;
if (serviceWorker.controller !== null) return true;
return (await serviceWorker.getRegistrations()).length > 0;
typeof globalThis[${this.restoreKey}] !== "function")) return "policy_unprotected";
if (serviceWorker.controller !== null) return "service_worker_blocked";
return (await serviceWorker.getRegistrations()).length > 0
? "service_worker_blocked"
: null;
})()`);
} catch (cause: unknown) {
if (frame.isDetached()) continue;
throw cause;
}
if (violatesPolicy)
if (failure === "policy_unprotected")
throw new BrowserObservationError(OPERATION, "target_not_allowed");
if (failure === "service_worker_blocked")
throw new BrowserObservationError(OPERATION, "service_worker_blocked");
if (failure !== null)
throw new BrowserObservationError(OPERATION, "protocol_error");
}
}
+16 -5
View File
@@ -65,7 +65,16 @@ vi.mock("./PlaywrightScenarioEvents.js", () => ({
}
result() {
return { events: [] };
return {
retained: 0,
dropped: 0,
items: [],
omissions: {
by_kind: [],
network_transactions: [],
network_transaction_range: null,
},
};
}
limitations() {
@@ -105,7 +114,7 @@ const fixture = () => {
const page = Object.assign(new EventEmitter(), {
url: () => "about:blank",
goto: vi.fn(async () => undefined),
evaluate: vi.fn(async (_script: unknown) => false),
evaluate: vi.fn(async (_script: unknown): Promise<unknown> => null),
addInitScript: vi.fn(async (_script: unknown) => undefined),
context: () => context,
frames: () => [page],
@@ -190,7 +199,9 @@ it("stops attached navigation without storage and retries rejected policy cleanu
it("rejects an init script accepted by CDP when the live realm is unprotected", async () => {
const { page, cleanup } = fixture();
page.evaluate.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
page.evaluate
.mockResolvedValueOnce(null)
.mockResolvedValueOnce("policy_unprotected");
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
@@ -484,7 +495,7 @@ it("waits for a pending registration installation before cancelled cleanup", asy
it("rejects attached block when caller-owned registrations already exist", async () => {
const { cleanup, page } = fixture();
page.evaluate.mockResolvedValueOnce(true);
page.evaluate.mockResolvedValueOnce("service_worker_blocked");
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
@@ -499,7 +510,7 @@ it("rejects attached block when caller-owned registrations already exist", async
PlaywrightScenarioSession.open(attached, {}, {}),
).rejects.toMatchObject({
_tag: "BrowserObservationError",
reason: "target_not_allowed",
reason: "service_worker_blocked",
});
expect(page.addInitScript).not.toHaveBeenCalled();
expect(cleanup.close).toHaveBeenCalledTimes(1);
+1
View File
@@ -247,6 +247,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
enabled: new Set(scenario.capture.events),
secrets,
network: scenario.capture.network,
limits: scenario.capture.limits,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
return new PlaywrightScenarioSession(opened, {
+36
View File
@@ -0,0 +1,36 @@
import { expect, it } from "vitest";
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
const input = analyzeWebBundleInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
allowed_origins: ["http://127.0.0.1:9222"],
target_id: "page-1",
fetch_source_maps: true,
});
it("reports an empty selected request set without starting fetch work", async () => {
const result = await fetchWebSourceMaps([], input, undefined, {
fetch: async () => {
throw new Error("No source-map request should be made");
},
});
expect(result).toEqual({
status: "not_requested",
requested: 0,
processed: 0,
items: [],
});
});
it("honors cancellation before returning an empty request set", async () => {
const controller = new AbortController();
const reason = new Error("cancelled before fetching");
controller.abort(reason);
await expect(fetchWebSourceMaps([], input, controller.signal)).rejects.toBe(
reason,
);
});
+12
View File
@@ -81,6 +81,14 @@ export const fetchWebSourceMaps = async (
signal?: AbortSignal,
host: SourceMapFetchHost = { fetch: globalThis.fetch },
): Promise<SourceMaps> => {
throwIfAborted(signal);
if (requests.length === 0)
return webSourceMapsSchema.parse({
status: "not_requested",
requested: 0,
processed: 0,
items: [],
});
const items: SourceMapItem[] = [];
const operationController = new AbortController();
const timeout = setTimeout(
@@ -170,6 +178,10 @@ export const fetchWebSourceMaps = async (
});
};
const throwIfAborted = (signal?: AbortSignal): void => {
if (signal?.aborted === true) throw signal.reason;
};
const sourceMapOutputLimitation = (unprocessed: number): string =>
`source-map output budget could not retain results for ${String(unprocessed)} requested map${unprocessed === 1 ? "" : "s"}. their results are unknown.`;
+69 -16
View File
@@ -8,6 +8,10 @@ import type {
BrowserNetworkBody,
BrowserNetworkHeaders,
} from "../../domain/browserNetworkEvidence.js";
import type {
BrowserScenarioEventAdmission,
BrowserScenarioEventBudgetReason,
} from "../../domain/browserScenarioCaptureValues.js";
import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
import { withPlaywrightExecutionBoundary } from "../PlaywrightExecutionBoundary.js";
@@ -20,9 +24,26 @@ export interface NetworkHeadersSource {
/** A bounded read cannot delay browser cleanup indefinitely. */
export interface NetworkReadOptions {
readonly timeoutMs: number;
readonly maxBodyBytes: number;
readonly maxBodyAllocationBytes: number;
readonly maxHeaderAllocationBytes: number;
readonly maxMetadataAllocationBytes: number;
readonly signal?: AbortSignal;
}
export type NetworkBodyCapture =
| BrowserNetworkBody
| {
readonly state: "omitted";
readonly reason: BrowserScenarioEventBudgetReason;
};
export type NetworkBodyPreflight = (input: {
readonly observedBytes: number;
readonly allocationBytes: number;
readonly mediaType: string | undefined;
}) => BrowserScenarioEventAdmission;
const credentialHeaders = new Set([
"authorization",
"proxy-authorization",
@@ -33,12 +54,14 @@ const credentialHeaders = new Set([
const errorMessage = (
cause: unknown,
secrets: BrowserScenarioSecrets,
maxMetadataAllocationBytes: number,
): string => {
const message =
cause instanceof Error && cause.message !== ""
? cause.message
: "Network read failed";
return secrets.redactionAllocationCost(message.length) <= 1024 * 1024
return secrets.redactionAllocationCost(message.length) <=
maxMetadataAllocationBytes
? secrets.redact(message)
: "Network read failed; diagnostic exceeds the allocation budget.";
};
@@ -58,10 +81,10 @@ export const captureNetworkHeaders = async (
let cost = 0;
for (const { name, value } of headers) {
cost += secrets.redactionAllocationCost(name.length + value.length) + 128;
if (cost > 1024 * 1024)
if (cost > options.maxHeaderAllocationBytes)
return {
state: "unavailable",
message: "Headers exceed the 1 MiB allocation budget.",
message: `Headers exceed the ${String(options.maxHeaderAllocationBytes)}-byte allocation budget.`,
};
}
return {
@@ -80,7 +103,10 @@ export const captureNetworkHeaders = async (
}),
};
} catch (cause: unknown) {
return { state: "unavailable", message: errorMessage(cause, secrets) };
return {
state: "unavailable",
message: errorMessage(cause, secrets, options.maxMetadataAllocationBytes),
};
}
};
@@ -91,7 +117,8 @@ export const captureNetworkBody = async (
source: NetworkHeadersSource,
secrets: BrowserScenarioSecrets,
options: NetworkReadOptions,
): Promise<BrowserNetworkBody> => {
preflight?: NetworkBodyPreflight,
): Promise<NetworkBodyCapture> => {
try {
const observed = await withPlaywrightExecutionBoundary(
read,
@@ -101,29 +128,55 @@ export const captureNetworkBody = async (
if (observed === null) return { state: "not_exposed" };
// This bounds subsequent redaction, base64 and validation copies, but the
// Playwright API itself has already materialized the body (see PR limits).
if (observed.length > 64 * 1024)
const maxBodyBytes = options.maxBodyBytes;
if (observed.length > maxBodyBytes)
return {
state: "unavailable",
reason: "body-read-failed",
message: "Body exceeds the 64 KiB retained-content budget.",
reason: "body-size-limit",
message: `Playwright materialized the body before its ${String(maxBodyBytes)}-byte retained-body budget was checked.`,
limit_bytes: maxBodyBytes,
observed_bytes: observed.length,
};
if (secrets.redactionAllocationCost(observed.length) > 1024 * 1024)
const bodyAllocationCost = secrets.redactionAllocationCost(observed.length);
const base64AllocationCost = Math.ceil(observed.length / 3) * 4 * 8 + 2;
const maxBodyAllocationBytes = options.maxBodyAllocationBytes;
const observedBodyAllocationBytes = Math.max(
bodyAllocationCost,
base64AllocationCost,
);
if (observedBodyAllocationBytes > maxBodyAllocationBytes)
return {
state: "unavailable",
reason: "body-read-failed",
message: "Body redaction exceeds the allocation budget.",
reason: "body-allocation-limit",
message:
"Body redaction exceeds the selected network body allocation budget.",
limit_allocation_bytes: maxBodyAllocationBytes,
observed_allocation_bytes: observedBodyAllocationBytes,
};
const retained = secrets.redactBytes(observed);
const contentType = source.headers()["content-type"];
if (
contentType !== undefined &&
secrets.redactionAllocationCost(contentType.length) > 64 * 1024
secrets.redactionAllocationCost(contentType.length) >
options.maxHeaderAllocationBytes
)
return {
state: "unavailable",
reason: "body-read-failed",
message: "Content type exceeds the retained metadata budget.",
reason: "body-allocation-limit",
message:
"Content type exceeds the selected network header allocation budget.",
limit_allocation_bytes: options.maxHeaderAllocationBytes,
observed_allocation_bytes: secrets.redactionAllocationCost(
contentType.length,
),
};
const admission = preflight?.({
observedBytes: observed.length,
allocationBytes: observedBodyAllocationBytes,
mediaType: contentType,
});
if (admission?.status === "omitted")
return { state: "omitted", reason: admission.reason };
const retained = secrets.redactBytes(observed);
return {
state: "captured",
representation:
@@ -149,7 +202,7 @@ export const captureNetworkBody = async (
: cause instanceof AnalysisTimeoutError
? "body-read-timeout"
: "body-read-failed",
message: errorMessage(cause, secrets),
message: errorMessage(cause, secrets, options.maxMetadataAllocationBytes),
};
}
};
@@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest";
import { browserScenarioSchema } from "../../domain/browserScenario.js";
import {
type BrowserScenarioEventAdmission,
browserScenarioEventSchema,
type BrowserScenarioEvent,
} from "../../domain/browserScenarioCaptureValues.js";
@@ -100,6 +101,7 @@ const harness = (
selected: BrowserNetworkContentSelection = selection,
signal?: AbortSignal,
secretValue = "私密",
limitsInput: unknown = {},
) => {
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: "/fixture/chrome" },
@@ -108,14 +110,17 @@ const harness = (
secrets: [
{ secret_id: "declared", environment_variable: "FIXTURE_SECRET" },
],
capture: { limits: limitsInput },
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {
FIXTURE_SECRET: secretValue,
});
if (secrets === undefined) throw new Error("Fixture secret was not resolved");
const events: BrowserScenarioEvent[] = [];
const omittedEventCounts = new Map<string, number>();
const collector = new PlaywrightScenarioNetwork({
selection: selected,
limits: scenario.capture.limits,
secrets,
readTimeoutMs: 50,
...(signal === undefined ? {} : { signal }),
@@ -124,10 +129,38 @@ const harness = (
events.push(
browserScenarioEventSchema.parse({ ...event, sequence, step_index: 0 }),
);
return sequence;
return { status: "retained", sequence };
},
admitText: () => ({ status: "retained", sequence: events.length + 1 }),
admitContentBody: () => ({
status: "retained",
sequence: events.length + 1,
}),
omitNetwork: (transactionId, kind, reason) => {
let omission = networkOmissions.get(transactionId);
if (omission === undefined) {
omission = { kinds: new Set(), reasons: new Set() };
networkOmissions.set(transactionId, omission);
}
omission.kinds.add(kind);
omission.reasons.add(reason);
},
omitEvent: (kind) => {
omittedEventCounts.set(kind, (omittedEventCounts.get(kind) ?? 0) + 1);
},
});
return { collector, events, secrets };
const networkOmissions = new Map<
string,
{ kinds: Set<string>; reasons: Set<string> }
>();
return {
collector,
events,
limits: scenario.capture.limits,
secrets,
networkOmissions,
omittedEventCounts,
};
};
const bodyText = (event: BrowserScenarioEvent) => {
@@ -350,6 +383,144 @@ it("does not read unfinished streaming responses and reports the cutoff", async
expect(collector.limitations().length).toBeGreaterThan(0);
});
it("queues completed body reads behind the active-read limit", async () => {
const { collector, events } = harness({
request_body: false,
response_body: true,
header_values: false,
});
const requests = Array.from(
{ length: 20 },
(_, index) => new RequestFixture(`request-${String(index)}`),
);
const releases: Array<(bytes: Buffer) => void> = [];
let activeReads = 0;
let maximumActiveReads = 0;
for (const request of requests) {
const response = new ResponseFixture(request, "unused");
response.read = () =>
new Promise((resolve) => {
activeReads += 1;
maximumActiveReads = Math.max(maximumActiveReads, activeReads);
releases.push((bytes) => {
activeReads -= 1;
resolve(bytes);
});
});
collector.request(request);
collector.response(response);
collector.finished(request);
}
let releasedReads = 0;
while (releasedReads < requests.length) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
const batch = releases.splice(0);
expect(batch.length).toBeGreaterThan(0);
batch.forEach((release) => release(Buffer.from("captured")));
releasedReads += batch.length;
}
await collector.finish();
const content = events.filter(
(event) => event.kind === "network-content" && event.phase === "response",
);
expect(maximumActiveReads).toBe(16);
expect(content).toHaveLength(20);
expect(
content.every(
(event) =>
event.kind === "network-content" && event.body.state === "captured",
),
).toBe(true);
expect(events.filter(({ kind }) => kind === "network-content")).toHaveLength(
20,
);
});
it("starts each queued body's timeout when its read begins", async () => {
vi.useFakeTimers();
const { collector, events } = harness({
request_body: false,
response_body: true,
header_values: false,
});
const requests = Array.from(
{ length: 20 },
(_, index) => new RequestFixture(`request-${String(index)}`),
);
for (const request of requests) {
const response = new ResponseFixture(request, "unused");
response.read = () =>
new Promise((resolve) => {
setTimeout(() => resolve(Buffer.from("captured")), 40);
});
collector.request(request);
collector.response(response);
collector.finished(request);
}
const finishing = collector.finish();
await vi.advanceTimersByTimeAsync(40);
await vi.advanceTimersByTimeAsync(40);
await finishing;
const content = events.filter(
(event) => event.kind === "network-content" && event.phase === "response",
);
expect(content).toHaveLength(20);
expect(
content.every(
(event) =>
event.kind === "network-content" && event.body.state === "captured",
),
).toBe(true);
});
it("settles queued reads as cancelled without starting their body APIs", async () => {
const controller = new AbortController();
const { collector, events } = harness(
{
request_body: false,
response_body: true,
header_values: false,
},
controller.signal,
);
const requests = Array.from(
{ length: 20 },
(_, index) => new RequestFixture(`request-${String(index)}`),
);
const responses = requests.map((request) => {
const response = new ResponseFixture(request, "unused");
response.read = () => new Promise(() => undefined);
return response;
});
requests.forEach((request, index) => {
collector.request(request);
collector.response(responses[index]!);
collector.finished(request);
});
expect(responses.filter(({ bodyReads }) => bodyReads > 0)).toHaveLength(16);
controller.abort();
await collector.finish();
expect(responses.filter(({ bodyReads }) => bodyReads > 0)).toHaveLength(16);
const content = events.filter(
(event) => event.kind === "network-content" && event.phase === "response",
);
expect(content).toHaveLength(20);
expect(
content.every(
(event) =>
event.kind === "network-content" &&
event.body.state === "unavailable" &&
event.body.reason === "cancelled",
),
).toBe(true);
});
it("bounds hung completed-body reads and rejects late mutation after finalization", async () => {
vi.useFakeTimers();
const { collector, events } = harness();
@@ -554,9 +725,9 @@ it("bounds admitted transactions before invoking content producers", async () =>
await collector.finish();
expect(admitted[0]?.bodyReads).toBe(1);
expect(
admitted
.slice(16)
.every((request) => request.bodyReads === 0 && request.headerReads === 0),
admitted.every(
(request) => request.bodyReads === 1 && request.headerReads === 1,
),
).toBe(true);
expect(rejected.bodyReads).toBe(0);
expect(rejected.headerReads).toBe(0);
@@ -568,6 +739,55 @@ it("bounds admitted transactions before invoking content producers", async () =>
expect(collector.limitations().join(" ")).toContain("resource budget");
});
it("uses the caller's transaction limit and reports the exact omitted ID range", async () => {
const { collector, events, omittedEventCounts } = harness(
selection,
undefined,
"fixture",
{ max_network_transactions: 2 },
);
const requests = Array.from(
{ length: 4 },
(_, index) => new RequestFixture(`body-${String(index)}`),
);
for (const request of requests) collector.request(request);
collector.response(new ResponseFixture(requests[2]!, "body-2"));
collector.finished(requests[2]!);
await collector.finish();
expect(collector.omittedTransactionRange()).toEqual({
first_transaction_id: "request-3",
last_transaction_id: "request-4",
count: 2,
reason: "max-network-transactions",
});
expect(events.filter(({ kind }) => kind === "request")).toHaveLength(2);
expect(Object.fromEntries(omittedEventCounts)).toEqual({
request: 2,
"network-content": 3,
response: 1,
"request-finished": 1,
});
expect(requests[2]?.bodyReads).toBe(0);
});
it("uses the caller's body-byte limit and returns typed size evidence", async () => {
const { collector, events } = harness(selection, undefined, "fixture", {
max_network_body_bytes: 4,
});
const request = new RequestFixture("12345");
collector.request(request);
await collector.finish();
expect(events.find(({ kind }) => kind === "network-content")).toMatchObject({
transaction_id: "request-1",
body: {
state: "unavailable",
reason: "body-size-limit",
limit_bytes: 4,
observed_bytes: 5,
},
});
});
it("keeps header names and selected body when unselected values are oversized", async () => {
const { collector, events } = harness({
request_body: true,
@@ -605,6 +825,9 @@ it("omits oversized body copies and retains ordinary content", async () => {
const content = events.filter((event) => event.kind === "network-content");
expect(content[0]?.body).toMatchObject({
state: "unavailable",
reason: "body-size-limit",
limit_bytes: 64 * 1024,
observed_bytes: 64 * 1024 + 1,
message: expect.stringContaining("budget"),
});
expect(bodyText(content[1]!)).toBe("ordinary");
@@ -628,13 +851,24 @@ it("reserves redaction expansion before creating replacement copies", async () =
});
it("does not read content when phase metadata was omitted", async () => {
const { secrets } = harness();
const { secrets, limits } = harness();
const redact = vi.spyOn(secrets, "redact");
const collector = new PlaywrightScenarioNetwork({
selection,
limits,
secrets,
admitText: () => false,
emit: () => 0,
admitText: () => ({
status: "omitted",
sequence: 1,
reason: "max-total-event-allocation-bytes",
}),
emit: (): BrowserScenarioEventAdmission => ({
status: "retained",
sequence: 1,
}),
admitContentBody: () => ({ status: "retained", sequence: 1 }),
omitNetwork: () => undefined,
omitEvent: () => undefined,
});
const request = new RequestFixture("ordinary");
collector.request(request);
@@ -642,7 +876,7 @@ it("does not read content when phase metadata was omitted", async () => {
expect(redact).not.toHaveBeenCalled();
expect(request.bodyReads + request.headerReads).toBe(0);
expect(collector.limitations().join(" ")).toContain(
"aggregate browser event budget",
"max-total-event-allocation-bytes",
);
expect(collector.limitations().join(" ")).toContain("incomplete");
});
+312 -92
View File
@@ -1,12 +1,19 @@
import type {
BrowserNetworkBody,
BrowserNetworkHeaders,
BrowserNetworkContentSelection,
} from "../../domain/browserNetworkEvidence.js";
import type { BrowserScenarioCaptureLimits } from "../../domain/browserScenarioCaptureLimits.js";
import type {
BrowserScenarioEventAdmission,
BrowserScenarioEventBudgetReason,
} from "../../domain/browserScenarioCaptureValues.js";
import type { BrowserScenarioEvent } from "../../domain/browserScenarioCaptureValues.js";
import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
import {
captureNetworkBody,
captureNetworkHeaders,
type NetworkBodyCapture,
type NetworkHeadersSource,
type NetworkReadOptions,
} from "./PlaywrightNetworkContent.js";
@@ -56,12 +63,42 @@ interface Transaction {
requestObserved: boolean;
}
interface QueuedContentRead {
readonly run: () => Promise<void>;
readonly resolve: () => void;
readonly reject: (cause: unknown) => void;
}
interface NetworkCaptureOptions {
readonly selection: BrowserNetworkContentSelection;
readonly limits: BrowserScenarioCaptureLimits;
readonly secrets: BrowserScenarioSecrets;
readonly emit: (event: UnindexedNetworkEvent) => number;
readonly emit: (
event: UnindexedNetworkEvent,
) => BrowserScenarioEventAdmission;
/** Check aggregate event capacity before redacting producer-owned metadata. */
readonly admitText?: (values: Iterable<string>) => boolean;
readonly admitText: (
kind: UnindexedNetworkEvent["kind"],
values: Iterable<string>,
) => BrowserScenarioEventAdmission;
readonly admitContentBody: (input: {
readonly transactionId: string;
readonly sourceEventSequence: number;
readonly phase: "request" | "response";
readonly headers: BrowserNetworkHeaders;
readonly observedBytes: number;
readonly allocationBytes: number;
readonly mediaType: string | undefined;
}) => BrowserScenarioEventAdmission;
readonly omitNetwork: (
transactionId: string,
kind: UnindexedNetworkEvent["kind"],
reason: BrowserScenarioEventBudgetReason,
) => void;
readonly omitEvent: (
kind: UnindexedNetworkEvent["kind"],
reason: BrowserScenarioEventBudgetReason,
) => void;
readonly signal?: AbortSignal;
/** Production uses 5s; a fixture can supply its own bounded-read clock. */
readonly readTimeoutMs?: number;
@@ -73,15 +110,33 @@ export class PlaywrightScenarioNetwork {
ScenarioNetworkRequest,
Transaction
>();
private readonly omittedRequests = new WeakSet<ScenarioNetworkRequest>();
private readonly omittedPhases = new WeakMap<
ScenarioNetworkRequest,
Set<string>
>();
private totalTransactions = 0;
private omittedTransactionCount = 0;
private firstOmittedTransaction = 0;
private lastOmittedTransaction = 0;
/** One request and one response-content job at most per admitted transaction. */
private readonly pending: Promise<void>[] = [];
private readonly queuedReads: QueuedContentRead[] = [];
private readonly gaps = new Set<string>();
private accepting = true;
private activeReads = 0;
private readonly readOptions: NetworkReadOptions;
private readonly limits: BrowserScenarioCaptureLimits;
constructor(private readonly options: NetworkCaptureOptions) {
this.limits = options.limits;
this.readOptions = {
timeoutMs: options.readTimeoutMs ?? 5_000,
maxBodyBytes: this.limits.max_network_body_bytes,
maxBodyAllocationBytes: this.limits.max_network_body_allocation_bytes,
maxHeaderAllocationBytes: this.limits.max_network_header_allocation_bytes,
maxMetadataAllocationBytes:
this.limits.max_network_metadata_allocation_bytes,
...(options.signal === undefined ? {} : { signal: options.signal }),
};
}
@@ -90,30 +145,59 @@ export class PlaywrightScenarioNetwork {
request(request: ScenarioNetworkRequest): void {
if (!this.accepting) return;
const transaction = this.transaction(request);
if (transaction === undefined || transaction.requestObserved) return;
if (!this.admitMetadata(request)) return;
if (transaction === undefined) {
this.omitOmittedTransactionPhase(request, "request");
if (
this.options.selection.request_body ||
this.options.selection.header_values
)
this.omitOmittedTransactionContent(request, "request");
return;
}
if (transaction.requestObserved) return;
if (!this.admitMetadata(transaction, "request", request)) return;
transaction.requestObserved = true;
const sequence = this.options.emit({
const admission = this.options.emit({
...this.metadata(request, transaction),
kind: "request",
status: null,
failure: null,
});
if (sequence === 0) return;
if (admission.status === "omitted") {
this.options.omitNetwork(transaction.id, "request", admission.reason);
return;
}
const sequence = admission.sequence;
if (
this.options.selection.request_body ||
this.options.selection.header_values
)
this.collect(transaction, sequence, "request", request, () =>
this.options.selection.request_body
? captureNetworkBody(
async () => request.postDataBuffer(),
"request",
request,
this.options.secrets,
this.readOptions,
)
: Promise.resolve({ state: "not_requested" }),
this.collect(
transaction,
sequence,
"request",
request,
(headers) =>
this.options.selection.request_body
? captureNetworkBody(
async () => request.postDataBuffer(),
"request",
request,
this.options.secrets,
this.readOptions,
(body) =>
this.options.admitContentBody({
transactionId: transaction.id,
sourceEventSequence: sequence,
phase: "request",
headers,
observedBytes: body.observedBytes,
allocationBytes: body.allocationBytes,
mediaType: body.mediaType,
}),
)
: Promise.resolve({ state: "not_requested" }),
this.options.selection.header_values,
);
}
@@ -122,9 +206,15 @@ export class PlaywrightScenarioNetwork {
if (!this.accepting) return;
const request = response.request();
const transaction = this.transaction(request);
if (transaction === undefined) {
this.omitOmittedTransactionPhase(request, "response");
this.observeOmittedTransactionPhase(request, "response");
return;
}
if (
transaction === undefined ||
!this.admitMetadata(
transaction,
"response",
request,
undefined,
[],
@@ -132,7 +222,7 @@ export class PlaywrightScenarioNetwork {
)
)
return;
const sequence = this.options.emit({
const admission = this.options.emit({
...this.metadata(request, transaction),
kind: "response",
url: this.options.secrets.sanitizeUrl(response.url()),
@@ -140,7 +230,11 @@ export class PlaywrightScenarioNetwork {
header_names: this.headerNames(response),
failure: null,
});
if (sequence === 0) return;
if (admission.status === "omitted") {
this.options.omitNetwork(transaction.id, "response", admission.reason);
return;
}
const sequence = admission.sequence;
transaction.response = { source: response, sequence };
}
@@ -148,15 +242,31 @@ export class PlaywrightScenarioNetwork {
finished(request: ScenarioNetworkRequest): void {
if (!this.accepting) return;
const transaction = this.transaction(request);
if (transaction === undefined || !this.admitMetadata(request)) return;
if (transaction === undefined) {
this.omitOmittedTransactionPhase(request, "request-finished");
const observed = this.omittedPhases.get(request);
if (
observed?.has("response") &&
(this.options.selection.response_body ||
this.options.selection.header_values)
)
this.omitOmittedTransactionContent(request, "response");
return;
}
if (!this.admitMetadata(transaction, "request-finished", request)) return;
transaction.terminal = "finished";
const sequence = this.options.emit({
const admission = this.options.emit({
...this.metadata(request, transaction),
kind: "request-finished",
status: null,
failure: null,
});
if (sequence === 0) {
if (admission.status === "omitted") {
this.options.omitNetwork(
transaction.id,
"request-finished",
admission.reason,
);
transaction.responseScheduled = true;
return;
}
@@ -167,18 +277,31 @@ export class PlaywrightScenarioNetwork {
failed(request: ScenarioNetworkRequest): void {
if (!this.accepting) return;
const transaction = this.transaction(request);
if (transaction === undefined) return;
if (transaction === undefined) {
this.omitOmittedTransactionPhase(request, "request-failed");
return;
}
transaction.terminal = "failed";
const failure = request.failure()?.errorText || "Unknown request failure";
if (!this.admitMetadata(request, undefined, [failure])) return;
if (
!this.admitMetadata(transaction, "request-failed", request, undefined, [
failure,
])
)
return;
const message = this.options.secrets.redact(failure);
const sequence = this.options.emit({
const admission = this.options.emit({
...this.metadata(request, transaction),
kind: "request-failed",
status: null,
failure: message,
});
if (sequence === 0) {
if (admission.status === "omitted") {
this.options.omitNetwork(
transaction.id,
"request-failed",
admission.reason,
);
transaction.responseScheduled = true;
return;
}
@@ -189,20 +312,21 @@ export class PlaywrightScenarioNetwork {
});
}
/** Stop observing, mark unfinished transactions, and settle bounded content reads. */
/** Stop observing, mark unfinished transactions, and drain admitted content reads. */
async finish(): Promise<void> {
if (!this.accepting) {
await Promise.all(this.pending);
await this.settlePendingReads();
return;
}
this.accepting = false;
for (const [request, transaction] of this.transactions) {
if (transaction.terminal === undefined) {
if (!this.admitMetadata(request)) continue;
if (!this.admitMetadata(transaction, "request-unfinished", request))
continue;
this.gaps.add(
"Some network requests had not completed when capture ended; later activity is unknown.",
);
const sequence = this.options.emit({
const admission = this.options.emit({
...this.metadata(request, transaction),
kind: "request-unfinished",
status: null,
@@ -212,7 +336,12 @@ export class PlaywrightScenarioNetwork {
? "cancelled"
: "capture-ended",
});
if (sequence === 0) {
if (admission.status === "omitted") {
this.options.omitNetwork(
transaction.id,
"request-unfinished",
admission.reason,
);
transaction.responseScheduled = true;
continue;
}
@@ -227,7 +356,15 @@ export class PlaywrightScenarioNetwork {
});
}
}
await Promise.all(this.pending);
await this.settlePendingReads();
}
private async settlePendingReads(): Promise<void> {
const results = await Promise.allSettled(this.pending);
const failed = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (failed !== undefined) throw failed.reason;
}
/** Missing selected content contributes to aggregate completeness. */
@@ -235,14 +372,38 @@ export class PlaywrightScenarioNetwork {
return [...this.gaps];
}
omittedTransactionRange(): {
readonly first_transaction_id: string;
readonly last_transaction_id: string;
readonly count: number;
readonly reason: "max-network-transactions";
} | null {
return this.omittedTransactionCount === 0
? null
: {
first_transaction_id: `request-${String(this.firstOmittedTransaction)}`,
last_transaction_id: `request-${String(this.lastOmittedTransaction)}`,
count: this.omittedTransactionCount,
reason: "max-network-transactions",
};
}
private transaction(
request: ScenarioNetworkRequest,
): Transaction | undefined {
const known = this.transactions.get(request);
if (known !== undefined) return known;
if (this.transactions.size >= 1024) {
if (this.omittedRequests.has(request)) return undefined;
this.totalTransactions += 1;
const id = `request-${String(this.totalTransactions)}`;
if (this.transactions.size >= this.limits.max_network_transactions) {
this.omittedRequests.add(request);
if (this.omittedTransactionCount === 0)
this.firstOmittedTransaction = this.totalTransactions;
this.lastOmittedTransaction = this.totalTransactions;
this.omittedTransactionCount += 1;
this.gaps.add(
"Network collection reached its 1024-transaction resource budget; further transactions were omitted.",
`Network collection reached its ${String(this.limits.max_network_transactions)}-transaction resource budget; omitted transaction IDs are listed in events.omissions.network_transaction_range.`,
);
return undefined;
}
@@ -250,7 +411,7 @@ export class PlaywrightScenarioNetwork {
const predecessorTransaction =
predecessor === null ? undefined : this.transactions.get(predecessor);
const transaction: Transaction = {
id: `request-${this.transactions.size + 1}`,
id,
redirectedFrom: predecessorTransaction?.id ?? null,
redirectedFromState:
predecessor === null
@@ -265,27 +426,58 @@ export class PlaywrightScenarioNetwork {
return transaction;
}
private admitProducerStrings(values: Iterable<string>): boolean {
const collected: string[] = [];
let cost = 0;
for (const value of values) {
cost += this.options.secrets.redactionAllocationCost(value.length) + 128;
if (cost > 1024 * 1024) {
this.gaps.add(
"Some network metadata exceeded its allocation budget and was omitted.",
);
return false;
}
collected.push(value);
}
if (this.options.admitText?.(collected) ?? true) return true;
private admitProducerStrings(
transaction: Transaction,
kind: UnindexedNetworkEvent["kind"],
values: Iterable<string>,
): boolean {
const admission = this.options.admitText(kind, values);
if (admission.status === "retained") return true;
this.options.omitNetwork(transaction.id, kind, admission.reason);
this.gaps.add(
"Some network metadata was omitted because the aggregate browser event budget was reached; affected transactions are incomplete.",
`Network ${kind} metadata was omitted for ${transaction.id} because ${admission.reason} was reached; network coverage is incomplete.`,
);
return false;
}
private omitOmittedTransactionPhase(
request: ScenarioNetworkRequest,
kind: UnindexedNetworkEvent["kind"],
): void {
if (!this.omittedRequests.has(request)) return;
if (!this.observeOmittedTransactionPhase(request, kind)) return;
this.options.omitEvent(kind, "max-network-transactions");
}
private observeOmittedTransactionPhase(
request: ScenarioNetworkRequest,
phase: string,
): boolean {
let observed = this.omittedPhases.get(request);
if (observed === undefined) {
observed = new Set();
this.omittedPhases.set(request, observed);
}
if (observed.has(phase)) return false;
observed.add(phase);
return true;
}
private omitOmittedTransactionContent(
request: ScenarioNetworkRequest,
phase: "request" | "response",
): void {
if (!this.omittedRequests.has(request)) return;
if (
!this.observeOmittedTransactionPhase(request, `network-content:${phase}`)
)
return;
this.options.omitEvent("network-content", "max-network-transactions");
}
private admitMetadata(
transaction: Transaction,
kind: UnindexedNetworkEvent["kind"],
source: NetworkHeadersSource,
url?: string,
additionalStrings: readonly string[] = [],
@@ -295,6 +487,8 @@ export class PlaywrightScenarioNetwork {
}[] = [],
): boolean {
return this.admitProducerStrings(
transaction,
kind,
this.metadataStrings(source, url, additionalStrings, additionalSources),
);
}
@@ -379,7 +573,7 @@ export class PlaywrightScenarioNetwork {
response.sequence,
"response",
response.source,
() =>
(headers) =>
this.options.selection.response_body
? unavailable === undefined
? captureNetworkBody(
@@ -388,9 +582,20 @@ export class PlaywrightScenarioNetwork {
response.source,
this.options.secrets,
this.readOptions,
(body) =>
this.options.admitContentBody({
transactionId: transaction.id,
sourceEventSequence: response.sequence,
phase: "response",
headers,
observedBytes: body.observedBytes,
allocationBytes: body.allocationBytes,
mediaType: body.mediaType,
}),
)
: Promise.resolve(unavailable)
: Promise.resolve({ state: "not_requested" }),
this.options.selection.header_values,
);
}
@@ -399,54 +604,69 @@ export class PlaywrightScenarioNetwork {
sequence: number,
phase: "request" | "response",
source: NetworkHeadersSource,
body: () => Promise<BrowserNetworkBody>,
body: (headers: BrowserNetworkHeaders) => Promise<NetworkBodyCapture>,
includeHeaders: boolean,
): void {
if (this.activeReads >= 16) {
const message = "Network content exceeds the 16 concurrent-read budget.";
this.gaps.add(message);
this.options.emit({
const collect = async (): Promise<void> => {
const retainedHeaders = includeHeaders
? await captureNetworkHeaders(
source,
this.options.secrets,
this.readOptions,
)
: { state: "not_requested" as const };
const retainedBody = await body(retainedHeaders);
if (retainedBody.state === "omitted") {
this.options.omitNetwork(
transaction.id,
"network-content",
retainedBody.reason,
);
return;
}
if (
retainedHeaders.state === "unavailable" ||
retainedBody.state === "unavailable" ||
retainedBody.state === "not_exposed"
)
this.gaps.add(
"Some selected network content was unavailable; per-transaction content states retain the reason.",
);
const admission = this.options.emit({
kind: "network-content",
transaction_id: transaction.id,
source_event_sequence: sequence,
phase,
headers: this.options.selection.header_values
? { state: "unavailable", message }
: { state: "not_requested" },
body: this.options.selection[
phase === "request" ? "request_body" : "response_body"
]
? { state: "unavailable", reason: "body-read-failed", message }
: { state: "not_requested" },
headers: retainedHeaders,
body: retainedBody,
});
return;
}
this.activeReads += 1;
const headers = this.options.selection.header_values
? captureNetworkHeaders(source, this.options.secrets, this.readOptions)
: Promise.resolve({ state: "not_requested" } as const);
if (admission.status === "omitted")
this.options.omitNetwork(
transaction.id,
"network-content",
admission.reason,
);
};
this.pending.push(
Promise.all([headers, body()])
.then(([retainedHeaders, retainedBody]) => {
if (
retainedHeaders.state === "unavailable" ||
retainedBody.state === "unavailable" ||
retainedBody.state === "not_exposed"
)
this.gaps.add(
"Some selected network content was unavailable; per-transaction content states retain the reason.",
);
this.options.emit({
kind: "network-content",
transaction_id: transaction.id,
source_event_sequence: sequence,
phase,
headers: retainedHeaders,
body: retainedBody,
});
})
.finally(() => {
this.activeReads -= 1;
}),
new Promise<void>((resolve, reject) => {
this.queuedReads.push({ run: collect, resolve, reject });
this.pumpContentReads();
}),
);
}
private pumpContentReads(): void {
while (this.activeReads < 16 && this.queuedReads.length > 0) {
const job = this.queuedReads.shift();
if (job === undefined) return;
this.activeReads += 1;
void job
.run()
.then(job.resolve, job.reject)
.finally(() => {
this.activeReads -= 1;
this.pumpContentReads();
});
}
}
}
+24 -5
View File
@@ -1,5 +1,6 @@
import { z } from "incur";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js";
import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js";
import { createAdbDeviceAnalysisProvider } from "../composition/adb.js";
@@ -351,12 +352,30 @@ export const registerAdbCommands = (
),
}),
run: ({ args, options }) => {
const extras = options.extra.map((entry) => {
const extras: {
readonly type: "string" | "boolean" | "int" | "long" | "float";
readonly key: string;
readonly value: string;
}[] = [];
for (const [index, entry] of options.extra.entries()) {
const first = entry.indexOf(":");
const second = entry.indexOf(":", first + 1);
if (first === -1 || second === -1)
throw new Error(`--extra expects type:key:value, received: ${entry}`);
return {
return logCliCommand(
logger,
CLI_COMMANDS.startAdbActivity,
async () => {
throw new AnalysisInputError("start_adb_activity", undefined, [
{
path: ["extras", index],
reason: "invalid_format",
message: "Expected --extra in type:key:value form.",
expected: "type:key:value",
},
]);
},
);
extras.push({
type: entry.slice(0, first) as
| "string"
| "boolean"
@@ -365,8 +384,8 @@ export const registerAdbCommands = (
| "float",
key: entry.slice(first + 1, second),
value: entry.slice(second + 1),
};
});
});
}
return execute(CLI_COMMANDS.startAdbActivity, "start_adb_activity", {
serial: args.serial,
action: args.action,
+11 -3
View File
@@ -45,10 +45,18 @@ export const registerCutterCommands = (
.refine((value) => !/[\r\n]/u.test(value)),
}),
options: z.object({
jsonOutput: z
.boolean()
.optional()
.describe(
"Return command-specific JSON output when available (independent of CLI output formatting).",
),
json: z
.boolean()
.default(false)
.describe("Return command-specific JSON output when available."),
.optional()
.describe(
"Deprecated compatibility input: use --json-output; pass --json=true or --json=false (bare --json formats CLI output).",
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
@@ -58,7 +66,7 @@ export const registerCutterCommands = (
session_id: args.sessionId,
expected_generation: args.expectedGeneration,
command: args.command,
json: options.json,
json: options.jsonOutput ?? options.json ?? false,
},
{ signal },
);
+15
View File
@@ -215,6 +215,21 @@ export const registerFridaCommands = (
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.instrumentWithFrida, async () => {
if (args.mode === "attach" && options.argument !== undefined)
return projectAnalysisError(
new AnalysisInputError(
CLI_COMMANDS.instrumentWithFrida,
undefined,
[
{
path: ["argument"],
reason: "invalid_value",
message:
"--argument is only valid in spawn mode; remove it when attaching to a process.",
},
],
),
);
if (
(options.script === undefined) ===
(options.scriptFile === undefined)
@@ -4,7 +4,7 @@ export const API_SURFACE_EXAMPLE = {
source: {
kind: "retained-evidence",
evidence_id:
"ev_9e218c18bc6159ec9c8492a9130c286496fb1b6d9f03ec2f4ea3321ce2b15097",
"ev_90073e9478eada1e2cc351b523e6ba9231c9b4bc9708773567b880094db9d4c7",
},
view: {
kind: "api-surface",
@@ -14,12 +14,12 @@ export const API_SURFACE_EXAMPLE = {
},
output: {
parent_evidence_id:
"ev_9e218c18bc6159ec9c8492a9130c286496fb1b6d9f03ec2f4ea3321ce2b15097",
"ev_90073e9478eada1e2cc351b523e6ba9231c9b4bc9708773567b880094db9d4c7",
parent_operation: "inspect_web_network_capture",
parent_digest:
"9e218c18bc6159ec9c8492a9130c286496fb1b6d9f03ec2f4ea3321ce2b15097",
"90073e9478eada1e2cc351b523e6ba9231c9b4bc9708773567b880094db9d4c7",
view_digest:
"47c1859b7a86dd10770061767300bdb6ec177a9254e8dc7863d5ea8603343da6",
"c9494341920dc4cff6ecc6d8ed2c5e3abf15976642c253248e220d45f290ca7b",
artifact: {
path: "/fixtures/api-surface.har",
sha256:
+33 -24
View File
@@ -1,12 +1,22 @@
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
import {
artifactGraphDigest,
artifactIdForContent,
artifactManifestId,
occurrenceIdForLocation,
} from "../domain/artifactIdentity.js";
import type {
ArtifactEdge,
ArtifactNode,
ArtifactOccurrence,
IntegrityContradiction,
} from "../domain/artifactGraph.js";
import { createEvidence } from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
const inventory = (digit: string) => {
const sha = digit.repeat(64);
const artifactId = `art_${digestCanonicalValue({ sha256: sha }, "Artifact example")}`;
const occurrenceId = `occ_${digestCanonicalValue({ root: artifactId }, "Artifact example")}`;
const nodes = [
const artifactId = artifactIdForContent(sha);
const nodes: ArtifactNode[] = [
{
artifact_id: artifactId,
format: "file",
@@ -18,9 +28,13 @@ const inventory = (digit: string) => {
limitations: [],
},
];
const occurrences = [
const occurrences: ArtifactOccurrence[] = [
{
occurrence_id: occurrenceId,
occurrence_id: occurrenceIdForLocation({
rootArtifactId: artifactId,
logicalPath: ".",
entryKind: "file",
}),
artifact_id: artifactId,
parent_occurrence_id: null,
logical_path: ".",
@@ -36,36 +50,31 @@ const inventory = (digit: string) => {
limitations: [],
},
];
const graphSha256 = digestCanonicalValue(
{
nodes,
occurrences,
edges: [],
integrity_contradictions: [],
},
"Artifact example",
);
const edges: ArtifactEdge[] = [];
const integrityContradictions: IntegrityContradiction[] = [];
const graphSha256 = artifactGraphDigest({
nodes,
occurrences,
edges,
contradictions: integrityContradictions,
});
return jsonValueSchema.parse({
manifest: {
manifest_id: `agm_${digestCanonicalValue(
{
root_artifact_id: artifactId,
graph_sha256: graphSha256,
},
"Artifact example",
)}`,
manifest_id: artifactManifestId(artifactId, graphSha256),
root_artifact_id: artifactId,
root_sha256: sha,
root_format: "file",
graph_sha256: graphSha256,
node_count: nodes.length,
occurrence_count: occurrences.length,
edge_count: 0,
edge_count: edges.length,
},
nodes,
occurrences,
edges: [],
edges,
provenance: [],
integrity_contradictions: integrityContradictions,
coverage: { scope: "root", status: "complete" },
limitations: [],
});
};
@@ -26,7 +26,7 @@ export const BROWSER_SCENARIO_TOOL_CONTRACTS = [
name: "capture_browser_scenario",
...toolContractMetadata("capture_browser_scenario"),
description:
"Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, and a URL-only final capture are defaults. capture.network selects request_body, response_body, and header_values independently; selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.",
"Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.",
kind: "browser-provider",
inputSchema: browserScenarioInputSchema,
outputSchema: evidenceResultOf(browserScenarioCaptureSchema),
+12 -1
View File
@@ -23,6 +23,7 @@ import {
browserCaptureComparisonInputSchema,
browserCaptureComparisonSchema,
} from "../domain/browserCaptureComparison.js";
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js";
import {
captureWebScreenshotInputSchema,
compareWebScreenshotsInputSchema,
@@ -85,6 +86,7 @@ const exampleScenarioCapture = () => ({
header_values: false,
},
event_families: [],
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
},
duration_ms: 10,
steps: [
@@ -117,7 +119,16 @@ const exampleScenarioCapture = () => ({
completeness: scenarioCompleteness,
},
],
events: { retained: 0, dropped: 0, items: [] },
events: {
retained: 0,
dropped: 0,
items: [],
omissions: {
by_kind: [],
network_transactions: [],
network_transaction_range: null,
},
},
completeness: scenarioCompleteness,
limitations: [],
});
+1 -1
View File
@@ -104,7 +104,7 @@ export const SESSION_TOOL_CONTRACTS = [
),
session(
"compare_artifacts",
"Compare complete artifact inventories by logical occurrence path, content identity, metadata, and graph relations. Pass the inventory Evidence nested in each inspect_artifact result as left and right. Every delta cites both inputs, and gaps yield truncated or unknown, never equivalence. Returns every change inline.",
"Compare complete artifact inventories by logical occurrence path, content identity, metadata, and graph relations. Pass the inventory Evidence nested in each inspect_artifact result as left and right, or pass its inspect_analysis_view summary while the complete parent Evidence remains retained in this session. Summary facts are not treated as a complete inventory; comparison resolves the exact retained parent. Every delta cites both complete inputs, and gaps yield truncated or unknown, never equivalence. Returns every change inline.",
artifactComparisonInputSchema,
),
session(
+2 -2
View File
@@ -45,7 +45,7 @@ import {
listArchitecturesSchema,
} from "../domain/native/nativeInspection.js";
import { artifactExtractionResultSchema } from "../domain/artifactGraph.js";
import { artifactInspectionOutputSchema } from "../domain/artifactInspection.js";
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
import { interfaceBuilderAnalysisSchema } from "../domain/apple/interfaceBuilderGraph.js";
import { dylibResolutionResultSchema } from "../domain/apple/dylibResolution.js";
import { keyedArchiveResultSchema } from "../domain/apple/keyedArchive.js";
@@ -404,7 +404,7 @@ export const nativeOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
inspect_artifact: evidenceResultOf(
z.union([
artifactInspectionOutputSchema,
artifactInspectionResultSchema,
artifactInventorySummaryViewResultSchema,
]),
),
+23 -5
View File
@@ -278,11 +278,11 @@ export class CutterBridgeClient {
),
}
: undefined);
this.#directory =
configured ??
(this.#platform === "win32" && localAppData
? join(localAppData, "REA", "CutterBridge")
: join(homedir(), ".cache", "rea", "cutter-bridge"));
this.#directory = resolveBridgeDirectory(
configured,
this.#platform,
localAppData,
);
}
async listSessions(): Promise<CutterBridgeDiscovery> {
@@ -889,3 +889,21 @@ export class CutterBridgeClient {
});
}
}
const resolveBridgeDirectory = (
configured: string | undefined,
platform: NodeJS.Platform,
localAppData: string | undefined,
): string => {
if (configured !== undefined && configured !== "") {
if (configured === "~") return homedir();
if (configured.startsWith("~/"))
return join(homedir(), configured.slice(2));
if (platform === "win32" && configured.startsWith("~\\"))
return join(homedir(), configured.slice(2));
return configured;
}
return platform === "win32" && localAppData
? join(localAppData, "REA", "CutterBridge")
: join(homedir(), ".cache", "rea", "cutter-bridge");
};
+9 -1
View File
@@ -35,6 +35,11 @@ import type { AnalysisErrorProjection } from "./analysisErrorProjection.js";
export const analysisErrorRemediationAction = (
error: AnalysisError,
): string => {
if (
error instanceof BrowserObservationError &&
error.reason === "service_worker_blocked"
)
return "Set environment.service_workers to allow to permit this capture, or remove the service worker registration through the browser. REA never removes existing registrations.";
if (error instanceof AnalysisCapabilityUnavailableError && error.retryable)
return "Review details.reason, then retry the same analysis after the condition clears.";
if (error instanceof EvidenceFileError && error.published !== undefined)
@@ -177,6 +182,7 @@ const browserErrorCategory = (
if (
reason === "target_not_found" ||
reason === "target_not_allowed" ||
reason === "service_worker_blocked" ||
reason === "target_changed" ||
reason === "endpoint_unreachable" ||
reason === "disconnected"
@@ -300,12 +306,14 @@ const hopperErrorUserMessage = (error: AnalysisError): string | undefined => {
const RESTART_OWNED_PROVIDER =
"Restart the owned provider by calling close_binary, then open the target again and retry.";
const RESTART_UNREACHABLE_HOPPER = `${RESTART_OWNED_PROVIDER} If Hopper may have exited, call export_evidence_bundle before close_binary; closing clears retained records.`;
const hopperProcessRemediation = (error: HopperProcessError): string => {
if (error.failureCode !== undefined && error.userMessage !== undefined)
return error.userMessage;
if (error.stage === "launch")
return "Review the captured launcher diagnostics and Hopper setup, then retry opening the target.";
if (error.providerState === "unreachable") return RESTART_UNREACHABLE_HOPPER;
if (providerRetryAction(error.providerState) === "restart_provider")
return RESTART_OWNED_PROVIDER;
return "Read binary_session.provider_operation_health for this request before retrying or restarting the owned provider.";
@@ -335,7 +343,7 @@ const hopperProcessMessage = (error: HopperProcessError): string => {
return `Hopper exited during ${where} (${exit}).${startup}${recovery}${request}`;
}
if (error.providerState === "unreachable")
return `Hopper became unreachable during ${where}; its process exit was not observed. ${RESTART_OWNED_PROVIDER}${startup}${request}`;
return `Hopper became unreachable during ${where}; its process exit was not observed. ${RESTART_UNREACHABLE_HOPPER}${startup}${request}`;
return `Hopper failed during ${where} and REA could not determine whether the provider is busy, exited, or unreachable.${startup} Read binary_session.provider_operation_health for this request before retrying or restarting.${request}`;
};
+42 -1
View File
@@ -392,7 +392,30 @@ it("retains native partial observations and cleanup uncertainty on provider fail
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
});
describe("analysis error projection: provider failures", () => {
describe("analysis error projection: browser provider failures", () => {
it("explains the service-worker policy refusal and names its recovery", () => {
const projected = projectAnalysisError(
new BrowserObservationError(
"capture_browser_scenario",
"service_worker_blocked",
),
);
expect(projected).toMatchObject({
code: "target_unavailable",
category: "unavailable",
retryable: false,
message: expect.stringContaining("service-worker policy is block"),
remediation: {
action: expect.stringContaining("environment.service_workers to allow"),
},
details: {
operation: "capture_browser_scenario",
reason: "service_worker_blocked",
},
});
});
it("projects the primary browser failure alongside incomplete cleanup", () => {
const projected = projectAnalysisError(
new BrowserObservationError(
@@ -421,7 +444,9 @@ describe("analysis error projection: provider failures", () => {
},
});
});
});
describe("analysis error projection: Hopper provider failures", () => {
it("reports a timed-out active Hopper request and actionable retry guidance", () => {
const projected = projectAnalysisError(
new HopperTimeoutError(30_000, "trace_feature", 42, "busy"),
@@ -487,7 +512,9 @@ describe("analysis error projection: provider failures", () => {
message: expect.stringContaining("Hopper analysis failed"),
});
});
});
describe("analysis error projection: detached provider diagnostics", () => {
it.each(["adapter", "cancelled", "timeout"])(
"preserves detached, actionable diagnostics for %s errors",
(kind) => {
@@ -515,7 +542,9 @@ describe("analysis error projection: provider failures", () => {
expect(error.diagnostics?.exit_code).toBe(1);
},
);
});
describe("analysis error projection: Hopper startup failures", () => {
it("maps the Linux startup compatibility exit codes without host internals", () => {
const expected = [
[70, "private_display_unavailable"],
@@ -765,6 +794,18 @@ describe("analysis error projection: operational diagnostics", () => {
expect(unknown.message).toContain("could not determine");
expect(unknown.message).not.toContain("stopped");
expect(unknown.remediation.action).toContain("provider_operation_health");
const unreachable = projectAnalysisError(
new HopperProcessError(null, {
operation: "search_strings",
providerState: "unreachable",
}),
);
expect(unreachable.message).toContain(
"If Hopper may have exited, call export_evidence_bundle before close_binary",
);
expect(unreachable.message).toContain("closing clears retained records");
expect(unreachable.remediation.action).toContain("export_evidence_bundle");
});
});
+1
View File
@@ -135,6 +135,7 @@ const browserErrorCode = (
if (
reason === "target_not_found" ||
reason === "target_not_allowed" ||
reason === "service_worker_blocked" ||
reason === "target_changed"
)
return "target_unavailable";
+16 -2
View File
@@ -234,6 +234,7 @@ describe("analysis snapshot contract", () => {
raw_result: null,
provider: binding.provider,
limitations: [],
relationship_coverage: null,
locations: [],
subject: target,
},
@@ -262,6 +263,7 @@ describe("analysis snapshot contract", () => {
raw_result: null,
provider: binding.provider,
limitations: [],
relationship_coverage: null,
locations: [],
subject: {
path: "a\u0000b",
@@ -293,6 +295,7 @@ describe("analysis snapshot contract", () => {
raw_result: null,
provider: binding.provider,
limitations: [],
relationship_coverage: null,
locations: [],
subject: null,
},
@@ -456,6 +459,7 @@ describe("analysis snapshot Evidence binding", () => {
raw_result: rawResult,
provider: binding.provider,
limitations: ["fixture limitation"],
relationship_coverage: null,
locations: [{ kind: "address" as const, address: "0x1000" }],
subject,
},
@@ -480,11 +484,21 @@ describe("analysis snapshot Evidence binding", () => {
workflow_entries: [],
evidence_bundle: createEvidenceBundle([evidence]),
};
const parsedLegacySnapshot = parseAnalysisSnapshot(snapshot);
const parsedSnapshot = parseAnalysisSnapshot(snapshot);
expect(
parsedLegacySnapshot.entries[0]?.execution.relationship_coverage,
parsedSnapshot.entries[0]?.execution.relationship_coverage,
).toBeNull();
const missingCoverage = structuredClone(snapshot);
const missingCoverageEntry = missingCoverage.entries[0];
if (missingCoverageEntry === undefined)
throw new Error("snapshot entry missing");
Reflect.deleteProperty(
missingCoverageEntry.execution,
"relationship_coverage",
);
expect(() => parseAnalysisSnapshot(missingCoverage)).toThrow();
expect(() =>
parseAnalysisSnapshot({
...snapshot,
+4 -8
View File
@@ -145,7 +145,7 @@ const entrySchema = z.object({
raw_result: jsonValueSchema.nullable(),
provider: committedProviderSchema,
limitations: z.array(z.string()),
relationship_coverage: relationshipCoverageSchema.nullable().default(null),
relationship_coverage: relationshipCoverageSchema.nullable(),
locations: z.array(evidenceLocationSchema),
subject: subjectSchema.nullable(),
}),
@@ -160,7 +160,7 @@ const workflowEntrySchema = z.object({
provider: committedProviderSchema,
analysis_profile: analysisProfileSchema,
limitations: z.array(z.string()),
relationship_coverage: relationshipCoverageSchema.nullable().default(null),
relationship_coverage: relationshipCoverageSchema.nullable(),
locations: z.array(evidenceLocationSchema),
subject: subjectSchema.nullable(),
}),
@@ -305,9 +305,7 @@ const snapshotContentDigest = (
format: execution.subject.format,
architecture: execution.subject.architecture,
},
...(execution.relationship_coverage === null
? {}
: { relationship_coverage: execution.relationship_coverage }),
relationship_coverage: execution.relationship_coverage,
});
return contentDigest({
...metadata,
@@ -872,9 +870,7 @@ const evidenceMatchesWorkflowEntry = (
const evidenceContentMetadata = (evidence: Evidence) => {
return {
limitations: evidence.limitations,
...(evidence.relationship_coverage === null
? {}
: { relationship_coverage: evidence.relationship_coverage }),
relationship_coverage: evidence.relationship_coverage,
locations: evidence.locations,
subject:
evidence.subject === null
@@ -4,6 +4,7 @@ import { expect, it } from "vitest";
import { functionDossierSchema } from "../hopperValues.js";
import { ghidraFunctionDossier } from "../ghidraValues.fixture.js";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js";
import {
analysisViewBindJavaScriptGraphs,
analysisViewJavaScriptAnalysis,
@@ -25,6 +26,7 @@ import {
pageViewCoverage,
} from "./analysisViewCoverage.js";
import { sourceMapReference } from "../javascript/javascriptApplicationGraph.fixture.js";
import { createArtifactInspection } from "../artifactInspection.js";
import { javascriptModulePath } from "./javascriptApplicationView.js";
import {
inspectAnalysisViewInputSchema,
@@ -162,6 +164,28 @@ it("preserves native API limitations and unavailable residual unknowns", () => {
);
});
it("rejects artifact summary views when inspection substep coverage count disagrees", () => {
const source = ARTIFACT_COMPARISON_EXAMPLE.left;
const inspection = createArtifactInspection(source);
const parent: AnalysisViewParent = {
evidenceId: `ev_${"a".repeat(64)}`,
operation: "inspect_artifact",
normalizedResult: {
...inspection,
coverage: {
...inspection.coverage,
substeps_completed: inspection.substeps.length + 1,
},
},
subject: source.subject,
limitations: [],
};
expect(projectAnalysisView(parent, { kind: "summary" })).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
});
it("pages every retained dossier collection and the native API record", () => {
const original = functionDossierSchema.parse(ghidraFunctionDossier());
const page = (
@@ -4,15 +4,11 @@ import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { err, ok, type Result } from "../result.js";
import {
parseArtifactInventoryEvidence,
parseArtifactInspectionInventoryResult,
parseArtifactInventoryResult,
validateArtifactInventorySubject,
type InventorySet,
} from "../artifactInventoryEvidence.js";
import {
artifactInspectionResultSchema,
type ArtifactInspectionResult,
} from "../artifactInspection.js";
import type { ArtifactInspectionResult } from "../artifactInspection.js";
import type {
AnalysisViewParent,
AnalysisViewRequest,
@@ -52,36 +48,14 @@ const parseSource = (
evidenceIds: [parent.evidenceId],
inspection: null,
});
const inspection = artifactInspectionResultSchema.parse(
const parsed = parseArtifactInspectionInventoryResult(
parent.normalizedResult,
parent.subject ?? null,
);
for (const step of inspection.substeps) {
if (
step.evidence_id !== step.evidence.evidence_id ||
step.evidence.operation !== step.operation
)
throw new TypeError(
"Inspection substep identity differs from its source Evidence",
);
}
const parsed = parseArtifactInventoryEvidence(
inspection.substeps.map(({ evidence }) => evidence),
);
const manifest = parsed.inventory.manifest;
validateArtifactInventorySubject(parent.subject ?? null, manifest);
if (
inspection.subject.manifest_id !== manifest.manifest_id ||
inspection.subject.root_artifact_id !== manifest.root_artifact_id ||
inspection.subject.root_sha256 !== manifest.root_sha256 ||
inspection.subject.root_format !== manifest.root_format
)
throw new TypeError(
"Inspection subject differs from its inventory manifest",
);
return ok({
inventory: parsed.inventory,
evidenceIds: parsed.evidence.map(({ evidence_id }) => evidence_id),
inspection,
evidenceIds: parsed.evidenceIds,
inspection: parsed.inspection,
});
} catch (cause: unknown) {
if (!(cause instanceof TypeError) && !(cause instanceof z.ZodError))
+2 -1
View File
@@ -138,7 +138,8 @@ const inventoryEvidence = (
occurrences: sortedOccurrences,
edges: [],
provenance: [],
coverage_status: "complete",
integrity_contradictions: [],
coverage: { scope: "container-entries", status: "complete" },
limitations: [],
}),
},
+66 -10
View File
@@ -590,9 +590,11 @@ describe("AppKit keyed archive connectors", () => {
});
describe("keyed archive unresolved connectors", () => {
// A rooted UIKit archive whose only connector is UID 3, with UID 1 as the
// owner, UID 2 as the destination and UID 7 as the "view" label.
const rootedOutletArchive = (connection: Record<string, unknown>) => ({
const rootedOutletArchive = (
connection: Record<string, unknown>,
connectionClass = "UIRuntimeOutletConnection",
label = "view",
) => ({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
@@ -606,14 +608,10 @@ describe("keyed archive unresolved connectors", () => {
},
{ $classname: "UIView", $classes: ["UIView", "UIResponder", "NSObject"] },
{
$classname: "UIRuntimeOutletConnection",
$classes: [
"UIRuntimeOutletConnection",
"UIRuntimeConnection",
"NSObject",
],
$classname: connectionClass,
$classes: [connectionClass, "UIRuntimeConnection", "NSObject"],
},
"view",
label,
{
$class: { CF$UID: 11 },
"NS.objects": [{ CF$UID: 1 }, { CF$UID: 2 }, { CF$UID: 3 }],
@@ -664,6 +662,43 @@ describe("keyed archive unresolved connectors", () => {
}),
);
});
it("projects keyed UIKit event connections as actions and retains the mask", () => {
const raw = rootedOutletArchive(
{
UISource: { CF$UID: 1 },
UIDestination: { CF$UID: 2 },
UILabel: { CF$UID: 7 },
UIEventMask: 64,
},
"UIRuntimeEventConnection",
"tapped:",
);
expect(parseInterfaceBuilderRecords(raw).connections[0]).toMatchObject({
kind: "action",
source_id: "1",
destination_id: "2",
label: "tapped:",
attributes: { UIEventMask: 64 },
});
const analysis = project(raw);
expect(analysis.graph.nodes).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "action", name: "tapped:" }),
expect.objectContaining({ kind: "objc_selector", name: "tapped:" }),
]),
);
expect(analysis.graph.edges).toContainEqual(
expect.objectContaining({ relation: "target_action" }),
);
expect(analysis.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "connections:Main.nib",
status: "complete",
examined: 1,
omitted: 0,
}),
);
});
it("reports a recognized connector with no resolvable owner as unresolved", () => {
const raw = rootedOutletArchive({
@@ -686,6 +721,27 @@ describe("keyed archive unresolved connectors", () => {
}),
);
expect(analysis.graph.truncated).toBe(true);
const eventWithoutSource = rootedOutletArchive(
{
UIDestination: { CF$UID: 2 },
UILabel: { CF$UID: 7 },
UIEventMask: 64,
},
"UIRuntimeEventConnection",
"tapped:",
);
expect(
parseInterfaceBuilderRecords(eventWithoutSource).unresolvedConnections,
).toBe(1);
expect(project(eventWithoutSource).graph.coverage).toContainEqual(
expect.objectContaining({
facet: "connections:Main.nib",
status: "partial",
reason: "connection_unresolved",
omitted: 1,
}),
);
});
});
@@ -523,7 +523,8 @@ const classifyConnection = (
): InterfaceBuilderConnection["kind"] => {
const value = type.toLowerCase();
if (value.includes("outlet")) return "outlet";
if (value.includes("action")) return "action";
if (value.includes("action") || value.includes("eventconnection"))
return "action";
if (value.includes("segue")) return "segue";
return "other";
};
+261
View File
@@ -0,0 +1,261 @@
/** Class-file identity used to disambiguate JVM CAFEBABE from Mach-O FAT. */
class ClassFileCursor {
private offset = 0;
private readonly view: DataView;
constructor(private readonly bytes: Uint8Array) {
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
get atEnd(): boolean {
return this.offset === this.bytes.byteLength;
}
u1(): number | null {
if (!this.has(1)) return null;
return this.bytes[this.offset++]!;
}
u2(): number | null {
if (!this.has(2)) return null;
const value = this.view.getUint16(this.offset, false);
this.offset += 2;
return value;
}
u4(): number | null {
if (!this.has(4)) return null;
const value = this.view.getUint32(this.offset, false);
this.offset += 4;
return value;
}
skip(length: number): boolean {
if (!Number.isSafeInteger(length) || length < 0 || !this.has(length))
return false;
this.offset += length;
return true;
}
private has(length: number): boolean {
return this.offset <= this.bytes.byteLength - length;
}
}
interface ConstantPool {
readonly tags: (number | null)[];
readonly first: (number | null)[];
readonly second: (number | null)[];
}
const readConstantPool = (
cursor: ClassFileCursor,
count: number,
): ConstantPool | null => {
const pool: ConstantPool = {
tags: Array.from({ length: count }, () => null),
first: Array.from({ length: count }, () => null),
second: Array.from({ length: count }, () => null),
};
for (let index = 1; index < count; index += 1) {
const tag = cursor.u1();
if (tag === null) return null;
if (!readConstant(cursor, tag, index, pool)) return null;
if (tag === 5 || tag === 6) index += 1;
}
return pool;
};
const readConstant = (
cursor: ClassFileCursor,
tag: number,
index: number,
pool: ConstantPool,
): boolean => {
switch (tag) {
case 1: {
const length = cursor.u2();
if (length === null || !cursor.skip(length)) return false;
break;
}
case 3:
case 4:
if (cursor.u4() === null) return false;
break;
case 5:
case 6:
if (index + 1 >= pool.tags.length || !cursor.skip(8)) return false;
break;
case 7:
case 8:
case 16:
case 19:
case 20:
pool.first[index] = cursor.u2();
if (pool.first[index] === null) return false;
break;
case 9:
case 10:
case 11:
case 12:
case 17:
case 18:
pool.first[index] = cursor.u2();
pool.second[index] = cursor.u2();
if (pool.first[index] === null || pool.second[index] === null)
return false;
break;
case 15:
pool.first[index] = cursor.u1();
pool.second[index] = cursor.u2();
if (pool.first[index] === null || pool.second[index] === null)
return false;
break;
default:
return false;
}
pool.tags[index] = tag;
return true;
};
const poolHasTag = (
pool: ConstantPool,
index: number,
...tags: number[]
): boolean =>
Number.isInteger(index) && index > 0 && tags.includes(pool.tags[index] ?? -1);
const hasValidPoolReferences = (pool: ConstantPool, major: number): boolean => {
for (let index = 1; index < pool.tags.length; index += 1) {
const tag = pool.tags[index] ?? null;
const first = pool.first[index] ?? null;
const second = pool.second[index] ?? null;
if ([7, 8, 16, 19, 20].includes(tag ?? -1)) {
if (first === null || !poolHasTag(pool, first, 1)) return false;
if (
(tag === 16 && major < 51) ||
((tag === 19 || tag === 20) && major < 53)
)
return false;
} else if ([9, 10, 11].includes(tag ?? -1)) {
if (
first === null ||
second === null ||
!poolHasTag(pool, first, 7) ||
!poolHasTag(pool, second, 12)
)
return false;
} else if (tag === 12) {
if (
first === null ||
second === null ||
!poolHasTag(pool, first, 1) ||
!poolHasTag(pool, second, 1)
)
return false;
} else if (tag === 15 && !validMethodHandle(pool, first, second, major)) {
return false;
} else if (tag === 17 || tag === 18) {
if (
second === null ||
!poolHasTag(pool, second, 12) ||
major < (tag === 17 ? 55 : 51)
)
return false;
}
}
return true;
};
const validMethodHandle = (
pool: ConstantPool,
kind: number | null,
reference: number | null,
major: number,
): boolean => {
if (kind === null || reference === null || kind < 1 || kind > 9 || major < 51)
return false;
if (kind <= 4) return poolHasTag(pool, reference, 9);
if (kind === 5 || kind === 8) return poolHasTag(pool, reference, 10);
if (kind === 6 || kind === 7)
return poolHasTag(pool, reference, ...(major >= 52 ? [10, 11] : [10]));
return poolHasTag(pool, reference, 11);
};
const readAttributes = (
cursor: ClassFileCursor,
pool: ConstantPool,
count: number,
): boolean => {
for (let index = 0; index < count; index += 1) {
const name = cursor.u2();
const length = cursor.u4();
if (
name === null ||
!poolHasTag(pool, name, 1) ||
length === null ||
!cursor.skip(length)
)
return false;
}
return true;
};
const readMembers = (cursor: ClassFileCursor, pool: ConstantPool): boolean => {
const count = cursor.u2();
if (count === null) return false;
for (let index = 0; index < count; index += 1) {
if (cursor.u2() === null) return false;
const name = cursor.u2();
const descriptor = cursor.u2();
const attributes = cursor.u2();
if (
name === null ||
descriptor === null ||
!poolHasTag(pool, name, 1) ||
!poolHasTag(pool, descriptor, 1) ||
attributes === null ||
!readAttributes(cursor, pool, attributes)
)
return false;
}
return true;
};
/** Validate class-file framing and constant-pool references before FAT parsing. */
export const isJvmClassFile = (bytes: Uint8Array): boolean => {
const cursor = new ClassFileCursor(bytes);
if (cursor.u4() !== 0xcafebabe) return false;
const minor = cursor.u2();
const major = cursor.u2();
const poolCount = cursor.u2();
if (
minor === null ||
major === null ||
major < 45 ||
poolCount === null ||
poolCount === 0
)
return false;
const pool = readConstantPool(cursor, poolCount);
if (pool === null || !hasValidPoolReferences(pool, major)) return false;
if (cursor.u2() === null) return false;
const thisClass = cursor.u2();
const superClass = cursor.u2();
if (
thisClass === null ||
!poolHasTag(pool, thisClass, 7) ||
superClass === null ||
(superClass !== 0 && !poolHasTag(pool, superClass, 7))
)
return false;
const interfaces = cursor.u2();
if (interfaces === null || !cursor.skip(interfaces * 2)) return false;
if (!readMembers(cursor, pool) || !readMembers(cursor, pool)) return false;
const attributes = cursor.u2();
return (
attributes !== null &&
readAttributes(cursor, pool, attributes) &&
cursor.atEnd
);
};
+4
View File
@@ -1,3 +1,5 @@
import { isJvmClassFile } from "./jvmClassFile.js";
/** Physical facts for one entry in a Mach-O FAT architecture table. */
export interface FatSliceDeclaration {
readonly index: number;
@@ -68,6 +70,8 @@ export const readFatContainerHeader = (
state: "malformed",
reason: "FAT file size is invalid or contradicts retained bytes",
};
if (bytes.byteLength === fileSize && isJvmClassFile(bytes))
return { state: "not-fat" };
if (bytes.byteLength < 8) {
return fileSize < 8
? { state: "malformed", reason: "FAT header is truncated" }
+48
View File
@@ -61,6 +61,7 @@ describe("artifact graph path and provenance fields", () => {
effects: ["read"],
},
],
coverage: { scope: "container-entries", status: "complete" },
integrity_contradictions: [],
limitations: [],
});
@@ -69,4 +70,51 @@ describe("artifact graph path and provenance fields", () => {
expect(parsed.provenance[0]?.arguments).toHaveLength(1_025);
expect(parsed.provenance[0]?.arguments[0]).toBe(arguments_[0]);
});
it("requires current coverage and integrity facts and rejects the obsolete status alias", () => {
const inventory = {
manifest: {
manifest_id: `agm_${"b".repeat(64)}`,
root_artifact_id: `art_${"a".repeat(64)}`,
root_sha256: "c".repeat(64),
root_format: "plist",
graph_sha256: "d".repeat(64),
node_count: 1,
occurrence_count: 1,
edge_count: 0,
},
nodes: [],
occurrences: [],
edges: [],
provenance: [],
integrity_contradictions: [],
limitations: [],
};
expect(artifactInventoryResultSchema.safeParse(inventory).success).toBe(
false,
);
const { integrity_contradictions: _facts, ...withoutIntegrityFacts } =
inventory;
const currentWithoutIntegrityFacts = {
...withoutIntegrityFacts,
coverage: { scope: "root", status: "complete" },
};
expect(
artifactInventoryResultSchema.safeParse(currentWithoutIntegrityFacts)
.success,
).toBe(false);
expect(
artifactInventoryResultSchema.safeParse({
...currentWithoutIntegrityFacts,
integrity_contradictions: [],
}).success,
).toBe(true);
expect(
artifactInventoryResultSchema.safeParse({
...inventory,
coverage_status: "complete",
}).success,
).toBe(false);
});
});
+4 -18
View File
@@ -196,32 +196,18 @@ export type ArtifactInventoryCoverage = z.infer<
typeof artifactInventoryCoverageSchema
>;
/** Complete inventory observation containing the stable artifact graph. */
export const artifactInventoryResultSchema = z.object({
/** Current inventory observation containing the stable artifact graph. */
export const artifactInventoryResultSchema = z.strictObject({
manifest: artifactGraphManifestSchema,
nodes: z.array(artifactNodeSchema),
occurrences: z.array(artifactOccurrenceSchema),
edges: z.array(artifactEdgeSchema),
provenance: z.array(artifactCommandSchema),
integrity_contradictions: z.array(integrityContradictionSchema).default([]),
// Omitted by older retained Evidence; the inventory parser owns its compatibility policy.
coverage: artifactInventoryCoverageSchema.optional(),
integrity_contradictions: z.array(integrityContradictionSchema),
coverage: artifactInventoryCoverageSchema,
limitations: z.array(z.string()),
/** Optional only for persisted inventory Evidence written before this field. */
coverage_status: z.enum(["complete", "partial"]).optional(),
});
/** Current producers always declare coverage; historical Evidence inputs may omit it. */
export const artifactInventoryOutputSchema =
artifactInventoryResultSchema.safeExtend({
coverage: artifactInventoryCoverageSchema,
coverage_status:
artifactInventoryResultSchema.shape.coverage_status.unwrap(),
});
export type ArtifactInventoryOutput = z.infer<
typeof artifactInventoryOutputSchema
>;
/** One safely materialized artifact beneath caller-approved output root. */
const extractedArtifactSchema = z.object({
artifact_id: artifactIdSchema,
+5 -21
View File
@@ -1,7 +1,6 @@
import { z } from "zod";
import {
artifactInventoryOutputSchema,
artifactInventoryResultSchema,
type ArtifactInventoryResult,
} from "./artifactGraph.js";
@@ -75,7 +74,10 @@ export const artifactInspectionResultSchema = z.strictObject({
operation: z.literal("inventory_artifact"),
status: z.literal("completed"),
evidence_id: evidenceIdSchema,
evidence: evidenceSchema,
evidence: evidenceSchema.safeExtend({
operation: z.literal("inventory_artifact"),
normalized_result: artifactInventoryResultSchema,
}),
}),
)
.min(1),
@@ -86,7 +88,7 @@ export const artifactInspectionResultSchema = z.strictObject({
next_probes: z.array(nextProbeSchema),
coverage: z.strictObject({
status: z.enum(["complete-within-substeps", "partial", "truncated"]),
substeps_completed: z.literal(1),
substeps_completed: z.number().int().min(1),
observations_retained: z.number().int().min(0),
observations_omitted: z.number().int().min(0),
hypotheses_retained: z.number().int().min(0),
@@ -104,24 +106,6 @@ export type ArtifactInspectionResult = z.infer<
typeof artifactInspectionResultSchema
>;
/**
* The current producer guarantees a concrete inventory payload. Specialize
* its output without narrowing the historical inspection/Evidence input
* parser or applying inventory defaults inside an authenticated imported record.
*/
export const artifactInspectionOutputSchema =
artifactInspectionResultSchema.safeExtend({
substeps: z
.array(
artifactInspectionResultSchema.shape.substeps.element.safeExtend({
evidence: evidenceSchema.safeExtend({
normalized_result: artifactInventoryOutputSchema,
}),
}),
)
.min(1),
});
/** Project one successful inventory Evidence record into an inspection report. */
export const createArtifactInspection = (
inventoryInput: unknown,
@@ -0,0 +1,75 @@
import { expect, it } from "vitest";
import { ARTIFACT_COMPARISON_EXAMPLE } from "../contracts/artifactComparisonExample.js";
import { createArtifactInspection } from "./artifactInspection.js";
import { parseArtifactInventoryEvidence } from "./artifactInventoryEvidence.js";
import { createEvidence } from "./evidence.js";
import { jsonValueSchema } from "./jsonValue.js";
const inventoryEvidence = ARTIFACT_COMPARISON_EXAMPLE.left;
const target = inventoryEvidence.subject;
if (target === null) throw new Error("Artifact fixture has no target");
const inspectionEvidence = (result: unknown, subject = target) =>
createEvidence(
{
path: subject.local_path,
sha256: subject.digest.sha256,
format: subject.format,
},
inventoryEvidence.provider,
{
operation: "inspect_artifact",
parameters: {},
result: jsonValueSchema.parse(result),
confidence: "observed",
authority: "shipped-artifact",
},
);
const inspection = createArtifactInspection(inventoryEvidence);
it("accepts an inspection whose carrier, declaration, and substep identities agree", () => {
expect(
parseArtifactInventoryEvidence(inspectionEvidence(inspection)).inventory,
).toMatchObject({ complete: true, coverage: { status: "complete" } });
});
it("rejects an inspection carrier whose Evidence subject disagrees with its nested inventory", () => {
const carrier = inspectionEvidence(inspection, {
...target,
digest: { sha256: "a".repeat(64) },
});
expect(() => parseArtifactInventoryEvidence(carrier)).toThrow(
"Artifact inspection Evidence subject does not match its root manifest",
);
});
it("rejects an inspection declaration whose root manifest disagrees with its nested inventory", () => {
const mismatched = {
...inspection,
subject: {
...inspection.subject,
root_artifact_id: `art_${"f".repeat(64)}`,
},
};
expect(() =>
parseArtifactInventoryEvidence(inspectionEvidence(mismatched)),
).toThrow(
"Artifact inspection subject does not match its inventory manifest",
);
});
it("rejects a substep evidence ID that disagrees with its authenticated nested Evidence", () => {
const [first, ...remaining] = inspection.substeps;
if (first === undefined) throw new Error("Expected inventory substep");
const mismatched = {
...inspection,
substeps: [{ ...first, evidence_id: `ev_${"f".repeat(64)}` }, ...remaining],
};
expect(() =>
parseArtifactInventoryEvidence(inspectionEvidence(mismatched)),
).toThrow(
"Artifact inspection substep evidence ID does not match nested Evidence",
);
});
+118 -96
View File
@@ -17,11 +17,10 @@ import {
occurrenceIdForLocation,
} from "./artifactIdentity.js";
import { parseEvidence, type Evidence } from "./evidence.js";
import { artifactInspectionResultSchema } from "./artifactInspection.js";
import {
isZipPackageFormat,
isZipInventoryScopeLimitation,
} from "./zipPackageFormat.js";
artifactInspectionResultSchema,
type ArtifactInspectionResult,
} from "./artifactInspection.js";
/** Complete or partially assembled inventory used by comparison workflows. */
export interface InventorySet {
@@ -50,50 +49,136 @@ export interface ParsedInventorySet {
readonly inventory: InventorySet;
}
interface ParsedArtifactInspectionInventory {
readonly inspection: ArtifactInspectionResult;
readonly inventory: InventorySet;
readonly evidenceIds: readonly string[];
}
/** Parse, merge, and verify inventory Evidence pages. */
export const parseArtifactInventoryEvidence = (
input: unknown,
): ParsedInventorySet => {
const pages = (Array.isArray(input) ? input : [input]).map((value) => {
const evidence = parseEvidence(value);
const inventoryEvidence =
evidence.operation === "inventory_artifact"
? evidence
: evidence.operation === "inspect_artifact"
? artifactInspectionResultSchema.parse(evidence.normalized_result)
.substeps[0]?.evidence
: undefined;
if (inventoryEvidence === undefined)
const evidence = (Array.isArray(input) ? input : [input]).map(parseEvidence);
const pages = evidence.flatMap((carrier) => {
if (carrier.operation === "inventory_artifact")
return [parseInventoryEvidencePage(carrier)];
if (carrier.operation !== "inspect_artifact")
throw new TypeError(
"Artifact comparison requires inspect_artifact Evidence",
);
const inventory = artifactInventoryResultSchema.parse(
inventoryEvidence.normalized_result,
);
validateArtifactInventorySubject(
inventoryEvidence.subject,
inventory.manifest,
);
validateInventoryPage(inventory);
return { evidence, inventory };
return parseInspectionInventoryPayload(
carrier.normalized_result,
carrier.subject,
).pages;
});
return {
evidence: pages.map(({ evidence }) => evidence),
inventory: assembleInventoryResults(
pages.map(({ inventory }) => inventory),
),
evidence,
inventory: assembleInventoryResults(pages),
};
};
/** Parse and authenticate an already-owned inspection payload and its pages. */
export const parseArtifactInspectionInventoryResult = (
input: unknown,
subject: Evidence["subject"],
): ParsedArtifactInspectionInventory => {
const parsed = parseInspectionInventoryPayload(input, subject);
return {
inspection: parsed.inspection,
inventory: assembleInventoryResults(parsed.pages),
evidenceIds: parsed.evidenceIds,
};
};
/** Shared owner boundary for full Evidence and already-authenticated parents. */
const parseInspectionInventoryPayload = (
input: unknown,
subject: Evidence["subject"],
): {
readonly inspection: ArtifactInspectionResult;
readonly pages: readonly ArtifactInventoryResult[];
readonly evidenceIds: readonly string[];
} => {
const inspection = artifactInspectionResultSchema.parse(input);
if (inspection.coverage.substeps_completed !== inspection.substeps.length)
throw new TypeError(
"Artifact inspection substep count does not match its coverage declaration",
);
const substeps = inspection.substeps.map((step) => {
const evidence = parseEvidence(step.evidence);
if (step.evidence_id !== evidence.evidence_id)
throw new TypeError(
"Artifact inspection substep evidence ID does not match nested Evidence",
);
return {
evidenceId: evidence.evidence_id,
page: parseInventoryEvidencePage(evidence),
};
});
const firstPage = substeps[0]?.page;
if (firstPage === undefined)
throw new TypeError("Artifact inspection requires inventory substeps");
validateInspectionSubject(subject, inspection, firstPage.manifest);
return {
inspection,
pages: substeps.map(({ page }) => page),
evidenceIds: substeps.map(({ evidenceId }) => evidenceId),
};
};
/** Ensure the inspection declaration and carrier identify its inventory. */
const validateInspectionSubject = (
subject: Evidence["subject"],
inspection: ArtifactInspectionResult,
manifest: ArtifactInventoryResult["manifest"],
): void => {
if (
inspection.subject.manifest_id !== manifest.manifest_id ||
inspection.subject.root_artifact_id !== manifest.root_artifact_id ||
inspection.subject.root_sha256 !== manifest.root_sha256 ||
inspection.subject.root_format !== manifest.root_format
)
throw new TypeError(
"Artifact inspection subject does not match its inventory manifest",
);
if (
subject === null ||
subject.digest.sha256 !== manifest.root_sha256 ||
subject.format !== manifest.root_format
)
throw new TypeError(
"Artifact inspection Evidence subject does not match its root manifest",
);
};
/** Validate one already authenticated parent payload with the same graph proofs. */
export const parseArtifactInventoryResult = (
input: unknown,
subject: Evidence["subject"],
): InventorySet => {
): InventorySet =>
assembleInventoryResults([parseInventoryPage(input, subject)]);
/** Parse one already authenticated inventory result into its validated facts. */
const parseInventoryPage = (
input: unknown,
subject: Evidence["subject"],
): ArtifactInventoryResult => {
const inventory = artifactInventoryResultSchema.parse(input);
validateArtifactInventorySubject(subject, inventory.manifest);
validateInventoryPage(inventory);
return assembleInventoryResults([inventory]);
return inventory;
};
/** Parse one authenticated inventory carrier into its validated owner facts. */
const parseInventoryEvidencePage = (
evidence: Evidence,
): ArtifactInventoryResult => {
if (evidence.operation !== "inventory_artifact")
throw new TypeError(
"Artifact inventory page must be inventory_artifact Evidence",
);
return parseInventoryPage(evidence.normalized_result, evidence.subject);
};
/** Both inventory carriers require the same authenticated root identity. */
@@ -137,10 +222,7 @@ const assembleInventoryResults = (
throw new TypeError("Artifact inventory requires Evidence pages");
const manifestJson = canonicalJson(first.manifest, "Artifact inventory");
const coverageJson = canonicalJson(
{
coverage: first.coverage ?? null,
coverage_status: first.coverage_status ?? null,
},
first.coverage,
"Artifact inventory coverage",
);
const nodes = new Map<string, ArtifactNode>();
@@ -150,13 +232,8 @@ const assembleInventoryResults = (
if (canonicalJson(page.manifest, "Artifact inventory") !== manifestJson)
throw new TypeError("Artifact inventory pages do not share one manifest");
if (
canonicalJson(
{
coverage: page.coverage ?? null,
coverage_status: page.coverage_status ?? null,
},
"Artifact inventory coverage",
) !== coverageJson
canonicalJson(page.coverage, "Artifact inventory coverage") !==
coverageJson
)
throw new TypeError(
"Artifact inventory pages do not share one coverage declaration",
@@ -221,7 +298,7 @@ const orderedInventory = (
edges,
integrityContradictions: first.integrity_contradictions,
limitations,
coverage: resolveInventoryCoverage({ ...first, limitations }),
coverage: first.coverage,
complete:
nodes.length === first.manifest.node_count &&
occurrences.length === first.manifest.occurrence_count &&
@@ -267,61 +344,6 @@ const orderOccurrences = (
});
};
// Normalize both retained coverage formats at this owning boundary.
const resolveInventoryCoverage = (
inventory: ArtifactInventoryResult,
): ArtifactInventoryCoverage => {
const legacy = legacyInventoryCoverage(
inventory.manifest.root_format,
inventory.limitations,
inventory.integrity_contradictions.length,
);
if (inventory.coverage !== undefined) {
if (
inventory.coverage_status !== undefined &&
(inventory.coverage.status === "complete") !==
(inventory.coverage_status === "complete")
)
throw new TypeError(
"Artifact inventory has conflicting coverage declarations",
);
return inventory.coverage;
}
return inventory.coverage_status === undefined
? legacy
: {
...legacy,
status:
inventory.coverage_status === "complete" ? "complete" : "unknown",
};
};
// Retained pre-coverage records cannot classify arbitrary prose. Recognize only
// the historical producer vocabulary here; unfamiliar diagnostics remain unknown.
const legacyInventoryCoverage = (
format: ArtifactInventoryResult["manifest"]["root_format"],
limitations: readonly string[],
contradictionCount: number,
): ArtifactInventoryCoverage => ({
scope:
isZipPackageFormat(format) ||
["directory", "asar", "dmg", "pkg", "mach-o-universal"].includes(format)
? "container-entries"
: "root",
status: limitations.some(
(limitation) =>
limitation !== "Artifact has no child container entries." &&
!isZipInventoryScopeLimitation(limitation) &&
!(
contradictionCount > 0 &&
limitation ===
`${String(contradictionCount)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`
),
)
? "unknown"
: "complete",
});
const validateCompleteInventory = (inventory: InventorySet): void => {
const nodeIds = new Set(inventory.nodes.map(({ artifact_id: id }) => id));
const occurrenceIds = new Set(
+1 -4
View File
@@ -7,9 +7,6 @@ import type {
/** Immutable inventory produced by one complete artifact scan. */
export interface ArtifactInventorySnapshot {
readonly coverage_status: NonNullable<
ArtifactInventoryResult["coverage_status"]
>;
readonly manifest: ArtifactInventoryResult["manifest"];
readonly nodes: readonly ArtifactNode[];
readonly occurrences: ArtifactInventoryResult["occurrences"];
@@ -18,6 +15,6 @@ export interface ArtifactInventorySnapshot {
ArtifactInventoryResult["provenance"][number]
>;
readonly integrity_contradictions: readonly IntegrityContradiction[];
readonly coverage?: ArtifactInventoryCoverage;
readonly coverage: ArtifactInventoryCoverage;
readonly limitations: readonly string[];
}
+21 -3
View File
@@ -26,9 +26,7 @@ export const browserNetworkContentSelectionSchema =
});
/** Self-verifying retained bytes, after any declared-secret redaction. */
export const browserNetworkBodySchema = z.union([
z.strictObject({ state: z.literal("not_requested") }),
z.strictObject({ state: z.literal("not_exposed") }),
const unavailableBrowserNetworkBodySchema = z.discriminatedUnion("reason", [
z.strictObject({
state: z.literal("unavailable"),
reason: z.enum([
@@ -41,6 +39,26 @@ export const browserNetworkBodySchema = z.union([
]),
message: z.string().min(1),
}),
z.strictObject({
state: z.literal("unavailable"),
reason: z.literal("body-size-limit"),
message: z.string().min(1),
limit_bytes: z.number().int().positive().safe(),
observed_bytes: z.number().int().positive().safe(),
}),
z.strictObject({
state: z.literal("unavailable"),
reason: z.literal("body-allocation-limit"),
message: z.string().min(1),
limit_allocation_bytes: z.number().int().positive().safe(),
observed_allocation_bytes: z.number().int().positive().safe(),
}),
]);
export const browserNetworkBodySchema = z.union([
z.strictObject({ state: z.literal("not_requested") }),
z.strictObject({ state: z.literal("not_exposed") }),
unavailableBrowserNetworkBodySchema,
z
.strictObject({
state: z.literal("captured"),
+2
View File
@@ -51,6 +51,8 @@ const observationFailureMessage = (
return `${operation} could not reach the selected loopback debugging endpoint. Verify that the target process is running with debugging enabled and that the endpoint's port matches it, then retry.`;
if (reason === "target_not_found")
return `${operation} could not find the selected target at the debugging endpoint. Refresh target discovery for that endpoint and retry with a returned target ID.`;
if (reason === "service_worker_blocked")
return `${operation} refused the selected page because it is controlled by or has a registered service worker while the service-worker policy is block. Set environment.service_workers to allow to permit observation, or remove the service worker registration through the browser.`;
if (reason === "disconnected")
return `${operation} lost its debugging connection. Verify that the target process is still running, refresh target discovery, and retry the observation.`;
return undefined;
+1
View File
@@ -24,6 +24,7 @@ export type BrowserObservationFailureReason =
| "invalid_endpoint_response"
| "target_not_found"
| "target_not_allowed"
| "service_worker_blocked"
| "target_changed"
| "protocol_error"
| "disconnected"
+28
View File
@@ -87,6 +87,34 @@ it("does not require duplicate origin scope declarations", () => {
);
});
it("accepts caller-selected safe capture limits and rejects invalid bounds", () => {
const base = {
browser: { mode: "launch", executable_path: "/opt/chromium" },
start_url: { url: "https://example.test/" },
actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
};
expect(
browserScenarioSchema.parse({
...base,
capture: {
limits: { max_total_event_allocation_bytes: 32 * 1024 * 1024 },
},
}).capture.limits,
).toMatchObject({
max_events: 10_000,
max_event_allocation_bytes: 4 * 1024 * 1024,
max_total_event_allocation_bytes: 32 * 1024 * 1024,
max_network_body_bytes: 64 * 1024,
});
for (const invalid of [0, Number.MAX_SAFE_INTEGER + 1, Number.NaN])
expect(
browserScenarioSchema.safeParse({
...base,
capture: { limits: { max_events: invalid } },
}).success,
).toBe(false);
});
it("accepts complete browser inputs beyond former string length caps", () => {
const longValue = "value".repeat(20_000);
const longToken = "a".repeat(70_000);
+136 -1
View File
@@ -9,6 +9,7 @@ import {
browserStepArtifactsSchema,
} from "./browserScenarioCaptureValues.js";
import { browserScenarioCaptureSchema } from "./browserScenarioCapture.js";
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "./browserScenarioCaptureLimits.js";
const networkEvent = {
sequence: 1,
@@ -217,10 +218,21 @@ it("accepts complete scenario output beyond former string caps", () => {
response_body: false,
header_values: false,
},
event_families: [],
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
},
duration_ms: 0,
steps: [step(0), step(1)],
events: { retained: 0, dropped: 0, items: [] },
events: {
retained: 0,
dropped: 0,
items: [],
omissions: {
by_kind: [],
network_transactions: [],
network_transaction_range: null,
},
},
completeness: {
status: "complete",
equality_eligible: true,
@@ -234,6 +246,129 @@ it("accepts complete scenario output beyond former string caps", () => {
expect(parsed.limitations).toHaveLength(65);
});
it("validates reported event limits and omission accounting", () => {
const outputStep = (index: number) => ({
step_index: index,
step_id: `step-${index}`,
action: "wait_for_timeout",
elapsed_ms: 0,
before_url: sanitizeBrowserUrl("http://127.0.0.1/"),
after_url: sanitizeBrowserUrl("http://127.0.0.1/"),
event_sequence_start: 1,
event_sequence_end: 0,
artifacts: {
screenshot: { state: "not_requested" },
dom: { state: "not_requested" },
accessibility: { state: "not_requested" },
url: { state: "not_requested" },
history: { state: "not_requested" },
storage: { state: "not_requested" },
},
completeness: {
status: "complete",
equality_eligible: true,
missing_sections: [],
truncated_sections: [],
},
status: "completed",
error: null,
});
const capture = browserScenarioCaptureSchema.safeParse({
browser: {
mode: "launch",
process_ownership: "provider-owned",
cleanup: "terminated-owned-process",
product: "Chromium",
version: "fixture",
},
scenario: {
start_origin: "http://127.0.0.1",
action_count: 1,
secret_references: [],
network_content: {
request_body: false,
response_body: true,
header_values: false,
},
event_families: ["network"],
limits: {
...DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
max_total_event_allocation_bytes: 32 * 1024 * 1024,
},
},
duration_ms: 1,
steps: [outputStep(0), outputStep(1)],
events: {
retained: 0,
dropped: 2,
items: [],
omissions: {
by_kind: [
{
kind: "network-content",
count: 1,
reasons: ["max-total-event-allocation-bytes"],
},
{
kind: "request",
count: 1,
reasons: ["max-network-transactions"],
},
],
network_transactions: [
{
transaction_id: "request-1",
omitted_kinds: ["network-content"],
reasons: ["max-total-event-allocation-bytes"],
},
],
network_transaction_range: {
first_transaction_id: "request-2",
last_transaction_id: "request-2",
count: 1,
reason: "max-network-transactions",
},
},
},
completeness: {
status: "incomplete",
equality_eligible: false,
missing_sections: ["events"],
truncated_sections: [],
},
limitations: ["Event coverage is incomplete."],
});
if (!capture.success) throw new Error("Expected valid omission accounting");
expect(capture.data.scenario.limits.max_total_event_allocation_bytes).toBe(
32 * 1024 * 1024,
);
expect(
browserScenarioCaptureSchema.safeParse({
...capture.data,
events: {
...capture.data.events,
dropped: 3,
},
}).success,
).toBe(false);
for (const limits of [
{},
{ max_events: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_events },
])
expect(
browserScenarioCaptureSchema.safeParse({
...capture.data,
scenario: { ...capture.data.scenario, limits },
}).success,
).toBe(false);
expect(
browserScenarioCaptureSchema.safeParse({
...capture.data,
scenario: { ...capture.data.scenario, limits: undefined },
}).success,
).toBe(false);
});
it("derives equality eligibility from exact completeness state", () => {
expect(
browserScenarioCompletenessSchema.safeParse({
+16 -1
View File
@@ -1,9 +1,11 @@
import { z } from "zod";
import { browserNetworkContentSelectionValuesSchema } from "./browserNetworkEvidence.js";
import { browserScenarioCaptureLimitsValuesSchema } from "./browserScenarioCaptureLimits.js";
import {
browserScenarioCompletenessSchema,
browserScenarioEventFamilySchema,
browserScenarioEventOmissionsSchema,
browserScenarioEventSchema,
browserScenarioStepSchema,
} from "./browserScenarioCaptureValues.js";
@@ -23,7 +25,8 @@ export const browserScenarioCaptureSchema = z
action_count: z.number().int().min(1),
secret_references: z.array(z.string().min(1)),
network_content: browserNetworkContentSelectionValuesSchema,
event_families: z.array(browserScenarioEventFamilySchema).optional(),
event_families: z.array(browserScenarioEventFamilySchema),
limits: browserScenarioCaptureLimitsValuesSchema,
}),
duration_ms: z.number().int().min(0),
steps: z.array(browserScenarioStepSchema).min(1),
@@ -31,6 +34,7 @@ export const browserScenarioCaptureSchema = z
retained: z.number().int().min(0),
dropped: z.number().int().min(0),
items: z.array(browserScenarioEventSchema),
omissions: browserScenarioEventOmissionsSchema,
}),
completeness: browserScenarioCompletenessSchema,
limitations: z.array(z.string().min(1)),
@@ -66,6 +70,17 @@ export const browserScenarioCaptureSchema = z
path: ["events", "retained"],
message: "Retained event count must equal items length",
});
if (
capture.events.omissions.by_kind.reduce(
(count, omission) => count + omission.count,
0,
) !== capture.events.dropped
)
context.addIssue({
code: "custom",
path: ["events", "omissions", "by_kind"],
message: "Per-kind omission counts must equal the dropped event count",
});
if (capture.steps.length !== capture.scenario.action_count + 1)
context.addIssue({
code: "custom",
@@ -0,0 +1,67 @@
import { z } from "zod";
const positiveSafeInteger = z.number().int().safe().positive();
export const DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS = {
max_events: 10_000,
max_event_allocation_bytes: 4 * 1024 * 1024,
max_total_event_allocation_bytes: 16 * 1024 * 1024,
max_network_transactions: 1024,
max_network_body_bytes: 64 * 1024,
max_network_body_allocation_bytes: 1024 * 1024,
max_network_header_allocation_bytes: 1024 * 1024,
max_network_metadata_allocation_bytes: 1024 * 1024,
} as const;
/** Caller-selected retained-output budgets; byte values are allocation-cost estimates. */
export const browserScenarioCaptureLimitsValuesSchema = z.strictObject({
max_events: positiveSafeInteger,
max_event_allocation_bytes: positiveSafeInteger,
max_total_event_allocation_bytes: positiveSafeInteger,
max_network_transactions: positiveSafeInteger,
max_network_body_bytes: positiveSafeInteger,
max_network_body_allocation_bytes: positiveSafeInteger,
max_network_header_allocation_bytes: positiveSafeInteger,
max_network_metadata_allocation_bytes: positiveSafeInteger,
});
/** Input budgets default omitted values; output evidence uses the values schema above. */
export const browserScenarioCaptureLimitsSchema =
browserScenarioCaptureLimitsValuesSchema.extend({
max_events:
browserScenarioCaptureLimitsValuesSchema.shape.max_events.default(
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_events,
),
max_event_allocation_bytes:
browserScenarioCaptureLimitsValuesSchema.shape.max_event_allocation_bytes.default(
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_event_allocation_bytes,
),
max_total_event_allocation_bytes:
browserScenarioCaptureLimitsValuesSchema.shape.max_total_event_allocation_bytes.default(
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_total_event_allocation_bytes,
),
max_network_transactions:
browserScenarioCaptureLimitsValuesSchema.shape.max_network_transactions.default(
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_transactions,
),
max_network_body_bytes:
browserScenarioCaptureLimitsValuesSchema.shape.max_network_body_bytes.default(
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_body_bytes,
),
max_network_body_allocation_bytes:
browserScenarioCaptureLimitsValuesSchema.shape.max_network_body_allocation_bytes.default(
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_body_allocation_bytes,
),
max_network_header_allocation_bytes:
browserScenarioCaptureLimitsValuesSchema.shape.max_network_header_allocation_bytes.default(
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_header_allocation_bytes,
),
max_network_metadata_allocation_bytes:
browserScenarioCaptureLimitsValuesSchema.shape.max_network_metadata_allocation_bytes.default(
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_metadata_allocation_bytes,
),
});
export type BrowserScenarioCaptureLimits = z.infer<
typeof browserScenarioCaptureLimitsValuesSchema
>;
@@ -279,6 +279,74 @@ const completenessSectionSchema = z.enum([
"popups",
"websockets",
]);
export const browserScenarioEventKindSchema = z.enum([
"console",
"page-error",
"request",
"response",
"request-failed",
"request-finished",
"request-unfinished",
"network-content",
"websocket-opened",
"websocket-closed",
"websocket-frame-sent",
"websocket-frame-received",
"frame-attached",
"frame-detached",
"frame-navigated",
"worker-created",
"worker-closed",
"popup-opened",
"popup-closed",
"download-cancelled",
]);
export const browserScenarioEventBudgetReasonSchema = z.enum([
"max-events",
"max-event-allocation-bytes",
"max-total-event-allocation-bytes",
"max-network-transactions",
"max-network-metadata-allocation-bytes",
"max-listener-owners",
]);
export const browserScenarioEventOmissionsSchema = z.strictObject({
by_kind: z.array(
z.strictObject({
kind: browserScenarioEventKindSchema,
count: z.number().int().positive(),
reasons: z.array(browserScenarioEventBudgetReasonSchema).min(1),
}),
),
network_transactions: z.array(
z.strictObject({
transaction_id: z.string().min(1),
omitted_kinds: z.array(browserScenarioEventKindSchema).min(1),
reasons: z.array(browserScenarioEventBudgetReasonSchema).min(1),
}),
),
network_transaction_range: z
.strictObject({
first_transaction_id: z.string().min(1),
last_transaction_id: z.string().min(1),
count: z.number().int().positive(),
reason: z.literal("max-network-transactions"),
})
.nullable(),
});
export type BrowserScenarioEventOmissions = z.infer<
typeof browserScenarioEventOmissionsSchema
>;
export type BrowserScenarioEventBudgetReason = z.infer<
typeof browserScenarioEventBudgetReasonSchema
>;
export type BrowserScenarioEventAdmission =
| { readonly status: "retained"; readonly sequence: number }
| {
readonly status: "omitted";
readonly sequence: number;
readonly reason: BrowserScenarioEventBudgetReason;
};
export type BrowserScenarioCompletenessSection = z.infer<
typeof completenessSectionSchema
>;
+30 -17
View File
@@ -11,6 +11,13 @@ import {
browserScenarioCaptureSchema,
type BrowserScenarioCapture,
} from "./browserScenarioCapture.js";
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "./browserScenarioCaptureLimits.js";
const noEventOmissions = {
by_kind: [],
network_transactions: [],
network_transaction_range: null,
} as const;
const networkCapture = (sourceSequence: number, receiptStep: number) => {
const base = scenarioCapture({});
@@ -65,7 +72,12 @@ const networkCapture = (sourceSequence: number, receiptStep: number) => {
},
event_families: ["network"],
},
events: { items: events, retained: 2, dropped: 0 },
events: {
items: events,
retained: 2,
dropped: 0,
omissions: noEventOmissions,
},
});
};
@@ -148,7 +160,7 @@ it("reports content-selection mismatch as unknown instead of a website change",
).toBe(false);
});
it("reports event-family mismatch and legacy selection as unknown", () => {
it("reports event-family selection mismatch as unknown", () => {
const consoleOnly = scenarioCapture({});
const withFamilies = (
capture: BrowserScenarioCapture,
@@ -193,18 +205,6 @@ it("reports event-family mismatch and legacy selection as unknown", () => {
).overall_status,
).toBe("unchanged");
const legacy = withFamilies(consoleOnly, undefined);
expect(legacy.scenario.event_families).toBeUndefined();
for (const [before, after] of [
[legacy, consoleOnly],
[legacy, legacy],
] as const)
expect(eventDiffs(compare(before, after))).toContainEqual(
expect.objectContaining({
status: "unknown",
reason: expect.stringContaining("Event family selection is unknown"),
}),
);
expect(
compareBrowserScenariosInputSchema.safeParse({
before_scenario: {
@@ -217,7 +217,7 @@ it("reports event-family mismatch and legacy selection as unknown", () => {
const quiet = browserScenarioCaptureSchema.parse({
...withFamilies(consoleOnly, []),
events: { retained: 0, dropped: 0, items: [] },
events: { retained: 0, dropped: 0, items: [], omissions: noEventOmissions },
});
const differentEvents = compare(quiet, widened);
expect(differentEvents.overall_status).toBe("unknown");
@@ -283,7 +283,12 @@ const concurrentCapture = (
).map((event, index) => ({ ...event, sequence: index + 1 }));
return browserScenarioCaptureSchema.parse({
...base,
events: { items: events, retained: events.length, dropped: 0 },
events: {
items: events,
retained: events.length,
dropped: 0,
omissions: noEventOmissions,
},
});
};
@@ -338,7 +343,12 @@ const redirectChainCapture = (swapLeaves = false, reverseReceipt = false) => {
.map((event, index) => ({ ...event, sequence: index + 1 }));
return browserScenarioCaptureSchema.parse({
...base,
events: { items: events, retained: events.length, dropped: 0 },
events: {
items: events,
retained: events.length,
dropped: 0,
omissions: noEventOmissions,
},
});
};
@@ -465,6 +475,7 @@ describe("browser scenario network comparison stability", () => {
events: {
retained: 2,
dropped: 0,
omissions: noEventOmissions,
items: [
{ ...consoleEvent, text },
{
@@ -938,6 +949,7 @@ const scenarioCapture = (
header_values: false,
},
event_families: ["console"],
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
},
duration_ms: options.elapsedMs ?? 10,
steps: [
@@ -983,6 +995,7 @@ const scenarioCapture = (
url: null,
},
],
omissions: noEventOmissions,
},
completeness,
limitations: [],
+1 -3
View File
@@ -158,15 +158,13 @@ export const networkContentCoverageReason = (
: undefined;
};
/** Different or unrecorded event-family selection is missing comparability, not an observed website change. */
/** Different event-family selection is missing comparability, not an observed website change. */
export const eventFamilyCoverageReason = (
before: BrowserScenarioCapture,
after: BrowserScenarioCapture,
): string | undefined => {
const left = before.scenario.event_families;
const right = after.scenario.event_families;
if (left === undefined || right === undefined)
return "Event family selection is unknown in a legacy capture; capture both sides with matching event families before comparing event evidence.";
const leftFamilies = new Set(left);
const rightFamilies = new Set(right);
return leftFamilies.size === rightFamilies.size &&
+8
View File
@@ -6,6 +6,10 @@ import { IDENTIFIER_PATTERN } from "./stringPatterns.js";
import { localPathStringSchema } from "./localPath.js";
import { browserNetworkContentSelectionSchema } from "./browserNetworkEvidence.js";
import { browserScenarioEventFamilySchema } from "./browserScenarioCaptureValues.js";
import {
browserScenarioCaptureLimitsSchema,
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
} from "./browserScenarioCaptureLimits.js";
import {
browserEndpointSchema,
@@ -292,6 +296,9 @@ export const browserScenarioCaptureSchema = z
at_end: z.array(snapshotKindSchema).default(["url"]),
events: z.array(browserScenarioEventFamilySchema).default([]),
network: browserNetworkContentSelectionSchema,
limits: browserScenarioCaptureLimitsSchema.default(
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
),
})
.default({
after_each_step: [],
@@ -302,6 +309,7 @@ export const browserScenarioCaptureSchema = z
response_body: false,
header_values: false,
},
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
})
.describe(
"Optional retained artifacts and event families. Defaults to only a final sanitized URL; request screenshots, DOM, accessibility, history, storage, and event capture explicitly.",
+15
View File
@@ -79,6 +79,21 @@ it("preserves omitted relationship diagnostics and authenticates reported diagno
).toThrow("semantic identifier");
});
it("requires explicit coverage in serialized Evidence while producers encode omission as unknown", () => {
const evidence = createEvidence(TARGET, PROVIDER, {
operation: "procedure_callees",
parameters: { procedure: "0x1000" },
result: ["0x2000"],
});
expect(evidence.relationship_coverage).toBeNull();
expect(parseEvidence(JSON.parse(JSON.stringify(evidence)))).toEqual(evidence);
const oldEnvelope = { ...evidence } as Record<string, unknown>;
delete oldEnvelope.relationship_coverage;
expect(evidenceSchema.safeParse(oldEnvelope).success).toBe(false);
expect(() => parseEvidence(oldEnvelope)).toThrow();
});
it("projects known provider fields and preserves open JSON payloads", () => {
const liveProvider = { ...PROVIDER, transient_status: "starting" };
const evidence = createEvidence(TARGET, liveProvider, {
+2 -4
View File
@@ -135,7 +135,7 @@ const evidenceBaseSchema = z
authority: evidenceAuthoritySchema,
environment: executionEnvironmentSchema.nullable(),
limitations: z.array(z.string()),
relationship_coverage: relationshipCoverageSchema.nullable().default(null),
relationship_coverage: relationshipCoverageSchema.nullable(),
locations: z.array(evidenceLocationSchema),
evidence_links: z.array(prefixedDigestSchema("ev")),
})
@@ -264,9 +264,7 @@ const semanticProjection = (evidence: EvidenceWithoutId): JsonValue => ({
authority: evidence.authority,
environment: evidence.environment,
limitations: evidence.limitations,
...(evidence.relationship_coverage === null
? {}
: { relationship_coverage: evidence.relationship_coverage }),
relationship_coverage: evidence.relationship_coverage,
locations: evidence.locations,
evidence_links: evidence.evidence_links,
});
+24
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
assessFunctionCallCoverage,
combineFunctionRelationshipCoverage,
isCallerOnlyIncompleteCoverage,
} from "./functionCallCoverage.js";
@@ -88,3 +89,26 @@ describe("isCallerOnlyIncompleteCoverage", () => {
).toBe(false);
});
});
describe("assessFunctionCallCoverage", () => {
it("keeps incomplete coverage and unresolved calls as equality blockers", () => {
expect(
assessFunctionCallCoverage(
{
status: "partial",
limitations: ["Read limit reached."],
diagnostics: ["Indirect calls are unresolved."],
},
{
unresolved_calls: [{ address: "0x1004", reason: "Computed target." }],
},
),
).toEqual({
blockers: [
"Read limit reached.",
"Unresolved call at 0x1004: Computed target.",
],
diagnostics: ["Indirect calls are unresolved."],
});
});
});
+14 -7
View File
@@ -78,16 +78,23 @@ export const isCallerOnlyIncompleteCoverage = (
);
};
/** Explain why two resolved call sets cannot establish call-set equality. */
export const functionCallCoverageLimitations = (
export interface FunctionCallCoverageAssessment {
/** Reasons the observed call sets cannot establish equality. */
readonly blockers: readonly string[];
/** Provider caveats that remain relevant even with complete coverage. */
readonly diagnostics: readonly string[];
}
/** Keep observed-set blockers separate from descriptive provider caveats. */
export const assessFunctionCallCoverage = (
coverage: RelationshipCoverage | null,
dossier: Pick<FunctionDossier, "unresolved_calls">,
): string[] => {
return uniqueSorted([
): FunctionCallCoverageAssessment => ({
blockers: uniqueSorted([
...relationshipCoverageLimitations(coverage),
...(coverage?.status === "complete" ? [] : (coverage?.diagnostics ?? [])),
...(dossier.unresolved_calls ?? []).map(
({ address, reason }) => `Unresolved call at ${address}: ${reason}`,
),
]);
};
]),
diagnostics: uniqueSorted(coverage?.diagnostics ?? []),
});
+90 -18
View File
@@ -291,25 +291,51 @@ describe("function comparison normalized identity", () => {
).toMatchObject({ status: "unknown" });
},
);
});
describe("function comparison address-derived call names", () => {
it.each([
{ name: "sub_deallocate", status: "unchanged" },
{ name: "sub_deadbeef", status: "unknown" },
{ name: "FUN_00401000", status: "unknown" },
{ name: "FUN_initialize", status: "unchanged" },
])("preserves the calls dimension for callee $name", ({ name, status }) => {
const calling = (base: "0x1000" | "0x2000") =>
functionDossierSchema.parse({
...dossier("return helper();", base),
callees: [procedure(base === "0x1000" ? "0x1010" : "0x2010", name)],
});
const result = compareFunctions(
observe("6", calling("0x1000")),
observe("7", calling("0x2000")),
);
expect(
result.dimensions.find(({ dimension }) => dimension === "calls"),
).toMatchObject({ status });
});
{ name: "sub_deallocate", status: "unchanged", diagnostic: undefined },
{
name: "sub_deadbeef",
status: "unknown",
diagnostic: "Provider retained an unresolved call target.",
},
{ name: "FUN_00401000", status: "unknown", diagnostic: undefined },
{ name: "FUN_initialize", status: "unchanged", diagnostic: undefined },
])(
"preserves the calls dimension for callee $name",
({ name, status, diagnostic }) => {
const calling = (base: "0x1000" | "0x2000") =>
functionDossierSchema.parse({
...dossier("return helper();", base),
callees: [procedure(base === "0x1000" ? "0x1010" : "0x2010", name)],
});
const result = compareFunctions(
observe("6", calling("0x1000")),
observe(
"7",
calling("0x2000"),
"rea-workflow",
diagnostic === undefined
? { status: "complete" }
: { status: "complete", diagnostics: [diagnostic] },
),
);
const calls = result.dimensions.find(
({ dimension }) => dimension === "calls",
);
expect(calls).toMatchObject({ status });
if (diagnostic !== undefined)
expect(calls).toMatchObject({
status: "unknown",
limitations: expect.arrayContaining([
"Address-derived callee or caller names cannot be matched safely.",
`Right: ${diagnostic}`,
]),
});
},
);
});
describe("function call relationship coverage", () => {
@@ -681,6 +707,52 @@ describe("function comparison unresolved text", () => {
});
describe("function comparison", () => {
it("retains complete call coverage diagnostics without blocking observed-set equality", () => {
const diagnostic = "Indirect calls remain unresolved.";
const left = observe("a", dossier("return 0;", "0x1000"), "rea-workflow", {
status: "complete",
diagnostics: [diagnostic],
});
const right = observe("b", dossier("return 0;", "0x2000"), "rea-workflow", {
status: "complete",
diagnostics: [diagnostic],
});
const comparison = compareFunctions(left, right);
expect(
comparison.dimensions.find(({ dimension }) => dimension === "calls"),
).toMatchObject({
status: "unchanged",
limitations: [`Left: ${diagnostic}`, `Right: ${diagnostic}`],
});
});
it("retains diagnostics alongside incomplete call coverage blockers", () => {
const diagnostic = "Indirect calls remain unresolved.";
const left = observe("c", dossier("return 0;", "0x1000"), "rea-workflow", {
status: "partial",
limitations: ["Provider read limit reached."],
diagnostics: [diagnostic],
});
const right = observe("d", dossier("return 0;", "0x2000"), "rea-workflow", {
status: "partial",
limitations: ["Provider read limit reached."],
diagnostics: [diagnostic],
});
expect(
compareFunctions(left, right).dimensions.find(
({ dimension }) => dimension === "calls",
),
).toMatchObject({
status: "unknown",
limitations: expect.arrayContaining([
"Left: Provider read limit reached.",
`Left: ${diagnostic}`,
"Right: Provider read limit reached.",
`Right: ${diagnostic}`,
]),
});
});
it("preserves text changes alongside complete inline dossier facets", () => {
const result = compareFunctions(
FUNCTION_COMPARISON_EXAMPLE.left,
+11 -4
View File
@@ -333,8 +333,12 @@ const compareCalls = (
const leftValues = callProjection(left);
const rightValues = callProjection(right);
const coverageLimitations = [
...left.callCoverageLimitations.map((reason) => `Left: ${reason}`),
...right.callCoverageLimitations.map((reason) => `Right: ${reason}`),
...left.callCoverageBlockers.map((reason) => `Left: ${reason}`),
...right.callCoverageBlockers.map((reason) => `Right: ${reason}`),
];
const coverageDiagnostics = [
...left.callCoverageDiagnostics.map((reason) => `Left: ${reason}`),
...right.callCoverageDiagnostics.map((reason) => `Right: ${reason}`),
];
if (coverageLimitations.length > 0)
return unresolvedDimension({
@@ -342,7 +346,7 @@ const compareCalls = (
links: context.links,
leftCount: leftValues.length,
rightCount: rightValues.length,
limitations: coverageLimitations,
limitations: [...coverageLimitations, ...coverageDiagnostics],
});
if ([...leftValues, ...rightValues].some(({ name }) => isAutoName(name)))
return unresolvedDimension({
@@ -352,6 +356,7 @@ const compareCalls = (
rightCount: rightValues.length,
limitations: [
"Address-derived callee or caller names cannot be matched safely.",
...coverageDiagnostics,
],
});
return compareValues(
@@ -359,6 +364,7 @@ const compareCalls = (
sorted(leftValues),
sorted(rightValues),
context,
coverageDiagnostics,
);
};
@@ -400,6 +406,7 @@ const compareValues = (
left: unknown,
right: unknown,
context: ComparisonContext,
limitations: readonly string[] = [],
): FunctionDimension => {
const leftJson = canonicalJson(left, "Function comparison");
const rightJson = canonicalJson(right, "Function comparison");
@@ -412,6 +419,6 @@ const compareValues = (
leftCount: Array.isArray(left) ? left.length : null,
rightCount: Array.isArray(right) ? right.length : null,
textDelta: null,
limitations: [],
limitations,
});
};
+6 -4
View File
@@ -1,7 +1,7 @@
import { parseEvidence, type Evidence } from "./evidence.js";
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
import { functionDossierSchema, type FunctionDossier } from "./hopperValues.js";
import { functionCallCoverageLimitations } from "./functionCallCoverage.js";
import { assessFunctionCallCoverage } from "./functionCallCoverage.js";
/** Complete function observation parsed from one analyze_function Evidence record. */
export interface FunctionSnapshot {
@@ -9,7 +9,8 @@ export interface FunctionSnapshot {
readonly subject: NonNullable<Evidence["subject"]>;
readonly dossier: FunctionDossier;
readonly limitations: readonly string[];
readonly callCoverageLimitations: readonly string[];
readonly callCoverageBlockers: readonly string[];
readonly callCoverageDiagnostics: readonly string[];
}
/** Parse one complete analyze_function Evidence record for comparison workflows. */
@@ -22,7 +23,7 @@ export const parseFunctionEvidence = (input: unknown): FunctionSnapshot => {
if (evidence.subject === null)
throw new TypeError("Function comparison requires artifact-bound Evidence");
const dossier = functionDossierSchema.parse(evidence.normalized_result);
const callCoverageLimitations = functionCallCoverageLimitations(
const callCoverage = assessFunctionCallCoverage(
evidence.relationship_coverage,
dossier,
);
@@ -33,6 +34,7 @@ export const parseFunctionEvidence = (input: unknown): FunctionSnapshot => {
limitations: [
...new Set([...evidence.limitations, ...dossier.limitations]),
].sort((left, right) => compareUnicodeCodePoints(left, right)),
callCoverageLimitations,
callCoverageBlockers: callCoverage.blockers,
callCoverageDiagnostics: callCoverage.diagnostics,
};
};
@@ -30,6 +30,58 @@ const HASH = {
const EVIDENCE_ID = `ev_${"a".repeat(64)}`;
describe("historical source to bundle comparison", () => {
it("matches an unchanged JSON source artifact by its exact content digest", () => {
const path = "src/data.json";
const artifact = createJavaScriptApplicationNode({
kind: "artifact",
identity: {
strategy: "content-digest",
stability: "global-exact",
sha256: HASH.unchanged,
},
observations: [
{
label: path,
properties: { path, file_kind: "json" },
evidence: artifactEvidence(HASH.unchanged, path),
},
],
});
const result = compareSourceToBundle({
reference: historicalGraph("complete", [path]),
application: {
evidenceId: EVIDENCE_ID,
rootArtifactSha256: HASH.artifact,
graph: createJavaScriptApplicationGraph({
schema: "JavaScriptApplicationGraph",
root_node_ids: [artifact.node_id],
nodes: [artifact],
edges: [],
coverage: {
status: "complete",
truncated: false,
omitted_count: 0,
limits: [],
},
limitations: [],
}),
},
});
expect(item(result, path)).toMatchObject({
status: "unchanged",
confidence: "exact",
current_node_ids: [artifact.node_id],
candidates: [
expect.objectContaining({
signals: expect.arrayContaining([
expect.objectContaining({ kind: "exact-source-digest" }),
]),
}),
],
});
});
it("classifies exact, changed, absent, split, and merged mappings stably", () => {
const reference = historicalGraph("complete");
const graph = applicationGraph("complete");
@@ -120,7 +172,9 @@ describe("historical source to bundle comparison", () => {
});
expect(result.coverage.status).toBe("partial");
});
});
describe("source to bundle candidate evaluation", () => {
it("evaluates all indexed candidates for each source file", () => {
const result = compareSourceToBundle({
reference: historicalGraph("complete", ["src/split.ts"]),
@@ -45,6 +45,9 @@ export interface SourceToBundleCandidateIndex {
}
const RELEVANT_NODE_KINDS = new Set([
// Non-JavaScript source files such as JSON are projected as artifacts.
// Their content digests and paths still identify historical source files.
"artifact",
"javascript-asset",
"javascript-module",
"source-module",
+320 -31
View File
@@ -1,10 +1,12 @@
import { describe, expect, it } from "vitest";
import { jsonValueSchema } from "../jsonValue.js";
import { parseProcessCapture } from "./processCaptureParsing.js";
import type { ProcessTraceSpecification } from "./processTraceSpecification.js";
import { processTraceSpecificationSchema as specificationSchema } from "./processTraceSpecification.js";
import { processTraceComparisonResultSchema } from "./processTraceEvaluation.js";
import {
capture,
emptyCapture,
partialSpecification,
processStarted,
terminal,
@@ -13,6 +15,35 @@ import {
import { compareProcessCaptures } from "./processComparison.js";
import { compareProcessTraces } from "./processTraceComparison.js";
const traced = (
left: ReturnType<typeof capture>,
right: ReturnType<typeof capture>,
) =>
compareProcessCaptures(left, right, {
traceSpecification: partialSpecification(),
});
const withPayloads = (data: string, command: string) =>
capture({
...values(["terminal", "process"]),
frames: [{ ...terminal, data }],
process_samples: [{ ...processStarted, command }],
});
type FiniteTraceLanguage = Extract<
ProcessTraceSpecification["language"],
{ kind: "finite_traces" }
>;
const finiteTraceSpecification = (
events: ProcessTraceSpecification["events"],
variants: FiniteTraceLanguage["variants"],
): ProcessTraceSpecification => ({
events,
language: { kind: "finite_traces", variants },
});
describe("generic capture trace comparison", () => {
it("requires explicit partial ordering and never infers causality from timestamps", () => {
const specification = partialSpecification();
@@ -73,8 +104,8 @@ describe("generic capture trace comparison", () => {
{ collection: "frames", index: 1 },
],
});
const specification: ProcessTraceSpecification = {
events: [
const specification = finiteTraceSpecification(
[
{
id: "tick1",
source: "terminal_raw",
@@ -88,14 +119,11 @@ describe("generic capture trace comparison", () => {
cardinality: { kind: "optional" },
},
],
language: {
kind: "finite_traces",
variants: [
{ id: "one", trace: ["tick1"] },
{ id: "two", trace: ["tick1", "tick2"] },
],
},
};
[
{ id: "one", trace: ["tick1"] },
{ id: "two", trace: ["tick1", "tick2"] },
],
);
expect(compareProcessTraces(one, two, specification)).toMatchObject({
verdict: "equivalent",
left: { matched_variant: "one" },
@@ -108,8 +136,8 @@ describe("generic capture trace comparison", () => {
// echoes those identifiers verbatim, so both must accept the same values
// rather than handing the caller a raw schema error for its own input.
const eventId = `e${"0".repeat(64)}`;
const specification: ProcessTraceSpecification = {
events: [
const specification = finiteTraceSpecification(
[
{
id: eventId,
source: "terminal_raw",
@@ -117,11 +145,8 @@ describe("generic capture trace comparison", () => {
cardinality: { kind: "required" },
},
],
language: {
kind: "finite_traces",
variants: [{ id: "only", trace: [eventId] }],
},
};
[{ id: "only", trace: [eventId] }],
);
expect(specificationSchema.safeParse(specification).success).toBe(true);
const left = capture(values(["terminal", "process"]));
expect(compareProcessTraces(left, left, specification)).toMatchObject({
@@ -131,6 +156,53 @@ describe("generic capture trace comparison", () => {
});
});
describe("finite trace variant mismatch attribution", () => {
it("reports tied best-prefix omissions across variants", () => {
const observed = capture({
frames: [terminal],
process_samples: [],
event_journal: [{ collection: "frames", index: 0 }],
});
const specification = finiteTraceSpecification(
[
{
id: "ready",
source: "terminal_raw",
exact: terminal,
cardinality: { kind: "required" },
},
{
id: "worker",
source: "process",
exact: processStarted,
cardinality: { kind: "optional" },
},
{
id: "worker_alt",
source: "process",
exact: { ...processStarted, command: "alternate-worker" },
cardinality: { kind: "optional" },
},
],
[
{ id: "ready_worker", trace: ["ready", "worker"] },
{ id: "ready_worker_alt", trace: ["ready", "worker_alt"] },
],
);
expect(
compareProcessTraces(observed, observed, specification),
).toMatchObject({
verdict: "nonconforming",
diagnostic: {
kind: "trace",
event_ids: ["worker", "worker_alt"],
locations: [],
},
});
});
});
describe("process trace constraints and evidence sufficiency", () => {
it("enforces exact and range cardinality for a repeated terminal observation", () => {
const repeated = (count: number) =>
@@ -288,38 +360,255 @@ describe("unknown process trace evidence", () => {
});
describe("process trace dimension attribution", () => {
const traced = (
left: ReturnType<typeof capture>,
right: ReturnType<typeof capture>,
) =>
compareProcessCaptures(left, right, {
traceSpecification: partialSpecification(),
it("applies passing filesystem trace timestamp omissions to exact comparison", () => {
const base = capture(values(["terminal", "process"]));
const left = base;
const right = parseProcessCapture({
...base,
filesystem_checkpoints: base.filesystem_checkpoints.map((checkpoint) =>
checkpoint.name === "after_settlement"
? { ...checkpoint, at_ms: checkpoint.at_ms + 10 }
: checkpoint,
),
});
const make = (data: string, command: string) =>
capture({
...values(["terminal", "process"]),
frames: [{ ...terminal, data }],
process_samples: [{ ...processStarted, command }],
const specification = finiteTraceSpecification(
emptyCapture.filesystem_checkpoints.map(
({ at_ms: _atMs, ...checkpoint }) => ({
id: checkpoint.name,
source: "filesystem" as const,
exact: jsonValueSchema.parse(checkpoint),
ignore_fields: ["at_ms"],
cardinality: { kind: "required" as const },
}),
),
[{ id: "checkpoints", trace: ["before", "after_settlement"] }],
);
expect(compareProcessCaptures(left, right)).toMatchObject({
filesystem: "changed",
first_divergence: { status: "found", dimension: "filesystem" },
});
expect(
compareProcessCaptures(left, right, {
traceSpecification: specification,
}),
).toMatchObject({
trace: { verdict: "equivalent" },
filesystem: "unchanged",
first_divergence: { status: "none" },
});
const finalFile = {
path: "root_0:created.txt",
type: "file" as const,
mode: 0o644,
size: 4,
sha256: "a".repeat(64),
symlink_target: null,
};
const created = {
path: finalFile.path,
status: "created" as const,
before: null,
after: finalFile,
};
const changedRight = parseProcessCapture({
...right,
filesystem_checkpoints: right.filesystem_checkpoints.map((checkpoint) =>
checkpoint.name === "after_settlement"
? { ...checkpoint, files: [finalFile], effects: [created] }
: checkpoint,
),
files_after: [finalFile],
filesystem_effects: [created],
truncation_details: {
...right.truncation_details,
filesystem_after: {
...right.truncation_details.filesystem_after,
hashed_bytes: finalFile.size,
},
},
});
expect(
compareProcessCaptures(left, changedRight, {
traceSpecification: specification,
}),
).toMatchObject({
trace: { verdict: "different" },
filesystem: "changed",
first_divergence: { status: "found", dimension: "filesystem" },
});
});
it("keeps equal dimensions unchanged when one side fails a predicate", () => {
expect(
traced(make("Ready", "worker"), make("Different", "worker")),
traced(
withPayloads("Ready", "worker"),
withPayloads("Different", "worker"),
),
).toMatchObject({
trace: { verdict: "different" },
terminal: "changed",
process: "unchanged",
});
expect(
traced(make("Ready", "worker"), make("Ready", "other-worker")),
traced(
withPayloads("Ready", "worker"),
withPayloads("Ready", "other-worker"),
),
).toMatchObject({
trace: { verdict: "different" },
terminal: "unchanged",
process: "changed",
});
});
});
it("still attributes an ordering difference to both ordered dimensions", () => {
describe("finite trace dimension attribution", () => {
it("attributes a finite-trace omission to the omitted event dimension", () => {
const left = capture({
frames: [terminal],
process_samples: [],
event_journal: [{ collection: "frames", index: 0 }],
});
const right = capture(values(["terminal", "process"]));
const specification = finiteTraceSpecification(
[
{
id: "ready",
source: "terminal_raw",
exact: terminal,
cardinality: { kind: "required" },
},
{
id: "worker",
source: "process",
exact: processStarted,
cardinality: { kind: "optional" },
},
],
[{ id: "ready_worker", trace: ["ready", "worker"] }],
);
expect(
compareProcessCaptures(left, right, {
traceSpecification: specification,
}),
).toMatchObject({
status: "changed",
process: "changed",
terminal: "unchanged",
trace: {
verdict: "different",
diagnostic: { kind: "trace", event_ids: ["worker"], locations: [] },
},
first_divergence: {
status: "found",
dimension: "process",
index: 0,
},
});
});
});
describe("unordered finite trace mismatch attribution", () => {
it("attributes only the missing member of an unordered group", () => {
const db = { sequence: 1, at_ms: 901, data: "DB" };
const alert = { ...processStarted, pid: 2, command: "other-worker" };
const left = capture({
frames: [terminal],
process_samples: [processStarted],
event_journal: [
{ collection: "frames", index: 0 },
{ collection: "process_samples", index: 0 },
],
});
const right = capture({
frames: [terminal, db],
process_samples: [processStarted],
event_journal: [
{ collection: "frames", index: 0 },
{ collection: "process_samples", index: 0 },
{ collection: "frames", index: 1 },
],
});
const specification = finiteTraceSpecification(
[
{
id: "ready",
source: "terminal_raw",
exact: terminal,
cardinality: { kind: "required" },
},
{
id: "worker",
source: "process",
exact: processStarted,
cardinality: { kind: "optional" },
},
{
id: "db",
source: "terminal_raw",
exact: db,
cardinality: { kind: "optional" },
},
{
id: "alert",
source: "process",
exact: alert,
cardinality: { kind: "optional" },
},
],
[
{
id: "ready_then_work",
trace: ["ready", { unordered: ["worker", "db"] }],
},
],
);
expect(
compareProcessCaptures(left, right, {
traceSpecification: specification,
}),
).toMatchObject({
trace: {
verdict: "different",
diagnostic: {
side: "left",
event_ids: ["db"],
locations: [],
},
},
terminal: "changed",
process: "unchanged",
first_divergence: { status: "found", dimension: "terminal", index: 1 },
});
const unexpected = capture({
frames: [terminal],
process_samples: [processStarted, alert],
event_journal: [
{ collection: "frames", index: 0 },
{ collection: "process_samples", index: 0 },
{ collection: "process_samples", index: 1 },
],
});
expect(
compareProcessTraces(unexpected, unexpected, specification),
).toMatchObject({
verdict: "nonconforming",
diagnostic: {
kind: "trace",
event_ids: ["db", "alert"],
locations: [{ collection: "process_samples", index: 1 }],
},
});
});
});
describe("partial-order dimension attribution", () => {
it("attributes an ordering difference to both ordered dimensions", () => {
expect(
traced(
capture(values(["terminal", "process"])),
@@ -46,7 +46,7 @@ export const traceCoversObservedDimension = (
case "exit":
return sources.has("lifecycle");
case "filesystem":
return false;
return sources.has("filesystem");
case "process":
return sources.has("process");
}
+91 -25
View File
@@ -123,29 +123,92 @@ const failure = (input: FailureInput): EvaluatedSide => ({
},
});
type FiniteTraceEvaluation =
| { readonly status: "pass"; readonly variant: string }
| {
readonly status: "fail";
readonly eventIds: readonly string[];
readonly locationIndexes: readonly number[];
};
/** Match variants once, retaining the best-prefix mismatch for attribution. */
const evaluateFiniteTrace = (
ids: readonly string[],
specification: ProcessTraceSpecification,
): string | null => {
if (specification.language.kind !== "finite_traces") return null;
for (const variant of specification.language.variants) {
let offset = 0;
let accepted = true;
for (const token of variant.trace) {
if (typeof token === "string") {
if (ids[offset] !== token) accepted = false;
offset += 1;
continue;
}
const actual = ids.slice(offset, offset + token.unordered.length).sort();
const expected = [...token.unordered].sort();
if (canonicalTraceJson(actual) !== canonicalTraceJson(expected))
accepted = false;
offset += token.unordered.length;
language: Extract<
ProcessTraceSpecification["language"],
{ kind: "finite_traces" }
>,
): FiniteTraceEvaluation => {
let bestPrefix = -1;
const bestEventIds = new Set<string>();
const bestLocationIndexes = new Set<number>();
const recordMismatch = (
offset: number,
missing: readonly string[],
unexpected: readonly { readonly id: string; readonly index: number }[],
): void => {
if (offset > bestPrefix) {
bestPrefix = offset;
bestEventIds.clear();
bestLocationIndexes.clear();
}
if (accepted && offset === ids.length) return variant.id;
if (offset !== bestPrefix) return;
for (const eventId of missing) bestEventIds.add(eventId);
for (const { id, index } of unexpected) {
bestEventIds.add(id);
bestLocationIndexes.add(index);
}
};
for (const variant of language.variants) {
let offset = 0;
let mismatched = false;
for (const token of variant.trace) {
const expected = typeof token === "string" ? [token] : token.unordered;
const actual = ids.slice(offset, offset + expected.length);
const remaining = new Map<string, number>();
for (const eventId of expected)
remaining.set(eventId, (remaining.get(eventId) ?? 0) + 1);
const unexpected: Array<{ readonly id: string; readonly index: number }> =
[];
for (const [index, eventId] of actual.entries()) {
const count = remaining.get(eventId) ?? 0;
if (count === 0)
unexpected.push({ id: eventId, index: offset + index });
else if (count === 1) remaining.delete(eventId);
else remaining.set(eventId, count - 1);
}
const missing = [...remaining].flatMap(([eventId, count]) =>
Array.from({ length: count }, () => eventId),
);
if (
missing.length > 0 ||
unexpected.length > 0 ||
actual.length < expected.length
) {
recordMismatch(offset, missing, unexpected);
mismatched = true;
break;
}
offset += expected.length;
}
if (mismatched) continue;
if (offset === ids.length) return { status: "pass", variant: variant.id };
const unexpected = ids[offset];
recordMismatch(
offset,
[],
unexpected === undefined ? [] : [{ id: unexpected, index: offset }],
);
}
return null;
return {
status: "fail",
eventIds: [...bestEventIds],
locationIndexes: [...bestLocationIndexes],
};
};
type MatchedTrace = {
@@ -391,20 +454,23 @@ const evaluateMatchedTrace = (
if (specification.language.kind === "partial_order")
return evaluatePartialOrder(trace, specification.language);
const ids = trace.rawTrace.map(({ event_id }) => event_id);
const variant = evaluateFiniteTrace(ids, specification);
return variant === null
const evaluation = evaluateFiniteTrace(ids, specification.language);
return evaluation.status === "fail"
? failure({
kind: "trace",
message: "Observed events do not match any finite trace variant.",
eventIds: ids,
locations: trace.rawTrace.map(({ location }) => location),
eventIds: evaluation.eventIds,
locations: evaluation.locationIndexes.flatMap((index) => {
const location = trace.rawTrace[index]?.location;
return location === undefined ? [] : [location];
}),
rawTrace: trace.rawTrace,
})
: {
result: {
status: "pass",
matched_variant: variant,
satisfied_constraints: [`variant:${variant}`],
matched_variant: evaluation.variant,
satisfied_constraints: [`variant:${evaluation.variant}`],
raw_trace: trace.rawTrace,
},
diagnostic: null,
@@ -323,11 +323,60 @@ describe("historical calls-only reconstruction changes", () => {
});
describe("function reconstruction call coverage", () => {
it("preserves complete call diagnostics while passing equal call claims", () => {
const diagnostic = "Indirect calls remain unresolved.";
const leftWithDiagnostic = source("a", {
relationshipCoverage: { status: "complete", diagnostics: [diagnostic] },
});
const rightWithDiagnostic = source("b", {
relationshipCoverage: { status: "complete", diagnostics: [diagnostic] },
});
const observed = compareFunctions(leftWithDiagnostic, rightWithDiagnostic);
expect(
observed.dimensions.find(({ dimension }) => dimension === "calls"),
).toMatchObject({
status: "unchanged",
limitations: [`Left: ${diagnostic}`, `Right: ${diagnostic}`],
});
const evidence = comparison(
{
left_evidence_id: leftWithDiagnostic.evidence_id,
right_evidence_id: rightWithDiagnostic.evidence_id,
},
{},
[leftWithDiagnostic, rightWithDiagnostic],
{},
observed,
);
expect(
admittedVerification(
evidence,
[leftWithDiagnostic, rightWithDiagnostic],
"calls",
)(),
).toMatchObject({
status: "pass",
claims: {
items: [
expect.objectContaining({
status: "pass",
observed_status: "unchanged",
limitations: expect.arrayContaining([
`Source ${leftWithDiagnostic.evidence_id}: ${diagnostic}`,
`Source ${rightWithDiagnostic.evidence_id}: ${diagnostic}`,
]),
}),
],
},
});
});
it("rechecks old unchanged comparisons without affecting unrelated finite dimensions", () => {
const incomplete = source("7", {
relationshipCoverage: {
status: "unknown",
reason: "Call graph unavailable.",
diagnostics: ["Indirect calls remain unresolved."],
},
});
const incompleteSides = {
@@ -352,6 +401,7 @@ describe("function reconstruction call coverage", () => {
observed_status: "unchanged",
limitations: expect.arrayContaining([
`Source ${incomplete.evidence_id}: Call graph unavailable.`,
`Source ${incomplete.evidence_id}: Indirect calls remain unresolved.`,
]),
}),
],
+33 -20
View File
@@ -19,7 +19,7 @@ import {
isProcessEvidenceProvider,
} from "./process/processEvidenceProvider.js";
import { functionDossierSchema } from "./hopperValues.js";
import { functionCallCoverageLimitations } from "./functionCallCoverage.js";
import { assessFunctionCallCoverage } from "./functionCallCoverage.js";
import {
deriveProcessComparisonStatus,
PROCESS_COMPARISON_DIMENSIONS,
@@ -64,7 +64,8 @@ const providers = {
interface FunctionSourceFacts {
readonly evidence_id: string;
readonly callCoverageLimitations: readonly string[];
readonly callCoverageBlockers: readonly string[];
readonly callCoverageDiagnostics: readonly string[];
}
const acceptsPredicate = (
@@ -149,13 +150,14 @@ const evaluateClaim = (
const functionSourceFacts = readValidatedSourceFacts(claim, sources);
const observation = comparisonObservation(claim, comparison);
const observed = observation.status;
const limitations = [
const callCoverage = sourceCallCoverage(
claim,
functionSourceFacts,
observation.hasIndependentFunctionChange === true,
);
const blockers = [
...authorityLimitations(claim, sources, sides),
...sourceCallCoverageLimitations(
claim,
functionSourceFacts,
observation.hasIndependentFunctionChange === true,
),
...callCoverage.blockers,
];
const relatedUnknowns = reconstructionClaimUnknowns(
heads,
@@ -164,7 +166,7 @@ const evaluateClaim = (
claim.dimension,
);
const relevantUnknowns = relatedUnknowns;
const status = classify(observed, limitations, relevantUnknowns);
const status = classify(observed, blockers, relevantUnknowns);
const displayedUnknowns = relevantUnknowns;
return reconstructionClaimResultSchema.parse({
claim_id: claim.claim_id,
@@ -180,7 +182,8 @@ const evaluateClaim = (
limitations: uniqueSorted([
...comparison.limitations,
...observation.limitations,
...limitations,
...blockers,
...callCoverage.diagnostics,
...displayedUnknowns.map(
({ question }) => `Residual unknown: ${question}`,
),
@@ -214,12 +217,14 @@ const readValidatedSourceFacts = (
parseProcessCapture(source.normalized_result);
else if (claim.kind === "structural-function") {
const dossier = functionDossierSchema.parse(source.normalized_result);
const callCoverage = assessFunctionCallCoverage(
source.relationship_coverage,
dossier,
);
functionSources.push({
evidence_id: source.evidence_id,
callCoverageLimitations: functionCallCoverageLimitations(
source.relationship_coverage,
dossier,
),
callCoverageBlockers: callCoverage.blockers,
callCoverageDiagnostics: callCoverage.diagnostics,
});
} else artifactInventoryResultSchema.parse(source.normalized_result);
}
@@ -366,20 +371,28 @@ const authorityLimitations = (
return limitations;
};
const sourceCallCoverageLimitations = (
const sourceCallCoverage = (
claim: Claim,
functionSources: readonly FunctionSourceFacts[],
hasIndependentFunctionChange: boolean,
): string[] => {
): { blockers: string[]; diagnostics: string[] } => {
if (
claim.kind !== "structural-function" ||
(claim.dimension !== "calls" &&
!(claim.dimension === "overall" && !hasIndependentFunctionChange))
)
return [];
return functionSources.flatMap(({ evidence_id, callCoverageLimitations }) =>
callCoverageLimitations.map((reason) => `Source ${evidence_id}: ${reason}`),
);
return { blockers: [], diagnostics: [] };
return {
blockers: functionSources.flatMap(({ evidence_id, callCoverageBlockers }) =>
callCoverageBlockers.map((reason) => `Source ${evidence_id}: ${reason}`),
),
diagnostics: functionSources.flatMap(
({ evidence_id, callCoverageDiagnostics }) =>
callCoverageDiagnostics.map(
(reason) => `Source ${evidence_id}: ${reason}`,
),
),
};
};
const classify = (
+14
View File
@@ -161,7 +161,9 @@ describe("web bundle analyzer", () => {
trust: "page-declared-untrusted",
});
});
});
describe("web bundle source-map coverage", () => {
it("reports unavailable source maps as partial without dropping requested maps", () => {
const analysis = analyzeCapturedWebBundle(inspection("export {};"), {
status: "unavailable",
@@ -191,6 +193,18 @@ describe("web bundle analyzer", () => {
});
});
it("keeps map-less captured source complete when no maps were requested", () => {
const analysis = analyzeCapturedWebBundle(inspection("export {};"), {
status: "not_requested",
requested: 0,
processed: 0,
items: [],
});
expect(analysis.completeness.status).toBe("complete");
expect(analysis.unknowns).toEqual([]);
});
it("rejects inconsistent source-map coverage counts", () => {
const result = analyzeCapturedWebBundle(inspection("export {};"));
expect(
-6
View File
@@ -55,9 +55,3 @@ export const zipPackageFormatForPath = (
/** Inventory lists entries, not all bytes surrounding the central directory. */
export const ZIP_NON_ENTRY_LIMITATION =
"ZIP inventory lists central-directory entries only. Non-entry bytes, including an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences.";
/** Recognize the same inventory scope in current and previously retained Evidence. */
export const isZipInventoryScopeLimitation = (limitation: string): boolean =>
limitation === ZIP_NON_ENTRY_LIMITATION ||
limitation ===
"ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences.";
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more