chore: merge environment serve (5e242c13) into environment export

This commit is contained in:
Edgar Arakelyan committed 2026-10-19 08:53:19 -07:00
commit ede425e9d5
327 files changed
+33468 -4062

No files matched your search

+65 -7
View File
@@ -12,16 +12,29 @@ struct ProcessIdentityObservation: Encodable {
let pid: Int32
let state: String
let identity: String?
let effective_uid: UInt32?
let lifecycle: String?
let relation: ProcessRelationObservation?
let reason: String?
}
private struct ProcessMetadata {
struct ProcessRelationObservation: Encodable {
let state: String
let parent_pid: Int32
let process_group_id: Int32
}
struct ProcessMetadata {
let identity: String
let effectiveUid: UInt32
let lifecycle: String
let parentPid: Int32
let processGroupId: Int32
let pointerSize: Int
}
struct ProcessSnapshot {
fileprivate let metadata: [Int32: ProcessMetadata]
let metadata: [Int32: ProcessMetadata]
let failure: String?
}
@@ -75,6 +88,10 @@ func readProcessSnapshot(
process.kp_proc.p_pid,
ProcessMetadata(
identity: "\(start.tv_sec):\(start.tv_usec)",
effectiveUid: process.kp_eproc.e_ucred.cr_uid,
lifecycle: process.kp_proc.p_stat == SZOMB ? "zombie" : "non-zombie",
parentPid: process.kp_eproc.e_ppid,
processGroupId: process.kp_eproc.e_pgid,
pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4
)
)
@@ -87,20 +104,61 @@ func readProcessSnapshot(
}
func readProcessIdentities(pids: [Int32]) -> [ProcessIdentityObservation] {
let snapshot = readProcessSnapshot()
readProcessIdentities(pids: pids, snapshot: readProcessSnapshot())
}
func readProcessIdentities(
pids: [Int32],
snapshot: ProcessSnapshot
) -> [ProcessIdentityObservation] {
return pids.map { pid in
guard pid > 0 else {
return unavailableIdentity(pid: pid, reason: "invalid_process_id")
}
guard let metadata = snapshot.metadata[pid] else {
return unavailableIdentity(
if let failure = snapshot.failure {
return unavailableIdentity(pid: pid, reason: failure)
}
// This records absence from this successful KERN_PROC_ALL snapshot only.
// Unlike the Linux procfs path, it has no PID-specific kill(0)
// corroboration; callers must not treat it as universal or future exit
// proof.
return ProcessIdentityObservation(
pid: pid,
reason: snapshot.failure ?? "process_not_in_snapshot"
state: "absent",
identity: nil,
effective_uid: nil,
lifecycle: nil,
relation: nil,
reason: "not-in-snapshot"
)
}
return ProcessIdentityObservation(pid: pid, state: "readable", identity: metadata.identity, reason: nil)
return ProcessIdentityObservation(
pid: pid,
state: "readable",
identity: metadata.identity,
effective_uid: metadata.effectiveUid,
lifecycle: metadata.lifecycle,
relation: ProcessRelationObservation(
state: "observed",
parent_pid: metadata.parentPid,
process_group_id: metadata.processGroupId
),
reason: nil
)
}
}
private func unavailableIdentity(pid: Int32, reason: String) -> ProcessIdentityObservation {
ProcessIdentityObservation(pid: pid, state: "unavailable", identity: nil, reason: reason)
ProcessIdentityObservation(
pid: pid,
state: "unavailable",
identity: nil,
effective_uid: nil,
lifecycle: nil,
relation: nil,
reason: reason
)
}
enum RunTokenReadError: Error {
+7 -2
View File
@@ -72,6 +72,9 @@ where the answer came from:
`exact_match` is true when every supplied path parameter and query value
equals the recorded one; it says nothing about parameters the caller omitted.
A path value matches a recorded segment as written or after percent-decoding;
when both kinds of match are available, the record whose segment equals the
value as written is chosen.
When no record matches, the tool still answers from the tool's first cited
record and reports `exact_match: false`. `body` is parsed JSON when the
recorded content type is JSON-like (`application/json`, `application/*+json`,
@@ -131,8 +134,10 @@ can see that the method, not the transport, is unsupported. Its request body is
read within the same 4 MiB bound the MCP endpoint applies: a larger body is
answered `413` with a JSON-RPC `-32000` error before any of it is parsed, and a
body that is not JSON is answered `400` with `-32700`. A notification (no `id`)
gets `202` and no body, a batch gets one reply per request in it, an empty batch
or a message that is not a JSON-RPC request gets `-32600 invalid request`.
gets `202` and no body, a batch gets one reply per request in it, and an empty
batch, a batch of more than 100 messages, or a message that is not a JSON-RPC
request (including an `id` that is not a string, number or null, or `params`
that are not an object or array) gets `-32600 invalid request`.
## The environment card
+6 -1
View File
@@ -160,7 +160,12 @@ launch builder. It is a configuration fact, not measured memory use or proof of
the effective JVM limit; JVM environment options may affect that limit.
When a Windows script wrapper selects the JVM, this value remains unknown.
Historical stdout and stderr remain available, but classification uses only the
failing operation's `failure_output`. Output draining waits at most one second,
failing operation's `failure_output`. Retained stdout and stderr share an 8 MiB
budget, but every drained byte is scanned for `java.lang.OutOfMemoryError`:
`failure_output.observed_oome` records the latest confirmed token's bounded line per stream from
the failing operation, with its byte offset, even after the budget is spent. These observations own OOME detection; retained text that ends at a class-name prefix cannot confirm a complete token. `line_complete` distinguishes a known line end from an incomplete trailing context; `invalid_utf8` marks replacement-decoded context.
Incomplete operation output without an observed OOME keeps the original failure code and explains that a memory cause cannot be ruled out. Incompleteness alone does not confirm a memory constraint or justify heap-specific advice. Token context stays within the failing operation's checkpoint; a class-name prefix in a different identifier is not OOME evidence. A trailing bare token requires an observed stream EOF to establish its boundary.
Output draining waits at most one second,
subject to earlier cancellation or the startup deadline. `output_incomplete`
marks a snapshot taken before stream closure; cleanup ownership remains with
the session until release succeeds.
+34
View File
@@ -350,6 +350,40 @@ validation and resource bounds. Omitted script metadata makes its affected
section explicitly truncated.
Disallowed-origin entries are filtered before retention.
Selected script-source collection shares a 32 MiB aggregate UTF-8 retention
budget and a 20-second deadline across the capture. An oversized source is
omitted while later smaller sources may still fit. When the deadline expires,
remaining source reads are skipped. Script metadata, per-item omission reasons,
and `script_sources` completeness remain available. A single CDP response can
still be materialized up to the separate 64 MiB transport limit before source
admission; the retention budget does not bound that transient allocation.
Bundle parsing and semantic inspection run sequentially in an owned child,
without executing captured code. `max_heap_mb` defaults to 1024 MiB V8
old-space; `analysis_timeout_ms` defaults to 30000 per script, including startup
and transfer. A failed or timed-out script becomes an explicit unknown while
completed findings and later scripts remain available. `total_analysis_timeout_ms`
defaults to 300000 across script analysis; expiry returns a typed timeout with
partial Evidence and identifies unstarted scripts. These controls are also
available as CLI flags with hyphenated names. Capture and optional source-map
fetching have separate budgets.
One web-bundle capture/analysis is admitted per REA process at a time. Completed
script results share an 8 MiB serialized transfer budget, with host heap
reservation checked before decoding and validating another result.
Exhausted result capacity stops further script launches and returns a typed
resource failure with completed facts and explicit unstarted-script unknowns.
Worker control frames are limited to 64 KiB. Cleanup must confirm child termination
before releasing its private files; failed cleanup retains its exact owner and
blocks replacement until retry succeeds. Cancellation retains completed bundle
facts, including cancellation during final cleanup.
The worker heap setting is not a process-family RSS limit. Node enforces V8
old-space flags; Bun ignores them. Windows Bun instead uses an owned process
commit limit with a 512 MiB runtime reserve; other Bun hosts have no enforced
memory limit. These bounds do not establish an aggregate host-memory limit
across other REA operations or external processes.
Resource-tree inventories distinguish a valid empty `resources` array from a
missing or malformed collection. Invalid resource entries are omitted with
incomplete coverage, while valid resources from that frame and other frames
+6
View File
@@ -286,6 +286,12 @@ reported separately as configured-secret exclusions.
Re-import saved graphs whose pattern-based exclusions lack `pattern` before
using them in source-to-bundle comparisons.
Entries that change during an import retain an unavailable-entry diagnostic
alongside readable siblings. If enumeration of an already admitted root fails,
the graph keeps its observed children and records the root failure as a
top-level limitation. That partial inventory does not establish absence among
unexamined children.
JavaScript and TypeScript import parsing requires valid UTF-8. Malformed source
bytes retain their original hashes and sizes with a decoding diagnostic; REA
does not infer module targets from replacement characters.
+3 -1
View File
@@ -457,7 +457,9 @@ disposable HOME and CODEX_HOME under `~/.cache/rea-agent-evaluations/`. Set
`REA_AGENT_EVAL_ROOT` to choose another parent directory outside the OS temporary
directory: current Codex refuses helper aliases under `/tmp`, preventing shell
commands and skill reads even if MCP works. Each owned run directory is removed
after verification unless fixture retention is requested. The lane
after verification unless fixture retention is requested. Cleanup attempts the
remaining run tree even if auth removal fails, and reports unresolved cleanup
together with the original verification failure. The lane
installs the packaged skill and MCP registration through public `rea setup`,
rather than copying a skill into a project that also inherits the caller's
skills and configuration. Only authentication is copied, when present, and it
+31 -3
View File
@@ -33,6 +33,12 @@ encoded path separators, ASAR directories or links, and nonexistent paths are
rejected. The endpoint exposes every eligible target and its local path
metadata.
Cancellation stops further file authorization after an already-started filesystem
operation settles. ASAR descriptor-close failures remain typed incomplete cleanup
with an exact retryable owner; they are not treated as a missing or unauthorized
page. A later authorization and provider shutdown retry retained descriptors
before reporting their release.
## Workflow
`list_electron_targets` returns every eligible target served by the supplied endpoint in one inline array.
@@ -68,7 +74,12 @@ Script metadata retains its execution-context frame ID when CDP supplies one. Th
inventories worker, service-worker, and shared-worker targets with
validated opener-target and parent-frame IDs. Worker discovery uses passive
target metadata; REA does not attach to or execute code in those targets.
Collection counts and aggregate script-source bytes are not capped. Like every
Script-source collection retains at most 32 MiB of UTF-8 text across scripts
and shares a 20-second collection deadline. Omitted sources retain their script
metadata and an explicit reason; later smaller sources may still fit after an
oversized source is omitted. The separate 64 MiB CDP response limit bounds one
wire message before retention admission, rather than total transient memory.
The source budget adds no script-count cap. Like every
target, frame, script, and resource, a worker URL must resolve to a local
file before it is retained. Relationship IDs improve
attribution but do not prove which static module started a worker or that its
@@ -193,8 +204,25 @@ Caller-defined `.listener` properties can change the identity exposed by
wrapper recognition. Re-registering a raw recording wrapper on another channel
retains the original registration's channel attribution.
Mutating `.listener` on a raw recording wrapper can also prevent removal through
application decorators; the reproduced identity conflict is tracked in
[#4513](https://github.com/morluto/rea/issues/4513).
application decorators that bypass the hook's removal methods. When a removal
request reaches the hook's methods and its registration order is known, private
callback ownership preserves removal by the original function and the newest
matching registration when public metadata is changed or deleted. Native removal
still determines getter execution, exceptions and removal meta-events.
If registration or removal methods differ from the Node methods captured at hook
initialization, the hook leaves listener callbacks unwrapped and records a
`listener-registration` observation gap. The application keeps its original
callback and receiver; callback execution remains unobserved. This comparison
assumes Node's EventEmitter prototype has not itself been globally decorated
before hook initialization. The provider loads the hook before the application
and excludes `NODE_OPTIONS` from the child environment.
An opaque later listener or unsupported emitter state can prevent proof of
removal order. The hook then delegates removal
to the native method and records a `listener-removal` gap rather than claiming
which registration was removed. These gaps are distinct from dropped records
and do not establish IPC delivery or successful removal.
### Target fuse prerequisites
+3 -1
View File
@@ -23,7 +23,9 @@ rea inspect-dart-aot /targets/Example.apk --abi armeabi-v7a
The equivalent MCP methods are `identify_flutter_build` (with `path`) and
`inspect_dart_aot` (with `path` and an optional `abi`; the default prefers
arm64-v8a, then x86_64, then x86, then armeabi-v7a, then the remaining
ABIs alphabetically).
ABIs alphabetically). An explicit `abi` is the directory label exactly as
`identify_flutter_build` reports it, so a payload under a nonstandard label
stays selectable and keeps that label in the result.
## Optional retained AOT delivery
+1 -1
View File
@@ -8,6 +8,6 @@ MCP exposes `list_frida_devices`, `list_frida_processes`, `start_frida_session`,
Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. Server shutdown stops admitting new Frida operations and waits for admitted startup, discovery, and queued work before releasing their resources. A failed cleanup remains reported and can be retried by closing the server again, rather than treating an unregistered session as fully released.
Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. A cancelled script load on an existing session releases only its new script before queued operations can run; the session and previously loaded scripts remain available. Once the provider commits a successful load, later request cancellation does not unload that script; use `unload_frida_script` to release it. If script cleanup fails, the script remains owned and a paused target cannot resume until the pending instrumentation is released. Queued script loads, resumes, and unloads check cancellation before dispatching native effects. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed.
Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. For a file, `source_sha256` identifies the original selected bytes. Invalid UTF-8 continues to use replacement decoding; Evidence then records that limitation and `raw_result.source_representation` with the encoding, decoding mode, and `loaded_source_sha256` of the UTF-8 string passed to Frida. Valid UTF-8 files and inline scripts do not need this additional representation fact. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. A cancelled script load on an existing session releases only its new script before queued operations can run; the session and previously loaded scripts remain available. If cancellation is observed when the native call returns, partial Evidence retains the source identity and captured messages even if that call rejects, including messages emitted during awaited cleanup. The caller's cancellation reason remains primary and a native rejection is retained as a sanitized diagnostic. A failure already observed before cleanup stays primary if cancellation arrives during cleanup. The cancellation cutoff is REA's observation of native completion, rather than a claim about unobservable native event order. `script_id` is REA's load-attempt identity. When creation rejects without returning a handle, `script_cleanup: "no_handle"` means REA acquired no handle to unload; it does not prove that the engine performed no work. Once the provider commits a successful load, later request cancellation does not unload that script; use `unload_frida_script` to release it. If script cleanup fails, the script remains owned and a paused target cannot resume until the pending instrumentation is released. Queued script loads, resumes, and unloads check cancellation before dispatching native effects. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed.
The [Frida Core API](https://frida.re/docs/frida-core-api/) documents device management, sessions, and script loading; the [JavaScript API](https://frida.re/docs/javascript-api/) documents target-side instrumentation. [Luma](https://luma.frida.re/) is Frida's standalone GUI; REA does not automate or control it. `npm run verify:frida` requires a Linux host with the optional Frida binding and uses a short-lived local Node.js fixture created by the verifier. Add `-- --address HOST:PORT --pid PID` to run the separate remote attach lane against a caller-selected target. Mock tests do not establish real device/server compatibility.
+9 -4
View File
@@ -376,10 +376,15 @@ Uninstall preserves Hopper, Node.js, Evidence files, captures, unrelated skills
and other MCP servers. Purging removes only REA's cache and state under
`~/.rea`. A client configuration that is malformed, unreadable, or at an unsafe
path stops the operation before anything is removed, and a client that fails
while being updated stops the remaining removals. For JSON clients, uninstall
removes every repeated `rea` member in the effective server table only when
each registration is REA-owned. An unowned earlier member, or an unowned
OpenCode V1 entry that removing V2 would expose, stops preflight before any
while being updated stops the remaining removals. Uninstall reports each
candidate `configuration_path` with its outcome. A client with multiple files
keeps their individual `actions` alongside its aggregate status and detail,
including earlier removals when a later file fails. The paths name
the discovered configuration candidates, not resolved physical file identities.
For JSON clients, uninstall removes every repeated `rea` member in the effective
server table only when each registration is REA-owned. An unowned earlier
member, or an unowned OpenCode V1 entry that removing V2 would expose, stops
preflight before any
removal. Repeats inside an inactive earlier copy of the server table stay
unchanged. A purge path that is a
symbolic link is retained and reported rather than followed. Uninstall checks
+11
View File
@@ -195,6 +195,17 @@ earlier read or captured alias. Unproved ordering across branches or function
activations remains conservative. Returned-reference effects use the same
read-position facts as call resolution.
Returned-reference effects share one property-projection state per source read.
When several paths reach that read, differing keys at the same depth become
wildcards; differing depths become an unknown-depth selection. This retains a
conservative superset of the represented local targets rather than enumerating
every alias history or dropping paths at a query cap. It can add possible effects,
including for finite projection cycles. Static limitations disclose this loss of
precision; semantic graphs retain `dynamic-property` unknowns at the affected
callee coordinates, and captured web bundles identify the affected script keys.
These candidates do not prove runtime calls or mutations. Existing source-worker
and result-admission limits still apply.
Mutation tracking covers explicit member assignments, updates, deletion, and
loop assignment targets, including the supported local aliases and shared
nested references. Calls conservatively invalidate exposed mutable references;
+9
View File
@@ -152,6 +152,15 @@ and uncollected scripts, but scripts collected before attachment may be
missing. Absence from the observation window never proves that a script or
behavior does not occur.
Cancellation stops new target-file authorization after the current filesystem
operation settles. Cancellation diagnostics retain the examined target prefix;
unexamined targets remain unknown. Observation cancellation keeps its original
reason and any completed Evidence. Finalization waits for REA's bounded socket
close (up to two seconds for graceful and forced confirmation). A failed close
retains its exact transport owner and blocks replacement connections until
cleanup succeeds; CLI finalization and MCP shutdown also retry retained cleanup.
Closing REA's connection does not stop the caller-owned runtime.
## Static correlation
Pass the resulting observation Evidence and an
+71 -5
View File
@@ -133,6 +133,12 @@ is null. `total` counts all matching heads; each page preserves complete records
in stable unknown ID order. Filters apply before paging. Pages reflect current
session state, so keep the unknown ledger unchanged while traversing it.
Automatically retained workflow questions include their immutable result
Evidence ID. Distinct results retain independent questions and source links;
repeating the same result does not create another unknown. Workflow Evidence
keeps the original residual question text. Explicit `record_unknown` requests
keep caller-selected identity and reject duplicate creation.
For a portable exported bundle, the CLI shares this projection:
`rea inspect-evidence-bundle '{"path":"/tmp/evidence.json","detail":"summary","filters":{"operation":"analyze_function"}}' --format json`.
The CLI validates and materializes that local bundle under its existing memory
@@ -450,13 +456,57 @@ as one JSON log line on stderr (`"msg":"MCP transport listening"` with `url`)
at every `REA_LOG_LEVEL`, because an ephemeral port is otherwise unreachable;
stdout stays unused.
This endpoint uses plain HTTP without TLS or client authentication. Use it only
within a trusted local or isolated environment; an explicit non-loopback bind
makes the same tools and active analysis state reachable at that address. It
does not provide separate users or independent target sessions. Standard client
registrations continue to use a dedicated stdio process.
The HTTP server is stateful and serves one client at a time on one server
instance. `initialize` returns an `Mcp-Session-Id`; `notifications/initialized`
is acknowledged with `202`; responses arrive as JSON or SSE frames; `DELETE
/mcp` with the session id ends the session with `200`. Ending a session is not
a server shutdown: the process keeps listening, the instance keeps its providers
and the shared active target, and the next client opens a fresh session against
them. The newest client replaces an open session: an `initialize` that arrives
them. A client ending or replacing a session cancels its running tool calls and
prompt completions, stops request-body reads and fences further message dispatch,
then waits up to ten seconds for request dispatch, handlers and cleanup to finish before reusing the
server. A timeout returns `503`; the old calls remain owned, and later requests
retry the drain rather than starting another session against those resources.
Requests that finish parsing or SDK dispatch after their session closed are
refused, and their response streams are cancelled. Tool callbacks whose
validation finishes after cancellation never start.
Body forwarding stops when SDK request processing ends, including early header
and size refusals, rather than remaining active until the session closes.
HTTP input/route dispatch is limited to eight concurrent requests by default,
shared by MCP and plain routes. Excess requests receive `503` with a closing
connection before their bodies are converted, read or cloned. An embedder can
select `maxConcurrentDispatches`; the SDK's per-body size limit still applies.
MCP responses with fully read or bodyless requests do not hold admission slots,
so an idle MCP SSE stream does not prevent later calls. Plain routes retain their
slots through response delivery and response-source cancellation cleanup. An
unread upload retains its slot until input finishes or either side closes,
even with a long response. Sequential keep-alive is supported; a peer pipelining
behind an unfinished response is disconnected before queued requests dispatch.
Concurrent MCP requests use separate connections.
This bounds concurrent input work, not total provider or process memory.
Provider-backed prompt completions use the same request owner and drain, so a
cancelled completion cannot keep using providers after handover or shutdown.
HTTP factories enable `RequestTrackingMcpServer` tracking so completion is tracked
even when cancellation suppresses the SDK's reply; stdio retains the SDK's
original callback behavior. Full server shutdown stops the listener and waits
for MCP work, plain-route handlers and response-source cancellation to settle
before running provider shutdown hooks or closing providers. Response producers
must finish provider work before closing their stream and expose asynchronous
disconnect cleanup through the stream's `cancel()` result. Cancellation does not
delay delivery of a HEAD reply; a caller retaining a sibling of a teed response
must consume or cancel that sibling before shutdown can finish. Failed response
cleanup remains owned, blocks further admission and prevents provider teardown.
The server continues cleanup
automatically when they finish, including after a client handover timed out.
All routes return `503` until the server factory succeeds; startup failure closes
the listener and joins its pending replies before rejecting the factory.
The newest client replaces an open session: an `initialize` that arrives
while a session is open ends that session instead of being refused, because a
client that exits without `DELETE` must not wedge the listener; the replaced
client's open streams are closed and its later requests are refused. Only an
@@ -467,12 +517,13 @@ session; any other sessionless POST goes to the live transport, which refuses
it without disturbing the session. Requests that carry no valid session id are
refused with `400` or `404` as the SDK transport defines. A bind is loopback by
address class, not spelling, and is classified from the address the listener
actually bound: any `127.0.0.0/8` address, `::1`, an IPv4-mapped loopback and
`localhost` names, including a host alias that resolves to one of them. On such
a bind, requests whose `Host` header is outside the SDK loopback allowlist plus
actually bound: any `127.0.0.0/8` address, `::1`, or an IPv4-mapped loopback.
Configured names, including `localhost` and its aliases, do not establish that
the bound address is local; names are added only after classifying the actual IP.
On such a bind, requests whose `Host` header is outside the SDK loopback allowlist plus
the configured name and the bound address are refused with `403`, and a browser
`Origin` outside that allowlist is refused the same way (a missing `Origin`
passes); a non-loopback bind relaxes both checks and logs the relaxation. A reply sent before a request body was read (a refused `Host` or `Origin`, an unknown route, the startup `503`) closes the connection after the reply, so an unread upload cannot keep a socket open. Request targets are taken verbatim as origin-form paths (`//host/path` is a path, not another origin); an absolute-form target, or one whose path URL parsing would rewrite (dot segments such as `/public/../mcp`), is refused with `400`, and a method Fetch does not support with `405`. Only a lone `initialize` can replace an open session; a batch that carries one is left to the transport's refusal. Scoped
passes); a non-loopback bind relaxes both checks and logs the relaxation. A reply sent before a request body was read (a refused `Host` or `Origin`, an unknown route, the startup `503`) closes the connection after the reply, so an unread upload cannot keep a socket open. Request targets are taken verbatim as origin-form paths (`//host/path` is a path, not another origin); an absolute-form target, a target containing a raw fragment delimiter (`#`), or one whose path URL parsing would rewrite (dot segments such as `/public/../mcp`), is refused with `400`, and a method Fetch does not support with `405`. Percent-encoded `%23` remains valid query data. Only a lone `initialize` can replace an open session; a batch that carries one is left to the transport's refusal. Scoped
IPv6 literals (`fe80::1%eth0`) are refused before binding, because no URL can
name them. Listener errors reach the runtime's connection-loss handler only
after a successful bind; a bind failure prints the listen-failure message alone. Request bodies are bounded by the SDK's
@@ -922,6 +973,21 @@ includes all dimension limitations. Equal CFGs establish only equal observed
block sizes and successor relationships. Text from different provider
identities remains unknown.
The `references` dimension preserves each edge's direction, endpoint identity
and available kind facts. Within one artifact it compares exact raw endpoint
coordinates, including the address space; hexadecimal case and zero padding
do not change identity. Resolving an endpoint to a containing procedure does
not replace the raw coordinate. Unparseable coordinates stay unknown.
Across artifacts, reference endpoints use stable procedure names. Missing or
address-generated names remain unknown. A name observed at multiple procedure
entries retains its label constraint but cannot establish equal endpoints.
Pairing preserves duplicate counts and considers alternative compatible
pairings; array order never identifies an edge. Compatible records with
unknown endpoint identity or kind facts remain `unknown`. Independent known
facts that contradict every possible pairing still establish `changed`.
Source Evidence retains the original addresses, names and unavailable facts.
### Comparison input shapes
`compare_web_captures` accepts exactly one of two input shapes:
+7 -5
View File
@@ -108,11 +108,13 @@ list does not establish that the target has no procedures.
| Artifact occurrence | Occurrence IDs present in schema-valid retained artifact inventory pages. |
| Residual unknown | Current non-resolved unknown heads only. |
Suggestions are Unicode-normalized, case-insensitive prefix matches. The server
deduplicates them and sorts by code point for deterministic results. Procedure
completion derives candidates from the complete inventory, so a name is offered
only when it identifies one address in that inventory. A completion reply
carries at most 100 values, the MCP limit. `total` counts every match and
Prefix matching is Unicode-normalized and case-insensitive; suggestions retain
their original spelling. The server deduplicates them and sorts by code point
for deterministic results. Procedure completion derives candidates from the
complete inventory, so a name is offered only when it identifies one address in
that inventory. Every name at a shared address participates in this check,
including later rows that establish ambiguity. A completion reply carries at
most 100 values, the MCP limit. `total` counts every match and
`hasMore` is `true` when values were omitted. Completion has no paging
controls. A longer prefix reaches an omitted value only when it excludes other
matches; matching uses normalized, case-insensitive forms, so a value can stay
+48
View File
@@ -38,6 +38,10 @@ supervision on macOS. REA removes that directory when it exits. REA does not
install Xcode, Command Line Tools, or other software. Capability checks prepare
this helper before reporting macOS capture as available.
Stopping process sampling or cancelling an ownership query waits for its issued
inspection command to exit. Closing a macOS ownership reader rejects new queries
and waits for admitted helper commands before removing their executable directory.
## Capture a command
For a small first capture, save this as `scenario.json`:
@@ -241,6 +245,15 @@ follow also remain unknown. Partial enumeration of one root does not erase
known effects in another, and unavailable content hashes do not by themselves
make path absence unknown.
When a follow-up read fails after an entry's metadata was observed, the
snapshot keeps unaffected observations and records an `entry_unavailable`
enumeration omission with the root-aliased path and system error code.
Successfully observed identity replacements use `identity_changed` with a null
system code. Unstable symlink targets are omitted; already observed directory
metadata remains available. The affected root stays incomplete, so omitted
entries do not prove deletion. A path missing at its initial lookup remains an
observed absence. Cancellation and unexpected I/O failures still stop capture.
Terminal frame `data` is the comparison text after the selected normalization.
New captures also preserve `raw_data` when normalization changes a PTY chunk;
otherwise `data` is already the original text. Older captures may lack the
@@ -419,6 +432,17 @@ Every capture requires `truncation_details`, with separate accounting for:
imply incomplete path enumeration.
- `process`: sampling limit and whether sampling ended partially. Coverage
remains `sampled`, including when that limit was not exhausted.
`sampling_failures` counts failed read observations plus at most one
unfinished pass interrupted by sampler shutdown. `first_sampling_failure`
retains the first attributed read diagnostic, with shutdown context appended
when applicable; interruption alone has an explicit shutdown diagnostic.
Denied or failed procfs reads mark
sampling partial while preserving readable process rows and thread children;
a process with unreadable identity is not attributed. Expected process-exit
races are separate from failed observations. Shutdown counts only when it
interrupts a read or leaves traversal unfinished; a completed pass does not
become partial merely because the sampler stops. Completed rows
remain available and issued reads settle before final coverage is returned.
The aggregate `truncated` flag summarizes these observations. Comparisons retain
results for unaffected dimensions and mark affected dimensions unknown; an
@@ -435,12 +459,36 @@ Each filesystem checkpoint starts with a fresh `file_bytes` budget across all
selected roots; it is not a per-file allowance. File contents are read in
64 KiB chunks for hashing and are not embedded in capture Evidence.
Ownership inspections bind run-token reads to native start identities and retain
related generations through reparenting, group changes, omitted rows, and cleanup
retries. The captured run keeps one evidence owner through settlement and cleanup.
An absent or changed token leaves a previously related generation unresolved;
current token proof is still required before signaling. Swept cleanup verifies the
remaining family after its group signals. Coverage is bounded by the native
snapshots and generations inspected; sampled group IDs select candidates.
When the host withholds an unrelated process’s ownership token, REA leaves that
process untouched and records its PID and reason in `cleanup.unverified_processes`
and process residual unknowns. Successful cleanup verifies the owned group;
it does not attribute those unrelated processes. Related or otherwise unexplained
unreadable processes still prevent successful cleanup.
Failed cleanup retains its exact resources for the owning session to retry.
The CLI retries before projecting its error; MCP retries pending cleanup before
admitting another capture and during session cleanup. Once every attributed
owner confirms release, the live error status clears `cleanup_incomplete`.
The original failure, observations and `cleanup_report` retain the facts from
the earlier attempt, including any cleanup failure recorded then.
Snapshot file handles, directory handles, and the selected executable reader
follow the same ownership rule. A failed close preserves completed observations
and any primary read failure; it cannot turn a completed digest into a successful
capture with unresolved cleanup. Interrupted snapshots retain examined entries
with incomplete coverage in `partial_observation`, alongside the failure.
File-handle retry uses the exact retained owner. After a directory close rejects,
Node does not expose enough handle validity to authorize another native close;
REA retains that unresolved owner and blocks further captures in the session.
On macOS, changing a Node process's `process.title` can make its run token
unreadable. Node documents that [setting the title overwrites argv memory](https://nodejs.org/download/release/v24.18.0/docs/api/process.html#processtitle).
With the pinned toolchain, a live child retained a readable start identity
+3
View File
@@ -18,8 +18,11 @@ can run.
For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI flag is `--follow-debug-links`; `--no-follow-debug-links` or `--follow-debug-links=false` keeps the default. A separate `true` or `false` word after the flag is not its value. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema.
For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the installed GNU objdump's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed.
```sh
rea inspect-with-objdump ./program disassemble
rea inspect-with-objdump ./program disassemble --symbol my_function
rea inspect-with-objdump ./program dwarf
rea inspect-with-objdump ./program dwarf --follow-debug-links
```
@@ -36,7 +36,9 @@ later increment.
contracts and digests.
- Replay by selecting, among the tool's cited records, the one whose path
segments at the parameter indexes match the most supplied values, then the
most supplied query values, keeping manifest order on ties; return `status`,
most of those segments equal to the value as written rather than only after
percent-decoding, then the most supplied query values, keeping manifest order
on ties; return `status`,
`content_type`, `body` (parsed JSON for JSON-like content types that parse,
else text; bodies over 256 KiB cut at a UTF-8 boundary with
`truncated: true`) and `replay` with `record_ordinal`, `evidence_id`,
@@ -29,8 +29,8 @@ client registration keep stdio.
- Bridge node:http to web Request/Response in `src/server/http/`, streaming
response chunks as they are produced and turning a client disconnect into a
request abort and body cancellation.
- Classify a loopback bind by address class (`127.0.0.0/8`, `::1`, IPv4-mapped
loopback, `localhost` names) from the address the listener bound, validate
- Classify a loopback bind by the actual bound IP (`127.0.0.0/8`, `::1`, or
IPv4-mapped loopback), without using the configured DNS name as evidence; validate
the `Host` and `Origin` headers with the SDK loopback allowlist plus the
configured name and the bound address on such binds, and relax both for
other binds and log that relaxation. Refuse scoped IPv6 literals before
+10 -1
View File
@@ -92,7 +92,7 @@ without serial scheduling. Pure domain/service, composition, conformance,
evaluation, CLI boundary, and MCP boundary projects share module graphs. Their
tests avoid process-global state and keep resource cleanup test-scoped. Each
MCP session still owns its resources, and CLI cases retain fresh command
instances or subprocesses. CLI module mocks belong in `tests/process-global/`
instances or subprocesses. CLI and MCP module mocks belong in `tests/process-global/`
so they keep per-file isolation. Product catalog verification stays with the
isolated filesystem boundary tests because it loads both source and compiled
module graphs. Adapter, other boundary, acceptance, and process-global projects
@@ -251,6 +251,15 @@ lineage followed by a confirmed passive family census. Windows controls require
a fresh lineage observation and do not signal cached descendant PIDs after root
termination. Simulated Windows controls do not establish native Windows behavior.
Native process-ownership controls distinguish start identity, zombie lifecycle,
current parent/group relation, and token-read evidence. Retain generation-bound
candidates through smaller lineage observations, table omissions, reparenting,
group changes, and failed cleanup retries. A readable token with unavailable
identity stays unresolved. Native UID facts, rather than an earlier process-table
row, determine whether a token denial concerns another user. Darwin tests compile
the helper and exercise snapshot growth, absence, lifecycle, and UID observations;
controlled Linux procfs tests do not establish real Linux exit or PID-reuse behavior.
For atomic writes, exercise cancellation on both sides of the documented commit
point and preserve any publication receipt. A late interruption does not prove
that no output was published.
+6
View File
@@ -55,6 +55,12 @@ it as inline Evidence to `inspect-analysis-view`; the same summary and capture
page contracts apply. Large complete records can still exceed MCP's response
budget; export the retained Evidence when a single record cannot fit.
If decoding completes before cancellation or private-root cleanup fails, the
failure retains the completed capture as Evidence with the requested record
selection and explicit sensitivity exclusions. Earlier decode failures remain
primary. Failed cleanup stays linked to its exact owner, including through
redaction, and its live status clears only after a confirmed retry.
## Formats and upstream profiles
HAR uses unchanged `har-schema@2.0.0` draft-06 schemas, `ajv@8.20.0`,
+1
View File
@@ -4,6 +4,7 @@
"src/main.ts",
"src/cli.ts",
"src/composition/javascriptAnalysisWorkerProcess.ts",
"src/composition/webBundleAnalysisWorkerProcess.ts",
"src/contracts/errorSchemas.ts",
"src/evaluation/AgentEvaluationFixtures.ts",
"src/application/RealHopperAssertions.ts",
+2 -1
View File
@@ -35,7 +35,8 @@
},
"type": "module",
"imports": {
"#javascript-analysis-worker": "./dist/composition/javascriptAnalysisWorkerProcess.js"
"#javascript-analysis-worker": "./dist/composition/javascriptAnalysisWorkerProcess.js",
"#web-bundle-analysis-worker": "./dist/composition/webBundleAnalysisWorkerProcess.js"
},
"bin": {
"rea": "scripts/rea.mjs",
+365 -74
View File
@@ -5,6 +5,7 @@ const applyArguments = Function.prototype.apply;
const bindFunction = Function.prototype.bind;
const callFunction = Function.prototype.call;
const arrayFrom = Array.from;
const arrayIsArray = Array.isArray;
const defineProperty = Object.defineProperty;
const ownPropertyDescriptor = Object.getOwnPropertyDescriptor;
const getPrototypeOf = Object.getPrototypeOf;
@@ -20,11 +21,28 @@ const sliceArguments = bindBuiltinMethod(Array.prototype.slice);
const weakMapHas = bindBuiltinMethod(WeakMap.prototype.has);
const weakMapGet = bindBuiltinMethod(WeakMap.prototype.get);
const weakMapSet = bindBuiltinMethod(WeakMap.prototype.set);
const weakSetAdd = bindBuiltinMethod(WeakSet.prototype.add);
const weakSetHas = bindBuiltinMethod(WeakSet.prototype.has);
const setHas = bindBuiltinMethod(Set.prototype.has);
const setAdd = bindBuiltinMethod(Set.prototype.add);
const hasOwnProperty = bindBuiltinMethod(Object.prototype.hasOwnProperty);
const { isProxy } = require("node:util").types;
const { EventEmitter: NodeEventEmitter } = require("node:events");
const nodeEventEmitterMethods = Object.create(null);
for (const method of [
"on",
"once",
"off",
"removeListener",
"removeAllListeners",
]) {
const descriptor = ownPropertyDescriptor(NodeEventEmitter.prototype, method);
nodeEventEmitterMethods[method] =
descriptor !== undefined && hasOwnProperty(descriptor, "value")
? descriptor.value
: undefined;
}
const {
createElectronActiveBoundaryPatches,
} = require("./electron-active-hook-boundaries.cjs");
@@ -59,7 +77,7 @@ let correlationSequence = 0;
const shape = (value, depth = 0) => {
if (depth > 2 || value === null) return value === null ? "null" : "nested";
if (Array.isArray(value)) return "array";
if (arrayIsArray(value)) return "array";
switch (typeof value) {
case "string":
return "string";
@@ -215,12 +233,12 @@ const record = (event) => {
error: false,
...event,
};
const shapeGroups = Array.isArray(raw.argument_shape_value_groups)
const shapeGroups = arrayIsArray(raw.argument_shape_value_groups)
? raw.argument_shape_value_groups
: Array.isArray(raw.argument_shape_values)
: arrayIsArray(raw.argument_shape_values)
? [raw.argument_shape_values]
: [];
const existingShapes = Array.isArray(raw.argument_shapes)
const existingShapes = arrayIsArray(raw.argument_shapes)
? raw.argument_shapes
: [];
const retainedEvent = {
@@ -268,7 +286,7 @@ const record = (event) => {
retentionBudgetBytes;
};
for (const group of shapeGroups) {
if (!Array.isArray(group)) continue;
if (!arrayIsArray(group)) continue;
for (const argument of group) {
countShape(argument);
if (shapeOverflow) break;
@@ -318,7 +336,7 @@ const record = (event) => {
return null;
}
for (const group of shapeGroups) {
if (!Array.isArray(group)) continue;
if (!arrayIsArray(group)) continue;
for (const argument of group)
retainedEvent.argument_shapes.push(shape(argument));
}
@@ -337,7 +355,7 @@ const recordRuntime = (kind, event, phase, details = {}) =>
const recordIpc = (kind, channel, args, details = {}) =>
(() => {
const values = Array.isArray(args) ? args : [];
const values = arrayIsArray(args) ? args : [];
return record({
kind,
channel: typeof channel === "string" ? channel : null,
@@ -346,14 +364,41 @@ const recordIpc = (kind, channel, args, details = {}) =>
});
})();
const recordIpcObservationGap = (channel, event, error = false) =>
const recordIpcObservationGap = (
channel,
event,
error = false,
target = "ipcMain.on",
) =>
recordRuntime("ipc-observation-gap", event, error ? "failed" : "observed", {
channel,
target: "ipcMain.on",
target,
process_type: "main",
error,
});
const forwardUnobservedRegistration = (
method,
receiver,
args,
recordGap,
target,
) => {
const gap =
recordGap && typeof args[1] === "function"
? recordIpcObservationGap(args[0], "listener-registration", false, target)
: null;
try {
return applyFunction(method, receiver, args);
} catch (cause) {
if (gap !== null) {
gap.error = true;
gap.phase = "failed";
}
throw cause;
}
};
const patchEmitter = (emitter, observeEvent) => {
if (emitter === null || emitter === undefined) return;
const original = emitter.emit;
@@ -409,6 +454,7 @@ const invokeObservedEventListener = (thisArg, channel, listener, args) => {
// wrapper, or the wrapper itself for "once". Public .listener is mutable;
// it is not the authority for the original function's identity.
const hookListenerRemovalTargets = new WeakMap();
const knownCanonicalListeners = new WeakSet();
const MAX_LISTENER_CHAIN_HOPS = 16;
// Parse native property lookup without invoking application getters or Proxy
@@ -458,6 +504,50 @@ const classifyHookListenerWrapper = (listener) => {
return typeof candidate === "function" ? "unknown" : "unowned";
};
const createObservedEventListener = (channel, listener) => {
const wrapper = function observedEventListener() {
return invokeObservedEventListener(this, channel, listener, arguments);
};
defineProperty(wrapper, "apply", {
configurable: true,
writable: true,
value: function nativeApply(...methodArgs) {
let applicationApply;
try {
applicationApply = listener.apply;
} catch (cause) {
recordIpcObservationGap(channel, "custom-apply", true);
throw cause;
}
if (applicationApply !== applyArguments) {
const gap = recordIpcObservationGap(channel, "custom-apply");
try {
return applyFunction(
applicationApply,
this === wrapper ? listener : this,
methodArgs,
);
} catch (cause) {
if (gap !== null) {
gap.error = true;
gap.phase = "failed";
}
throw cause;
}
}
return applyFunction(applyArguments, this, methodArgs);
},
});
defineProperty(wrapper, "listener", {
configurable: true,
enumerable: true,
writable: true,
value: listener,
});
weakMapSet(hookListenerRemovalTargets, wrapper, listener);
return wrapper;
};
const createObservedOnceCallback = (target, channel, listener, receiver) => {
// A private callable target avoids Proxy get invariants on an app's
// non-configurable, read-only call/apply properties. It never escapes
@@ -517,7 +607,45 @@ const createObservedOnceCallback = (target, channel, listener, receiver) => {
const patchIpcMain = (ipcMain) => {
if (ipcMain === null || ipcMain === undefined) return;
const { handle, on, once, removeAllListeners } = ipcMain;
if (isProxy(ipcMain)) {
recordIpcObservationGap(null, "listener-registration");
return;
}
const methodDescriptors = Object.create(null);
let methodsKnown = true;
for (const method of [
"on",
"once",
"off",
"removeListener",
"removeAllListeners",
]) {
const descriptor = lookupPropertyDescriptor(ipcMain, method);
if (
descriptor === null ||
descriptor === undefined ||
!hasOwnProperty(descriptor, "value")
) {
methodsKnown = false;
methodDescriptors[method] = undefined;
} else {
methodDescriptors[method] = descriptor.value;
if (descriptor.value !== nodeEventEmitterMethods[method])
methodsKnown = false;
}
}
const on = methodDescriptors.on;
const once = methodDescriptors.once;
if (!methodsKnown && (typeof on !== "function" || typeof once !== "function"))
recordIpcObservationGap(null, "listener-registration");
const handle = ipcMain.handle;
const removeAllListeners = methodsKnown
? methodDescriptors.removeAllListeners
: undefined;
const nativeRemoveListener = methodsKnown
? methodDescriptors.removeListener
: undefined;
const nativeOff = methodsKnown ? methodDescriptors.off : undefined;
let unobservedOnceScope = null;
if (typeof handle === "function")
ipcMain.handle = function patchedIpcMainHandle(channel, listener) {
@@ -546,11 +674,28 @@ const patchIpcMain = (ipcMain) => {
};
if (typeof on !== "function") return;
ipcMain.on = function patchedIpcMainOn(channel, listener) {
if (this !== ipcMain)
return forwardUnobservedRegistration(
on,
this,
arguments,
true,
"ipcMain.on",
);
if (!methodsKnown) {
return forwardUnobservedRegistration(
on,
this,
arguments,
unobservedOnceScope === null,
"ipcMain.on",
);
}
if (typeof listener !== "function") return on.call(this, channel, listener);
if (unobservedOnceScope !== null) {
const scope = unobservedOnceScope;
const result = on.call(this, channel, listener);
recordIpcObservationGap(channel, "once-metadata");
recordIpcObservationGap(channel, "once-metadata", false, "ipcMain.once");
if (scope.channel === channel) scope.gapRecorded = true;
return result;
}
@@ -561,66 +706,48 @@ const patchIpcMain = (ipcMain) => {
recordIpcObservationGap(channel, "listener-chain");
return result;
}
const wrapped = function wrappedEventListener() {
return invokeObservedEventListener(this, channel, listener, arguments);
};
// Native single-listener dispatch reads .apply; array dispatch invokes the
// function directly. Let Node select that path without inspecting or
// copying the emitter's listener storage, including during reentrant emits.
defineProperty(wrapped, "apply", {
configurable: true,
writable: true,
value: function nativeApply(...methodArgs) {
let applicationApply;
try {
applicationApply = listener.apply;
} catch (cause) {
recordIpcObservationGap(channel, "custom-apply", true);
throw cause;
}
if (applicationApply !== applyArguments) {
// Custom apply owns the original list's interpretation. Forward it
// unchanged rather than executing getters to invent callback facts.
const gap = recordIpcObservationGap(channel, "custom-apply");
try {
return applyFunction(
applicationApply,
this === wrapped ? listener : this,
methodArgs,
);
} catch (cause) {
if (gap !== null) {
gap.error = true;
gap.phase = "failed";
}
throw cause;
}
}
// Native Function.apply performs its own list conversion before the
// recording wrapper observes an actual invocation frame.
// Borrowing the exposed method keeps its dynamic receiver.
return applyFunction(applyArguments, this, methodArgs);
},
});
defineProperty(wrapped, "listener", {
configurable: true,
enumerable: true,
writable: true,
value: listener,
});
weakMapSet(hookListenerRemovalTargets, wrapped, listener);
return on.call(this, channel, wrapped);
const wrapped = createObservedEventListener(channel, listener);
const result = on.call(this, channel, wrapped);
weakSetAdd(knownCanonicalListeners, listener);
return result;
};
// Native once owns firing, removal and callback dispatch. Node versions
// differ in whether they read callback.call/apply. Observe actual invocation
// through a private callable Proxy instead of copying either implementation.
if (typeof once === "function")
ipcMain.once = function patchedIpcMainOnce(channel, listener) {
if (
typeof listener !== "function" ||
this === null ||
(typeof this !== "object" && typeof this !== "function")
)
if (this !== ipcMain)
return forwardUnobservedRegistration(
once,
this,
arguments,
true,
"ipcMain.once",
);
if (!methodsKnown) {
if (typeof listener !== "function")
return forwardUnobservedRegistration(
once,
this,
arguments,
false,
"ipcMain.once",
);
const previousScope = unobservedOnceScope;
unobservedOnceScope = { channel };
try {
return forwardUnobservedRegistration(
once,
this,
arguments,
true,
"ipcMain.once",
);
} finally {
unobservedOnceScope = previousScope;
}
}
if (typeof listener !== "function")
return once.call(this, channel, listener);
const target = this;
const metadata = lookupPropertyDescriptor(
@@ -650,7 +777,12 @@ const patchIpcMain = (ipcMain) => {
// An app decorator can register through an unhooked alias and then
// throw. Preserve a gap for either outcome, not proof of registration.
if (!scope.gapRecorded)
recordIpcObservationGap(channel, "once-metadata", failed);
recordIpcObservationGap(
channel,
"once-metadata",
failed,
"ipcMain.once",
);
}
}
const receiver = new ProxyConstructor(
@@ -685,9 +817,169 @@ const patchIpcMain = (ipcMain) => {
receiver,
);
const result = applyFunction(once, receiver, [channel, callback]);
weakSetAdd(knownCanonicalListeners, listener);
return result === receiver ? target : result;
};
if (typeof removeAllListeners !== "function") return;
const selectCanonicalRemovalArgument = (receiver, channel, removalTarget) => {
if (isProxy(receiver)) return { kind: "unknown" };
const eventMapDescriptor = ownPropertyDescriptor(receiver, "_events");
if (
eventMapDescriptor === undefined ||
!hasOwnProperty(eventMapDescriptor, "value")
)
return { kind: "unknown" };
const eventMap = eventMapDescriptor.value;
if (eventMap === undefined) return { kind: "absent" };
if (
eventMap === null ||
typeof eventMap !== "object" ||
isProxy(eventMap) ||
getPrototypeOf(eventMap) !== null
)
return { kind: "unknown" };
const eventDescriptor = ownPropertyDescriptor(eventMap, channel);
if (eventDescriptor === undefined) return { kind: "absent" };
if (!hasOwnProperty(eventDescriptor, "value")) return { kind: "unknown" };
const list = eventDescriptor.value;
if (list === undefined) return { kind: "absent" };
const examineEntry = (entry) => {
if (entry === removalTarget)
return { kind: "matched", nativeArgument: removalTarget };
const ownedTarget = weakMapGet(hookListenerRemovalTargets, entry);
if (ownedTarget === removalTarget) {
const descriptor = listenerDataDescriptor(entry);
return {
kind: "matched",
nativeArgument:
descriptor !== null &&
descriptor !== undefined &&
hasOwnProperty(descriptor, "value") &&
descriptor.value === removalTarget
? removalTarget
: entry,
};
}
const descriptor = listenerDataDescriptor(entry);
// An application getter, Proxy or opaque descriptor may match or throw
// during native removal. Do not pre-read it or choose an earlier entry;
// let Node execute the original removal path exactly once.
if (descriptor === null) return { kind: "unknown" };
const linkedTarget = descriptor?.value;
if (linkedTarget === removalTarget)
return { kind: "matched", nativeArgument: removalTarget };
if (
weakMapGet(hookListenerRemovalTargets, linkedTarget) === removalTarget
)
return { kind: "matched", nativeArgument: linkedTarget };
return null;
};
if (typeof list === "function")
return examineEntry(list) ?? { kind: "absent" };
if (isProxy(list)) return { kind: "unknown" };
if (!arrayIsArray(list)) return { kind: "unknown" };
const lengthDescriptor = ownPropertyDescriptor(list, "length");
if (
lengthDescriptor === undefined ||
!hasOwnProperty(lengthDescriptor, "value") ||
typeof lengthDescriptor.value !== "number" ||
lengthDescriptor.value < 0 ||
lengthDescriptor.value > 0xffffffff ||
lengthDescriptor.value % 1 !== 0
)
return { kind: "unknown" };
for (let index = lengthDescriptor.value - 1; index >= 0; index -= 1) {
const entryDescriptor = ownPropertyDescriptor(list, index);
if (
entryDescriptor === undefined ||
!hasOwnProperty(entryDescriptor, "value") ||
typeof entryDescriptor.value !== "function"
)
return { kind: "unknown" };
const selected = examineEntry(entryDescriptor.value);
if (selected !== null) return selected;
}
return { kind: "absent" };
};
const removeWithCanonicalOwnership = (
nativeMethod,
receiver,
args,
boundary,
) => {
const channel = args[0];
const removalTarget = args[1];
if (
receiver !== ipcMain ||
(typeof channel !== "string" && typeof channel !== "symbol") ||
!weakSetHas(knownCanonicalListeners, removalTarget)
)
return applyFunction(nativeMethod, receiver, args);
const selected = selectCanonicalRemovalArgument(
receiver,
channel,
removalTarget,
);
if (selected.kind === "unknown")
recordIpcObservationGap(
typeof channel === "string" ? channel : null,
"listener-removal",
false,
boundary,
);
if (
selected.kind !== "matched" ||
selected.nativeArgument === removalTarget
)
return applyFunction(nativeMethod, receiver, args);
// Native removal must receive the argument identity that proves its newest
// match. Preserve native ownership of traversal, mutation and meta-events.
const nativeReceiver = new ProxyConstructor(
{ __proto__: null },
{
get(_target, key) {
const value = getProperty(receiver, key, receiver);
if (key !== "emit" || typeof value !== "function") return value;
return function emitWithCanonicalRemovalMeta(event, ...eventArgs) {
if (event === "removeListener") {
if (eventArgs[1] === selected.nativeArgument) {
eventArgs[1] = removalTarget;
} else {
const canonical = weakMapGet(
hookListenerRemovalTargets,
eventArgs[1],
);
if (canonical !== undefined && canonical !== eventArgs[1])
eventArgs[1] = canonical;
}
}
return applyFunction(value, receiver, [event, ...eventArgs]);
};
},
set(_target, key, value) {
return setProperty(receiver, key, value, receiver);
},
},
);
const nativeArgs = [channel, selected.nativeArgument];
for (let index = 2; index < args.length; index += 1)
nativeArgs[index] = args[index];
const result = applyFunction(nativeMethod, nativeReceiver, nativeArgs);
return result === nativeReceiver ? receiver : result;
};
if (methodsKnown && typeof nativeRemoveListener === "function")
ipcMain.removeListener = function patchedIpcMainRemoveListener(...args) {
return removeWithCanonicalOwnership(
nativeRemoveListener,
this,
args,
"ipcMain.removeListener",
);
};
if (methodsKnown && typeof nativeOff === "function")
ipcMain.off = function patchedIpcMainOff(...args) {
return removeWithCanonicalOwnership(nativeOff, this, args, "ipcMain.off");
};
if (!methodsKnown || typeof removeAllListeners !== "function") return;
// Node owns its live-array traversal. A forwarding receiver translates only
// our "on" entries before calling the current application removal method,
// including decorators installed/replaced during traversal. Application
@@ -697,6 +989,8 @@ const patchIpcMain = (ipcMain) => {
...args
) {
const emitter = this;
if (emitter !== ipcMain)
return applyFunction(removeAllListeners, emitter, args);
// A separate empty target also permits forwarding a non-writable app
// method; a Proxy of the emitter itself would violate get invariants.
const receiver = new ProxyConstructor(
@@ -716,13 +1010,10 @@ const patchIpcMain = (ipcMain) => {
hookListenerRemovalTargets,
entry,
);
// Translate only a still-valid data link. If app code changed it
// to an accessor, native removal owns when that getter executes.
if (
removalTarget !== undefined &&
removalTarget !== entry &&
listenerDataDescriptor(entry)?.value === removalTarget
)
// The registration registry owns the original callback even
// when its public data link was deleted or changed. The native
// boundary below still owns any accessor read and its outcome.
if (removalTarget !== undefined && removalTarget !== entry)
methodArgs[1] = removalTarget;
}
return applyFunction(value, emitter, methodArgs);
+14 -3
View File
@@ -26,6 +26,7 @@ import {
summarizeFactualCorrectness,
} from "../dist/evaluation/AgentEvaluationReport.js";
import { PRODUCT_IDENTITY } from "../dist/identity.js";
import { closeVerifierResources } from "./lib/verifier-cleanup.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -68,6 +69,7 @@ const evaluationEnvironment = {
PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ""}`,
};
let primaryError;
try {
const codexVersion = await readCommandVersion(codex);
await Promise.all([
@@ -391,11 +393,20 @@ try {
throw new Error(
`Agent routing/workflow/answer checks failed: ${failed.map(({ id }) => id).join(", ")}`,
);
} catch (cause) {
primaryError = cause;
throw cause;
} finally {
// Even a deliberately retained fixture directory must never retain auth.
await rm(authPath, { force: true });
if (process.env.REA_AGENT_EVAL_KEEP_FIXTURES !== "true")
await rm(evaluationRoot, { recursive: true, force: true });
await closeVerifierResources(
[
() => rm(authPath, { force: true }),
...(process.env.REA_AGENT_EVAL_KEEP_FIXTURES === "true"
? []
: [() => rm(evaluationRoot, { recursive: true, force: true })]),
],
primaryError,
);
}
async function createTargets(root, includeManaged) {
+12 -1
View File
@@ -160,7 +160,18 @@ const browserParameters = (
}
: {}),
...("fetch_source_maps" in input
? { fetch_source_maps: input.fetch_source_maps }
? {
fetch_source_maps: input.fetch_source_maps,
...(input.max_heap_mb === undefined
? {}
: { max_heap_mb: input.max_heap_mb }),
...(input.analysis_timeout_ms === undefined
? {}
: { analysis_timeout_ms: input.analysis_timeout_ms }),
...(input.total_analysis_timeout_ms === undefined
? {}
: { total_analysis_timeout_ms: input.total_analysis_timeout_ms }),
}
: {}),
};
};
@@ -137,8 +137,8 @@ it("retains captured page Evidence when bundle analysis fails after capture", as
"cancelled",
{ partialObservation: inspection },
);
const browser = {
identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }),
const browser: BrowserObservationPort = {
...provider(async () => ok(report)),
analyzeBundle: async () => ({ ok: false as const, error: failure }),
} as unknown as BrowserObservationPort;
const bundleInput = analyzeWebBundleInputSchema.parse({
@@ -159,3 +159,59 @@ it("retains captured page Evidence when bundle analysis fails after capture", as
});
}
});
it("retains completed bundle facts as bundle Evidence after cancellation", async () => {
const { analyzeCapturedWebBundle } =
await import("../domain/webBundleAnalyzer.js");
const { AnalysisCancelledError } =
await import("../domain/analysisErrorCore.js");
const bundle = analyzeCapturedWebBundle(passiveNetworkCapture());
const reason = { requested: "stop after completed script" };
const failure = new AnalysisCancelledError("analyze_web_bundle", {
cause: reason,
partialObservation: bundle,
});
const browser: BrowserObservationPort = {
...provider(async () => ok(report)),
analyzeBundle: async () => ({ ok: false as const, error: failure }),
};
const selected = analyzeWebBundleInputSchema.parse({
cdp_endpoint: input.cdp_endpoint.recorded,
target_id: "page",
max_heap_mb: 256,
analysis_timeout_ms: 100,
total_analysis_timeout_ms: 1000,
});
const result = await analyzeWebBundle(browser, selected);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBe(failure);
expect(result.error.cause).toBe(reason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "analyze_web_bundle",
parameters: expect.objectContaining({
max_heap_mb: 256,
analysis_timeout_ms: 100,
total_analysis_timeout_ms: 1000,
}),
normalized_result: bundle,
});
});
it("preserves a prior caller reason without admitting browser work", async () => {
const reason = new AnalysisOutputError(
"inspect_web_page",
"caller supplied reason",
);
const result = await listBrowserTargets(
provider(async () => {
throw new Error("Cancelled inventory must not start");
}),
input,
{ signal: AbortSignal.abort(reason) },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancelled inventory");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
});
+71 -11
View File
@@ -3,7 +3,10 @@ import type {
InspectWebPageInput,
ListBrowserTargetsInput,
} from "../domain/browserObservation.js";
import type { AnalyzeWebBundleInput } from "../domain/webBundleAnalysis.js";
import {
webBundleAnalysisSchema,
type AnalyzeWebBundleInput,
} from "../domain/webBundleAnalysis.js";
import type { ObserveWebSessionInput } from "../domain/browserSession.js";
import type { DiscoverWebMcpToolsInput } from "../domain/webMcpDiscovery.js";
import type { BrowserCaptureComparisonInput } from "../domain/browserCaptureComparison.js";
@@ -24,6 +27,7 @@ import { createBrowserEvidence } from "./BrowserEvidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
/** List browser targets within the endpoint and origin scope in the request. */
export const listBrowserTargets = async (
@@ -34,7 +38,11 @@ export const listBrowserTargets = async (
const ready = requireBrowser(browser, "list_browser_targets");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("list_browser_targets"));
return err(
new AnalysisCancelledError("list_browser_targets", {
cause: options.signal.reason,
}),
);
const result = await ready.value.listTargets(input, options);
return result.ok
? browserEvidence(
@@ -56,10 +64,14 @@ export const inspectWebPage = async (
const ready = requireBrowser(browser, "inspect_web_page");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("inspect_web_page"));
return err(
new AnalysisCancelledError("inspect_web_page", {
cause: options.signal.reason,
}),
);
const result = await ready.value.inspectPage(input, options);
if (!result.ok)
return retainPageInspection(
return retainBrowserObservation(
result.error,
input,
ready.value,
@@ -85,10 +97,14 @@ export const analyzeWebBundle = async (
const ready = requireBrowser(browser, "analyze_web_bundle");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("analyze_web_bundle"));
return err(
new AnalysisCancelledError("analyze_web_bundle", {
cause: options.signal.reason,
}),
);
const analyzed = await ready.value.analyzeBundle(input, options);
if (!analyzed.ok)
return retainPageInspection(
return retainBrowserObservation(
analyzed.error,
input,
ready.value,
@@ -112,7 +128,11 @@ export const observeWebSession = async (
const ready = requireBrowser(browser, "observe_web_session");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("observe_web_session"));
return err(
new AnalysisCancelledError("observe_web_session", {
cause: options.signal.reason,
}),
);
const observed = await ready.value.observeSession(input, options);
return observed.ok
? browserEvidence(
@@ -134,7 +154,11 @@ export const discoverWebMcpTools = async (
const ready = requireBrowser(browser, "discover_webmcp_tools");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("discover_webmcp_tools"));
return err(
new AnalysisCancelledError("discover_webmcp_tools", {
cause: options.signal.reason,
}),
);
const discovered = await ready.value.discoverWebMcpTools(input, options);
return discovered.ok
? browserEvidence(
@@ -176,7 +200,11 @@ export const captureWebScreenshot = async (
const ready = requireBrowser(browser, "capture_web_screenshot");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("capture_web_screenshot"));
return err(
new AnalysisCancelledError("capture_web_screenshot", {
cause: options.signal.reason,
}),
);
const captured = await ready.value.captureScreenshot(input, options);
return captured.ok
? browserEvidence(
@@ -247,14 +275,46 @@ const browserEvidence = (
);
};
/** Convert a provider-retained page capture without claiming bundle analysis completed. */
const retainPageInspection = (
/** Retain the completed capture or bundle facts under their actual operation. */
const retainBrowserObservation = (
error: AnalysisError,
input: InspectWebPageInput | AnalyzeWebBundleInput,
provider: BrowserObservationPort,
errorOperation: "inspect_web_page" | "analyze_web_bundle",
): Result<Evidence, AnalysisError> => {
const partial = error.partialObservation;
if (
errorOperation === "analyze_web_bundle" &&
partial !== undefined &&
!("evidence_id" in partial) &&
"observations" in partial &&
"capture" in partial
) {
const bundle = webBundleAnalysisSchema.safeParse(partial);
if (!bundle.success) {
const malformed = new AnalysisOutputError(
errorOperation,
"Browser provider retained malformed completed bundle facts.",
{ cause: bundle.error },
);
return err(
error.cleanupIncomplete
? analysisErrorWithCleanupFailure(
malformed,
error,
errorOperation,
).bindCleanupDependencies([malformed, error])
: malformed,
);
}
error.partialObservation = createBrowserEvidence(
"analyze_web_bundle",
input,
bundle.data,
provider.identity(),
);
return err(error);
}
if (
partial === undefined ||
"evidence_id" in partial ||
@@ -0,0 +1,89 @@
import { expect, it, vi } from "vitest";
import { z } from "zod";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { executeProviderOperation } from "./ProviderOperationExecution.js";
it("projects request-schema issues without exposing the request envelope", async () => {
const execute = vi.fn();
const result = await executeProviderOperation(
"list_jeb_units",
{ parent_unit_path: "" },
undefined,
z.strictObject({
operation: z.literal("list_jeb_units"),
input: z.strictObject({ parent_unit_path: z.string().min(1) }),
}),
{ execute },
"observed",
);
expect(result.ok).toBe(false);
if (result.ok || !(result.error instanceof AnalysisInputError))
throw new Error("Expected input error");
expect(result.error.issues).toEqual([
{
path: ["parent_unit_path"],
reason: "out_of_range",
minimum: 1,
},
]);
expect(result.error.cause).toBeInstanceOf(z.ZodError);
expect(execute).not.toHaveBeenCalled();
});
it("removes only the owned envelope prefix while preserving nested caller paths", async () => {
const execute = vi.fn();
const result = await executeProviderOperation(
"inspect",
{ input: "", rows: [{}], mode: "foreign", extra: "caller-only-marker" },
undefined,
z.strictObject({
operation: z.literal("inspect"),
input: z.strictObject({
input: z.string().min(1),
rows: z.array(z.strictObject({ name: z.string() })),
mode: z.enum(["first", "second"]),
}),
}),
{ execute },
"observed",
);
if (result.ok || !(result.error instanceof AnalysisInputError))
throw new Error("Expected input error");
expect(result.error.issues).toEqual([
{ path: ["input"], reason: "out_of_range", minimum: 1 },
{
path: ["rows", 0, "name"],
reason: "missing_argument",
expected: "string",
},
{ path: ["mode"], reason: "invalid_value", expected: ["first", "second"] },
{ path: ["extra"], reason: "unknown_argument" },
]);
expect(JSON.stringify(result.error.issues)).not.toContain(
"caller-only-marker",
);
expect(execute).not.toHaveBeenCalled();
});
it("preserves operation correction outside the input envelope", async () => {
const execute = vi.fn();
const result = await executeProviderOperation(
"unknown",
{},
undefined,
z.strictObject({
operation: z.literal("known"),
input: z.strictObject({}),
}),
{ execute },
"observed",
);
if (result.ok || !(result.error instanceof AnalysisInputError))
throw new Error("Expected input error");
expect(result.error.issues).toEqual([
{ path: ["operation"], reason: "invalid_value", expected: ["known"] },
]);
expect(execute).not.toHaveBeenCalled();
});
+13 -1
View File
@@ -8,6 +8,7 @@ import {
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
import type { Evidence } from "../domain/evidence.js";
import { projectInputIssues } from "../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../domain/result.js";
import {
createAnalysisExecutionEvidence,
@@ -52,7 +53,18 @@ export const executeProviderOperation = async <
);
const request = schema.safeParse({ operation, input });
if (!request.success)
return err(new AnalysisInputError(operation, { cause: request.error }));
return err(
new AnalysisInputError(
operation,
{ cause: request.error },
projectInputIssues(request.error.issues, { operation, input }).map(
(issue) =>
issue.path[0] === "input"
? { ...issue, path: issue.path.slice(1) }
: issue,
),
),
);
const executed = await provider.execute(request.data, options);
if (!executed.ok) return executed;
const evidence = createAnalysisExecutionEvidence(
@@ -419,6 +419,10 @@ export const parseReferenceSourceEntries = (
parseFailures: [],
limitations: [...read.limitations],
};
if (read.rootFailure !== undefined)
output.limitations.push(
`Reference root enumeration is incomplete: ${read.rootFailure.message}`,
);
const packageMainInventory = readPackageMains(read);
const context: SpecifierContext = {
lookupPath: createReferenceSourcePathLookup(filePaths),
+7 -12
View File
@@ -8,7 +8,10 @@ import {
} from "./HermesClientHome.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { resolveWindowsClaudeDesktopLocation } from "./ClaudeDesktopConfigLocation.js";
import type { MsixPackageView } from "./MsixPackageStorage.js";
import {
type MsixPackageView,
windowsAppDataRoots,
} from "./MsixPackageStorage.js";
/** One supported client configuration location. */
export interface SetupClient {
@@ -117,11 +120,7 @@ const vscodeUserDirectory = ({
env,
}: ClientPathContext): string => {
if (platform === "win32")
return join(
env.APPDATA ?? join(home, "AppData", "Roaming"),
"Code",
"User",
);
return join(windowsAppDataRoots(home, env).roaming, "Code", "User");
if (platform === "darwin")
return join(home, "Library", "Application Support", "Code", "User");
return join(xdgConfigHome({ home, platform, env }), "Code", "User");
@@ -133,11 +132,7 @@ const traeUserDirectory = ({
env,
}: ClientPathContext): string => {
if (platform === "win32")
return join(
env.APPDATA ?? join(home, "AppData", "Roaming"),
"Trae",
"User",
);
return join(windowsAppDataRoots(home, env).roaming, "Trae", "User");
if (platform === "darwin")
return join(home, "Library", "Application Support", "Trae", "User");
return join(xdgConfigHome({ home, platform, env }), "Trae", "User");
@@ -479,7 +474,7 @@ const geminiSettings = ({
const devinDirectory = ({ home, platform, env }: ClientPathContext): string =>
platform === "win32"
? join(env.APPDATA ?? join(home, "AppData", "Roaming"), "devin")
? join(windowsAppDataRoots(home, env).roaming, "devin")
: join(env.XDG_CONFIG_HOME || join(home, ".config"), "devin");
/** Stable product metadata used to derive setup discovery and documentation. */
+40 -38
View File
@@ -100,11 +100,17 @@ const systemFileSystem = (
removeEmptyDirectory: (path) => rmdir(path),
});
/** One explicitly classified uninstall action. */
export interface UninstallItem {
readonly name: string;
/** One completed or refused action, preserving its candidate configuration path. */
export interface UninstallAction {
readonly status: "removed" | "retained" | "skipped" | "failed";
readonly detail: string;
readonly configuration_path?: string;
}
/** One client summary with its constituent actions when multiple paths apply. */
export interface UninstallItem extends UninstallAction {
readonly name: string;
readonly actions?: readonly UninstallAction[];
}
/** Structured, idempotent result of removing REA-owned state. */
@@ -143,7 +149,8 @@ export const runUninstall = async (
const blocked: UninstallItem[] = [];
for (const client of clients) {
const blocking = await host.inspectClient(client);
if (blocking !== undefined) blocked.push(blocking);
if (blocking !== undefined)
blocked.push({ ...blocking, configuration_path: client.configPath });
}
const skillBlocker = await host.inspectSkill?.();
if (skillBlocker !== undefined) blocked.push(skillBlocker);
@@ -211,7 +218,7 @@ const removeAll = async (
const items: UninstallItem[] = [];
for (const client of clients) {
const removed = await host.removeClient(client);
items.push(removed);
items.push({ ...removed, configuration_path: client.configPath });
if (removed.status === "failed")
return [
...items,
@@ -1062,47 +1069,42 @@ const item = (
/**
* Layered clients are removed file by file, but callers look up one status by
* client name. A missing earlier file must not hide a later removal.
* client name. Aggregate status does not invalidate any constituent outcome.
*/
const collapseUninstallItems = (
items: readonly UninstallItem[],
): UninstallItem[] => {
const collapsed: UninstallItem[] = [];
const grouped = new Map<string, UninstallAction[]>();
for (const next of items) {
const index = collapsed.findIndex(({ name }) => name === next.name);
const current = collapsed[index];
if (current === undefined) {
collapsed.push(next);
continue;
}
collapsed[index] = mergeUninstallItems(current, next);
const { name, actions, ...action } = next;
const group = grouped.get(name) ?? [];
group.push(
...(actions !== undefined && actions.length > 0 ? actions : [action]),
);
grouped.set(name, group);
}
return collapsed;
return [...grouped].map(([name, actions]) => {
const first = actions[0];
if (first === undefined)
throw new Error("Uninstall outcome group has no actions.");
if (actions.length === 1) return { name, ...first };
const status = actions.reduce(
(selected, action) =>
uninstallStatusPriority(action.status) >
uninstallStatusPriority(selected)
? action.status
: selected,
first.status,
);
return {
name,
status,
detail: [...new Set(actions.map(({ detail }) => detail))].join(" "),
actions,
};
});
};
const mergeUninstallItems = (
current: UninstallItem,
next: UninstallItem,
): UninstallItem => {
const status = preferredUninstallStatus(current.status, next.status);
const details = [current, next]
.filter(
(entry) =>
entry.status === status ||
(status === "removed" && entry.status === "retained"),
)
.map(({ detail }) => detail);
return item(current.name, status, [...new Set(details)].join(" "));
};
const preferredUninstallStatus = (
current: UninstallItem["status"],
next: UninstallItem["status"],
): UninstallItem["status"] =>
uninstallStatusPriority(next) > uninstallStatusPriority(current)
? next
: current;
const uninstallStatusPriority = (status: UninstallItem["status"]): number => {
switch (status) {
case "failed":
@@ -1,7 +1,7 @@
import { expect, it } from "vitest";
import { WebNetworkCaptureService } from "./WebNetworkCaptureService.js";
import { historicalHar } from "../../tests/fixtures/historicalHar.js";
import { ok } from "../domain/result.js";
import { err, ok } from "../domain/result.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { parseEvidence } from "../domain/evidence.js";
import {
@@ -12,6 +12,11 @@ import {
webNetworkCaptureSchema,
type WebNetworkCapture,
} from "../domain/webNetworkCapture.js";
import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
} from "../artifacts/AnalysisCleanupLease.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
it.each(["REDACTED", "[", "]", "/capture.har"])(
"excludes sensitive path text even when a replacement marker would collide: %s",
@@ -166,6 +171,100 @@ it("retains valid completed capture Evidence on late cancellation and preserves
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
});
it("projects completed raw capture facts on failure through the caller's selection and sensitivity rules", async () => {
const report = fixture();
report.artifact.path = "/private-marker/capture.har";
report.limitations = ["private-marker producer limitation"];
const reason = { request: "cancel after decoder completion" };
const error = new AnalysisCancelledError("inspect_web_network_capture", {
cause: reason,
partialObservation: report,
});
const result = await new WebNetworkCaptureService({
inspect: () => Promise.resolve({ ok: false, error }),
}).inspect({
capture_path: report.artifact.path,
format: "har",
record_ordinals: [0],
sensitive_values: ["private-marker"],
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Cancellation must remain the primary result");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
const partial = parseEvidence(result.error.partialObservation);
expect(partial).toMatchObject({
operation: "inspect_web_network_capture",
parameters: { record_ordinals: [0] },
subject: { local_path: "" },
normalized_result: {
artifact: { path: "", sha256: "a".repeat(64) },
records: [{ ordinal: 0 }],
},
});
expect(JSON.stringify(partial)).not.toContain("private-marker");
});
it("does not expose a malformed capture-shaped partial observation", async () => {
const malformed = fixture();
malformed.artifact.path = "/private-marker";
malformed.artifact.sha256 = "invalid";
const error = new AnalysisCancelledError("inspect_web_network_capture", {
partialObservation: malformed,
});
const result = await new WebNetworkCaptureService({
inspect: () => Promise.resolve({ ok: false, error }),
}).inspect({
capture_path: "/capture.har",
format: "har",
sensitive_values: ["private-marker"],
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.partialObservation).toBeUndefined();
});
it("keeps a redacted cleanup error linked to its live owner through service close", async () => {
const report = fixture();
const cleanupPath = "/tmp/private-marker-capture-root";
const lease = new AnalysisCleanupLease(async () => {}, [cleanupPath]);
const original = new ProviderCleanupError(
"test-capture",
[cleanupPath],
{ reason: "private-marker cleanup remains pending" },
{
operation: "inspect_web_network_capture",
partialObservation: report,
},
);
new AnalysisCleanupLeaseGroup(original, [lease]);
const service = new WebNetworkCaptureService({
inspect: () => Promise.resolve(err(original)),
close: () => lease.release(),
});
const result = await service.inspect({
capture_path: report.artifact.path,
format: "har",
sensitive_values: ["private-marker"],
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Retained cleanup must remain a failure");
const returnedError = result.error;
expect(returnedError.cleanupIncomplete).toBe(true);
expect(returnedError.cleanupResources).toEqual([
"/tmp/[REDACTED]-capture-root",
]);
expect(JSON.stringify(projectAnalysisError(returnedError))).not.toContain(
"private-marker",
);
expect(parseEvidence(returnedError.partialObservation)).toMatchObject({
operation: "inspect_web_network_capture",
});
await service.close();
expect(returnedError.cleanupIncomplete).toBe(false);
expect(returnedError.cleanupResources).toEqual([]);
});
it.each([
["producer"],
["artifact"],
@@ -379,3 +478,68 @@ it.each(["12345", "67890", "234", "789"])(
expect(parseEvidence(result.value)).toEqual(result.value);
},
);
it("preserves a pre-abort reason without entering the capture adapter", async () => {
const reason = new Error("stop historical capture inspection");
const controller = new AbortController();
controller.abort(reason);
let providerCalls = 0;
const service = new WebNetworkCaptureService({
inspect: async () => {
providerCalls += 1;
return ok(fixture());
},
});
const result = await service.inspect(
{
capture_path: "/capture.har",
format: "har",
sensitive_values: [],
},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
}
expect(providerCalls).toBe(0);
});
it("reports malformed retained facts while keeping their cleanup owner retryable", async () => {
const report = fixture();
report.artifact.sha256 = "invalid";
const lease = new AnalysisCleanupLease(async () => {}, [
"/tmp/private-marker-root",
]);
const cleanup = new ProviderCleanupError(
"test-historical-port",
["/tmp/private-marker-root"],
{ reason: "close denied" },
{ operation: "inspect_web_network_capture", partialObservation: report },
);
new AnalysisCleanupLeaseGroup(cleanup, [lease]);
const service = new WebNetworkCaptureService({
inspect: async () => err(cleanup),
close: async () => {
await lease.release();
},
});
const result = await service.inspect({
capture_path: "/capture.har",
format: "har",
sensitive_values: ["private-marker"],
});
if (result.ok) throw new Error("Expected malformed retained evidence");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisOutputError);
expect(result.error.partialObservation).toBeUndefined();
expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain(
"private-marker",
);
await service.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(result.error).code).toBe("unreadable_output");
});
+62 -8
View File
@@ -19,8 +19,11 @@ import {
inspectWebNetworkCaptureInputSchema,
captureSensitiveValues,
webNetworkCaptureSchema,
type InspectWebNetworkCaptureInput,
type WebNetworkCapture,
} from "../domain/webNetworkCapture.js";
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
const OPERATION = "inspect_web_network_capture";
@@ -39,7 +42,11 @@ export class WebNetworkCaptureService {
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
return err(
new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
}),
);
const failure = (error: AnalysisError): Result<never, AnalysisError> =>
err(redactExplicitFailure(error, captureSensitiveValues(rawInput)));
const parsed = inspectWebNetworkCaptureInputSchema.safeParse(rawInput);
@@ -74,7 +81,37 @@ export class WebNetworkCaptureService {
]),
);
const loaded = await this.port.inspect(input, options);
if (!loaded.ok) return failure(loaded.error);
if (!loaded.ok) {
const partial = webNetworkCaptureSchema.safeParse(
loaded.error.partialObservation,
);
let partialFailure: AnalysisError | undefined;
if (partial.success) {
const projected = this.#projectCapture(input, partial.data);
if (projected.ok) loaded.error.partialObservation = projected.value;
else partialFailure = projected.error;
} else if (isCapturePartialCandidate(loaded.error.partialObservation)) {
partialFailure = new AnalysisOutputError(
OPERATION,
"Capture adapter retained malformed historical evidence.",
{ cause: partial.error },
);
}
if (partialFailure !== undefined) {
loaded.error.partialObservation = undefined;
partialFailure.retainSecondaryFailure(loaded.error);
return failure(
loaded.error.cleanupIncomplete
? analysisErrorWithCleanupFailure(
partialFailure,
loaded.error,
OPERATION,
).bindCleanupDependencies([partialFailure, loaded.error])
: partialFailure,
);
}
return failure(loaded.error);
}
const report = webNetworkCaptureSchema.safeParse(loaded.value);
if (!report.success)
return failure(
@@ -83,10 +120,18 @@ export class WebNetworkCaptureService {
"Capture adapter returned malformed historical evidence.",
),
);
const value = report.data;
const projected = this.#projectCapture(input, report.data);
if (!projected.ok) return failure(projected.error);
return completeProviderEvidence(projected.value, options?.signal);
}
#projectCapture(
input: InspectWebNetworkCaptureInput,
value: WebNetworkCapture,
): Result<Evidence, AnalysisError> {
const artifactSha256 = value.artifact.sha256;
if (artifactSha256 === null)
return failure(
return err(
new AnalysisOutputError(
OPERATION,
"Capture adapter omitted its observed artifact digest before explicit projection.",
@@ -98,7 +143,7 @@ export class WebNetworkCaptureService {
value.total_records !== value.records.length ||
value.records.some((record, index) => record.ordinal !== index)
)
return failure(
return err(
new AnalysisOutputError(
OPERATION,
"Capture adapter changed the selected artifact, format or original record sequence.",
@@ -113,7 +158,7 @@ export class WebNetworkCaptureService {
for (const ordinal of selected) {
const record = safeValue.records[ordinal];
if (record === undefined)
return failure(
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["record_ordinals"],
@@ -158,7 +203,7 @@ export class WebNetworkCaptureService {
record_ordinals: selected,
sensitive_value_count: input.sensitive_values.length,
});
return completeProviderEvidence(
return ok(
createEvidence(
safeSha256 === null
? undefined
@@ -201,7 +246,16 @@ export class WebNetworkCaptureService {
safePath === "" ? [] : [{ kind: "artifact-path", path: safePath }],
},
),
options?.signal,
);
}
}
const isCapturePartialCandidate = (value: unknown): boolean => {
if (typeof value !== "object" || value === null || Array.isArray(value))
return false;
const candidate = value as Record<string, unknown>;
return (
"artifact" in candidate &&
["format", "records", "decoder"].some((field) => field in candidate)
);
};
+6 -1
View File
@@ -29,10 +29,15 @@ it("rejects malformed input and prior cancellation without calling a provider",
if (malformed.ok)
throw new Error("Invalid endpoint must fail before execution");
expect(malformed.error._tag).toBe("AnalysisInputError");
const cancelled = await service.inspect({}, { signal: AbortSignal.abort() });
const reason = { requested: "stop before provider admission" };
const cancelled = await service.inspect(
{},
{ signal: AbortSignal.abort(reason) },
);
if (cancelled.ok)
throw new Error("Prior cancellation must fail before inspection");
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
expect(cancelled.error.cause).toBe(reason);
});
it("preserves actual provider failures and rejects changed target/selector identities", async () => {
+3 -1
View File
@@ -187,7 +187,9 @@ const parseInput = <T>(
options: ExecutionOptions | undefined,
): Result<T, AnalysisError> => {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(operation));
return err(
new AnalysisCancelledError(operation, { cause: options.signal.reason }),
);
const parsed = schema.safeParse(rawInput);
return parsed.success
? ok(parsed.data)
+44 -3
View File
@@ -212,7 +212,7 @@ describe("workflow residual question retention", () => {
expect(recordWorkflowUnknowns(input).ok).toBe(true);
expect(records.listUnknowns()).toMatchObject([
{
question: result.residual_unknowns[0],
question: `${result.residual_unknowns[0]} (Evidence ${evidence.evidence_id})`,
domain: "native-api",
supporting_evidence_ids: [evidence.evidence_id],
required_authority: "shipped-artifact",
@@ -220,6 +220,47 @@ describe("workflow residual question retention", () => {
]);
});
it("retains one identical residual question per distinct workflow Evidence", () => {
const records = new InvestigationRecords();
const result = {
residual_unknowns: ["Which provider resolves this boundary?"],
};
const evidence = ["0x1000", "0x2000"].map((procedure) =>
createWorkflowEvidence({
target: undefined,
operation: "inspect_native_api",
parameters: { procedure },
result,
upstreamProfile: undefined,
upstreamLimitations: [],
}),
);
expect(evidence[0]?.evidence_id).not.toBe(evidence[1]?.evidence_id);
for (const item of evidence) {
expect(records.recordEvidence(item).ok).toBe(true);
const input = {
name: "inspect_native_api",
result,
evidenceId: item.evidence_id,
recordUnknown: (
unknown: Parameters<InvestigationRecords["recordUnknown"]>[0],
) => records.recordUnknown(unknown),
};
expect(recordWorkflowUnknowns(input).ok).toBe(true);
expect(recordWorkflowUnknowns(input).ok).toBe(true);
}
expect(records.listUnknowns()).toHaveLength(2);
expect(
records
.listUnknowns()
.map(({ supporting_evidence_ids }) => supporting_evidence_ids),
).toEqual(
expect.arrayContaining(evidence.map(({ evidence_id }) => [evidence_id])),
);
});
it.each([
["spaces", " Which boundary? "],
["line feed and tab", "\nWhich boundary?\t"],
@@ -248,7 +289,7 @@ describe("workflow residual question retention", () => {
}).ok,
).toBe(true);
expect(records.listUnknowns()).toMatchObject([
{ question: "Which boundary?" },
{ question: `Which boundary? (Evidence ${evidence.evidence_id})` },
]);
expect(evidence.normalized_result).toEqual(result);
},
@@ -328,7 +369,7 @@ it.each([
}).ok,
).toBe(true);
expect(records.listUnknowns()).toMatchObject([
{ question: "Which boundary?" },
{ question: `Which boundary? (Evidence ${evidence.evidence_id})` },
]);
expect(records.evidenceById(evidence.evidence_id)).toEqual(evidence);
expect(evidence.normalized_result).toMatchObject({
+4 -3
View File
@@ -23,6 +23,7 @@ import {
import type { JsonValue } from "../domain/jsonValue.js";
import type { UnknownRegistryPort } from "./investigation/InvestigationRecordPort.js";
import { UnknownRegistryError } from "../domain/unknownRegistryError.js";
import { workflowUnknownInput } from "./investigation/WorkflowUnknown.js";
import {
renderSourceLimitations,
sourceLimitationKey,
@@ -180,12 +181,10 @@ export const recordWorkflowUnknowns = ({
return { ok: true, value: null };
for (const question of result.residual_unknowns) {
if (typeof question !== "string") continue;
const recorded = recordUnknown({
const input = workflowUnknownInput(evidenceId, {
question,
severity: "medium",
domain: name === "inspect_native_api" ? "native-api" : "control-flow",
supporting_evidence_ids: [evidenceId],
contradicting_evidence_ids: [],
required_authority: "shipped-artifact",
required_confidence: "observed",
required_environment: null,
@@ -200,6 +199,8 @@ export const recordWorkflowUnknowns = ({
],
relationships: [],
});
if (!input.ok) return input;
const recorded = recordUnknown(input.value);
if (
!recorded.ok &&
!(
@@ -158,6 +158,28 @@ describe("replayEnvironmentTool selection", () => {
expect(unmatched.replay.recorded_query).toBe("?query=probe&type=dash-db");
});
it("prefers a recorded segment equal to the argument over one that only matches after decoding", () => {
const users = { ...tool("get_users_by_id"), record_ordinals: [901, 902] };
const exchange = (ordinal: number, path: string): RecordedExchange => ({
ordinal,
path,
query: null,
status: 200,
contentType: null,
bodyText: null,
});
const exchanges = new Map<number, RecordedExchange>([
[901, exchange(901, "/api/users/a%252Fb")],
[902, exchange(902, "/api/users/a%2Fb")],
]);
expect(
selectReplayExchange(users, exchanges, { id: "a%2Fb" }),
).toMatchObject({ exchange: { ordinal: 902 }, exact: true });
expect(selectReplayExchange(users, exchanges, { id: "a/b" })).toMatchObject(
{ exchange: { ordinal: 902 }, exact: true },
);
});
it("prefers path matches over query matches and keeps manifest order on ties", () => {
const search = tool("get_search");
const [first, second] = search.record_ordinals;
@@ -38,6 +38,8 @@ export interface ReplayResult {
interface MatchScore {
readonly pathMatches: number;
/** Path matches whose recorded segment equals the value as given, not only after decoding. */
readonly verbatimMatches: number;
readonly queryMatches: number;
readonly exact: boolean;
}
@@ -60,11 +62,13 @@ const scoreExchange = (
const segments = exchange.path.split("/").slice(1);
let pathProvided = 0;
let pathMatches = 0;
let verbatimMatches = 0;
for (const param of tool.params) {
const value = args[param.name];
if (typeof value !== "string") continue;
pathProvided += 1;
const segment = segments[param.index];
if (segment === value) verbatimMatches += 1;
if (
segment !== undefined &&
(segment === value || decoded(segment) === value)
@@ -82,12 +86,20 @@ const scoreExchange = (
}
return {
pathMatches,
verbatimMatches,
queryMatches,
exact: pathMatches === pathProvided && queryMatches === queryProvided,
};
};
/** Choose the record whose path segments, then query values, best match the call; ties keep manifest order. */
const outranks = (score: MatchScore, best: MatchScore): boolean =>
score.pathMatches !== best.pathMatches
? score.pathMatches > best.pathMatches
: score.verbatimMatches !== best.verbatimMatches
? score.verbatimMatches > best.verbatimMatches
: score.queryMatches > best.queryMatches;
/** Choose the record whose path segments, then verbatim spellings, then query values best match the call; ties keep manifest order. */
export const selectReplayExchange = (
tool: EnvironmentManifestTool,
exchanges: ReadonlyMap<number, RecordedExchange>,
@@ -101,12 +113,7 @@ export const selectReplayExchange = (
const exchange = exchanges.get(ordinal);
if (exchange === undefined) continue;
const score = scoreExchange(tool, queryArguments, exchange, args);
if (
best === undefined ||
score.pathMatches > best.score.pathMatches ||
(score.pathMatches === best.score.pathMatches &&
score.queryMatches > best.score.queryMatches)
)
if (best === undefined || outranks(score, best.score))
best = { exchange, score };
}
return best === undefined
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, onTestFinished } from "vitest";
import { createDeferred } from "../../../tests/fixtures/binarySession.js";
import type {
Device,
@@ -9,7 +10,12 @@ import type {
Session,
Signal,
} from "frida";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { jsonObjectSchema } from "../../domain/jsonValue.js";
import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { FridaInstrumentationManager } from "../../frida/FridaInstrumentationManager.js";
const signal = <Handler extends (...args: never[]) => void>() => {
@@ -1247,6 +1253,70 @@ it("bounds oversized Frida message metadata before retaining it", async () => {
await manager.closeAll();
});
it.each([
{
name: "ASCII at the limit",
input: "x".repeat(32_768),
retained: "x".repeat(32_768),
truncated: false,
},
{
name: "a trailing unpaired high surrogate",
input: "x".repeat(32_765) + "\ud800",
retained: "x".repeat(32_765),
truncated: true,
},
{
name: "a trailing unpaired low surrogate",
input: "x".repeat(32_765) + "\udc00",
retained: "x".repeat(32_765),
truncated: true,
},
{
name: "a valid surrogate pair at the limit",
input: "x".repeat(32_764) + "\ud800\udc00",
retained: "x".repeat(32_764) + "\ud800\udc00",
truncated: false,
},
{
name: "repeated unpaired high surrogates",
input: "\ud800".repeat(10_922),
retained: "\ud800".repeat(5_461),
truncated: true,
},
])(
"bounds JSON metadata bytes for $name",
async ({ input, retained, truncated }) => {
const { bindings, manager, started } = await startLocalAttachedSession();
onTestFinished(() => manager.closeAll());
if (!started.ok) throw started.error;
bindings.device.session.nextMessage = {
type: "error",
description: input,
stack: input,
fileName: input,
};
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "// inert fixture",
});
if (!loaded.ok) throw loaded.error;
const message = jsonObjectSchema.parse(loaded.value.messages[0]);
for (const field of ["description", "stack", "file_name"]) {
expect(
Buffer.byteLength(JSON.stringify(message[field])) - 2,
).toBeLessThanOrEqual(32_768);
expect(message[field]).toBe(retained);
expect(message[`${field}_truncated`] ?? false).toBe(truncated);
}
expect(loaded.value.messagesTruncated).toBe(truncated);
expect(manager.status(started.value.sessionId)).toMatchObject({
messages: [message],
messagesTruncated: truncated,
});
},
);
it("serializes script loading against concurrent close and unloads the loaded script", async () => {
const bindings = new FakeDeviceManager();
let finishScriptLoad: (() => void) | undefined;
@@ -1345,3 +1415,96 @@ it("names the unknown session or script ID it rejects", async () => {
});
await manager.closeAll();
});
it.each([false, true])(
"retains messages emitted during cancelled script cleanup after native rejection=%s",
async (rejectNative) => {
const { bindings, manager, started } = await startLocalAttachedSession();
onTestFinished(() => manager.closeAll());
if (!started.ok) throw started.error;
const session = bindings.device.session;
const entered = createDeferred<void>();
const held = createDeferred<void>();
onTestFinished(() => held.resolve());
session.onScriptLoadStarted = entered.resolve;
session.scriptLoadGate = held.promise;
const nativeFailure = new Error("native load failed");
if (rejectNative) session.nextLoadFailure = nativeFailure;
const controller = new AbortController();
const reason = new Error("cancel before native completion");
const loading = manager.loadScript(
started.value.sessionId,
{ sourceKind: "inline", source: "send('marker')" },
controller.signal,
);
await entered.promise;
const script = session.scripts[0];
if (script === undefined) throw new Error("Missing admitted script");
const unload = script.unload.bind(script);
script.unload = async () => {
script.message.emit({ type: "send", payload: "during cleanup" }, null);
script.message.emit(
{ type: "send", description: "x".repeat(40_000) },
null,
);
await unload();
};
controller.abort(reason);
held.resolve();
const loaded = await loading;
if (loaded.ok) throw new Error("Cancelled load succeeded");
expect(loaded.error).toBeInstanceOf(FridaScriptLoadCancelledError);
if (!(loaded.error instanceof FridaScriptLoadCancelledError))
throw loaded.error;
expect(loaded.error.cause).toBe(reason);
expect(loaded.error.script.messages).toContainEqual(
expect.objectContaining({ payload: "during cleanup" }),
);
expect(loaded.error.script.messagesTruncated).toBe(true);
expect(loaded.error.diagnostics).toEqual(
rejectNative ? { native_failure: nativeFailure.message } : undefined,
);
expect(loaded.error.cleanupIncomplete).toBe(false);
expect(script.isDestroyed).toBe(true);
expect(manager.status(started.value.sessionId)?.scripts).toEqual([]);
expect(session.isDetached()).toBe(false);
},
);
it("preserves a native rejection observed before cancellation during script cleanup", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
onTestFinished(() => manager.closeAll());
if (!started.ok) throw started.error;
const session = bindings.device.session;
const entered = createDeferred<void>();
const held = createDeferred<void>();
onTestFinished(() => held.resolve());
const create = session.createScript.bind(session);
session.createScript = async () => {
const script = await create();
script.unload = async () => {
entered.resolve();
await held.promise;
const owned = session.scripts[0];
if (owned !== undefined) owned.isDestroyed = true;
};
return script;
};
const failure = new Error("native rejection first");
session.nextLoadFailure = failure;
const controller = new AbortController();
const loading = manager.loadScript(
started.value.sessionId,
{ sourceKind: "inline", source: "void 0" },
controller.signal,
);
await entered.promise;
controller.abort(new Error("late abort during cleanup"));
held.resolve();
const result = await loading;
if (result.ok) throw new Error("Failed load succeeded");
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
expect(result.error.cause).toBe(failure);
expect(result.error.cleanupIncomplete).toBe(false);
expect(manager.status(started.value.sessionId)?.scripts).toEqual([]);
});
@@ -59,17 +59,28 @@ export interface FridaScriptObservation {
readonly scriptId: string;
readonly sourceKind: "inline" | "file";
readonly sourcePath: string | null;
/** Digest of selected file bytes, or the inline source encoded as UTF-8. */
readonly sourceSha256: string;
readonly sourceRepresentation?: {
readonly encoding: "utf8";
readonly decoding: "replacement";
readonly loadedSourceSha256: string;
};
readonly messages: readonly JsonValue[];
readonly messagesTruncated: boolean;
}
export class FridaScriptLoadCancelledError extends AnalysisCancelledError {
readonly scriptHandleAcquired: boolean;
constructor(
readonly script: FridaScriptObservation,
options?: AnalysisErrorOptions,
options?: AnalysisErrorOptions & {
readonly scriptHandleAcquired?: boolean;
},
) {
super("load_frida_script", options);
this.scriptHandleAcquired = options?.scriptHandleAcquired ?? true;
}
}
@@ -1,3 +1,4 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -7,6 +8,7 @@ import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js";
import type { FridaInstrumentationPort } from "./FridaInstrumentationPort.js";
import { FridaInstrumentationService } from "./FridaInstrumentationService.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
@@ -275,6 +277,127 @@ it("reports the spawned session when cancellation arrives during resume", async
expect(closes).toBe(1);
});
it.each([false, true])(
"preserves a native resume failure when cancellation arrives before it settles (cleanupFails=%s)",
async (cleanupFails) => {
const controller = new AbortController();
const abortReason = new Error("late cancellation during native resume");
const primary = new AnalysisCapabilityUnavailableError(
"frida",
"resume_frida_session",
"native resume failed",
{ diagnostics: { native_status: "resume_rejected" } },
);
let markResumeEntered: (() => void) | undefined;
let finishResume:
| ((result: {
readonly ok: false;
readonly error: AnalysisCapabilityUnavailableError;
}) => void)
| undefined;
const resumeEntered = new Promise<void>((resolve) => {
markResumeEntered = resolve;
});
let closes = 0;
const service = new FridaInstrumentationService(
createProvider({
resumeSession() {
markResumeEntered?.();
return new Promise((resolve) => {
finishResume = resolve;
});
},
async closeSession() {
closes += 1;
return cleanupFails
? {
ok: false,
error: new AnalysisCapabilityUnavailableError(
"frida",
"close_frida_session",
"session cleanup failed",
),
}
: { ok: true, value: { targetResumeObserved: false } };
},
}),
);
const pending = service.instrument(
{
mode: "spawn",
deviceId: "local",
program: "/fixture",
source: { sourceKind: "inline", source: "send('resume-failure')" },
durationMs: 0,
},
controller.signal,
);
await resumeEntered;
controller.abort(abortReason);
finishResume?.({ ok: false, error: primary });
const result = await pending;
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).not.toBeInstanceOf(AnalysisCancelledError);
if (cleanupFails) {
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
expect(result.error.cause).toBe(primary);
expect(result.error.diagnostics).toEqual({
native_status: "resume_rejected",
});
expect(result.error.cleanup).toMatchObject({
reason: expect.stringContaining("session cleanup failed"),
resources: [createSession().sessionId],
});
} else {
expect(result.error).toBe(primary);
}
expect(closes).toBe(1);
},
);
it("keeps a typed resume cancellation as cancellation when its signal is aborted", async () => {
const controller = new AbortController();
const abortReason = new Error("native resume was cancelled");
let closes = 0;
const service = new FridaInstrumentationService(
createProvider({
async resumeSession() {
controller.abort(abortReason);
return {
ok: false,
error: new AnalysisCancelledError("resume_frida_session", {
cause: abortReason,
}),
};
},
async closeSession() {
closes += 1;
return { ok: true, value: { targetResumeObserved: false } };
},
}),
);
const result = await service.instrument(
{
mode: "spawn",
deviceId: "local",
program: "/fixture",
source: { sourceKind: "inline", source: "send('resume-cancel')" },
durationMs: 0,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(abortReason);
expect(closes).toBe(1);
});
it("does not infer a Frida resume for a running attached target on cancellation", async () => {
const controller = new AbortController();
const service = new FridaInstrumentationService(
@@ -366,11 +489,186 @@ it("retains an observed spawn resume after the target detaches before cancellati
});
});
it("returns typed partial Evidence when cancellation arrives during successful final cleanup", async () => {
const controller = new AbortController();
const abortReason = new Error("cancelled during final detach");
const script = {
...createScriptObservation(),
messages: [{ type: "send", payload: "completed-before-detach" }],
};
let closes = 0;
const service = new FridaInstrumentationService(
createProvider({
async loadScript() {
return { ok: true, value: script };
},
async closeSession() {
closes += 1;
controller.abort(abortReason);
return { ok: true, value: { targetResumeObserved: true } };
},
}),
);
const result = await service.instrument(
{
mode: "spawn",
deviceId: "local",
program: "/fixture",
source: {
sourceKind: "inline",
source: "send('completed-before-detach')",
},
durationMs: 0,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(abortReason);
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
messages: script.messages,
source_sha256: script.sourceSha256,
cleanup_error: null,
target_state_before_cleanup: "paused",
target_resume_observed: true,
instrumentation_cleanup: "released",
target_liveness_after_cleanup: "unknown",
},
});
expect(closes).toBe(1);
});
it("keeps cancellation primary and records failed final cleanup without retrying it", async () => {
const controller = new AbortController();
const abortReason = { source: "test", stage: "final-detach" };
let closes = 0;
const service = new FridaInstrumentationService(
createProvider({
async loadScript() {
return { ok: true, value: createScriptObservation() };
},
async closeSession() {
closes += 1;
controller.abort(abortReason);
return {
ok: false,
error: new AnalysisCapabilityUnavailableError(
"frida",
"close_frida_session",
"detach acknowledgement failed",
{
cleanup: {
reason: "session remains owned",
resources: [createSession().sessionId],
},
},
),
};
},
}),
);
const result = await service.instrument(
{
mode: "spawn",
deviceId: "local",
program: "/fixture",
source: { sourceKind: "inline", source: "send('ready')" },
durationMs: 0,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(abortReason);
expect(result.error.cleanup).toEqual({
reason:
"Provider frida cannot execute close_frida_session: detach acknowledgement failed",
resources: [createSession().sessionId],
});
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
cleanup_error:
"Provider frida cannot execute close_frida_session: detach acknowledgement failed",
instrumentation_cleanup: "incomplete_or_unknown",
},
});
expect(closes).toBe(1);
});
it("retains a spawned resume reported after detach when remote cleanup fails", async () => {
const controller = new AbortController();
const abortReason = new Error("cancelled during remote cleanup");
let statusReads = 0;
let closes = 0;
const service = new FridaInstrumentationService(
createProvider({
status() {
statusReads += 1;
return {
...createSession(),
state: statusReads === 1 ? "paused" : "detached",
targetResumeObserved: statusReads > 1,
scripts: [],
messages: [],
messagesTruncated: false,
};
},
async closeSession() {
closes += 1;
controller.abort(abortReason);
return {
ok: false,
error: new AnalysisCapabilityUnavailableError(
"frida",
"close_frida_session",
"remote removal failed after detach",
),
};
},
}),
);
const result = await service.instrument(
{
mode: "spawn",
deviceId: "local",
program: "/fixture",
source: { sourceKind: "inline", source: "send('ready')" },
durationMs: 0,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(abortReason);
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
target_state_before_cleanup: "paused",
target_resume_observed: true,
cleanup_error:
"Provider frida cannot execute close_frida_session: remote removal failed after detach",
instrumentation_cleanup: "incomplete_or_unknown",
},
});
expect(statusReads).toBe(2);
expect(closes).toBe(1);
});
it.each([
{ cleanupState: "running" as const, resumeObserved: true },
{ cleanupState: "running" as const, resumeObserved: false },
{ cleanupState: "paused" as const, resumeObserved: false },
{ cleanupState: "detached" as const, resumeObserved: true },
])(
"uses a spawned target state transition after failed cleanup ($cleanupState)",
"uses explicit resume evidence after failed cleanup ($cleanupState)",
async ({ cleanupState, resumeObserved }) => {
const controller = new AbortController();
let statusCalls = 0;
@@ -385,7 +683,7 @@ it.each([
return {
...createSession(),
state: statusCalls === 1 ? "paused" : cleanupState,
targetResumeObserved: false,
targetResumeObserved: statusCalls > 1 && resumeObserved,
scripts: [],
messages: [],
messagesTruncated: false,
@@ -610,6 +908,161 @@ it("retains a cancelled provider load's source and messages in one-shot Evidence
});
});
it.each([
["before cleanup", false],
["before cleanup", true],
["during cleanup", false],
["during cleanup", true],
] as const)(
"keeps an ordinary provider load failure primary when cancellation arrives %s (cleanupFails=%s)",
async (abortTiming, cleanupFails) => {
const controller = new AbortController();
const abortReason = new Error("late caller cancellation");
const primary = new AnalysisCapabilityUnavailableError(
"frida",
"load_frida_script",
"native script compilation failed",
{ diagnostics: { native_status: "compile_rejected" } },
);
let closes = 0;
const service = new FridaInstrumentationService(
createProvider({
async loadScript() {
if (abortTiming === "before cleanup") controller.abort(abortReason);
return { ok: false, error: primary };
},
async closeSession() {
closes += 1;
if (abortTiming === "during cleanup") controller.abort(abortReason);
return cleanupFails
? {
ok: false,
error: new AnalysisCapabilityUnavailableError(
"frida",
"close_frida_session",
"session cleanup failed",
),
}
: { ok: true, value: { targetResumeObserved: false } };
},
}),
);
const result = await service.instrument(
{
mode: "spawn",
program: "/fixture",
source: { sourceKind: "inline", source: "send('ready');" },
durationMs: 0,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).not.toBeInstanceOf(AnalysisCancelledError);
expect(controller.signal.reason).toBe(abortReason);
if (cleanupFails) {
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
expect(result.error.cause).toBe(primary);
expect(result.error.diagnostics).toEqual({
native_status: "compile_rejected",
});
expect(result.error.cleanup).toMatchObject({
reason: expect.stringContaining("session cleanup failed"),
resources: [createSession().sessionId],
});
} else {
expect(result.error).toBe(primary);
}
expect(closes).toBe(1);
},
);
it("preserves typed load cancellation cause, safe diagnostics, and decoded source representation in partial Evidence", async () => {
const directory = await mkdtemp(
join(tmpdir(), "rea-frida-cancelled-source-"),
);
const path = join(directory, "invalid-utf8.js");
await writeFile(path, Buffer.from([0x2f, 0x2f, 0x20, 0xff, 0x0a]));
try {
const controller = new AbortController();
const abortReason = new Error("native load cancelled after decoding");
const diagnostics = {
native_failure: "script load cancelled; transport_token=<redacted>",
read_attempt: 1,
};
const script = {
...createScriptObservation(),
sourceKind: "file" as const,
sourcePath: path,
sourceRepresentation: {
encoding: "utf8" as const,
decoding: "replacement" as const,
loadedSourceSha256: "b".repeat(64),
},
messages: [{ type: "send", payload: "retained-before-cancel" }],
};
let closes = 0;
const service = new FridaInstrumentationService(
createProvider({
async loadScript() {
controller.abort(abortReason);
return {
ok: false,
error: new FridaScriptLoadCancelledError(script, {
cause: abortReason,
diagnostics,
}),
};
},
async closeSession() {
closes += 1;
return { ok: true, value: { targetResumeObserved: false } };
},
}),
);
const result = await service.instrument(
{
mode: "spawn",
program: "/fixture",
source: { sourceKind: "file", path },
durationMs: 0,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(abortReason);
expect(result.error.diagnostics).toEqual(diagnostics);
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
source_kind: "file",
source_path: path,
source_sha256: script.sourceSha256,
messages: script.messages,
instrumentation_cleanup: "released",
},
raw_result: {
source_representation: {
encoding: "utf8",
decoding: "replacement",
loaded_source_sha256: "b".repeat(64),
},
},
limitations: expect.arrayContaining([
expect.stringContaining("Invalid UTF-8"),
]),
});
expect(closes).toBe(1);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it("preserves a provider-committed load when cancellation arrives during result delivery", async () => {
const controller = new AbortController();
let unloads = 0;
@@ -1,7 +1,9 @@
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
import type { z } from "zod";
import type { toolContract } from "../../contracts/toolContracts.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -24,6 +26,26 @@ import type {
StartFridaSessionInput,
} from "./FridaInstrumentationPort.js";
type DeviceListOutput = z.output<
ReturnType<typeof toolContract<"list_frida_devices">>["outputSchema"]
>;
type ProcessListOutput = z.output<
ReturnType<typeof toolContract<"list_frida_processes">>["outputSchema"]
>;
type FridaSessionCloseResult = Awaited<
ReturnType<FridaInstrumentationPort["closeSession"]>
>;
type FridaSessionCancellation = {
readonly operation: string;
readonly input: StartFridaSessionInput;
readonly cause: unknown;
readonly diagnostics?: AnalysisError["diagnostics"];
readonly observation: FridaSessionStatus;
readonly closed: FridaSessionCloseResult;
readonly statusAfterCleanup: FridaSessionStatus | undefined;
readonly script: FridaScriptObservation | undefined;
};
const unreadableScriptFile = async (
source: FridaScriptSource,
): Promise<AnalysisError | undefined> => {
@@ -90,12 +112,33 @@ export class FridaInstrumentationService {
this.#provider = provider;
}
listDevices(remote?: FridaRemoteConnection) {
return this.#provider.listDevices(remote);
async listDevices(
remote?: FridaRemoteConnection,
): Promise<Result<DeviceListOutput, AnalysisError>> {
const result = await this.#provider.listDevices(remote);
return result.ok
? ok({
devices: result.value.devices.map(({ deviceId, name, type }) => ({
device_id: deviceId,
name,
type,
})),
cleanup_error: result.value.cleanupError,
})
: result;
}
listProcesses(selector: FridaDeviceSelector) {
return this.#provider.listProcesses(selector);
async listProcesses(
selector: FridaDeviceSelector,
): Promise<Result<ProcessListOutput, AnalysisError>> {
const result = await this.#provider.listProcesses(selector);
return result.ok
? ok({
device_id: result.value.deviceId,
processes: [...result.value.processes],
cleanup_error: result.value.cleanupError,
})
: result;
}
async startSession(input: StartFridaSessionInput, signal?: AbortSignal) {
@@ -124,8 +167,12 @@ export class FridaInstrumentationService {
session: FridaSessionObservation,
input: StartFridaSessionInput,
cause: unknown,
loaded?: FridaScriptObservation,
load?: {
readonly script: FridaScriptObservation;
readonly diagnostics?: AnalysisError["diagnostics"];
},
): Promise<Result<never, AnalysisError>> {
const loaded = load?.script;
const status = this.#provider.status(session.sessionId);
const observation = status ?? {
...session,
@@ -136,45 +183,16 @@ export class FridaInstrumentationService {
};
const closed = await this.#provider.closeSession(session.sessionId);
const statusAfterCleanup = this.#provider.status(session.sessionId);
const partialObservation = fridaEvidence(
operation,
{
...sessionStatusOutput(observation),
...(loaded === undefined
? {}
: {
source_kind: loaded.sourceKind,
source_path: loaded.sourcePath,
source_sha256: loaded.sourceSha256,
}),
cleanup_error: closed.ok ? null : closed.error.message,
target_state_before_cleanup: observation.state,
target_resume_observed:
observation.targetResumeObserved ||
(observation.mode === "spawn" && observation.state === "running") ||
(closed.ok && closed.value.targetResumeObserved) ||
(observation.mode === "spawn" &&
observation.state !== "running" &&
statusAfterCleanup?.state === "running"),
instrumentation_cleanup: closed.ok
? "released"
: "incomplete_or_unknown",
target_liveness_after_cleanup: "unknown",
},
fridaParameters(session.sessionId, input, session.deviceId),
);
return err(
new AnalysisCancelledError(operation, {
...(cause === undefined ? {} : { cause }),
partialObservation,
...(closed.ok
? {}
: {
cleanup: {
reason: closed.error.message,
resources: [session.sessionId],
},
}),
cancelledFridaSession({
operation,
input,
cause,
diagnostics: load?.diagnostics,
observation,
closed,
statusAfterCleanup,
script: loaded,
}),
);
}
@@ -195,9 +213,14 @@ export class FridaInstrumentationService {
return err(cancelledScriptEvidence(sessionId, loaded.error));
return loaded.ok
? ok(
fridaEvidence("load_frida_script", scriptOutput(loaded.value), {
session_id: sessionId,
}),
fridaEvidence(
"load_frida_script",
scriptOutput(loaded.value),
{
session_id: sessionId,
},
loaded.value,
),
)
: loaded;
}
@@ -255,17 +278,15 @@ export class FridaInstrumentationService {
input.source,
signal,
);
let scriptObservation: FridaScriptObservation | undefined;
if (loaded.ok) scriptObservation = loaded.value;
else if (loaded.error instanceof FridaScriptLoadCancelledError)
scriptObservation = loaded.error.script;
if (signal?.aborted)
if (!loaded.ok && loaded.error instanceof AnalysisCancelledError)
return this.#cancelSession(
"instrument_with_frida",
started.value,
input,
signal.reason,
scriptObservation,
loaded.error.cause,
loaded.error instanceof FridaScriptLoadCancelledError
? loaded.error
: undefined,
);
if (!loaded.ok) {
const closed = await this.#provider.closeSession(started.value.sessionId);
@@ -279,20 +300,23 @@ export class FridaInstrumentationService {
),
);
}
if (signal?.aborted)
return this.#cancelSession(
"instrument_with_frida",
started.value,
input,
signal.reason,
{ script: loaded.value },
);
if (started.value.mode === "spawn") {
const resumed = await this.#provider.resumeSession(
started.value.sessionId,
signal,
);
if (signal?.aborted)
return this.#cancelSession(
"instrument_with_frida",
started.value,
input,
signal.reason,
loaded.value,
);
if (!resumed.ok) {
if (
!resumed.ok &&
!(signal?.aborted && resumed.error instanceof AnalysisCancelledError)
) {
const closed = await this.#provider.closeSession(
started.value.sessionId,
);
@@ -306,6 +330,14 @@ export class FridaInstrumentationService {
),
);
}
if (signal?.aborted)
return this.#cancelSession(
"instrument_with_frida",
started.value,
input,
signal.reason,
{ script: loaded.value },
);
}
try {
await delay(input.durationMs, undefined, { signal });
@@ -314,8 +346,8 @@ export class FridaInstrumentationService {
"instrument_with_frida",
started.value,
input,
cause,
loaded.value,
signal?.aborted ? signal.reason : cause,
{ script: loaded.value },
);
}
if (signal?.aborted)
@@ -324,7 +356,7 @@ export class FridaInstrumentationService {
started.value,
input,
signal.reason,
loaded.value,
{ script: loaded.value },
);
const status = this.#provider.status(started.value.sessionId);
const observation = status ?? {
@@ -336,16 +368,27 @@ export class FridaInstrumentationService {
};
const closed = await this.#provider.closeSession(started.value.sessionId);
const cleanupError = closed.ok ? null : closed.error.message;
if (signal?.aborted)
return err(
cancelledFridaSession({
operation: "instrument_with_frida",
input,
cause: signal.reason,
observation,
closed,
statusAfterCleanup: this.#provider.status(started.value.sessionId),
script: loaded.value,
}),
);
const evidence = fridaEvidence(
"instrument_with_frida",
{
...sessionStatusOutput(observation),
source_kind: loaded.value.sourceKind,
source_path: loaded.value.sourcePath,
source_sha256: loaded.value.sourceSha256,
...sourceOutput(loaded.value),
cleanup_error: cleanupError,
},
fridaParameters(started.value.sessionId, input, started.value.deviceId),
loaded.value,
);
return ok({
evidence,
@@ -366,6 +409,7 @@ const fridaEvidence = (
operation: string,
result: unknown,
parameters: unknown,
script?: FridaScriptObservation,
): Evidence =>
createEvidence(
undefined,
@@ -375,15 +419,76 @@ const fridaEvidence = (
operation,
parameters: jsonObjectSchema.parse(parameters),
result: jsonValueSchema.parse(result),
...(script?.sourceRepresentation === undefined
? {}
: {
rawResult: {
source_representation: {
encoding: script.sourceRepresentation.encoding,
decoding: script.sourceRepresentation.decoding,
loaded_source_sha256:
script.sourceRepresentation.loadedSourceSha256,
},
},
}),
confidence: "observed",
authority: "external-service",
environment: null,
limitations: [
"The target operating system and architecture were not reported by this observation.",
...(script?.sourceRepresentation === undefined
? []
: [
"Invalid UTF-8 in the selected script file was decoded with replacement characters. source_sha256 identifies the original file bytes; raw_result.source_representation.loaded_source_sha256 identifies the UTF-8 encoding of the string passed to Frida.",
]),
],
},
);
const cancelledFridaSession = ({
operation,
input,
cause,
diagnostics,
observation,
closed,
statusAfterCleanup,
script,
}: FridaSessionCancellation): AnalysisCancelledError => {
const cleanupError = closed.ok ? null : closed.error.message;
return new AnalysisCancelledError(operation, {
...(cause === undefined ? {} : { cause }),
diagnostics,
partialObservation: fridaEvidence(
operation,
{
...sessionStatusOutput(observation),
...(script === undefined ? {} : sourceOutput(script)),
cleanup_error: cleanupError,
target_state_before_cleanup: observation.state,
target_resume_observed:
observation.targetResumeObserved ||
(closed.ok && closed.value.targetResumeObserved) ||
statusAfterCleanup?.targetResumeObserved === true,
instrumentation_cleanup: closed.ok
? "released"
: "incomplete_or_unknown",
target_liveness_after_cleanup: "unknown",
},
fridaParameters(observation.sessionId, input, observation.deviceId),
script,
),
...(closed.ok
? {}
: {
cleanup: {
reason: closed.error.message,
resources: [observation.sessionId],
},
}),
});
};
const fridaParameters = (
sessionId: string,
input: StartFridaSessionInput,
@@ -424,6 +529,10 @@ const withCleanupFailure = (
`${original.message}; cleanup failed: ${cleanupReason}`,
{
cause: original,
diagnostics: original.diagnostics,
...(original.partialObservation === undefined
? {}
: { partialObservation: original.partialObservation }),
cleanup: {
reason: cleanupReason,
resources: [sessionId],
@@ -442,6 +551,7 @@ export const withRecoveredFridaCleanup = (
error.reason,
{
cause: error.cause,
diagnostics: error.diagnostics,
...(error.capturedOutput === undefined
? {}
: { capturedOutput: error.capturedOutput }),
@@ -455,11 +565,15 @@ export const withRecoveredFridaCleanup = (
)
: error;
const scriptOutput = (script: FridaScriptObservation) => ({
script_id: script.scriptId,
const sourceOutput = (script: FridaScriptObservation) => ({
source_kind: script.sourceKind,
source_path: script.sourcePath,
source_sha256: script.sourceSha256,
});
const scriptOutput = (script: FridaScriptObservation) => ({
script_id: script.scriptId,
...sourceOutput(script),
messages: script.messages,
messages_truncated: script.messagesTruncated,
});
@@ -485,16 +599,20 @@ const cancelledScriptEvidence = (
): AnalysisCancelledError =>
new AnalysisCancelledError(error.operation, {
cause: error.cause,
diagnostics: error.diagnostics,
cleanup: error.cleanup,
partialObservation: fridaEvidence(
error.operation,
{
...scriptOutput(error.script),
cleanup_error: error.cleanup?.reason ?? null,
script_cleanup: error.cleanupIncomplete
? "incomplete_or_unknown"
: "released",
script_cleanup: !error.scriptHandleAcquired
? "no_handle"
: error.cleanupIncomplete
? "incomplete_or_unknown"
: "released",
},
{ session_id: sessionId },
error.script,
),
});
@@ -0,0 +1,29 @@
import {
recordUnknownInputSchema,
type RecordUnknownInput,
} from "../../domain/residualUnknown.js";
import { err, ok, type Result } from "../../domain/result.js";
import { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
/** Scope an automatic question to its immutable result before registry mutation. */
export const workflowUnknownInput = (
evidenceId: string,
input: Omit<
RecordUnknownInput,
"supporting_evidence_ids" | "contradicting_evidence_ids"
>,
): Result<RecordUnknownInput, UnknownRegistryError> => {
const question = recordUnknownInputSchema.shape.question.safeParse(
input.question,
);
if (!question.success)
return err(
new UnknownRegistryError("invalid-transition", { cause: question.error }),
);
return ok({
...input,
question: `${question.data} (Evidence ${evidenceId})`,
supporting_evidence_ids: [evidenceId],
contradicting_evidence_ids: [],
});
};
@@ -205,3 +205,42 @@ it("retains completed target facts when cancellation arrives before Evidence pub
});
}
});
it.each(["list", "inspect"] as const)(
"preserves a pre-abort reason before Electron %s provider work",
async (kind) => {
const reason = new Error(`stop Electron ${kind}`);
const controller = new AbortController();
controller.abort(reason);
let providerCalls = 0;
const provider: ElectronObservationPort = {
identity: () => identity,
listTargets: async () => {
providerCalls += 1;
return err(new AnalysisCancelledError("list_electron_targets"));
},
inspectPage: async () => {
providerCalls += 1;
return err(new AnalysisCancelledError("inspect_electron_page"));
},
};
const result =
kind === "list"
? await listElectronTargets(
provider,
{ cdp_endpoint: input.cdp_endpoint },
{ signal: controller.signal },
)
: await inspectElectronPage(provider, input, {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
}
expect(providerCalls).toBe(0);
},
);
@@ -29,7 +29,11 @@ export const listElectronTargets = async (
const ready = requireProvider(provider, "list_electron_targets");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("list_electron_targets"));
return err(
new AnalysisCancelledError("list_electron_targets", {
cause: options.signal.reason,
}),
);
const result = await ready.value.listTargets(input, options);
if (!result.ok) return result;
const report = electronTargetListSchema.safeParse(result.value);
@@ -61,7 +65,11 @@ export const inspectElectronPage = async (
const ready = requireProvider(provider, "inspect_electron_page");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("inspect_electron_page"));
return err(
new AnalysisCancelledError("inspect_electron_page", {
cause: options.signal.reason,
}),
);
const result = await ready.value.inspectPage(input, options);
if (!result.ok) {
const partial = result.error.partialObservation;
@@ -13,6 +13,8 @@ import type { Result } from "../../domain/result.js";
/** Provider-neutral boundary for passive Node/Electron Inspector observation. */
export interface JavaScriptRuntimeObservationPort {
/** Retry retained Inspector and associated file cleanup during shutdown. */
close?(): Promise<void>;
identity(): ProviderIdentity;
listTargets(
input: ListJavaScriptRuntimeTargetsInput,
@@ -271,3 +271,49 @@ it("reports non-JSON Inspector output before late cancellation", async () => {
error: { _tag: "AnalysisOutputError", partialObservation: undefined },
});
});
it.each(["list", "observe"] as const)(
"preserves a pre-abort reason before Inspector %s provider work",
async (kind) => {
const reason = new Error(`stop Inspector ${kind}`);
const controller = new AbortController();
controller.abort(reason);
let providerCalls = 0;
const provider: JavaScriptRuntimeObservationPort = {
identity: () => ({ id: "test", name: "Test", version: "1" }),
listTargets: async () => {
providerCalls += 1;
return err(
new AnalysisCancelledError("list_javascript_runtime_targets"),
);
},
observe: async () => {
providerCalls += 1;
return err(new AnalysisCancelledError("observe_javascript_runtime"));
},
};
const result =
kind === "list"
? await listJavaScriptRuntimeTargets(
provider,
{ inspector_endpoint: "http://127.0.0.1:9229" },
{ signal: controller.signal },
)
: await observeJavaScriptRuntime(
provider,
{
inspector_endpoint: "http://127.0.0.1:9229",
target_id: "target",
observation_ms: 0,
},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
}
expect(providerCalls).toBe(0);
},
);
@@ -28,7 +28,11 @@ export const listJavaScriptRuntimeTargets = async (
const ready = requireProvider(provider, "list_javascript_runtime_targets");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("list_javascript_runtime_targets"));
return err(
new AnalysisCancelledError("list_javascript_runtime_targets", {
cause: options.signal.reason,
}),
);
const result = await ready.value.listTargets(input, options);
if (!result.ok) return result;
const report = javascriptRuntimeTargetListSchema.safeParse(result.value);
@@ -58,7 +62,11 @@ export const observeJavaScriptRuntime = async (
const ready = requireProvider(provider, "observe_javascript_runtime");
if (!ready.ok) return ready;
if (options.signal?.aborted)
return err(new AnalysisCancelledError("observe_javascript_runtime"));
return err(
new AnalysisCancelledError("observe_javascript_runtime", {
cause: options.signal.reason,
}),
);
const result = await ready.value.observe(input, options);
if (!result.ok) {
const partial = result.error.partialObservation;
@@ -39,6 +39,7 @@ import {
unknownSemanticEvidence,
} from "./JavaScriptSemanticGraphEvidence.js";
import {
RETURNED_REFERENCE_WIDENING_LIMITATION,
semanticCoverageResourceLimits,
semanticResourceLimitCoverage,
} from "../../domain/javascript/javascriptSemanticCoverage.js";
@@ -555,6 +556,19 @@ function* projectFileSteps(
if (moduleNode === null) return [];
state.roots.add(moduleNode.node_id);
projectFileResourceLimits({ file, ir, state, moduleNode });
for (const location of ir.coverage.widenedReturnedReferences ?? [])
addSemanticGraphUnknown(state, {
node_id: moduleNode.node_id,
family: "data-flow",
relation_kinds: ["defines"],
reason: "dynamic-property",
detail: RETURNED_REFERENCE_WIDENING_LIMITATION,
candidate_node_ids: [],
evidence: {
...unknownSemanticEvidence(file, location),
limitations: [RETURNED_REFERENCE_WIDENING_LIMITATION],
},
});
yield* projectElectronSemanticCallPaths(file, paths, state);
// Node creation keeps source order, which the per-file node budget relies
// on; yields between nodes do not change it.
@@ -52,10 +52,17 @@ describe("JebAnalysisService admission", () => {
const result = await service.execute("list_jeb_units", {
count: 101,
index: -1,
parent_unit_path: "",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisInputError);
if (!(result.error instanceof AnalysisInputError)) throw result.error;
expect(result.error.issues).toContainEqual({
path: ["parent_unit_path"],
reason: "out_of_range",
minimum: 1,
});
expect(requests).toHaveLength(0);
});
+12 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import {
AnalysisCancelledError,
AnalysisInputError,
@@ -20,6 +21,7 @@ import { parseProcessCapture } from "../../domain/process/processCaptureParsing.
import { createProgressReporter } from "../ProgressReporter.js";
import { captureProcessScenario } from "../../process/capture/ProcessHarness.js";
import { ProcessCaptureResourceScope } from "../../process/capture/ProcessCaptureLifecycle.js";
import { ProcessCaptureError } from "../../process/capture/ProcessCaptureError.js";
import {
PROCESS_PROVIDER,
isProcessEvidenceProvider,
@@ -84,7 +86,16 @@ export const captureProcessScenarioFile = async (
try {
await resourceScope.close();
} catch (cause: unknown) {
if (!captured.ok) return cliAnalysisError(captured.error);
if (!captured.ok && cause instanceof AnalysisError)
return cliAnalysisError(
captured.error instanceof ProcessCaptureError
? captured.error.withFinalization({ cleanupFailure: cause })
: analysisErrorWithCleanupFailure(
captured.error,
cause,
"capture_process_scenario",
).bindCleanupDependencies([captured.error, cause]),
);
throw cause;
}
if (!captured.ok) return cliAnalysisError(captured.error);
@@ -190,11 +190,16 @@ export class ReverseEngineeringService {
);
const input = parsed.data;
const command = this.#commands.objdump(input);
let parameters: Readonly<Record<string, JsonValue>>;
if (input.operation === "disassemble") {
const { symbol, ...base } = input;
parameters = symbol === undefined ? base : { ...base, symbol };
} else parameters = input;
return this.#observe(
"inspect_with_objdump",
command,
input.operation === "architectures" ? undefined : input.path,
input,
parameters,
options.signal,
);
}
@@ -581,6 +586,12 @@ export class ReverseEngineeringService {
]),
"A non-zero process exit is preserved as an observed command result.",
...(operation === "inspect_with_objdump" &&
typeof parameters.symbol === "string"
? [
"Disassembly is scoped to the caller-selected symbol using the provider's matching and symbol-boundary semantics; an empty match does not establish artifact-wide absence.",
]
: []),
...(operation === "inspect_with_objdump" &&
parameters.follow_debug_links === true
? [
"DWARF output may include local debug-link files whose identities are not bound to Evidence.",
@@ -0,0 +1,89 @@
import {
createAsarHeaderSignatureProbe,
type FinalAsarHeaderProbeObservation,
} from "./AsarHeader.js";
import {
createXmlPlistSignatureProbe,
MAX_XML_PLIST_PROBE_BYTES,
} from "../domain/apple/appleArchiveFormat.js";
/** Initial bytes required by the other bounded format classifiers. */
export const ARTIFACT_CLASSIFICATION_PREFIX_BYTES = 8_192;
/** Retained bytes and independently parsed ASAR signature facts. */
export interface ArtifactClassificationEvidence {
readonly prefix: Buffer;
readonly asarHeader: FinalAsarHeaderProbeObservation;
}
/** Share acquisition bounds and signature observations across files and streams. */
export const createArtifactClassificationProbe = () => {
const xml = createXmlPlistSignatureProbe();
const asar = createAsarHeaderSignatureProbe();
const blocks: Buffer[] = [];
let retainedBytes = 0;
let observedBytes = 0;
const retain = (bytes: Uint8Array): void => {
let offset = 0;
while (offset < bytes.length) {
const blockOffset = retainedBytes % ARTIFACT_CLASSIFICATION_PREFIX_BYTES;
if (blockOffset === 0)
blocks.push(Buffer.allocUnsafe(ARTIFACT_CLASSIFICATION_PREFIX_BYTES));
const count = Math.min(
ARTIFACT_CLASSIFICATION_PREFIX_BYTES - blockOffset,
bytes.length - offset,
);
blocks[blocks.length - 1]!.set(
bytes.subarray(offset, offset + count),
blockOffset,
);
retainedBytes += count;
offset += count;
}
};
return {
get length(): number {
return observedBytes;
},
get limit(): number {
if (observedBytes < ARTIFACT_CLASSIFICATION_PREFIX_BYTES)
return ARTIFACT_CLASSIFICATION_PREFIX_BYTES;
return Math.max(
ARTIFACT_CLASSIFICATION_PREFIX_BYTES,
xml.state === "pending" ? MAX_XML_PLIST_PROBE_BYTES : 0,
asar.observation.state === "pending" ? asar.limit : 0,
);
},
write(bytes: Uint8Array): void {
asar.write(bytes);
let offset = 0;
while (offset < bytes.length) {
const limit =
retainedBytes < ARTIFACT_CLASSIFICATION_PREFIX_BYTES
? ARTIFACT_CLASSIFICATION_PREFIX_BYTES
: xml.state === "pending"
? MAX_XML_PLIST_PROBE_BYTES
: retainedBytes;
if (retainedBytes >= limit) break;
const selected = bytes.subarray(
offset,
offset + Math.min(limit - retainedBytes, bytes.length - offset),
);
retain(selected);
xml.write(selected);
offset += selected.length;
}
observedBytes += bytes.length;
},
finish(archiveBytes: number): ArtifactClassificationEvidence {
const prefix = Buffer.allocUnsafe(retainedBytes);
let copied = 0;
for (const block of blocks) {
const count = Math.min(block.length, retainedBytes - copied);
prefix.set(block.subarray(0, count), copied);
copied += count;
}
return { prefix, asarHeader: asar.finish(archiveBytes) };
},
};
};
+20
View File
@@ -0,0 +1,20 @@
import { Readable } from "node:stream";
import { expect, it } from "vitest";
import { hashReadable } from "./ArtifactHash.js";
it("preserves cancellation arriving with the final stream result", async () => {
const controller = new AbortController();
const reason = { stage: "source-eof" };
const source = Readable.from(
(async function* () {
yield Buffer.from("complete bytes");
controller.abort(reason);
})(),
);
await expect(hashReadable(source, controller.signal)).rejects.toMatchObject({
reason: "cancelled",
cause: reason,
});
});
+7 -32
View File
@@ -4,20 +4,16 @@ import type { Readable } from "node:stream";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { streamChunkToBuffer } from "./StreamBytes.js";
import {
createXmlPlistSignatureProbe,
MAX_XML_PLIST_PROBE_BYTES,
} from "../domain/apple/appleArchiveFormat.js";
createArtifactClassificationProbe,
type ArtifactClassificationEvidence,
} from "./ArtifactClassificationProbe.js";
/** Full stream digest and byte count with bounded classification evidence. */
export type HashResult = {
export type HashResult = ArtifactClassificationEvidence & {
readonly sha256: string;
readonly bytes: number;
readonly prefix: Buffer;
};
/** Bounded classification evidence; this is not an executable format size limit. */
export const ARTIFACT_CLASSIFICATION_PREFIX_BYTES = 8_192;
/** Preserve the inventory cancellation reason at a stream boundary. */
export const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
@@ -34,36 +30,15 @@ export const hashReadable = async (
signal?: AbortSignal,
): Promise<HashResult> => {
const hash = createHash("sha256");
const prefixes: Buffer[] = [];
let prefixBytes = 0;
const xmlProbe = createXmlPlistSignatureProbe();
const probe = createArtifactClassificationProbe();
let bytes = 0;
for await (const raw of stream) {
abortIfNeeded(signal);
const chunk = streamChunkToBuffer(raw);
bytes += chunk.length;
hash.update(chunk);
let chunkOffset = 0;
while (
chunkOffset < chunk.length &&
(prefixBytes < ARTIFACT_CLASSIFICATION_PREFIX_BYTES ||
(xmlProbe.state === "pending" &&
prefixBytes < MAX_XML_PLIST_PROBE_BYTES))
) {
const limit =
prefixBytes < ARTIFACT_CLASSIFICATION_PREFIX_BYTES
? ARTIFACT_CLASSIFICATION_PREFIX_BYTES
: MAX_XML_PLIST_PROBE_BYTES;
const selected = chunk.subarray(
chunkOffset,
Math.min(chunkOffset + limit - prefixBytes, chunk.length),
);
prefixes.push(selected);
prefixBytes += selected.length;
chunkOffset += selected.length;
xmlProbe.write(selected);
}
probe.write(chunk);
}
abortIfNeeded(signal);
return { sha256: hash.digest("hex"), bytes, prefix: Buffer.concat(prefixes) };
return { sha256: hash.digest("hex"), bytes, ...probe.finish(bytes) };
};
+6 -3
View File
@@ -63,7 +63,7 @@ export class AsarArtifactReader implements ArtifactReader {
const snapshotPath = this.#snapshotPath();
this.#resetArchiveState();
this.#entries.clear();
const admitted = await this.#admitHeader(snapshotPath);
const admitted = await this.#admitHeader(snapshotPath, signal);
this.#archiveSize = admitted.archiveBytes;
this.#headerSize = admitted.headerBytes;
abortIfNeeded(signal);
@@ -122,7 +122,7 @@ export class AsarArtifactReader implements ArtifactReader {
}
}
async #admitHeader(snapshotPath: string) {
async #admitHeader(snapshotPath: string, signal?: AbortSignal) {
const headerHandle = await this.#openOwned(
snapshotPath,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
@@ -134,7 +134,10 @@ export class AsarArtifactReader implements ArtifactReader {
}
| { readonly ok: false; readonly cause: unknown };
try {
admission = { ok: true, value: await admitAsarHeader(headerHandle) };
admission = {
ok: true,
value: await admitAsarHeader(headerHandle, signal),
};
} catch (cause: unknown) {
admission = { ok: false, cause };
}
+80
View File
@@ -0,0 +1,80 @@
import type { Stats } from "node:fs";
import { expect, it, vi } from "vitest";
import {
admitAsarHeader,
createAsarHeaderSignatureProbe,
} from "./AsarHeader.js";
import { asarHeaderBytes } from "../../tests/fixtures/asarHeader.js";
const makeArchive = (): Buffer => asarHeaderBytes(' \t\r\n{"files":{}} ');
it("fills the fixed Pickle prefix across controlled short reads", async () => {
const archive = makeArchive();
const calls: Array<readonly [number, number, number]> = [];
const handle = {
stat: vi.fn(
async () =>
({
isFile: () => true,
size: archive.length,
}) as Stats,
),
read: vi.fn(
async (
target: Buffer,
offset: number,
length: number,
position: number,
) => {
calls.push([offset, length, position]);
const bytesRead = Math.min(3, length, archive.length - position);
archive.copy(target, offset, position, position + bytesRead);
return { bytesRead, buffer: target };
},
),
} as unknown as Parameters<typeof admitAsarHeader>[0];
await expect(admitAsarHeader(handle)).resolves.toEqual({
archiveBytes: archive.length,
headerBytes: archive.readUInt32LE(4),
jsonBytes: archive.readUInt32LE(12),
});
expect(calls).toEqual([
[0, 16, 0],
[3, 13, 3],
[6, 10, 6],
[9, 7, 9],
[12, 4, 12],
[15, 1, 15],
]);
});
it("recognizes JSON whitespace before the object token and finalizes file extents", () => {
const archive = makeArchive();
const probe = createAsarHeaderSignatureProbe();
probe.write(archive.subarray(0, 7));
expect(probe.observation.state).toBe("pending");
probe.write(archive.subarray(7, 21));
expect(probe.observation.state).toBe("matched");
const result = probe.finish(archive.length);
expect(result).toMatchObject({
state: "matched",
extents: {
archiveBytes: archive.length,
headerBytes: archive.readUInt32LE(4),
jsonBytes: archive.readUInt32LE(12),
},
signatureOffset: 20,
});
});
it("rejects a signature whose declared header exceeds the observed archive", () => {
const archive = makeArchive();
const probe = createAsarHeaderSignatureProbe();
probe.write(archive);
expect(probe.finish(archive.length - 1)).toMatchObject({
state: "rejected",
reason: "archive-too-short",
});
});
+335 -25
View File
@@ -1,49 +1,359 @@
import type { Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { err, ok, type Result } from "../domain/result.js";
/** Shared resource budget for the eagerly decoded ASAR header, not member bytes. */
export const MAX_ASAR_HEADER_BYTES = 16 * 1024 * 1024;
/** Admit both Chromium pickle extents before an upstream decoder allocates them. */
export const admitAsarHeader = async (
handle: Pick<FileHandle, "read"> & { stat(): Promise<Stats> },
): Promise<{
/** Absolute archive prefix required to inspect an admitted header. */
export const MAX_ASAR_HEADER_PROBE_BYTES = MAX_ASAR_HEADER_BYTES + 8;
export interface AsarHeaderExtents {
readonly archiveBytes: number;
readonly headerBytes: number;
readonly jsonBytes: number;
}> => {
}
export type AsarHeaderProbeExtents = Pick<
AsarHeaderExtents,
"headerBytes" | "jsonBytes"
> & { readonly archiveBytes?: number };
export type AsarHeaderProbeObservation =
| {
readonly state: "pending";
readonly extents?: AsarHeaderProbeExtents;
readonly scannedJsonBytes: number;
}
| {
/** The declared JSON begins with an object token; this does not validate JSON. */
readonly state: "matched";
readonly extents: AsarHeaderProbeExtents;
readonly signatureOffset: number;
readonly scannedJsonBytes: number;
}
| {
readonly state: "rejected";
readonly reason:
| "invalid-pickle-extents"
| "archive-too-short"
| "json-not-object";
readonly extents?: AsarHeaderProbeExtents;
readonly scannedJsonBytes: number;
}
| {
readonly state: "limit";
readonly extents?: AsarHeaderProbeExtents;
readonly scannedJsonBytes: number;
readonly limitBytes: number;
};
export type FinalAsarHeaderProbeObservation = Exclude<
AsarHeaderProbeObservation,
{ readonly state: "pending" }
>;
/**
* Parse the fixed two-Pickle prefix and optionally check it against the actual
* observed archive size. This establishes byte extents only; JSON validity and
* the ASAR header schema belong to the decoder at the interpretation boundary.
*/
export const parseAsarHeaderExtents = (
prefix: Uint8Array,
archiveBytes?: number,
): Result<Omit<AsarHeaderExtents, "archiveBytes">, ArtifactReaderFailure> => {
if (prefix.byteLength < 16)
return err(
new ArtifactReaderFailure(
"format",
"Truncated ASAR Pickle extent prefix",
),
);
const bytes = Buffer.from(prefix.buffer, prefix.byteOffset, 16);
const headerBytes = bytes.readUInt32LE(4);
const payloadBytes = bytes.readUInt32LE(8);
const jsonBytes = bytes.readUInt32LE(12);
if (
bytes.readUInt32LE(0) !== 4 ||
headerBytes < 8 ||
payloadBytes !== headerBytes - 4 ||
jsonBytes > payloadBytes - 4
)
return err(
new ArtifactReaderFailure(
"format",
"ASAR header has invalid Pickle extents",
),
);
if (
archiveBytes !== undefined &&
(!Number.isSafeInteger(archiveBytes) ||
archiveBytes < 8 ||
headerBytes > archiveBytes - 8)
)
return err(
new ArtifactReaderFailure(
"format",
"ASAR header leaves its observed container",
),
);
return ok({ headerBytes, jsonBytes });
};
/**
* Bounded ASAR signature probe. It retains only the 16-byte fixed prefix and
* scans the declared JSON bytes until the first non-JSON-whitespace byte.
*/
export interface AsarHeaderSignatureProbe {
readonly limit: number;
readonly observation: AsarHeaderProbeObservation;
write(input: Uint8Array): AsarHeaderProbeObservation;
finish(archiveBytes: number): FinalAsarHeaderProbeObservation;
}
class AsarHeaderSignatureProbeImpl implements AsarHeaderSignatureProbe {
readonly limit = MAX_ASAR_HEADER_PROBE_BYTES;
readonly #fixed = Buffer.alloc(16);
#fixedBytes = 0;
#scannedJsonBytes = 0;
#extents: Omit<AsarHeaderExtents, "archiveBytes"> | undefined;
#stop: ProbeStop = { state: "pending" };
get observation(): AsarHeaderProbeObservation {
return this.#observation();
}
/** Consume sequential archive bytes without retaining JSON or member data. */
write(input: Uint8Array): AsarHeaderProbeObservation {
if (this.#stop.state === "finalized") return this.#stop.observation;
const bytes = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
let offset = 0;
while (offset < bytes.length && !this.#isTerminal()) {
if (this.#fixedBytes < this.#fixed.length) {
const count = Math.min(
this.#fixed.length - this.#fixedBytes,
bytes.length - offset,
);
bytes.copy(this.#fixed, this.#fixedBytes, offset, offset + count);
this.#fixedBytes += count;
offset += count;
if (this.#fixedBytes === this.#fixed.length) this.#parseFixed();
continue;
}
const extents = this.#extents;
if (extents === undefined || this.#stop.state === "matched") break;
const jsonRemaining = extents.jsonBytes - this.#scannedJsonBytes;
if (jsonRemaining <= 0) {
this.#stop = { state: "rejected", reason: "json-not-object" };
break;
}
const archiveBytesScanned = 16 + this.#scannedJsonBytes;
if (archiveBytesScanned >= this.limit) {
this.#stop = { state: "limit" };
break;
}
const count = Math.min(
bytes.length - offset,
jsonRemaining,
this.limit - archiveBytesScanned,
);
for (let index = 0; index < count; index += 1) {
const value = bytes[offset + index]!;
if (!isJsonWhitespace(value)) {
if (value === 0x7b)
this.#stop = {
state: "matched",
signatureOffset: 16 + this.#scannedJsonBytes,
extents,
};
else this.#stop = { state: "rejected", reason: "json-not-object" };
break;
}
this.#scannedJsonBytes += 1;
}
offset += count;
if (this.#stop.state !== "pending") break;
if (this.#scannedJsonBytes >= extents.jsonBytes) {
this.#stop = { state: "rejected", reason: "json-not-object" };
break;
}
if (16 + this.#scannedJsonBytes >= this.limit)
this.#stop = { state: "limit" };
}
return this.#observation();
}
/** Finalize against the actual byte count; a signature alone is not validity. */
finish(archiveBytes: number): FinalAsarHeaderProbeObservation {
if (this.#stop.state === "finalized") return this.#stop.observation;
if (this.#fixedBytes < this.#fixed.length) {
this.#stop = { state: "rejected", reason: "invalid-pickle-extents" };
} else if (this.#extents !== undefined) {
if (
!Number.isSafeInteger(archiveBytes) ||
archiveBytes < 8 ||
this.#extents.headerBytes > archiveBytes - 8
) {
this.#stop = { state: "rejected", reason: "archive-too-short" };
} else if (
this.#extents.headerBytes > MAX_ASAR_HEADER_BYTES &&
this.#stop.state === "matched"
) {
this.#stop = { state: "limit" };
}
}
const observation = this.#observation(archiveBytes);
this.#stop = { state: "finalized", observation };
return observation;
}
#parseFixed(): void {
const result = parseAsarHeaderExtents(this.#fixed);
if (!result.ok) {
this.#stop = { state: "rejected", reason: "invalid-pickle-extents" };
return;
}
this.#extents = result.value;
if (this.#extents.jsonBytes === 0)
this.#stop = { state: "rejected", reason: "json-not-object" };
}
#isTerminal(): boolean {
return this.#stop.state !== "pending";
}
#observation(): AsarHeaderProbeObservation;
#observation(archiveBytes: number): FinalAsarHeaderProbeObservation;
#observation(
archiveBytes?: number,
): AsarHeaderProbeObservation | FinalAsarHeaderProbeObservation {
if (this.#stop.state === "finalized") return this.#stop.observation;
const extents: AsarHeaderProbeExtents | undefined =
this.#extents === undefined
? undefined
: archiveBytes === undefined
? this.#extents
: { archiveBytes, ...this.#extents };
switch (this.#stop.state) {
case "rejected":
return {
state: "rejected",
reason: this.#stop.reason,
...(extents === undefined ? {} : { extents }),
scannedJsonBytes: this.#scannedJsonBytes,
};
case "limit":
return {
state: "limit",
...(extents === undefined ? {} : { extents }),
scannedJsonBytes: this.#scannedJsonBytes,
limitBytes: this.limit,
};
case "matched":
return {
state: "matched",
extents:
archiveBytes === undefined
? this.#stop.extents
: { archiveBytes, ...this.#stop.extents },
signatureOffset: this.#stop.signatureOffset,
scannedJsonBytes: this.#scannedJsonBytes,
};
case "pending":
if (archiveBytes !== undefined && this.#extents !== undefined) {
const finalizedExtents = {
archiveBytes,
...this.#extents,
};
if (this.#scannedJsonBytes >= this.#extents.jsonBytes)
return {
state: "rejected",
reason: "json-not-object",
extents: finalizedExtents,
scannedJsonBytes: this.#scannedJsonBytes,
};
return {
state: "limit",
extents: finalizedExtents,
scannedJsonBytes: this.#scannedJsonBytes,
limitBytes: this.limit,
};
}
return {
state: "pending",
...(extents === undefined ? {} : { extents }),
scannedJsonBytes: this.#scannedJsonBytes,
};
}
}
}
type ProbeStop =
| { readonly state: "pending" }
| {
readonly state: "matched";
readonly signatureOffset: number;
readonly extents: Omit<AsarHeaderExtents, "archiveBytes">;
}
| {
readonly state: "rejected";
readonly reason:
| "invalid-pickle-extents"
| "archive-too-short"
| "json-not-object";
}
| { readonly state: "limit" }
| {
readonly state: "finalized";
readonly observation: FinalAsarHeaderProbeObservation;
};
const isJsonWhitespace = (value: number): boolean =>
value === 0x20 || value === 0x09 || value === 0x0a || value === 0x0d;
/** Admit Pickle extents before an upstream decoder allocates them. */
export const admitAsarHeader = async (
handle: Pick<FileHandle, "read"> & { stat(): Promise<Stats> },
signal?: AbortSignal,
): Promise<AsarHeaderExtents> => {
assertArtifactReadActive(signal);
const observed = await handle.stat();
assertArtifactReadActive(signal);
if (!observed.isFile())
throw new ArtifactReaderFailure(
"format",
"ASAR container is not a regular file",
);
const prefix = Buffer.alloc(16);
const read = await handle.read(prefix, 0, prefix.length, 0);
if (read.bytesRead !== prefix.length || prefix.readUInt32LE(0) !== 4)
throw new ArtifactReaderFailure(
"format",
"Truncated or invalid ASAR size pickle",
let bytesRead = 0;
while (bytesRead < prefix.length) {
assertArtifactReadActive(signal);
const read = await handle.read(
prefix,
bytesRead,
prefix.length - bytesRead,
bytesRead,
);
const headerBytes = prefix.readUInt32LE(4);
const payloadBytes = prefix.readUInt32LE(8);
const jsonBytes = prefix.readUInt32LE(12);
if (
headerBytes < 8 ||
headerBytes > observed.size - 8 ||
payloadBytes !== headerBytes - 4 ||
jsonBytes > payloadBytes - 4
)
throw new ArtifactReaderFailure(
"format",
"ASAR header leaves its container or has invalid pickle extents",
);
if (headerBytes > MAX_ASAR_HEADER_BYTES)
assertArtifactReadActive(signal);
if (read.bytesRead === 0)
throw new ArtifactReaderFailure("format", "Truncated ASAR size pickle");
bytesRead += read.bytesRead;
}
const parsed = parseAsarHeaderExtents(prefix, observed.size);
if (!parsed.ok) throw parsed.error;
if (parsed.value.headerBytes > MAX_ASAR_HEADER_BYTES)
throw new ArtifactReaderFailure(
"limit",
`ASAR header exceeds the ${String(MAX_ASAR_HEADER_BYTES)} byte decode budget`,
);
return { archiveBytes: observed.size, headerBytes, jsonBytes };
return {
archiveBytes: observed.size,
...parsed.value,
};
};
export const createAsarHeaderSignatureProbe = (): AsarHeaderSignatureProbe =>
new AsarHeaderSignatureProbeImpl();
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import { CPU, fatImage, machoImage } from "../apple/MachoImage.fixture.js";
import {
classifyArtifactContent,
createArtifactEdges,
createOccurrence,
indexOccurrencesByPath,
@@ -11,6 +10,8 @@ import {
import { artifactIdForContent } from "../../domain/artifactIdentity.js";
import type { ArtifactEntry } from "../ArtifactReader.js";
import { classifyArtifactBytesAtPath } from "../../../tests/fixtures/artifactClassification.js";
const entry = (path: string, kind: ArtifactEntry["kind"]): ArtifactEntry => ({
path,
kind,
@@ -168,7 +169,7 @@ describe("bounded FAT inventory classification", () => {
const bytes = fat();
for (const length of [4, 7, 8, 27]) {
expect(
classifyArtifactContent(
classifyArtifactBytesAtPath(
"native",
bytes.subarray(0, length),
bytes.length,
@@ -176,7 +177,7 @@ describe("bounded FAT inventory classification", () => {
).toEqual({ kind: "unknown", format: "unknown" });
}
expect(
classifyArtifactContent(
classifyArtifactBytesAtPath(
"native.class",
bytes.subarray(0, 28),
bytes.length,
@@ -197,7 +198,7 @@ describe("bounded FAT inventory classification", () => {
] as const)("keeps %s unknown", (_label, mutate) => {
const bytes = fat();
mutate(bytes);
expect(classifyArtifactContent("native", bytes)).toEqual({
expect(classifyArtifactBytesAtPath("native", bytes)).toEqual({
kind: "unknown",
format: "unknown",
});
@@ -206,7 +207,7 @@ describe("bounded FAT inventory classification", () => {
it("does not infer Mach-O from an out-of-bounds architecture count", () => {
const bytes = fat();
bytes.writeUInt32BE(0xffffffff, 4);
expect(classifyArtifactContent("native", bytes)).toEqual({
expect(classifyArtifactBytesAtPath("native", bytes)).toEqual({
kind: "resource",
format: "file",
});
@@ -215,7 +216,7 @@ describe("bounded FAT inventory classification", () => {
it("does not lose unsafe FAT64 range values to number conversion", () => {
const bytes = fat(true);
bytes.writeBigUInt64BE(BigInt(Number.MAX_SAFE_INTEGER) + 1n, 16);
expect(classifyArtifactContent("native", bytes)).toEqual({
expect(classifyArtifactBytesAtPath("native", bytes)).toEqual({
kind: "unknown",
format: "unknown",
});
@@ -229,7 +230,7 @@ describe("bounded FAT inventory classification", () => {
]),
);
bytes.writeUInt32BE(bytes.length, 40);
expect(classifyArtifactContent("native", bytes)).toEqual({
expect(classifyArtifactBytesAtPath("native", bytes)).toEqual({
kind: "unknown",
format: "unknown",
});
@@ -239,9 +240,13 @@ describe("bounded FAT inventory classification", () => {
const cpu = { type: 0x12345678, subtype: 0 };
const bytes = Buffer.from(fatImage([{ cpu, bytes: machoImage({ cpu }) }]));
expect(
classifyArtifactContent("native", bytes.subarray(0, 28), bytes.length),
classifyArtifactBytesAtPath(
"native",
bytes.subarray(0, 28),
bytes.length,
),
).toEqual({ kind: "unknown", format: "unknown" });
expect(classifyArtifactContent("native", bytes)).toEqual({
expect(classifyArtifactBytesAtPath("native", bytes)).toEqual({
kind: "executable",
format: "mach-o-universal",
});
@@ -251,7 +256,7 @@ describe("bounded FAT inventory classification", () => {
"keeps invalid or contradictory file size %s unknown",
(size) => {
const bytes = fat();
expect(classifyArtifactContent("native", bytes, size)).toEqual({
expect(classifyArtifactBytesAtPath("native", bytes, size)).toEqual({
kind: "unknown",
format: "unknown",
});
@@ -263,19 +268,19 @@ it("preserves path-selected inventory roles for XML content", () => {
const xml = Buffer.from(
'<?xml version="1.0"?><plist version="1.0"><dict/></plist>',
);
expect(classifyArtifactContent("state.archive", xml)).toEqual({
expect(classifyArtifactBytesAtPath("state.archive", xml)).toEqual({
kind: "plist",
format: "plist",
});
expect(classifyArtifactContent("state.js", xml)).toEqual({
expect(classifyArtifactBytesAtPath("state.js", xml)).toEqual({
kind: "javascript",
format: "javascript-bundle",
});
expect(classifyArtifactContent("state.map", xml)).toEqual({
expect(classifyArtifactBytesAtPath("state.map", xml)).toEqual({
kind: "source-map",
format: "source-map",
});
expect(classifyArtifactContent("state.asar", xml)).toEqual({
expect(classifyArtifactBytesAtPath("state.asar", xml)).toEqual({
kind: "resource",
format: "file",
});
@@ -1,3 +1,5 @@
import type { ArtifactClassificationEvidence } from "../ArtifactClassificationProbe.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
import { isJavaScriptSourcePath } from "../../domain/javascript/javascriptArtifactFiles.js";
import { digestCanonicalValue } from "../../domain/canonicalDigest.js";
@@ -473,12 +475,13 @@ export const classifyArtifactPath = (
/** Classify executable formats from bytes while retaining path-specific roles. */
export const classifyArtifactContent = (
path: string,
prefix: Buffer,
fileSize = prefix.length,
evidence: ArtifactClassificationEvidence,
fileSize = evidence.prefix.length,
): {
readonly kind: ArtifactOccurrence["artifact_kind"];
readonly format: ArtifactOccurrence["artifact_format"];
} => {
const { prefix, asarHeader } = evidence;
const format = classifyArtifactBytes(prefix, fileSize);
const byPath = classifyArtifactPath(path);
if (
@@ -491,27 +494,19 @@ export const classifyArtifactContent = (
if (
format === "file" &&
path.toLowerCase().endsWith(".asar") &&
hasAsarHeader(prefix)
)
return { kind: "container", format: "asar" };
asarHeader.state !== "rejected"
) {
if (asarHeader.state === "limit")
throw new ArtifactReaderFailure(
"limit",
"ASAR header exceeds the bounded classification or decode budget",
);
if (asarHeader.state === "matched")
return { kind: "container", format: "asar" };
}
return artifactRoleForFormat(path, format);
};
/**
* Electron ASAR starts with a size pickle whose payload is one uint32, then
* a header pickle whose payload begins with a JSON object.
*/
export const hasAsarHeader = (prefix: Buffer): boolean => {
if (prefix.length < 17 || prefix.readUInt32LE(0) !== 4) return false;
const headerSize = prefix.readUInt32LE(4);
const payloadSize = prefix.readUInt32LE(8);
if (headerSize < 16 || payloadSize !== headerSize - 4) return false;
const stringLength = prefix.readUInt32LE(12);
return (
stringLength >= 2 && stringLength <= payloadSize - 4 && prefix[16] === 0x7b
);
};
const artifactRoleForFormat = (
path: string,
format: ArtifactOccurrence["artifact_format"],
+46 -42
View File
@@ -5,14 +5,8 @@ import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.j
import type { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
import {
abortIfNeeded,
ARTIFACT_CLASSIFICATION_PREFIX_BYTES,
} from "../ArtifactHash.js";
import {
createXmlPlistSignatureProbe,
MAX_XML_PLIST_PROBE_BYTES,
} from "../../domain/apple/appleArchiveFormat.js";
import { abortIfNeeded } from "../ArtifactHash.js";
import { createArtifactClassificationProbe } from "../ArtifactClassificationProbe.js";
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
import {
hasZipSignature,
@@ -143,41 +137,36 @@ const classifyRootFormat = async (
handle: FileHandle,
signal?: AbortSignal,
): Promise<ArtifactOccurrence["artifact_format"]> => {
const magic = Buffer.alloc(ARTIFACT_CLASSIFICATION_PREFIX_BYTES);
const observed = await handle.read(magic, 0, magic.length, 0);
let prefix = magic.subarray(0, observed.bytesRead);
const lower = path.toLowerCase();
if (hasZipSignature(prefix)) return zipRootPackageFormat(lower);
if (
lower.endsWith(".pkg") &&
prefix.subarray(0, 4).equals(Buffer.from("xar!"))
)
return "pkg";
if (lower.endsWith(".dmg") && (await hasKolyTrailer(handle))) return "dmg";
const xmlProbe = createXmlPlistSignatureProbe();
xmlProbe.write(prefix);
const chunks = [prefix];
let prefixBytes = prefix.length;
while (
xmlProbe.state === "pending" &&
prefixBytes < MAX_XML_PLIST_PROBE_BYTES
) {
const probe = createArtifactClassificationProbe();
const chunk = Buffer.alloc(64 * 1024);
while (probe.length < probe.limit) {
abortIfNeeded(signal);
const chunk = Buffer.alloc(
Math.min(64 * 1024, MAX_XML_PLIST_PROBE_BYTES - prefixBytes),
const read = await handle.read(
chunk,
0,
Math.min(chunk.length, probe.limit - probe.length),
probe.length,
);
const read = await handle.read(chunk, 0, chunk.length, prefixBytes);
abortIfNeeded(signal);
if (read.bytesRead === 0) break;
const selected = chunk.subarray(0, read.bytesRead);
chunks.push(selected);
prefixBytes += selected.length;
xmlProbe.write(selected);
probe.write(chunk.subarray(0, read.bytesRead));
}
prefix = Buffer.concat(chunks);
// ASAR is chosen from its header pickle; any other suffix is a role hint.
return classifyArtifactContent(path, prefix, (await handle.stat()).size)
.format;
const archiveBytes = (await handle.stat()).size;
const evidence = probe.finish(archiveBytes);
abortIfNeeded(signal);
const lower = path.toLowerCase();
if (hasZipSignature(evidence.prefix)) return zipRootPackageFormat(lower);
if (
lower.endsWith(".pkg") &&
evidence.prefix.subarray(0, 4).equals(Buffer.from("xar!"))
)
return "pkg";
if (
lower.endsWith(".dmg") &&
(await hasKolyTrailer(handle, archiveBytes, signal))
)
return "dmg";
return classifyArtifactContent(path, evidence, archiveBytes).format;
};
/** App Pack is a verified ZIP root role; nested .app members retain their observed format. */
@@ -186,10 +175,25 @@ const zipRootPackageFormat = (lower: string): ZipPackageFormat =>
? "app-pack"
: (zipPackageFormatForPath(lower) ?? "zip");
const hasKolyTrailer = async (handle: FileHandle): Promise<boolean> => {
const size = (await handle.stat()).size;
const hasKolyTrailer = async (
handle: FileHandle,
size: number,
signal?: AbortSignal,
): Promise<boolean> => {
if (size < 512) return false;
const trailer = Buffer.alloc(4);
const read = await handle.read(trailer, 0, trailer.length, size - 512);
return read.bytesRead === 4 && trailer.equals(Buffer.from("koly"));
let bytes = 0;
while (bytes < trailer.length) {
abortIfNeeded(signal);
const read = await handle.read(
trailer,
bytes,
trailer.length - bytes,
size - 512 + bytes,
);
abortIfNeeded(signal);
if (read.bytesRead === 0) return false;
bytes += read.bytesRead;
}
return trailer.equals(Buffer.from("koly"));
};
+1 -1
View File
@@ -317,7 +317,7 @@ const digestArtifactEntry = async (
const classified =
entry.kind === "slice"
? ({ kind: "universal-slice", format: "mach-o" } as const)
: classifyArtifactContent(logicalPath, digest.prefix, digest.bytes);
: classifyArtifactContent(logicalPath, digest, digest.bytes);
return {
node: createArtifactNode({
sha256: digest.sha256,
+42 -11
View File
@@ -12,11 +12,10 @@ import {
type BrowserTargetList,
type WebPageInspection,
} from "../domain/browserObservationSchemas.js";
import { analyzeCapturedWebBundle } from "../domain/webBundleAnalyzer.js";
import {
webBundleAnalysisSchema,
type AnalyzeWebBundleInput,
type WebBundleAnalysis,
import { IsolatedWebBundleAnalyzer } from "./IsolatedWebBundleAnalyzer.js";
import type {
AnalyzeWebBundleInput,
WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import {
webObservationSessionSchema,
@@ -93,9 +92,31 @@ const CLEANUP_DOMAINS = [
/** Passive browser observation through a selected user-owned CDP endpoint. */
export class CdpBrowserProvider implements BrowserObservationPort {
readonly #owner = new CdpTargetSessionOwner();
readonly #bundleAnalyzer = new IsolatedWebBundleAnalyzer();
close(): Promise<void> {
return this.#owner.close();
async close(): Promise<void> {
const outcomes = await Promise.allSettled([
this.#owner.close(),
this.#bundleAnalyzer.close(),
]);
const failures = outcomes.flatMap((outcome) =>
outcome.status === "rejected" ? [outcome.reason as unknown] : [],
);
if (failures.length === 1) throw failures[0];
if (failures.length > 1) {
const typed = failures.filter(
(failure): failure is AnalysisError => failure instanceof AnalysisError,
);
throw new ProviderCleanupError(
CDP_BROWSER_PROVIDER_IDENTITY.id,
typed.flatMap((failure) => failure.cleanupResources),
{ reason: "Browser transport and analyzer cleanup remain incomplete" },
{
operation: "analyze_web_bundle",
cause: new AggregateError(failures),
},
).bindCleanupDependencies(typed);
}
}
identity(): ProviderIdentity {
@@ -166,6 +187,8 @@ export class CdpBrowserProvider implements BrowserObservationPort {
input: AnalyzeWebBundleInput,
options: ExecutionOptions = {},
): Promise<Result<WebBundleAnalysis, AnalysisError>> {
const admission = await this.#bundleAnalyzer.reserve();
if (!admission.ok) return admission;
let captured: CapturedPage | undefined;
try {
captured = await this.#capture(input, options, "analyze_web_bundle");
@@ -182,10 +205,16 @@ export class CdpBrowserProvider implements BrowserObservationPort {
options.signal,
)
: undefined;
return ok(
webBundleAnalysisSchema.parse(
analyzeCapturedWebBundle(captured.inspection, sourceMaps),
),
return await this.#bundleAnalyzer.analyze(
captured.inspection,
sourceMaps ?? {
status: "not_requested",
requested: 0,
processed: 0,
items: [],
},
input,
options.signal,
);
} catch (cause: unknown) {
// fetchWebSourceMaps rethrows the exact abort reason. Preserve cancellation
@@ -201,6 +230,8 @@ export class CdpBrowserProvider implements BrowserObservationPort {
);
}
return err(providerError(cause, "analyze_web_bundle", options.signal));
} finally {
this.#bundleAnalyzer.releaseReservation();
}
}
+5 -2
View File
@@ -1,10 +1,11 @@
import { inferJsonShape, type JsonShape } from "../domain/jsonShape.js";
import { decodePaddedBase64 } from "../domain/paddedBase64.js";
import {
recordValue,
cdpStringValue,
type UnknownRecord,
} from "./CdpCaptureValues.js";
import { decodeBase64, isJsonContentType } from "./CdpCaptureEventHelpers.js";
import { isJsonContentType } from "./CdpCaptureEventHelpers.js";
import type { CdpCaptureEventsState } from "./CdpCaptureEventState.js";
import type { NetworkState } from "./CdpCaptureEventTypes.js";
@@ -20,7 +21,9 @@ export const ingestResponseBodyShape = (
invalidResponseBodyShape(state, requestId);
return;
}
const decoded = base64Encoded ? decodeBase64(body)?.toString("utf8") : body;
const decoded = base64Encoded
? decodePaddedBase64(body)?.toString("utf8")
: body;
if (decoded === undefined) {
invalidResponseBodyShape(state, requestId);
return;
+5 -4
View File
@@ -1,6 +1,7 @@
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import { inferJsonShape } from "../domain/jsonShape.js";
import { paddedBase64ByteLength } from "../domain/paddedBase64.js";
import { safeResponseMetadata } from "./CdpSafeMetadata.js";
import {
allowedSanitizedUrl,
@@ -18,7 +19,6 @@ import {
} from "./CdpCaptureEventBodyShapes.js";
import {
consolePrimitive,
decodeBase64,
exclusionReasonForUrl,
executionContextKey,
firstCallFrame,
@@ -508,14 +508,15 @@ export const handleWebSocketFrame = (
const response = recordValue(params.response);
const payload = cdpStringValue(response?.payloadData) ?? "";
const opcode = Math.max(0, Math.trunc(numberValue(response?.opcode) ?? 0));
const decoded = opcode === 1 ? undefined : decodeBase64(payload);
if (opcode !== 1 && decoded === undefined)
const decodedBytes =
opcode === 1 ? undefined : paddedBase64ByteLength(payload);
if (opcode !== 1 && decodedBytes === undefined)
state.completeness.exclude("websocket_frames", "invalid_protocol_value");
connection.events.push({
direction,
opcode,
payload_bytes:
opcode === 1 ? Buffer.byteLength(payload) : (decoded?.byteLength ?? null),
opcode === 1 ? Buffer.byteLength(payload) : (decodedBytes ?? null),
payload_shape: captureWebSocketShape(state, payload, opcode),
});
};
-7
View File
@@ -111,10 +111,3 @@ export const consolePrimitive = (
return undefined;
}
};
const BASE64 = /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/u;
export const decodeBase64 = (value: string): Buffer | undefined =>
value.length % 4 === 0 && BASE64.test(value)
? Buffer.from(value, "base64")
: undefined;
+4 -14
View File
@@ -4,6 +4,7 @@ import canonicalize from "canonicalize";
import type { InspectWebPageInput } from "../domain/browserObservation.js";
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import { decodePaddedBase64 } from "../domain/paddedBase64.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { CdpConnection } from "./CdpConnection.js";
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
@@ -413,13 +414,13 @@ const addCacheEntry = async (
run.limitations,
);
const body = cdpStringValue(recordValue(recordValue(raw)?.response)?.body);
const decoded = body === undefined ? null : decodeBase64(body);
const complete = decoded !== null;
const decoded = body === undefined ? undefined : decodePaddedBase64(body);
const complete = decoded !== undefined;
if (!complete) run.state.complete = false;
addFingerprint(run.state, {
scope: "cache_entry",
identity: { cache: cache.name, requestURL, requestMethod, requestHeaders },
value: decoded ?? undefined,
value: decoded,
complete,
});
};
@@ -541,14 +542,3 @@ const remoteObjectComplete = (value: unknown): boolean => {
return false;
}
};
const decodeBase64 = (value: string): Buffer | null => {
if (
value.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
value,
)
)
return null;
return Buffer.from(value, "base64");
};
+30
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { delayWithCancellation } from "./CdpCaptureValues.js";
describe("cancellable browser observation delays", () => {
@@ -24,4 +25,33 @@ describe("cancellable browser observation delays", () => {
await waiting;
expect(completed).toBe(true);
});
it("preserves exact cancellation reasons before and during a capture wait", async () => {
const preAbortedReason = { _tag: "pre-aborted" };
const preAborted = new AbortController();
preAborted.abort(preAbortedReason);
const beforeStart = await delayWithCancellation(
1,
"observe_web_execution",
preAborted.signal,
).catch((error: unknown) => error);
expect(beforeStart).toBeInstanceOf(AnalysisCancelledError);
expect(beforeStart).toMatchObject({ cause: preAbortedReason });
vi.useFakeTimers();
const reason = new Error("capture wait cancelled");
const active = new AbortController();
const waiting = delayWithCancellation(
10_000,
"observe_web_execution",
active.signal,
).catch((error: unknown) => error);
active.abort(reason);
const duringWait = await waiting;
expect(duringWait).toBeInstanceOf(AnalysisCancelledError);
expect(duringWait).toMatchObject({ cause: reason });
expect(vi.getTimerCount()).toBe(0);
});
});
+5 -2
View File
@@ -125,7 +125,8 @@ export const delayWithCancellation = async (
const maximumTimerDelay = 2_147_483_647;
let remaining = durationMs;
while (remaining > 0) {
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
if (signal?.aborted === true)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
const delay = Math.min(remaining, maximumTimerDelay);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
@@ -135,7 +136,9 @@ export const delayWithCancellation = async (
const onAbort = (): void => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(new AnalysisCancelledError(operation));
reject(
new AnalysisCancelledError(operation, { cause: signal?.reason }),
);
};
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted === true) onAbort();
+15 -9
View File
@@ -66,7 +66,8 @@ export class CdpConnection {
limits?: { readonly maxPayloadBytes: number },
onConnected?: (connection: CdpConnection) => void,
): Promise<CdpConnection> {
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
if (signal?.aborted === true)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
const maxPayloadBytes =
limits === undefined
? WEB_RUNTIME_LIMITS.protocolBytes
@@ -152,7 +153,9 @@ export class CdpConnection {
if (this.#closed || this.socket.readyState !== WebSocket.OPEN)
throw this.#transportError(this.#transportFailure);
if (signal?.aborted === true)
throw new AnalysisCancelledError(this.operation);
throw new AnalysisCancelledError(this.operation, {
cause: signal.reason,
});
const id = this.#nextId;
this.#nextId += 1;
const message = JSON.stringify({
@@ -166,7 +169,11 @@ export class CdpConnection {
const pending = this.#pending.get(id);
if (pending === undefined) return;
this.#complete(id, pending);
reject(new AnalysisCancelledError(this.operation));
reject(
new AnalysisCancelledError(this.operation, {
cause: signal?.reason,
}),
);
};
const timer = setTimeout(() => {
const pending = this.#pending.get(id);
@@ -446,11 +453,9 @@ const waitForOpen = async (
const onFailure = (cause: unknown): void => {
cleanup();
reject(
signal?.aborted === true
? new AnalysisCancelledError(operation)
: new BrowserObservationError(operation, "endpoint_unreachable", {
cause,
}),
new BrowserObservationError(operation, "endpoint_unreachable", {
cause,
}),
);
};
const onClose = (code: number, reason: Buffer): void => {
@@ -461,8 +466,9 @@ const waitForOpen = async (
);
};
const onAbort = (): void => {
cleanup();
terminateSocket(socket);
onFailure(new AnalysisCancelledError(operation));
reject(new AnalysisCancelledError(operation, { cause: signal?.reason }));
};
const cleanup = (): void => {
clearTimeout(timer);
+72
View File
@@ -159,3 +159,75 @@ describe("Electron frame capture", () => {
});
});
});
it("admits a prefix and fitting suffix around an oversized Electron source", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-electron-source-budget-"));
temporary.push(root);
const paths = await Promise.all(
["prefix.js", "oversized.js", "suffix.js"].map(async (name) => {
const path = join(root, name);
await writeFile(path, "metadata");
return path;
}),
);
const sourceById = {
prefix: "p".repeat(16 * 1024 * 1024),
oversized: "o".repeat(17 * 1024 * 1024),
suffix: "s".repeat(1024 * 1024),
};
const scripts: ElectronScriptDraft[] = paths.map((path, index) => {
const scriptId = ["prefix", "oversized", "suffix"][index];
if (scriptId === undefined) throw new Error("Missing script identity");
return {
scriptId,
rawUrl: pathToFileURL(path).href,
hash: scriptId,
length: null,
isModule: false,
language: "JavaScript",
frameId: null,
};
});
const completeness = new CdpCaptureCompleteness();
const capture = await captureElectronScripts({
connection: {
send: async (
_method: string,
params: Readonly<Record<string, unknown>>,
) => ({
scriptSource:
sourceById[
(params as { scriptId: keyof typeof sourceById }).scriptId
],
}),
} as unknown as CdpConnection,
sessionId: undefined,
request: inspectElectronPageInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "page-1",
observation_ms: 0,
include_script_sources: true,
}),
scripts,
frameIds: new Set(),
completeness,
});
const byHash = new Map(
capture.items.map((item) => [item.cdp_hash, item.source]),
);
expect(capture.total).toBe(3);
expect([...byHash.keys()].sort()).toEqual(["oversized", "prefix", "suffix"]);
expect(byHash.get("prefix")?.included).toBe(true);
expect(byHash.get("oversized")).toEqual({
included: false,
reason:
"script source was omitted because the aggregate 32 MiB source budget was exhausted",
});
expect(byHash.get("suffix")?.included).toBe(true);
expect(completeness.snapshot().excluded).toContainEqual({
section: "script_sources",
reason: "resource_budget_exhausted",
count: 1,
});
});
+41 -6
View File
@@ -5,6 +5,7 @@ import type {
InspectElectronPageInput,
} from "../domain/javascript/electronObservation.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { CdpConnection, CdpEvent } from "./CdpConnection.js";
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
@@ -153,7 +154,11 @@ const authorizeInitialFrames = async (
context.sessionId,
context.signal,
);
const frames = await captureFrames(result, state.completeness);
const frames = await captureFrames(
result,
state.completeness,
context.signal,
);
if (frames[0] === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
return frames;
@@ -219,7 +224,7 @@ const assertStableMainFrame = async (
context.sessionId,
context.signal,
);
const afterPath = await mainFilePath(result);
const afterPath = await mainFilePath(result, context.signal);
if (afterPath === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
if (state.navigationDuringCapture || afterPath !== expectedPath)
@@ -239,6 +244,7 @@ const navigatedFrameId = (event: CdpEvent): string | undefined => {
export const captureFrames = async (
result: unknown,
completeness: CdpCaptureCompleteness,
signal?: AbortSignal,
): Promise<ElectronPageInspection["frames"]> => {
const frameTree = recordValue(requiredRecord(result).frameTree);
if (frameTree === undefined)
@@ -247,13 +253,18 @@ export const captureFrames = async (
for (const tree of walkFrameTrees(frameTree, () =>
completeness.exclude("frames", "invalid_protocol_value"),
)) {
throwIfElectronInspectionCancelled(signal);
const frame = recordValue(tree.frame);
const frameId = cdpStringValue(frame?.id);
if (frame === undefined || frameId === undefined) {
completeness.exclude("frames", "invalid_protocol_value");
continue;
}
const path = await authorizedElectronFile(cdpStringValue(frame.url) ?? "");
const path = await authorizedElectronFile(
cdpStringValue(frame.url) ?? "",
signal,
"inspect_electron_page",
);
if (path === undefined) {
completeness.exclude("frames", "out_of_target_scope");
continue;
@@ -285,8 +296,11 @@ const captureResources = async (
for (const resource of frameTreeResources(root, (count) =>
completeness.exclude("resources", "invalid_protocol_value", count),
)) {
throwIfElectronInspectionCancelled(context.signal);
const path = await authorizedElectronFile(
cdpStringValue(resource.url) ?? "",
context.signal,
"inspect_electron_page",
);
if (path === undefined) {
completeness.exclude("resources", "out_of_target_scope");
@@ -336,12 +350,19 @@ const captureDom = async (
const nodes: ElectronPageInspection["dom"]["nodes"] = [];
let total = 0;
for (const document of snapshotDocuments(result.documents, invalid)) {
throwIfElectronInspectionCancelled(context.signal);
const documentUrl = cdpSnapshotString(strings, document.documentURL);
if (documentUrl === undefined) {
invalid(1);
continue;
}
if ((await authorizedElectronFile(documentUrl)) === undefined) {
if (
(await authorizedElectronFile(
documentUrl,
context.signal,
"inspect_electron_page",
)) === undefined
) {
completeness.exclude("dom", "out_of_target_scope");
continue;
}
@@ -376,10 +397,24 @@ const captureDom = async (
return { total_nodes: total, nodes };
};
const mainFilePath = async (result: unknown): Promise<string | undefined> => {
const mainFilePath = async (
result: unknown,
signal?: AbortSignal,
): Promise<string | undefined> => {
const frameTree = recordValue(requiredRecord(result).frameTree);
const frame = recordValue(frameTree?.frame);
return await authorizedElectronFile(cdpStringValue(frame?.url) ?? "");
return await authorizedElectronFile(
cdpStringValue(frame?.url) ?? "",
signal,
"inspect_electron_page",
);
};
const throwIfElectronInspectionCancelled = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new AnalysisCancelledError("inspect_electron_page", {
cause: signal.reason,
});
};
const report = async (
+208 -19
View File
@@ -15,11 +15,11 @@ import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
discoverCdpEndpoint,
hasCdpTargetWebSocket,
type CdpEndpointDiscovery,
type CdpEndpointTarget,
} from "./CdpEndpoint.js";
import {
@@ -28,7 +28,11 @@ import {
type CdpTargetSession,
} from "./CdpTargetSession.js";
import { inspectCdpElectronPage } from "./CdpElectronInspection.js";
import { authorizedElectronFile } from "./ElectronFileScope.js";
import {
authorizedElectronFile,
mapElectronFileAuthorizationFailure,
retryElectronFileCleanup,
} from "./ElectronFileScope.js";
import { CDP_ELECTRON_PROVIDER_IDENTITY } from "./providerIdentities.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
@@ -41,9 +45,58 @@ import {
/** Passive Electron provider for local file pages exposed by loopback CDP. */
export class CdpElectronProvider implements ElectronObservationPort {
readonly #owner = new CdpTargetSessionOwner();
#activeOperations = 0;
#closing = false;
#closePromise: Promise<void> | undefined;
readonly #idleWaiters = new Set<() => void>();
close(): Promise<void> {
return this.#owner.close();
async close(): Promise<void> {
if (this.#closePromise === undefined) {
this.#closing = true;
const attempt = this.#closeAfterOperations();
let tracked: Promise<void>;
tracked = attempt.catch((cause: unknown) => {
if (this.#closePromise === tracked) this.#closePromise = undefined;
throw cause;
});
this.#closePromise = tracked;
}
await this.#closePromise;
}
async #closeAfterOperations(): Promise<void> {
while (this.#activeOperations > 0)
await new Promise<void>((resolve) => this.#idleWaiters.add(resolve));
const outcomes = await Promise.allSettled([
this.#owner.close(),
retryElectronFileCleanup(),
]);
const failures = outcomes.flatMap((outcome) =>
outcome.status === "rejected" ? [outcome.reason as unknown] : [],
);
const mapped = failures.map(
(failure) =>
mapElectronFileAuthorizationFailure(
failure,
CDP_ELECTRON_PROVIDER_IDENTITY.id,
"inspect_electron_page",
) ?? failure,
);
if (mapped.length === 1) throw mapped[0];
if (mapped.length > 1) {
const typed = mapped.filter(
(failure): failure is AnalysisError => failure instanceof AnalysisError,
);
throw new ProviderCleanupError(
CDP_ELECTRON_PROVIDER_IDENTITY.id,
typed.flatMap((failure) => failure.cleanupResources),
{ reason: "Electron transport and file cleanup remain incomplete" },
{
operation: "inspect_electron_page",
cause: new AggregateError(mapped),
},
).bindCleanupDependencies(typed);
}
}
identity(): ProviderIdentity {
@@ -54,30 +107,58 @@ export class CdpElectronProvider implements ElectronObservationPort {
input: ListElectronTargetsInput,
options: ExecutionOptions = {},
): Promise<Result<ElectronTargetList, AnalysisError>> {
return await this.#withAdmission(
"list_electron_targets",
async () => await this.#listTargets(input, options),
);
}
async #listTargets(
input: ListElectronTargetsInput,
options: ExecutionOptions,
): Promise<Result<ElectronTargetList, AnalysisError>> {
const allowed: ElectronTargetList["targets"] = [];
let discovery: CdpEndpointDiscovery | undefined;
let unsupportedUrl = 0;
let nonPage = 0;
let unconnectable = 0;
let examined = 0;
try {
const discovery = await discoverCdpEndpoint(
discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
"list_electron_targets",
options.signal,
);
const allowed = [];
let unsupportedUrl = 0;
let nonPage = 0;
let unconnectable = 0;
throwIfElectronOperationCancelled(
options.signal,
"list_electron_targets",
);
for (const target of discovery.targets) {
throwIfElectronOperationCancelled(
options.signal,
"list_electron_targets",
);
if (target.type !== "page") {
examined += 1;
nonPage += 1;
continue;
}
const path = await authorizedElectronFile(target.url);
const path = await authorizedElectronFile(
target.url,
options.signal,
"list_electron_targets",
);
if (path === undefined) {
examined += 1;
unsupportedUrl += 1;
continue;
}
if (!hasCdpTargetWebSocket(discovery, target)) {
examined += 1;
unconnectable += 1;
continue;
}
examined += 1;
allowed.push({
target_id: target.id,
type: target.type,
@@ -89,6 +170,10 @@ export class CdpElectronProvider implements ElectronObservationPort {
allowed.sort((left, right) =>
compareUnicodeCodePoints(left.target_id, right.target_id),
);
throwIfElectronOperationCancelled(
options.signal,
"list_electron_targets",
);
return ok(
electronTargetListSchema.parse({
browser: discovery.version,
@@ -113,13 +198,65 @@ export class CdpElectronProvider implements ElectronObservationPort {
}),
);
} catch (cause: unknown) {
return err(providerError(cause, "list_electron_targets"));
const fileFailure = mapElectronFileAuthorizationFailure(
cause,
CDP_ELECTRON_PROVIDER_IDENTITY.id,
"list_electron_targets",
{
completed_prefix: {
browser_version: discovery?.version ?? null,
examined_targets: examined,
discovered_targets: discovery?.targets.length ?? null,
allowed_targets: allowed,
excluded: {
unsupported_url: unsupportedUrl,
non_page: nonPage,
},
unconnectable_targets: unconnectable,
coverage:
"partial: target authorization stopped; remaining targets are unknown",
},
},
);
if (fileFailure !== undefined) return err(fileFailure);
return err(
options.signal?.aborted === true
? new AnalysisCancelledError("list_electron_targets", {
cause: options.signal.reason,
diagnostics: {
completed_prefix: {
browser_version: discovery?.version ?? null,
examined_targets: examined,
discovered_targets: discovery?.targets.length ?? null,
allowed_targets: allowed,
excluded: {
unsupported_url: unsupportedUrl,
non_page: nonPage,
},
unconnectable_targets: unconnectable,
coverage:
"partial: target authorization stopped; remaining targets are unknown",
},
},
})
: providerError(cause, "list_electron_targets"),
);
}
}
async inspectPage(
input: InspectElectronPageInput,
options: ExecutionOptions = {},
): Promise<Result<ElectronPageInspection, AnalysisError>> {
return await this.#withAdmission(
"inspect_electron_page",
async () => await this.#inspectPage(input, options),
);
}
async #inspectPage(
input: InspectElectronPageInput,
options: ExecutionOptions,
): Promise<Result<ElectronPageInspection, AnalysisError>> {
let targetSession: CdpTargetSession | undefined;
let cleanupFailure: AnalysisError | undefined;
@@ -130,7 +267,11 @@ export class CdpElectronProvider implements ElectronObservationPort {
"inspect_electron_page",
options.signal,
);
const target = await authorizeTarget(discovery.targets, input.target_id);
const target = await authorizeTarget(
discovery.targets,
input.target_id,
options.signal,
);
targetSession = await openCdpTargetSession(
discovery,
target,
@@ -241,31 +382,79 @@ export class CdpElectronProvider implements ElectronObservationPort {
if (finalCancellation !== undefined) return err(finalCancellation);
return result;
}
async #withAdmission<Value>(
operation: "list_electron_targets" | "inspect_electron_page",
work: () => Promise<Result<Value, AnalysisError>>,
): Promise<Result<Value, AnalysisError>> {
if (this.#closing)
return err(
new ProviderAdapterError(CDP_ELECTRON_PROVIDER_IDENTITY.id, operation, {
diagnostics: { reason: "provider_closed" },
userMessage: "Electron provider is closed to new observations.",
}),
);
this.#activeOperations += 1;
try {
return await work();
} catch (cause: unknown) {
return err(providerError(cause, operation));
} finally {
this.#activeOperations -= 1;
if (this.#activeOperations === 0) {
for (const resolve of this.#idleWaiters) resolve();
this.#idleWaiters.clear();
}
}
}
}
const authorizeTarget = async (
targets: readonly CdpEndpointTarget[],
targetId: string,
signal?: AbortSignal,
): Promise<CdpEndpointTarget> => {
throwIfElectronOperationCancelled(signal, "inspect_electron_page");
const target = targets.find(({ id }) => id === targetId);
if (target === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_found");
if (
target.type !== "page" ||
(await authorizedElectronFile(target.url)) === undefined
)
if (target.type !== "page")
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
const path = await authorizedElectronFile(
target.url,
signal,
"inspect_electron_page",
);
throwIfElectronOperationCancelled(signal, "inspect_electron_page");
if (path === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
return target;
};
const throwIfElectronOperationCancelled = (
signal: AbortSignal | undefined,
operation: "list_electron_targets" | "inspect_electron_page",
): void => {
if (signal?.aborted === true)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
};
const providerError = (
cause: unknown,
operation: BrowserObservationOperation,
): AnalysisError =>
cause instanceof BrowserObservationError && cause.operation !== operation
operation: "list_electron_targets" | "inspect_electron_page",
): AnalysisError => {
const fileFailure = mapElectronFileAuthorizationFailure(
cause,
CDP_ELECTRON_PROVIDER_IDENTITY.id,
operation,
);
if (fileFailure !== undefined) return fileFailure;
return cause instanceof BrowserObservationError &&
cause.operation !== operation
? new BrowserObservationError(operation, cause.reason, { cause })
: cause instanceof AnalysisError
? cause
: new ProviderAdapterError(CDP_ELECTRON_PROVIDER_IDENTITY.id, operation, {
cause,
});
};
+106 -48
View File
@@ -10,6 +10,12 @@ import { requiredScriptSource } from "./CdpCaptureValues.js";
import type { ElectronScriptDraft } from "./CdpElectronScriptEvents.js";
import { authorizedElectronFile } from "./ElectronFileScope.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
CdpScriptSourceBudget,
} from "./CdpScriptSourceBudget.js";
interface ElectronScriptCaptureInput {
readonly connection: CdpConnection;
@@ -28,33 +34,70 @@ export const captureElectronScripts = async (
let total = 0;
const items: ElectronPageInspection["scripts"]["items"] = [];
const seen = new Set<string>();
for (const script of input.scripts) {
const path = await authorizedElectronFile(script.rawUrl);
if (path === undefined) {
input.completeness.exclude("scripts", "out_of_target_scope");
continue;
const sourceBudget =
input.request.include_script_sources && input.scripts.length > 0
? new CdpScriptSourceBudget(input.signal)
: undefined;
try {
for (const script of input.scripts) {
throwIfCallerCancelled(input.signal);
const path = await authorizedElectronFile(
script.rawUrl,
input.signal,
"inspect_electron_page",
);
if (path === undefined) {
input.completeness.exclude("scripts", "out_of_target_scope");
continue;
}
const identity = {
frame_id:
script.frameId !== null && input.frameIds.has(script.frameId)
? script.frameId
: null,
file_path: path,
cdp_hash: script.hash,
length: script.length,
is_module: script.isModule,
language: script.language,
};
const scriptKey = `electron_script_${digestCanonicalValue(identity, "CDP capture")}`;
if (seen.has(scriptKey)) continue;
seen.add(scriptKey);
total += 1;
let source: ElectronScriptSource = input.request.include_script_sources
? {
included: false,
reason: CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
}
: {
included: false,
reason: "source capture was not selected",
};
if (
input.request.include_script_sources &&
isSourceDeadlineExpired(sourceBudget)
) {
excludeScriptSource(input.completeness);
} else if (
input.request.include_script_sources &&
sourceBudget !== undefined
) {
try {
source = await captureScriptSource(input, script, sourceBudget);
} catch (cause: unknown) {
throwIfCallerCancelled(input.signal);
if (isSourceDeadlineExpired(sourceBudget)) {
excludeScriptSource(input.completeness);
} else {
throw cause;
}
}
}
items.push({ script_key: scriptKey, ...identity, source });
}
const identity = {
frame_id:
script.frameId !== null && input.frameIds.has(script.frameId)
? script.frameId
: null,
file_path: path,
cdp_hash: script.hash,
length: script.length,
is_module: script.isModule,
language: script.language,
};
const scriptKey = `electron_script_${digestCanonicalValue(identity, "CDP capture")}`;
if (seen.has(scriptKey)) continue;
seen.add(scriptKey);
total += 1;
const capturedSource = await captureScriptSource(input, script);
items.push({
script_key: scriptKey,
...identity,
source: capturedSource.source,
});
} finally {
sourceBudget?.dispose();
}
if (!input.request.include_script_sources)
input.completeness.exclude("script_sources", "not_approved", total);
@@ -72,29 +115,44 @@ type ElectronScriptSource =
const captureScriptSource = async (
input: ElectronScriptCaptureInput,
script: ElectronScriptDraft,
): Promise<{
readonly source: ElectronScriptSource;
}> => {
if (!input.request.include_script_sources)
return {
source: {
included: false,
reason: "source capture was not selected",
},
};
const text = requiredScriptSource(
await input.connection.send(
"Debugger.getScriptSource",
{ scriptId: script.scriptId },
input.sessionId,
input.signal,
),
"inspect_electron_page",
sourceBudget: CdpScriptSourceBudget,
): Promise<ElectronScriptSource> => {
const response = await input.connection.send(
"Debugger.getScriptSource",
{ scriptId: script.scriptId },
input.sessionId,
sourceBudget.signal,
);
throwIfCallerCancelled(input.signal);
const text = requiredScriptSource(response, "inspect_electron_page");
if (sourceBudget.timedOut) {
excludeScriptSource(input.completeness);
return {
included: false,
reason: CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
};
}
if (!sourceBudget.admit(text)) {
excludeScriptSource(input.completeness);
return { included: false, reason: CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON };
}
return {
source: {
included: true,
artifact: createWebTextArtifact(text, "text/javascript"),
},
included: true,
artifact: createWebTextArtifact(text, "text/javascript"),
};
};
const excludeScriptSource = (completeness: CdpCaptureCompleteness): void => {
completeness.exclude("script_sources", "resource_budget_exhausted");
};
const throwIfCallerCancelled = (signal: AbortSignal | undefined): void => {
if (signal?.aborted === true)
throw new AnalysisCancelledError("inspect_electron_page", {
cause: signal.reason,
});
};
const isSourceDeadlineExpired = (
sourceBudget: CdpScriptSourceBudget | undefined,
): boolean => sourceBudget?.timedOut === true;
+14 -1
View File
@@ -9,6 +9,7 @@ import type { CdpEndpointTarget } from "./CdpEndpoint.js";
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
import { authorizedElectronFile } from "./ElectronFileScope.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
/** Inventory local worker targets without attaching to them. */
export const captureElectronWorkers = async (input: {
@@ -42,6 +43,7 @@ export const captureElectronWorkers = async (input: {
return [];
}
for (const entry of targetInfos) {
throwIfElectronWorkerCaptureCancelled(input.signal);
const target = recordValue(entry);
if (target === undefined) {
input.completeness.exclude("workers", "invalid_protocol_value");
@@ -58,7 +60,11 @@ export const captureElectronWorkers = async (input: {
input.completeness.exclude("workers", "out_of_target_scope");
continue;
}
const path = await authorizedElectronFile(cdpStringValue(target.url) ?? "");
const path = await authorizedElectronFile(
cdpStringValue(target.url) ?? "",
input.signal,
"inspect_electron_page",
);
if (path === undefined) {
input.completeness.exclude("workers", "out_of_target_scope");
continue;
@@ -86,6 +92,13 @@ export const captureElectronWorkers = async (input: {
);
};
const throwIfElectronWorkerCaptureCancelled = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new AnalysisCancelledError("inspect_electron_page", {
cause: signal.reason,
});
};
const boundedTargetField = (value: unknown): string | null => {
const field = cdpStringValue(value);
return field === undefined || field === "" ? null : field;
+223
View File
@@ -0,0 +1,223 @@
import { createServer, type RequestListener, type Server } from "node:http";
import { once } from "node:events";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { afterEach, describe, expect, it } from "vitest";
import {
discoverCdpEndpoint,
readCdpJson,
withCdpDiscoveryDeadline,
} from "./CdpEndpoint.js";
const servers: Server[] = [];
const listen = async (
handler: RequestListener,
): Promise<{ readonly server: Server; readonly endpoint: string }> => {
const server = createServer(handler);
servers.push(server);
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Expected a TCP listener address.");
return {
server,
endpoint: `http://127.0.0.1:${String(address.port)}`,
};
};
afterEach(async () => {
await Promise.all(
servers.splice(0).map(async (server) => {
server.closeAllConnections();
if (server.listening) {
server.close();
await once(server, "close");
}
}),
);
});
describe("CDP discovery cancellation ownership", () => {
it("preserves the exact reason for a pre-aborted discovery request", async () => {
let requests = 0;
const { endpoint } = await listen((_request, response) => {
requests += 1;
response.end("{}");
});
const reason = { _tag: "caller-stop" };
const controller = new AbortController();
controller.abort(reason);
const failure = await readCdpJson(
new URL("/json/version", endpoint),
"inspect_web_page",
controller.signal,
).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(AnalysisCancelledError);
expect(failure).toMatchObject({ cause: reason });
expect(requests).toBe(0);
});
it("cancels an in-flight discovery at the HTTP producer with the original reason", async () => {
let requestedPath: string | undefined;
let requestSeen!: () => void;
const seen = new Promise<void>((resolve) => {
requestSeen = resolve;
});
const { endpoint } = await listen((request) => {
requestedPath = request.url;
requestSeen();
});
const reason = new Error("operator stopped discovery");
const controller = new AbortController();
const discovery = discoverCdpEndpoint(
endpoint,
"inspect_web_page",
controller.signal,
).catch((error: unknown) => error);
await seen;
controller.abort(reason);
const failure = await discovery;
expect(requestedPath).toBe("/json/version");
expect(failure).toBeInstanceOf(AnalysisCancelledError);
expect(failure).toMatchObject({ cause: reason });
});
it("does not replace a primary discovery failure when cancellation arrives later", async () => {
const primary = new BrowserObservationError(
"inspect_web_page",
"protocol_error",
);
const controller = new AbortController();
const failure = await withCdpDiscoveryDeadline(
"inspect_web_page",
controller.signal,
async () => {
throw primary;
},
100,
).catch((error: unknown) => error);
controller.abort(new Error("late caller cancellation"));
expect(failure).toBe(primary);
});
it("does not replace a plain physical failure rejected before a late abort", async () => {
const primary = new Error("socket failed before cancellation");
const controller = new AbortController();
const pending = withCdpDiscoveryDeadline(
"inspect_web_page",
controller.signal,
() => Promise.reject(primary),
100,
).catch((error: unknown) => error);
controller.abort(new Error("later caller cancellation"));
const failure = await pending;
expect(failure).toBe(primary);
});
it("classifies its own discovery deadline as timeout", async () => {
const failure = await withCdpDiscoveryDeadline(
"inspect_web_page",
undefined,
(signal) =>
new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(signal.reason), {
once: true,
});
}),
1,
).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(BrowserObservationError);
expect(failure).toMatchObject({ reason: "timeout" });
});
it("normalizes a typed cancellation caused by its own deadline to timeout", async () => {
const failure = await withCdpDiscoveryDeadline(
"inspect_web_page",
undefined,
(signal) =>
new Promise((_resolve, reject) => {
signal.addEventListener(
"abort",
() =>
reject(
new AnalysisCancelledError("inspect_web_page", {
cause: signal.reason,
}),
),
{ once: true },
);
}),
1,
).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(BrowserObservationError);
expect(failure).toMatchObject({ reason: "timeout" });
});
it("treats the caller's AnalysisError abort reason as cancellation when caller wins first", async () => {
const reason = new BrowserObservationError(
"inspect_web_page",
"protocol_error",
);
const controller = new AbortController();
const failure = await withCdpDiscoveryDeadline(
"inspect_web_page",
controller.signal,
async () => {
controller.abort(reason);
throw reason;
},
0,
).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(AnalysisCancelledError);
expect(failure).toMatchObject({ cause: reason });
});
it("keeps a deadline rejection when the caller aborts only after timeout", async () => {
const controller = new AbortController();
const failure = await withCdpDiscoveryDeadline(
"inspect_web_page",
controller.signal,
(signal) =>
new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(signal.reason), {
once: true,
});
}),
0,
).catch((error: unknown) => error);
controller.abort(new Error("caller stopped after timeout"));
expect(failure).toBeInstanceOf(BrowserObservationError);
expect(failure).toMatchObject({ reason: "timeout" });
});
});
it("preserves an independent typed cancellation when the discovery deadline also expires", async () => {
const primary = new AnalysisCancelledError("inspect_web_page", {
cause: "independent cancellation",
});
const failure = await withCdpDiscoveryDeadline(
"inspect_web_page",
undefined,
(signal) =>
new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(primary), { once: true });
}),
1,
).catch((cause: unknown) => cause);
expect(failure).toBe(primary);
});
+43 -13
View File
@@ -157,21 +157,50 @@ export const withCdpDiscoveryDeadline = async <T>(
timeoutMs: number = CDP_DISCOVERY_RESPONSE_LIMITS.timeoutMs,
): Promise<T> => {
const deadline = new AbortController();
const timer = setTimeout(() => deadline.abort(), timeoutMs);
let abortSource: "caller" | "deadline" | undefined;
let abortReason: unknown;
const abort = (source: "caller" | "deadline", reason: unknown): void => {
if (abortSource !== undefined) return;
abortSource = source;
abortReason = reason;
deadline.abort(reason);
};
const onCallerAbort = (): void => {
if (signal?.aborted === true) abort("caller", signal.reason);
};
const timer = setTimeout(
() => abort("deadline", new Error("CDP discovery deadline exceeded")),
timeoutMs,
);
signal?.addEventListener("abort", onCallerAbort, { once: true });
onCallerAbort();
try {
return await work(
signal === undefined
? deadline.signal
: AbortSignal.any([signal, deadline.signal]),
);
return await work(deadline.signal);
} catch (cause: unknown) {
if (deadline.signal.aborted && signal?.aborted !== true)
throw new BrowserObservationError(operation, "timeout", {
detail: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`,
});
if (cause === abortReason) {
if (abortSource === "caller")
throw new AnalysisCancelledError(operation, { cause: abortReason });
if (abortSource === "deadline")
throw new BrowserObservationError(operation, "timeout", {
detail: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`,
});
}
// A concrete failure produced before cancellation remains authoritative.
if (cause instanceof AnalysisError) {
if (
abortSource === "deadline" &&
cause instanceof AnalysisCancelledError &&
cause.cause === abortReason
)
throw new BrowserObservationError(operation, "timeout", {
detail: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`,
});
throw cause;
}
throw cause;
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onCallerAbort);
}
};
@@ -409,7 +438,7 @@ export const readCdpJson = async (
): Promise<unknown> =>
await new Promise((resolve, reject) => {
if (signal?.aborted === true) {
reject(new AnalysisCancelledError(operation));
reject(new AnalysisCancelledError(operation, { cause: signal.reason }));
return;
}
let response: IncomingMessage | undefined;
@@ -431,7 +460,7 @@ export const readCdpJson = async (
};
const onAbort = (): void => {
if (signal?.aborted === true)
finish(new AnalysisCancelledError(operation));
finish(new AnalysisCancelledError(operation, { cause: signal.reason }));
};
request_ = request(
url,
@@ -523,7 +552,8 @@ const endpointFailure = (
signal?: AbortSignal,
): AnalysisError => {
if (cause instanceof AnalysisError) return cause;
if (signal?.aborted === true) return new AnalysisCancelledError(operation);
if (signal?.aborted === true)
return new AnalysisCancelledError(operation, { cause: signal.reason });
return new BrowserObservationError(operation, "endpoint_unreachable", {
cause,
});
+184
View File
@@ -0,0 +1,184 @@
import { createHash } from "node:crypto";
import { expect, it, vi } from "vitest";
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
import { CdpCaptureEvents } from "./CdpCaptureEvents.js";
import { captureStorageFingerprints } from "./CdpCaptureStorageFingerprints.js";
import type { CdpConnection } from "./CdpConnection.js";
const ORIGIN = "https://example.test";
const CDP_ENDPOINT = "http://127.0.0.1:9222";
const BODY_BYTES = 4 * 1024 * 1024;
const captureCacheFingerprint = async (encodedBody: string) => {
const send = vi.fn(async (method: string) => {
if (method === "Network.getCookies") return { cookies: [] };
if (method === "CacheStorage.requestEntries")
return {
cacheDataEntries: [
{
requestURL: `${ORIGIN}/cached`,
requestMethod: "GET",
requestHeaders: [],
},
],
returnCount: 1,
};
if (method === "CacheStorage.requestCachedResponse")
return { response: { body: encodedBody } };
throw new Error(`Unexpected CDP command ${method}`);
});
const input = inspectWebPageInputSchema.parse({
cdp_endpoint: CDP_ENDPOINT,
allowed_origins: [ORIGIN],
target_id: "page",
});
return captureStorageFingerprints({
context: {
connection: { send } as unknown as CdpConnection,
sessionId: "page-session",
input,
},
origin: ORIGIN,
cookieUrl: `${ORIGIN}/`,
local: { items: [], complete: true },
session: { items: [], complete: true },
indexedDbNames: [],
caches: [{ name: "cache", id: "cache-id" }],
limitations: [],
});
};
const createEvents = (includeJsonBodyShapes = true): CdpCaptureEvents =>
new CdpCaptureEvents(
inspectWebPageInputSchema.parse({
cdp_endpoint: CDP_ENDPOINT,
allowed_origins: [ORIGIN],
target_id: "page",
include_json_body_shapes: includeJsonBodyShapes,
}),
new Set([ORIGIN]),
);
const startJsonResponse = (
events: CdpCaptureEvents,
requestId: string,
path: string,
): void => {
events.ingest({
method: "Network.requestWillBeSent",
params: {
requestId,
request: { url: `${ORIGIN}${path}`, method: "GET" },
initiator: { type: "other" },
timestamp: 1,
},
});
events.ingest({
method: "Network.responseReceived",
params: {
requestId,
response: {
url: `${ORIGIN}${path}`,
status: 200,
mimeType: "application/json",
},
},
});
};
it("fingerprints large cached bodies and retains CDP padding-bit acceptance", async () => {
const body = Buffer.alloc(BODY_BYTES, 0x81);
const large = await captureCacheFingerprint(body.toString("base64"));
expect(large.complete).toBe(true);
expect(large.items[0]?.value_sha256).toBe(
createHash("sha256").update(body).digest("hex"),
);
const padBits = await captureCacheFingerprint("e31=");
expect(padBits.items[0]?.value_sha256).toBe(
createHash("sha256").update(Buffer.from("e31=", "base64")).digest("hex"),
);
const malformed = await captureCacheFingerprint("%%%not-base64%%%");
expect(malformed.complete).toBe(false);
expect(malformed.items[0]?.value_sha256).toBeNull();
});
it("decodes large JSON response bodies and preserves malformed and pad-bit behavior", () => {
const events = createEvents();
startJsonResponse(events, "large-json", "/large.json");
const encoded = Buffer.from(
JSON.stringify({ value: "x".repeat(BODY_BYTES) }),
).toString("base64");
const candidate = events.responseBodyRequests()[0];
expect(candidate).toBeDefined();
events.ingestResponseBody(candidate!, {
body: encoded,
base64Encoded: true,
});
expect(events.network.get("large-json")?.body_shapes.response).toMatchObject({
root_type: "object",
node_count: 2,
});
startJsonResponse(events, "pad-bits", "/pad-bits.json");
const padBits = events
.responseBodyRequests()
.find(({ requestId }) => requestId === "pad-bits");
expect(padBits).toBeDefined();
events.ingestResponseBody(padBits!, {
body: "e31=",
base64Encoded: true,
});
expect(events.network.get("pad-bits")?.body_shapes.response?.root_type).toBe(
"object",
);
startJsonResponse(events, "malformed", "/malformed.json");
const malformed = events
.responseBodyRequests()
.find(({ requestId }) => requestId === "malformed");
expect(malformed).toBeDefined();
events.ingestResponseBody(malformed!, {
body: "%%%not-base64%%%",
base64Encoded: true,
});
expect(events.network.get("malformed")?.body_shapes.response).toBeNull();
});
it("counts large binary WebSocket frames without accepting malformed base64", () => {
const events = createEvents(false);
const encoded = Buffer.alloc(BODY_BYTES, 0x81).toString("base64");
events.ingest({
method: "Network.webSocketCreated",
params: { requestId: "socket", url: "wss://example.test/socket" },
});
events.ingest({
method: "Network.webSocketFrameReceived",
params: {
requestId: "socket",
response: { opcode: 2, payloadData: encoded },
},
});
expect(
events.webSocketConnectionsById.get("socket")?.events[0]?.payload_bytes,
).toBe(BODY_BYTES);
events.ingest({
method: "Network.webSocketFrameReceived",
params: {
requestId: "socket",
response: { opcode: 2, payloadData: "%%%not-base64%%%" },
},
});
expect(
events.webSocketConnectionsById.get("socket")?.events[1]?.payload_bytes,
).toBeNull();
expect(events.completeness.snapshot().excluded).toContainEqual({
section: "websocket_frames",
reason: "invalid_protocol_value",
count: 1,
});
});
+6 -1
View File
@@ -370,8 +370,13 @@ const waitForWindow = async (
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
removeEnd();
reject(new AnalysisCancelledError("observe_web_session"));
reject(
new AnalysisCancelledError("observe_web_session", {
cause: signal?.reason,
}),
);
};
const timer = setTimeout(() => finish("window_elapsed"), durationMs);
removeEnd = capture.onEnd(finish);
+209
View File
@@ -0,0 +1,209 @@
import { expect, it, vi } from "vitest";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
import { CdpCaptureEvents } from "./CdpCaptureEvents.js";
import type { CdpConnection } from "./CdpConnection.js";
import { captureScripts } from "./CdpPageCaptureScripts.js";
import type { CaptureContext } from "./CdpPageCapture.js";
import type { CapturedScript } from "./CdpCaptureEventTypes.js";
const script = (scriptId: string): CapturedScript => ({
scriptId,
rawUrl: `https://example.test/${scriptId}.js`,
url: `https://example.test/${scriptId}.js`,
origin: "https://example.test",
hash: scriptId,
length: null,
isModule: false,
language: "JavaScript",
sourceMapUrl: null,
sourceMapRawUrl: null,
executionContextKey: null,
frameId: null,
});
const capture = async (
sources: Readonly<Record<string, string>>,
includeScriptSources = true,
signal?: AbortSignal,
) => {
const input = inspectWebPageInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "page-1",
allowed_origins: ["https://example.test"],
observation_ms: 0,
include_script_sources: includeScriptSources,
});
const events = new CdpCaptureEvents(input, new Set(input.allowed_origins));
for (const id of Object.keys(sources)) events.scripts.set(id, script(id));
const send = vi.fn(async (method: string, params: unknown) => {
if (method !== "Debugger.getScriptSource")
throw new Error(`Unexpected CDP command: ${method}`);
const scriptId = (params as { scriptId: string }).scriptId;
return { scriptSource: sources[scriptId] };
});
const context = {
connection: { send } as unknown as CdpConnection,
sessionId: undefined,
operation: "inspect_web_page",
discovery: undefined,
target: undefined,
input,
...(signal === undefined ? {} : { signal }),
} as unknown as CaptureContext;
const result = await captureScripts({
context,
events,
rawResources: [],
resources: [],
frameIds: new Set(),
});
return { result, events, send };
};
it("admits a prefix and fitting suffix around an oversized script source", async () => {
const prefix = "p".repeat(16 * 1024 * 1024);
const oversized = "o".repeat(17 * 1024 * 1024);
const suffix = "s".repeat(1024 * 1024);
const { result, events } = await capture({ prefix, oversized, suffix });
const byId = new Map(
result.inventory.items.map((item) => [item.cdp_hash, item.source]),
);
expect(byId.get("prefix")).toMatchObject({ included: true });
expect(byId.get("oversized")).toEqual({
included: false,
reason:
"script source was omitted because the aggregate 32 MiB source budget was exhausted",
});
expect(byId.get("suffix")).toMatchObject({ included: true });
expect(events.completeness.snapshot().excluded).toContainEqual({
section: "script_sources",
reason: "resource_budget_exhausted",
count: 1,
});
});
it("preserves the caller cancellation reason when a source read is pending", async () => {
const controller = new AbortController();
const reason = new AnalysisCancelledError("inspect_web_page");
const input = inspectWebPageInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "page-1",
allowed_origins: ["https://example.test"],
observation_ms: 0,
include_script_sources: true,
});
const events = new CdpCaptureEvents(input, new Set(input.allowed_origins));
events.scripts.set("first", script("first"));
const context = {
connection: {
send: async () => {
controller.abort(reason);
throw new AnalysisCancelledError("inspect_web_page");
},
} as unknown as CdpConnection,
sessionId: undefined,
operation: "inspect_web_page",
discovery: undefined,
target: undefined,
input,
signal: controller.signal,
} as unknown as CaptureContext;
await expect(
captureScripts({
context,
events,
rawResources: [],
resources: [],
frameIds: new Set(),
}),
).rejects.toMatchObject({ _tag: "AnalysisCancelledError", cause: reason });
});
it("stops source reads at the shared collection deadline and retains metadata", async () => {
vi.useFakeTimers();
try {
const input = inspectWebPageInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "page-1",
allowed_origins: ["https://example.test"],
observation_ms: 0,
include_script_sources: true,
});
const events = new CdpCaptureEvents(input, new Set(input.allowed_origins));
events.scripts.set("first", script("first"));
events.scripts.set("second", script("second"));
const send = vi.fn(
(
_method: string,
_params: unknown,
_sessionId: string | undefined,
signal?: AbortSignal,
) =>
new Promise((_resolve, reject) => {
signal?.addEventListener(
"abort",
() => reject(new AnalysisCancelledError("inspect_web_page")),
{ once: true },
);
}),
);
const context = {
connection: { send } as unknown as CdpConnection,
sessionId: undefined,
operation: "inspect_web_page",
discovery: undefined,
target: undefined,
input,
} as unknown as CaptureContext;
const pending = captureScripts({
context,
events,
rawResources: [],
resources: [],
frameIds: new Set(),
});
await vi.advanceTimersByTimeAsync(20_000);
const result = await pending;
expect(send).toHaveBeenCalledTimes(1);
expect(result.inventory.items.map((item) => item.source)).toEqual([
{
included: false,
reason:
"script source was omitted because the 20-second source collection deadline elapsed",
},
{
included: false,
reason:
"script source was omitted because the 20-second source collection deadline elapsed",
},
]);
expect(events.completeness.snapshot().excluded).toContainEqual({
section: "script_sources",
reason: "resource_budget_exhausted",
count: 2,
});
} finally {
vi.useRealTimers();
}
});
it("leaves source-off capture unchanged and does not issue source reads", async () => {
const { result, events, send } = await capture({ first: "unused" }, false);
expect(send).not.toHaveBeenCalled();
expect(result.inventory.items[0]?.source).toEqual({
included: false,
reason: "source capture was not selected",
});
expect(events.completeness.snapshot().excluded).toContainEqual({
section: "script_sources",
reason: "not_approved",
count: 1,
});
});
+93 -26
View File
@@ -14,6 +14,13 @@ import {
import type { CaptureContext } from "./CdpPageCapture.js";
import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import {
CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
CdpScriptSourceBudget,
} from "./CdpScriptSourceBudget.js";
export interface CapturedScripts {
readonly inventory: WebPageInspection["scripts"];
@@ -51,17 +58,54 @@ export const captureScripts = async (
readonly item: ScriptDraftItem;
readonly sourceMapRawUrl: string | null;
}[] = [];
for (const script of events.scripts.values()) {
state ??= {
context,
events,
frameIds,
reconcileResource: createCapturedWebScriptReconciler(
rawResources,
resources,
),
};
drafts.push(await buildScriptDraft(script, state));
const scripts = [...events.scripts.values()];
const sourceBudget =
context.input.include_script_sources && scripts.length > 0
? new CdpScriptSourceBudget(context.signal)
: undefined;
try {
for (const script of scripts) {
state ??= {
context,
events,
frameIds,
reconcileResource: createCapturedWebScriptReconciler(
rawResources,
resources,
),
};
let source: WebPageInspection["scripts"]["items"][number]["source"] =
context.input.include_script_sources
? sourceExcluded(CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON)
: sourceExcluded("source capture was not selected");
if (
context.input.include_script_sources &&
isSourceDeadlineExpired(sourceBudget)
) {
events.completeness.exclude(
"script_sources",
"resource_budget_exhausted",
);
} else if (
context.input.include_script_sources &&
sourceBudget !== undefined
) {
try {
source = await captureScriptSource(script, state, sourceBudget);
} catch (cause: unknown) {
throwIfCallerCancelled(context.signal, context.operation);
if (isSourceDeadlineExpired(sourceBudget))
events.completeness.exclude(
"script_sources",
"resource_budget_exhausted",
);
else throw cause;
}
}
drafts.push(buildScriptDraft(script, state, source));
}
} finally {
sourceBudget?.dispose();
}
if (!context.input.include_script_sources)
events.completeness.exclude(
@@ -72,17 +116,14 @@ export const captureScripts = async (
return keyAndBuildInventory(drafts, events.scripts.size);
};
const buildScriptDraft = async (
const buildScriptDraft = (
script: CapturedScript,
state: ScriptDraftState,
): Promise<{
source: WebPageInspection["scripts"]["items"][number]["source"],
): {
readonly item: ScriptDraftItem;
readonly sourceMapRawUrl: string | null;
}> => {
let source: WebPageInspection["scripts"]["items"][number]["source"] = state
.context.input.include_script_sources
? await captureScriptSource(script, state)
: sourceExcluded("source capture was not selected");
} => {
const inventoryScript = {
frame_id: state.events.frameForScript(script, state.frameIds),
url: script.url,
@@ -109,20 +150,46 @@ const buildScriptDraft = async (
const captureScriptSource = async (
script: CapturedScript,
state: ScriptDraftState,
sourceBudget: CdpScriptSourceBudget,
): Promise<WebPageInspection["scripts"]["items"][number]["source"]> => {
const { context } = state;
const content = requiredScriptSource(
await context.connection.send(
"Debugger.getScriptSource",
{ scriptId: script.scriptId },
context.sessionId,
context.signal,
),
context.operation,
const response = await context.connection.send(
"Debugger.getScriptSource",
{ scriptId: script.scriptId },
context.sessionId,
sourceBudget.signal,
);
throwIfCallerCancelled(context.signal, context.operation);
const content = requiredScriptSource(response, context.operation);
if (sourceBudget.timedOut) {
state.events.completeness.exclude(
"script_sources",
"resource_budget_exhausted",
);
return sourceExcluded(CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON);
}
if (!sourceBudget.admit(content)) {
state.events.completeness.exclude(
"script_sources",
"resource_budget_exhausted",
);
return sourceExcluded(CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON);
}
return sourceResult(content);
};
const throwIfCallerCancelled = (
signal: AbortSignal | undefined,
operation: BrowserObservationOperation,
): void => {
if (signal?.aborted === true)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
};
const isSourceDeadlineExpired = (
sourceBudget: CdpScriptSourceBudget | undefined,
): boolean => sourceBudget?.timedOut === true;
const keyAndBuildInventory = (
drafts: readonly {
readonly item: ScriptDraftItem;
+41
View File
@@ -0,0 +1,41 @@
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
const MIB = 1024 * 1024;
export const CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON = `script source was omitted because the aggregate ${String(WEB_RUNTIME_LIMITS.sourceBytes / MIB)} MiB source budget was exhausted`;
export const CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON = `script source was omitted because the ${String(WEB_RUNTIME_LIMITS.commandTimeoutMs / 1000)}-second source collection deadline elapsed`;
/** Shared admission for source text retained from passive CDP script reads. */
export class CdpScriptSourceBudget {
readonly #deadline: ProviderStartupDeadline;
#retainedBytes = 0;
constructor(signal?: AbortSignal) {
this.#deadline = new ProviderStartupDeadline(
WEB_RUNTIME_LIMITS.commandTimeoutMs,
signal,
);
}
get signal(): AbortSignal {
return this.#deadline.signal;
}
get timedOut(): boolean {
return this.#deadline.interruption === "timeout";
}
/** Admit exact UTF-8 source bytes before building or retaining its artifact. */
admit(text: string): boolean {
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > WEB_RUNTIME_LIMITS.sourceBytes - this.#retainedBytes)
return false;
this.#retainedBytes += bytes;
return true;
}
dispose(): void {
this.#deadline.dispose();
}
}
@@ -37,9 +37,12 @@ it.skipIf(process.platform === "win32")(
await once(spawned.process.stdout!, "data");
const lineage = await observeOwnedProcessLineage(spawned.ownership);
expect(lineage.status).toBe("verified");
const evidenceOwner = spawned.ownership.evidenceOwner;
if (evidenceOwner === undefined)
throw new Error("owned spawn omitted persistent ownership evidence");
const owner = new ElectronCaptureCleanup(
{ close },
spawned.ownership,
{ ...spawned.ownership, evidenceOwner },
lineage,
);
expect(await owner.release(scope)).toMatchObject({
+19 -14
View File
@@ -1,4 +1,4 @@
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createDeferred } from "../../tests/fixtures/binarySession.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
@@ -7,33 +7,38 @@ import * as ownershipObservation from "../process/ProcessOwnershipObservation.js
import * as processCleanup from "./ElectronProcessCleanup.js";
import * as processOwnership from "../process/ProcessOwnership.js";
import type {
OwnedProcessGroup,
PersistentOwnedProcessGroup,
ProcessLineageObservation,
} from "../process/ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js";
import { ElectronCaptureCleanup } from "./ElectronCaptureCleanup.js";
const ownership: OwnedProcessGroup = {
runId: "authored-run-token",
leaderPid: 424242,
processGroupId: 424242,
};
let ownership: PersistentOwnedProcessGroup;
beforeEach(() => {
ownership = {
runId: "authored-run-token",
leaderPid: 424242,
processGroupId: 424242,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
};
});
const verified: ProcessLineageObservation = {
status: "verified",
observedAt: "2026-10-17T00:00:00Z",
lineage: {
runId: ownership.runId,
launcherPid: ownership.leaderPid,
runId: "authored-run-token",
launcherPid: 424242,
launcherParentPid: 1,
processGroupId: ownership.processGroupId,
processGroupId: 424242,
descendants: [],
},
};
const unavailable: ProcessLineageObservation = {
status: "unavailable",
observedAt: "2026-10-17T00:00:01Z",
runId: ownership.runId,
launcherPid: ownership.leaderPid,
processGroupId: ownership.processGroupId,
runId: "authored-run-token",
launcherPid: 424242,
processGroupId: 424242,
reason: "authored identity unavailable",
};
afterEach(() => {
@@ -311,7 +316,7 @@ it("retains a Windows family left after root termination without signaling cache
{
pid: 424243,
parentPid: ownership.leaderPid,
processGroupId: ownership.processGroupId,
processGroupId: 424242,
},
],
},
+10 -8
View File
@@ -8,7 +8,7 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { observeOwnedProcessLineage } from "../process/ProcessOwnershipObservation.js";
import type {
OwnedProcessGroup,
PersistentOwnedProcessGroup,
ProcessCleanupResult,
ProcessLineageObservation,
} from "../process/ProcessOwnership.js";
@@ -21,15 +21,17 @@ type ElectronApplication = Awaited<ReturnType<typeof electron.launch>>;
export class ElectronCaptureCleanup {
readonly #transport: AnalysisCleanupLease;
readonly #processes: AnalysisCleanupLease;
readonly #ownership: PersistentOwnedProcessGroup | undefined;
#closeOperation: Promise<void> | undefined;
#lineage: ProcessLineageObservation | undefined;
#processResult: ProcessCleanupResult | undefined;
constructor(
application: Pick<ElectronApplication, "close">,
ownership: OwnedProcessGroup | undefined,
ownership: PersistentOwnedProcessGroup | undefined,
lineage: ProcessLineageObservation | undefined,
) {
this.#ownership = ownership;
this.#lineage = lineage;
this.#transport = new AnalysisCleanupLease(async () => {
// A deadline stops this wait, not the underlying Playwright close. Reuse
@@ -49,21 +51,21 @@ export class ElectronCaptureCleanup {
);
}, ["browser_transport"]);
const resources =
ownership === undefined
this.#ownership === undefined
? ["owned_process_group"]
: [
"owned_process_group",
`run:${ownership.runId}`,
`process-group:${String(ownership.processGroupId)}`,
`run:${this.#ownership.runId}`,
`process-group:${String(this.#ownership.processGroupId)}`,
];
this.#processes = new AnalysisCleanupLease(async () => {
const cleanup =
ownership === undefined
this.#ownership === undefined
? {
cleaned: false as const,
reason: "owned Electron process identity was unavailable",
}
: await this.#cleanupProcesses(ownership);
: await this.#cleanupProcesses(this.#ownership);
this.#processResult = cleanup;
if (!cleanup.cleaned)
throw new ArtifactReaderFailure("unavailable", cleanup.reason, {
@@ -103,7 +105,7 @@ export class ElectronCaptureCleanup {
}
async #cleanupProcesses(
ownership: OwnedProcessGroup,
ownership: PersistentOwnedProcessGroup,
): Promise<ProcessCleanupResult> {
const current = await observeOwnedProcessLineage(ownership);
if (current.status === "verified") this.#lineage = current;
@@ -0,0 +1,297 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, expect, it, vi } from "vitest";
const handleState = vi.hoisted(() => ({
archive: undefined as string | undefined,
failCloseCount: 0,
closeController: undefined as AbortController | undefined,
closeReason: undefined as unknown,
readController: undefined as AbortController | undefined,
readReason: undefined as unknown,
statController: undefined as AbortController | undefined,
statReason: undefined as unknown,
readFailure: undefined as Error | undefined,
events: [] as string[],
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
if (String(args[0]) !== handleState.archive) return handle;
handleState.events.push("open");
return new Proxy(handle, {
get(target, property, receiver) {
if (property === "stat" && handleState.statController !== undefined)
return async () => {
const result = await target.stat();
handleState.statController?.abort(handleState.statReason);
handleState.statController = undefined;
return result;
};
if (property === "read" && handleState.readFailure !== undefined)
return async () => {
const failure = handleState.readFailure;
handleState.readFailure = undefined;
throw failure;
};
if (property === "read" && handleState.readController !== undefined)
return async (...args: Parameters<typeof target.read>) => {
const response = await target.read(...args);
handleState.readController?.abort(handleState.readReason);
handleState.readController = undefined;
return response;
};
if (property === "close")
return async () => {
handleState.events.push("close");
if (handleState.closeController !== undefined) {
handleState.closeController.abort(handleState.closeReason);
handleState.closeController = undefined;
}
if (handleState.failCloseCount > 0) {
handleState.failCloseCount -= 1;
throw new Error("controlled ASAR descriptor close failure");
}
return await target.close();
};
return Reflect.get(target, property, receiver) as unknown;
},
});
},
};
});
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { CdpElectronProvider } from "./CdpElectronProvider.js";
import {
authorizedElectronFile,
mapElectronFileAuthorizationFailure,
retryElectronFileCleanup,
} from "./ElectronFileScope.js";
const roots: string[] = [];
afterEach(async () => {
await retryElectronFileCleanup();
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
handleState.archive = undefined;
handleState.failCloseCount = 0;
handleState.closeController = undefined;
handleState.closeReason = undefined;
handleState.readController = undefined;
handleState.readReason = undefined;
handleState.statController = undefined;
handleState.statReason = undefined;
handleState.readFailure = undefined;
handleState.events = [];
});
const asarPage = async (): Promise<string> => {
const root = await mkdtemp(join(tmpdir(), "rea-electron-file-scope-"));
roots.push(root);
const archive = join(root, "app.asar");
const json = Buffer.from(
JSON.stringify({ files: { "index.html": { size: 1, offset: "0" } } }),
);
const archiveBytes = Buffer.alloc(16 + json.length + 1);
const headerBytes = 8 + json.length;
archiveBytes.writeUInt32LE(4, 0);
archiveBytes.writeUInt32LE(headerBytes, 4);
archiveBytes.writeUInt32LE(headerBytes - 4, 8);
archiveBytes.writeUInt32LE(json.length, 12);
json.copy(archiveBytes, 16);
archiveBytes[16 + json.length] = 0;
await writeFile(archive, archiveBytes);
handleState.archive = archive;
return pathToFileURL(join(archive, "index.html")).href;
};
it("retains a failed ASAR close through denial and retries before reopening", async () => {
const location = await asarPage();
const missing = pathToFileURL(
join(dirname(fileURLToPath(location)), "missing.js"),
).href;
handleState.failCloseCount = 1;
let failure: unknown;
try {
await authorizedElectronFile(missing, undefined, "list_electron_targets");
} catch (cause: unknown) {
failure = cause;
}
if (!(failure instanceof ArtifactReaderFailure))
throw new Error("Expected an ASAR descriptor cleanup failure");
expect(failure.cleanup?.resources).toEqual([handleState.archive]);
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"list_electron_targets",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).toBeUndefined();
await expect(
authorizedElectronFile(missing, undefined, "list_electron_targets"),
).resolves.toBeUndefined();
expect(handleState.events).toEqual([
"open",
"close",
"close",
"open",
"close",
]);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("keeps cancellation primary when ASAR close fails, then retries the same handle", async () => {
const location = await asarPage();
const controller = new AbortController();
const reason = new Error("cancel during ASAR close");
handleState.closeController = controller;
handleState.closeReason = reason;
handleState.failCloseCount = 1;
let failure: unknown;
try {
await authorizedElectronFile(
location,
controller.signal,
"inspect_electron_page",
);
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"inspect_electron_page",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(mapped?.cleanupPrimary?.cause).toBe(reason);
await expect(authorizedElectronFile(location)).resolves.toBe(
`${handleState.archive}/index.html`,
);
expect(handleState.events).toEqual([
"open",
"close",
"close",
"open",
"close",
]);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("keeps an earlier read failure primary over a later abort and close failure", async () => {
const location = await asarPage();
const controller = new AbortController();
const reason = new Error("late cancellation after read failure");
const readFailure = new Error("controlled ASAR header read failure");
handleState.closeController = controller;
handleState.closeReason = reason;
handleState.failCloseCount = 1;
handleState.readFailure = readFailure;
let failure: unknown;
try {
await authorizedElectronFile(location, controller.signal);
} catch (cause: unknown) {
failure = cause;
}
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"inspect_electron_page",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).not.toBeInstanceOf(AnalysisCancelledError);
expect(mapped?.cleanupPrimary?.cause).toBe(readFailure);
await expect(authorizedElectronFile(location)).resolves.toBe(
`${handleState.archive}/index.html`,
);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("preserves an arbitrary cancellation reason observed during ASAR read", async () => {
const location = await asarPage();
const controller = new AbortController();
const reason = { source: "caller", token: 17 };
handleState.readController = controller;
handleState.readReason = reason;
handleState.failCloseCount = 1;
let failure: unknown;
try {
await authorizedElectronFile(location, controller.signal);
} catch (cause: unknown) {
failure = cause;
}
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"inspect_electron_page",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(mapped?.cleanupPrimary?.cause).toBe(reason);
await expect(authorizedElectronFile(location)).resolves.toBe(
`${handleState.archive}/index.html`,
);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("preserves cancellation observed after ASAR stat when final close fails", async () => {
const location = await asarPage();
const controller = new AbortController();
const reason = { source: "caller", stage: "descriptor-stat" };
handleState.statController = controller;
handleState.statReason = reason;
handleState.failCloseCount = 1;
let failure: unknown;
try {
await authorizedElectronFile(location, controller.signal);
} catch (cause: unknown) {
failure = cause;
}
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"inspect_electron_page",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(mapped?.cleanupPrimary?.cause).toBe(reason);
await expect(authorizedElectronFile(location)).resolves.toBe(
`${handleState.archive}/index.html`,
);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("retries a failed ASAR descriptor lease during provider shutdown", async () => {
const location = await asarPage();
handleState.failCloseCount = 1;
await expect(authorizedElectronFile(location)).rejects.toBeInstanceOf(
ArtifactReaderFailure,
);
await new CdpElectronProvider().close();
expect(handleState.events).toEqual(["open", "close", "close"]);
});
+175 -35
View File
@@ -2,16 +2,117 @@ import type { Stats } from "node:fs";
import { open, realpath, stat } from "node:fs/promises";
import { isAbsolute, join, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
ArtifactFileCleanupFailure,
assertArtifactReadActive,
bindArtifactFileCleanup,
withArtifactFileHandle,
} from "../artifacts/ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
admitAsarHeader,
MAX_ASAR_HEADER_BYTES,
} from "../artifacts/AsarHeader.js";
type ElectronFileOperation =
| "inspect_electron_page"
| "list_electron_targets"
| "observe_javascript_runtime"
| "list_javascript_runtime_targets";
// ASAR descriptor ownership outlives an individual provider call; every later
// authorization retries retained close leases before admitting new work.
const resources = new ArtifactResourceScope();
/** Resolve one local file URL while rejecting remote hosts and encoded separators. */
export const authorizedElectronFile = async (
value: string,
signal?: AbortSignal,
operation: ElectronFileOperation = "inspect_electron_page",
): Promise<string | undefined> => {
try {
return await resources.run(
async () => await resolveAuthorizedElectronFile(value, signal),
);
} catch (cause: unknown) {
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
throw cause;
if (signal?.aborted === true)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
throw cause;
}
};
/** Retry retained ASAR descriptor cleanup during provider shutdown. */
export const retryElectronFileCleanup = async (): Promise<void> =>
await resources.retryCleanup();
/** Translate a retained ASAR descriptor failure at its provider boundary. */
export const mapElectronFileAuthorizationFailure = (
cause: unknown,
providerId: string,
operation: ElectronFileOperation,
diagnostics?: Readonly<Record<string, JsonValue>>,
): AnalysisError | undefined => {
if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined)
return undefined;
const physical =
cause instanceof ArtifactFileCleanupFailure
? cause.fileCleanupFailure
: undefined;
const physicalPrimary =
physical?.outcome.kind === "failed" ? physical.outcome.cause : undefined;
const cancellationReason =
physical?.completionCancellation === undefined
? physicalPrimary instanceof ArtifactReaderFailure &&
physicalPrimary.reason === "cancelled"
? physicalPrimary.cancellation === undefined
? physicalPrimary.cause
: physicalPrimary.cancellation.reason
: undefined
: physical.completionCancellation.reason;
const hasCancellation =
physical?.completionCancellation !== undefined ||
(physicalPrimary instanceof ArtifactReaderFailure &&
physicalPrimary.reason === "cancelled");
const primary = hasCancellation
? new AnalysisCancelledError(operation, {
cause: cancellationReason,
...(diagnostics === undefined ? {} : { diagnostics }),
})
: physicalPrimary instanceof AnalysisError
? physicalPrimary
: physicalPrimary === undefined
? undefined
: new ProviderAdapterError(providerId, operation, {
cause: physicalPrimary,
});
const cleanup = new ProviderCleanupError(
providerId,
cause.cleanup.resources,
{ reason: cause.cleanup.reason },
{ operation, cause },
);
bindArtifactFileCleanup(cause, cleanup);
if (primary === undefined) return cleanup;
const composed = analysisErrorWithCleanupFailure(primary, cleanup, operation);
bindArtifactFileCleanup(cause, composed);
return composed;
};
const resolveAuthorizedElectronFile = async (
value: string,
signal?: AbortSignal,
): Promise<string | undefined> => {
throwIfAborted(signal);
if (/%(?:2f|5c)/iu.test(value)) return undefined;
let url: URL;
try {
@@ -38,13 +139,21 @@ export const authorizedElectronFile = async (
}
if (!isAbsolute(path) || path.includes("\0")) return undefined;
try {
if ((await stat(path)).isFile()) return await realpath(path);
const metadata = await stat(path);
throwIfAborted(signal);
if (metadata.isFile()) {
const canonical = await realpath(path);
throwIfAborted(signal);
return canonical;
}
} catch (cause: unknown) {
throwIfAborted(signal);
// Electron serves packaged pages from inside app.asar, which the host
// filesystem cannot stat; resolve those through the archive header below.
void cause;
}
return await authorizedAsarMember(path);
throwIfAborted(signal);
return await authorizedAsarMember(path, signal);
};
/**
@@ -54,9 +163,11 @@ export const authorizedElectronFile = async (
*/
const authorizedAsarMember = async (
path: string,
signal?: AbortSignal,
): Promise<string | undefined> => {
const segments = path.split(sep);
for (let index = 1; index < segments.length - 1; index += 1) {
throwIfAborted(signal);
if (!(segments[index] ?? "").toLowerCase().endsWith(".asar")) continue;
const archive = segments.slice(0, index + 1).join(sep);
const member = segments.slice(index + 1);
@@ -64,13 +175,20 @@ const authorizedAsarMember = async (
return undefined;
try {
const metadata = await stat(archive);
throwIfAborted(signal);
if (!metadata.isFile()) continue;
const canonical = await realpath(archive);
const files = await asarFiles(canonical, metadata);
throwIfAborted(signal);
const files = await asarFiles(canonical, metadata, signal, resources);
throwIfAborted(signal);
return isAsarRegularFile(files, member)
? join(canonical, ...member)
: undefined;
} catch (cause: unknown) {
if (cause instanceof ArtifactFileCleanupFailure) throw cause;
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
throw cause;
throwIfAborted(signal);
// An unreadable or malformed archive cannot authorize its members.
void cause;
return undefined;
@@ -98,44 +216,62 @@ const archiveIdentity = (metadata: ArchiveIdentity): string =>
const asarFiles = async (
archive: string,
metadata: ArchiveIdentity,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
): Promise<unknown> => {
throwIfAborted(signal);
const identity = archiveIdentity(metadata);
const cached = headerCache.get(archive);
if (cached?.identity === identity) return cached.files;
const handle = await open(archive, "r");
let files: unknown;
let jsonBytes: number;
try {
const opened = await handle.stat();
if (!opened.isFile() || archiveIdentity(opened) !== identity)
throw new Error("ASAR identity changed before header read");
const admitted = await admitAsarHeader(handle);
jsonBytes = admitted.jsonBytes;
const json = Buffer.alloc(jsonBytes);
let offset = 0;
while (offset < json.length) {
const part = await handle.read(
json,
offset,
json.length - offset,
16 + offset,
const admitted = await withArtifactFileHandle(
archive,
handle,
resources,
signal,
async (file) => {
let jsonBytes: number;
throwIfAborted(signal);
const opened = await file.stat();
throwIfAborted(signal);
if (!opened.isFile() || archiveIdentity(opened) !== identity)
throw new Error("ASAR identity changed before header read");
const header = await admitAsarHeader(file, signal);
throwIfAborted(signal);
jsonBytes = header.jsonBytes;
const json = Buffer.alloc(jsonBytes);
let offset = 0;
while (offset < json.length) {
throwIfAborted(signal);
const part = await file.read(
json,
offset,
json.length - offset,
16 + offset,
);
throwIfAborted(signal);
if (part.bytesRead === 0) throw new Error("Truncated ASAR JSON header");
offset += part.bytesRead;
}
const afterRead = await file.stat();
throwIfAborted(signal);
if (archiveIdentity(afterRead) !== identity)
throw new Error("ASAR identity changed during header read");
const parsed: unknown = JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(json),
);
if (part.bytesRead === 0) throw new Error("Truncated ASAR JSON header");
offset += part.bytesRead;
}
if (archiveIdentity(await handle.stat()) !== identity)
throw new Error("ASAR identity changed during header read");
const parsed: unknown = JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(json),
);
if (!isRecord(parsed) || !isRecord(parsed.files))
throw new Error("ASAR header has no file tree");
files = parsed.files;
} finally {
await handle.close();
}
if (!isRecord(parsed) || !isRecord(parsed.files))
throw new Error("ASAR header has no file tree");
return { files: parsed.files, jsonBytes };
},
);
throwIfAborted(signal);
headerCache.delete(archive);
headerCache.set(archive, { identity, files, bytes: jsonBytes });
headerCache.set(archive, {
identity,
files: admitted.files,
bytes: admitted.jsonBytes,
});
const retainedBytes = () =>
[...headerCache.values()].reduce((sum, entry) => sum + entry.bytes, 0);
while (
@@ -143,7 +279,7 @@ const asarFiles = async (
retainedBytes() > MAX_ASAR_HEADER_BYTES
)
headerCache.delete(headerCache.keys().next().value ?? archive);
return files;
return admitted.files;
};
const isAsarRegularFile = (
@@ -166,3 +302,7 @@ const isAsarRegularFile = (
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const throwIfAborted = (signal?: AbortSignal): void => {
assertArtifactReadActive(signal);
};
+378
View File
@@ -0,0 +1,378 @@
import { describe, expect, it, vi } from "vitest";
import type {
OwnedProcessGroup,
ProcessLineageObservation,
ProcessOwnershipHost,
ProcessTableEntry,
} from "../process/ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js";
import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js";
import { observeOwnedProcessLineage } from "../process/ProcessOwnershipObservation.js";
describe.skipIf(process.platform === "win32")(
"Electron cleanup ownership evidence",
() => {
it("keeps a previously token-owned helper unresolved after it reparents and changes groups", async () => {
let rows: readonly ProcessTableEntry[] = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "electron",
},
{
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "electron-helper",
},
];
let withholdToken = false;
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
const ownership: OwnedProcessGroup = {
runId: "electron-run",
leaderPid: 100,
processGroupId: 100,
expectedParentPid: 1,
expectedCommand: "electron",
sweepTokenOwnedProcesses: true,
evidenceOwner,
};
const host: ProcessOwnershipHost = {
platform: process.platform,
listProcesses: () => Promise.resolve(rows),
environment: () => Promise.resolve({}),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
withholdToken
? {
state: "unavailable" as const,
reason: "platform_binary_environment_withheld",
}
: { state: "readable" as const, runId: "electron-run" },
]),
),
),
processIdentities: (members) =>
Promise.resolve(
new Map(
members.map((member) => [
member.pid,
withholdToken && member.pid === 100
? {
state: "absent" as const,
reason:
process.platform === "darwin"
? ("not-in-snapshot" as const)
: ("not-found-in-caller-pid-namespace" as const),
}
: {
state: "readable" as const,
identity: `start-${String(member.pid)}`,
lifecycle: {
state: "observed" as const,
lifecycle: "non-zombie" as const,
},
relation: {
state: "observed" as const,
parentPid: member.parentPid,
processGroupId: member.processGroupId,
},
},
]),
),
),
signalGroup: vi.fn(() => {
rows = [
{
pid: 101,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "electron-helper",
},
];
withholdToken = true;
}),
};
const lineage: ProcessLineageObservation = {
status: "verified",
observedAt: "2026-10-19T00:00:00.000Z",
lineage: {
runId: ownership.runId,
launcherPid: ownership.leaderPid,
launcherParentPid: 1,
processGroupId: ownership.processGroupId,
descendants: [{ pid: 101, parentPid: 100, processGroupId: 100 }],
},
};
const result = await cleanupElectronProcesses(ownership, lineage, {
platform: process.platform,
terminateTree: vi.fn(),
processOwnershipHost: host,
});
expect(result.cleaned).toBe(false);
if (result.cleaned)
throw new Error("expected cleanup to remain unresolved");
expect(result.reason).toContain(
"process ownership token could not be read",
);
expect(evidenceOwner.candidates).toMatchObject([
{
pid: 101,
identity: "start-101",
applicability: "token-verified",
scopeEvidence: "run-token",
relation: { parentPid: 1, processGroupId: 200 },
},
]);
});
},
);
it.each([
{ peerFails: false, withholdToken: true },
{ peerFails: true, withholdToken: true },
{ peerFails: false, withholdToken: false },
])(
"retains detached lineage evidence (initial peer failure: $peerFails, withheld token: $withholdToken)",
async ({ peerFails, withholdToken }) => {
let detached = false;
const launcher: ProcessTableEntry = {
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "electron",
};
const child: ProcessTableEntry = {
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "helper",
};
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
const ownership = {
runId: "lineage-run",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true as const,
evidenceOwner,
};
let launcherExited = false;
let childExited = false;
const signalGroup = vi.fn((groupId: number) => {
if (groupId === 100) launcherExited = true;
if (groupId === 200) childExited = true;
});
const host: ProcessOwnershipHost = {
listProcesses: async () => [
...(launcherExited ? [] : [launcher]),
...(childExited
? []
: [
detached
? { ...child, parentPid: 1, processGroupId: 200 }
: child,
]),
...(!detached && peerFails ? [{ ...child, pid: 102 }] : []),
],
environment: async () => ({}),
runTokens: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
(detached && withholdToken && pid === 101) ||
(peerFails && pid === 102)
? {
state: "unavailable" as const,
reason: "platform_binary_environment_withheld",
}
: { state: "readable" as const, runId: ownership.runId },
]),
),
processIdentities: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
(launcherExited && pid === 100) ||
(childExited && pid === 101) ||
(detached && pid === 102)
? { state: "absent" as const, reason: "not-in-snapshot" as const }
: {
state: "readable" as const,
identity: `generation-${pid}`,
lifecycle: {
state: "observed" as const,
lifecycle: "non-zombie" as const,
},
relation: {
state: "observed" as const,
parentPid: pid === 100 || detached ? 1 : 100,
processGroupId: detached && pid === 101 ? 200 : 100,
},
},
]),
),
signalGroup,
};
const first = await observeOwnedProcessLineage(ownership, host);
expect(first.status).toBe(peerFails ? "unavailable" : "verified");
expect(evidenceOwner.candidates).toContainEqual(
expect.objectContaining({
pid: 101,
identity: "generation-101",
applicability: "token-verified",
}),
);
detached = true;
const current = await observeOwnedProcessLineage(ownership, host);
expect(current).toMatchObject({
status: "verified",
lineage: { descendants: [] },
});
const result = await cleanupElectronProcesses(ownership, current, {
platform: "darwin",
terminateTree: vi.fn(),
processOwnershipHost: host,
});
if (withholdToken) {
expect(result).toMatchObject({ cleaned: false });
expect(signalGroup).not.toHaveBeenCalledWith(200, expect.anything());
expect(evidenceOwner.candidates).toContainEqual(
expect.objectContaining({
pid: 101,
identity: "generation-101",
applicability: "token-verified",
}),
);
} else {
expect(result).toMatchObject({ cleaned: true, signaled: true });
expect(signalGroup.mock.calls).toEqual([
[100, "SIGKILL"],
[200, "SIGKILL"],
]);
expect(evidenceOwner.candidates).toEqual([]);
}
},
);
it.skipIf(process.platform === "win32")(
"signals every verified captured group before the shared final census",
async () => {
let rows: readonly ProcessTableEntry[] = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "electron",
},
{
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "renderer",
},
{
pid: 200,
parentPid: 101,
processGroupId: 200,
state: "S",
command: "helper-group-leader",
},
{
pid: 201,
parentPid: 200,
processGroupId: 200,
state: "S",
command: "helper",
},
];
const signalGroups: number[] = [];
const ownership = {
runId: "multi-group-run",
leaderPid: 100,
processGroupId: 100,
expectedParentPid: 1,
sweepTokenOwnedProcesses: true as const,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
};
const host: ProcessOwnershipHost = {
platform: process.platform,
listProcesses: async () => rows,
environment: async () => ({}),
runTokens: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: ownership.runId },
]),
),
processIdentities: async (members) => {
const liveByPid = new Map(rows.map((row) => [row.pid, row]));
return new Map(
members.map((member) => {
const current = liveByPid.get(member.pid);
return [
member.pid,
current === undefined
? {
state: "absent" as const,
reason: "not-in-snapshot" as const,
}
: {
state: "readable" as const,
identity: `generation-${String(member.pid)}`,
relation: {
state: "observed" as const,
parentPid: current.parentPid,
processGroupId: current.processGroupId,
},
},
] as const;
}),
);
},
signalGroup: (groupId) => {
signalGroups.push(groupId);
rows = rows.filter(({ processGroupId }) => processGroupId !== groupId);
},
};
const lineage: ProcessLineageObservation = {
status: "verified",
observedAt: "2026-10-19T00:00:00.000Z",
lineage: {
runId: ownership.runId,
launcherPid: ownership.leaderPid,
launcherParentPid: 1,
processGroupId: ownership.processGroupId,
descendants: [
{ pid: 101, parentPid: 100, processGroupId: 100 },
{ pid: 200, parentPid: 101, processGroupId: 200 },
{ pid: 201, parentPid: 200, processGroupId: 200 },
],
},
};
const result = await cleanupElectronProcesses(ownership, lineage, {
platform: process.platform,
terminateTree: vi.fn(),
processOwnershipHost: host,
});
expect(result).toMatchObject({ cleaned: true, signaled: true });
expect(signalGroups).toEqual([100, 200]);
expect(rows).toEqual([]);
},
);
+35 -27
View File
@@ -3,22 +3,27 @@ import {
cleanupWindowsProcessTree,
verifyNoTokenOwnedProcesses,
type OwnedProcessGroup,
type PersistentOwnedProcessGroup,
type ProcessCleanupResult,
type ProcessLineageObservation,
type OwnershipSweepUnverifiedProcess,
type ProcessOwnershipHost,
} from "../process/ProcessOwnership.js";
import { systemProcessOwnershipHost } from "../process/ProcessOwnershipObservation.js";
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnershipProcessTree.js";
/** Clean up an Electron launch. Windows signals a process only after lineage is verified. */
export const cleanupElectronProcesses = async (
ownership: OwnedProcessGroup,
ownership: PersistentOwnedProcessGroup,
lineage: ProcessLineageObservation | undefined,
host: {
readonly platform: NodeJS.Platform;
readonly terminateTree: typeof cleanupWindowsProcessTree;
readonly processOwnershipHost?: ProcessOwnershipHost;
} = {
platform: process.platform,
terminateTree: cleanupWindowsProcessTree,
processOwnershipHost: systemProcessOwnershipHost,
},
): Promise<ProcessCleanupResult> => {
if (
@@ -36,9 +41,18 @@ export const cleanupElectronProcesses = async (
const root =
host.platform === "win32"
? { cleaned: true as const, signaled: false }
: await cleanupOwnedProcessGroup(ownership);
: await cleanupOwnedProcessGroup(ownership, host.processOwnershipHost);
if (!root.cleaned) return root;
const released = await verifyFamilyRelease(ownership, root);
if (
host.platform !== "win32" &&
ownership.sweepTokenOwnedProcesses === true
)
return root;
const released = await verifyFamilyRelease(
ownership,
root,
host.processOwnershipHost,
);
return released.cleaned
? released
: {
@@ -50,7 +64,9 @@ export const cleanupElectronProcesses = async (
// /t already includes descendants. Do not signal cached child PIDs again
// after awaiting root termination: they may have exited or been reused.
const root = await host.terminateTree(ownership.leaderPid);
return root.cleaned ? verifyFamilyRelease(ownership, root) : root;
return root.cleaned
? verifyFamilyRelease(ownership, root, host.processOwnershipHost)
: root;
}
const groupIds = selectCapturedProcessGroupIds(
ownership.leaderPid,
@@ -59,36 +75,27 @@ export const cleanupElectronProcesses = async (
process_group_id: processGroupId,
})),
);
let signaled = false;
const unverified: OwnershipSweepUnverifiedProcess[] = [];
for (const processGroupId of groupIds) {
const cleanupOwnership: OwnedProcessGroup =
processGroupId === ownership.processGroupId
? { ...ownership, leaderPid: processGroupId, processGroupId }
: {
runId: ownership.runId,
leaderPid: processGroupId,
processGroupId,
};
const result = await cleanupOwnedProcessGroup(cleanupOwnership);
if (!result.cleaned) return result;
signaled ||= result.signaled;
unverified.push(...(result.unverified ?? []));
}
return verifyFamilyRelease(ownership, {
cleaned: true,
signaled,
...(unverified.length === 0 ? {} : { unverified }),
});
const sampledProcessGroupIds = [
...new Set([...(ownership.sampledProcessGroupIds ?? []), ...groupIds]),
];
const root = await cleanupOwnedProcessGroup(
{ ...ownership, sampledProcessGroupIds },
host.processOwnershipHost,
);
if (!root.cleaned) return root;
return ownership.sweepTokenOwnedProcesses === true
? root
: verifyFamilyRelease(ownership, root, host.processOwnershipHost);
};
const verifyFamilyRelease = async (
ownership: OwnedProcessGroup,
ownership: PersistentOwnedProcessGroup,
root: Extract<ProcessCleanupResult, { readonly cleaned: true }>,
processHost: ProcessOwnershipHost | undefined,
): Promise<ProcessCleanupResult> => {
const remaining = await verifyNoTokenOwnedProcesses(
ownership.runId,
undefined,
processHost,
ownership.captureBaseline,
{
leaderPid: ownership.leaderPid,
@@ -96,6 +103,7 @@ const verifyFamilyRelease = async (
...(ownership.sampledProcessGroupIds === undefined
? {}
: { sampledProcessGroupIds: ownership.sampledProcessGroupIds }),
evidenceOwner: ownership.evidenceOwner,
},
);
const unverified = [
@@ -0,0 +1,479 @@
import { expect, it } from "vitest";
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import type {
AnalyzeWebBundleInput,
WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
import { analyzeCapturedWebScript } from "../domain/webBundleAnalyzer.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok } from "../domain/result.js";
import {
IsolatedWebBundleAnalyzer,
type WebBundleAnalysisWorkerPort,
} from "./IsolatedWebBundleAnalyzer.js";
const origin = "https://app.example.test";
const noSourceMaps: WebBundleAnalysis["observations"]["source_maps"] = {
status: "not_requested",
requested: 0,
processed: 0,
items: [],
};
const input: AnalyzeWebBundleInput = analyzeWebBundleInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "page-1",
include_script_sources: true,
});
it("preserves completed prefix and suffix facts when one script times out", async () => {
const captured = inspection([
"import './prefix.js'; fetch('/prefix');",
"function stalled() {}",
"import './suffix.js'; fetch('/suffix');",
]);
const calls: string[] = [];
const analyzer = new IsolatedWebBundleAnalyzer(() => ({
analyze: async (script) => {
calls.push(script.script_key);
if (calls.length === 2)
return err({
error: new AnalysisTimeoutError("analyze_web_bundle", 5),
completed: null,
});
return ok(analyzeCapturedWebScript(script));
},
close: async () => {},
}));
expect((await analyzer.reserve()).ok).toBe(true);
const result = await analyzer.analyze(captured, noSourceMaps, input);
analyzer.releaseReservation();
expect(calls).toEqual(
captured.scripts.items.map(({ script_key }) => script_key),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(
result.value.observations.chunks.edges.map(({ specifier }) => specifier),
).toEqual(["./prefix.js", "./suffix.js"]);
expect(result.value.observations.endpoints.map(({ value }) => value)).toEqual(
["/prefix", "/suffix"],
);
expect(result.value.completeness).toMatchObject({
status: "partial",
parsed_scripts: 2,
parse_failures: 0,
});
expect(result.value.unknowns).toContainEqual({
dimension: "javascript_analysis",
reason: expect.stringContaining("deadline"),
affected_script_keys: [captured.scripts.items[1]?.script_key],
});
});
it("marks the unstarted suffix when the aggregate deadline expires", async () => {
const captured = inspection([
"fetch('/prefix');",
"fetch('/stalled');",
"fetch('/not-started');",
]);
const calls: string[] = [];
const analyzer = new IsolatedWebBundleAnalyzer(() => ({
analyze: async (script, _timeout, signal) => {
calls.push(script.script_key);
if (calls.length === 1) return ok(analyzeCapturedWebScript(script));
await waitForAbort(signal);
return err({
error: new AnalysisTimeoutError("analyze_web_bundle", 50),
completed: null,
});
},
close: async () => {},
}));
expect((await analyzer.reserve()).ok).toBe(true);
const result = await analyzer.analyze(captured, noSourceMaps, {
...input,
total_analysis_timeout_ms: 50,
});
analyzer.releaseReservation();
expect(calls).toEqual(
captured.scripts.items.slice(0, 2).map(({ script_key }) => script_key),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected total deadline failure");
expect(result.error._tag).toBe("AnalysisTimeoutError");
expect(result.error.partialObservation).toMatchObject({
completeness: { status: "partial", parsed_scripts: 1 },
observations: { endpoints: [{ value: "/prefix" }] },
});
expect(result.error.partialObservation).toMatchObject({
unknowns: expect.arrayContaining([
expect.objectContaining({
dimension: "javascript_analysis",
affected_script_keys: [captured.scripts.items[1]?.script_key],
}),
expect.objectContaining({
dimension: "javascript_analysis",
affected_script_keys: [captured.scripts.items[2]?.script_key],
}),
]),
});
});
it("stops after a middle-script result-capacity failure and retains prefix facts", async () => {
const captured = inspection([
"import './prefix.js'; fetch('/prefix');",
"fetch('/capacity-blocked');",
"fetch('/must-not-start');",
]);
const calls: string[] = [];
const caller = new AbortController();
const primary = new AnalysisResourceConstraintError(
"analyze_web_bundle",
"memory",
"No result capacity remains",
{ result_bytes: 0 },
);
const analyzer = new IsolatedWebBundleAnalyzer(() => ({
analyze: async (script) => {
calls.push(script.script_key);
if (calls.length === 1) return ok(analyzeCapturedWebScript(script));
return err({
error: primary,
completed: null,
continuation: "result_capacity_exhausted",
});
},
close: async () => {
caller.abort(new Error("later cancellation during final close"));
},
}));
expect((await analyzer.reserve()).ok).toBe(true);
const result = await analyzer.analyze(
captured,
noSourceMaps,
input,
caller.signal,
);
analyzer.releaseReservation();
expect(calls).toEqual(
captured.scripts.items.slice(0, 2).map(({ script_key }) => script_key),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected result-capacity failure");
expect(result.error).toBe(primary);
expect(result.error.partialObservation).toMatchObject({
completeness: { status: "partial", parsed_scripts: 1 },
observations: {
chunks: { edges: [{ specifier: "./prefix.js" }] },
endpoints: [{ value: "/prefix" }],
},
unknowns: expect.arrayContaining([
expect.objectContaining({
dimension: "javascript_analysis",
affected_script_keys: [captured.scripts.items[1]?.script_key],
}),
expect.objectContaining({
dimension: "javascript_analysis",
affected_script_keys: [captured.scripts.items[2]?.script_key],
}),
]),
});
});
it("carries the exact caller cancellation reason and completed prefix facts", async () => {
const captured = inspection([
"fetch('/prefix');",
"fetch('/cancelled');",
"fetch('/cancelled-suffix');",
]);
const started = deferred<void>();
const controller = new AbortController();
const analyzer = new IsolatedWebBundleAnalyzer(() => ({
analyze: async (script, _timeout, signal) => {
if (script.script_key === captured.scripts.items[0]?.script_key)
return ok(analyzeCapturedWebScript(script));
started.resolve();
await waitForAbort(signal);
return err({
error: new AnalysisCancelledError("analyze_web_bundle", {
cause: signal?.reason,
}),
completed: null,
});
},
close: async () => {},
}));
expect((await analyzer.reserve()).ok).toBe(true);
const exactReason = new Error("caller stopped analysis");
const pending = analyzer.analyze(
captured,
noSourceMaps,
input,
controller.signal,
);
await started.promise;
controller.abort(exactReason);
const result = await pending;
analyzer.releaseReservation();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(exactReason);
expect(result.error.partialObservation).toMatchObject({
completeness: { status: "partial", parsed_scripts: 1 },
observations: { endpoints: [{ value: "/prefix" }] },
unknowns: expect.arrayContaining([
expect.objectContaining({
dimension: "javascript_analysis",
affected_script_keys: [captured.scripts.items[1]?.script_key],
}),
expect.objectContaining({
dimension: "javascript_analysis",
affected_script_keys: [captured.scripts.items[2]?.script_key],
}),
]),
});
});
it("retains all completed facts when cancellation arrives during worker close", async () => {
const captured = inspection(["fetch('/one');", "fetch('/two');"]);
const closeStarted = deferred<void>();
let closeWorker: (() => void) | undefined;
const analyzer = new IsolatedWebBundleAnalyzer(() => ({
analyze: async (script) => ok(analyzeCapturedWebScript(script)),
close: async () => {
closeStarted.resolve();
await new Promise<void>((resolve) => {
closeWorker = resolve;
});
},
}));
expect((await analyzer.reserve()).ok).toBe(true);
const pending = analyzer.analyze(captured, noSourceMaps, input);
await closeStarted.promise;
const closing = analyzer.close();
closeWorker?.();
await closing;
const result = await pending;
analyzer.releaseReservation();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation during finalization");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.partialObservation).toMatchObject({
completeness: { status: "complete", parsed_scripts: 2 },
observations: { endpoints: [{ value: "/one" }, { value: "/two" }] },
});
});
it("retains cleanup ownership after mixed worker/close failure and resolves it on retry", async () => {
const captured = inspection(["fetch('/partial');"]);
const primary = new ProviderCleanupError(
"web-bundle-static-analysis",
["worker-job"],
{ reason: "analysis cleanup pending" },
{ operation: "analyze_web_bundle" },
);
const closeFailure = new ProviderCleanupError(
"web-bundle-static-analysis",
["worker-root"],
{ reason: "worker close pending" },
{ operation: "analyze_web_bundle" },
);
let closeAttempts = 0;
const analyzer = new IsolatedWebBundleAnalyzer(() => ({
analyze: async () => err({ error: primary, completed: null }),
close: async () => {
closeAttempts += 1;
if (closeAttempts <= 2) throw closeFailure;
closeFailure.resolveCleanup();
primary.resolveCleanup();
},
}));
expect((await analyzer.reserve()).ok).toBe(true);
const result = await analyzer.analyze(captured, noSourceMaps, input);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected mixed failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupPrimary).toBe(primary);
expect(result.error.cleanupResources).toEqual(["worker-job", "worker-root"]);
expect(result.error.partialObservation).toMatchObject({
completeness: { status: "partial", parsed_scripts: 0 },
});
const replacement = new IsolatedWebBundleAnalyzer(() => inertWorker());
analyzer.releaseReservation();
const blocked = await replacement.reserve();
expect(blocked.ok).toBe(false);
await analyzer.close();
expect(closeAttempts).toBe(3);
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
expect((await replacement.reserve()).ok).toBe(true);
replacement.releaseReservation();
});
it("enforces one process-wide capture reservation across controller instances", async () => {
const first = new IsolatedWebBundleAnalyzer(() => inertWorker());
const second = new IsolatedWebBundleAnalyzer(() => inertWorker());
expect((await first.reserve()).ok).toBe(true);
const blocked = await second.reserve();
expect(blocked.ok).toBe(false);
if (blocked.ok) throw new Error("Expected process-wide admission refusal");
expect(blocked.error._tag).toBe("AnalysisResourceConstraintError");
first.releaseReservation();
expect((await second.reserve()).ok).toBe(true);
second.releaseReservation();
});
const inertWorker = (): WebBundleAnalysisWorkerPort => ({
analyze: async () =>
err({
error: new AnalysisOutputError("analyze_web_bundle", "unused"),
completed: null,
}),
close: async () => {},
});
const waitForAbort = (signal: AbortSignal | undefined): Promise<void> => {
if (signal?.aborted) return Promise.resolve();
return new Promise((resolve) => {
signal?.addEventListener("abort", () => resolve(), { once: true });
});
};
const deferred = <T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
};
const inspection = (sources: readonly string[]) =>
webPageInspectionSchema.parse({
browser: {
product: "Fake Chrome",
protocol_version: "1.3",
revision: "1",
user_agent: "fake",
js_version: "1",
},
target: {
target_id: "page-1",
type: "page",
title: "App",
url: `${origin}/app`,
origin,
attached: false,
},
capture_window: {
started_at: "2026-07-14T00:00:00.000Z",
ended_at: "2026-07-14T00:00:01.000Z",
observation_ms: 1_000,
},
completeness: {
status: "attach_limited",
conditions: ["attach_limited"],
policy_filtered_sections: [],
attach_limited_sections: ["network_requests"],
truncated_sections: [],
unavailable_sections: [],
excluded: [],
dropped_events: {
scripts: 0,
network_requests: 0,
console_events: 0,
websocket_connections: 0,
websocket_frames: 0,
webmcp_tools: 0,
timeline_events: 0,
total: 0,
},
},
frames: [],
dom: { total_nodes: 0, nodes: [] },
accessibility: {
total_nodes: 0,
text_capture: {
status: "not_approved",
retained_bytes: 0,
excluded_fields: 0,
},
nodes: [],
},
scripts: {
total: sources.length,
items: sources.map((source, index) => ({
script_key: `scr_${String(index + 1).repeat(64)}`,
url: `${origin}/assets/${String(index)}.js`,
origin,
cdp_hash: `hash-${String(index)}`,
length: Buffer.byteLength(source),
is_module: true,
language: "JavaScript",
source_map_url: null,
resource_reconciliation: {
status: "unmatched",
reason: "no_exact_sanitized_url",
},
source: {
included: true,
artifact: createWebTextArtifact(source, "text/javascript"),
},
})),
},
resources: [],
network: {
requests: [],
websocket_connections: [],
coverage_started_at: "2026-07-14T00:00:00.000Z",
prior_activity_available: false,
},
console: {
events: [],
coverage_started_at: "2026-07-14T00:00:00.000Z",
prior_activity_available: false,
},
workers: [],
metadata: {
responses: [],
dom_urls: [],
agent_hints: [],
excluded_dom_urls: 0,
headers_allowlisted: true,
},
storage: {
origin,
usage_bytes: null,
quota_bytes: null,
local_storage_keys: [],
session_storage_keys: [],
indexed_db_names: [],
cache_names: [],
values_redacted: true,
},
limitations: [],
});
+336
View File
@@ -0,0 +1,336 @@
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import type {
AnalyzeWebBundleInput,
WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import { buildCapturedWebBundleAnalysis } from "../domain/webBundleAnalyzerResult.js";
import { isIncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { WebBundleScriptAnalysis } from "../domain/webBundleScriptAnalysis.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisTimeoutError,
AnalysisResourceConstraintError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js";
import { WEB_BUNDLE_WORKER_LIMITS } from "./WebBundleAnalysisWorkerProtocol.js";
// A process-wide admission, rather than one permit per provider instance, bounds
// simultaneous web analyzer children and their source/result transfer reservations.
export type WebBundleAnalysisWorkerPort = Pick<
WebBundleAnalysisWorker,
"analyze" | "close"
>;
let activeAnalyzer: IsolatedWebBundleAnalyzer | undefined;
/** Own one bundle's sequential analysis and finalization through provider close. */
export class IsolatedWebBundleAnalyzer {
static readonly #pendingCleanup = new Set<IsolatedWebBundleAnalyzer>();
#closed = false;
#running: Promise<Result<WebBundleAnalysis, AnalysisError>> | undefined;
#controller: AbortController | undefined;
#worker: WebBundleAnalysisWorkerPort | undefined;
constructor(
readonly createWorker: (heapMb: number) => WebBundleAnalysisWorkerPort = (
heapMb,
) => new WebBundleAnalysisWorker(heapMb),
) {}
/** Reserve capacity before capture, so concurrent captures cannot retain source inventories. */
async reserve(): Promise<Result<void, AnalysisError>> {
if (this.#closed)
return err(new AnalysisCancelledError("analyze_web_bundle"));
if (activeAnalyzer !== undefined)
return err(
new AnalysisResourceConstraintError(
"analyze_web_bundle",
"capacity",
"Another web-bundle capture or analysis owns the process-wide source and result reservation",
{ concurrent_web_bundle_analyses: 1 },
),
);
// Retry exact earlier owners before a replacement capture can retain sources.
try {
for (const owner of [...IsolatedWebBundleAnalyzer.#pendingCleanup])
await owner.#releaseWorker();
await WebBundleAnalysisWorker.retryPending();
await this.#releaseWorker();
} catch (cause) {
return err(
cause instanceof AnalysisError
? cause
: new ProviderCleanupError(
"web-bundle-static-analysis",
[],
{ reason: "Earlier web analyzer cleanup remains incomplete" },
{ operation: "analyze_web_bundle", cause },
),
);
}
if (this.#closed)
return err(new AnalysisCancelledError("analyze_web_bundle"));
if (activeAnalyzer !== undefined)
return err(
new AnalysisResourceConstraintError(
"analyze_web_bundle",
"capacity",
"Another web-bundle capture or analysis owns the process-wide source and result reservation",
{ concurrent_web_bundle_analyses: 1 },
),
);
activeAnalyzer = this;
return ok(undefined);
}
releaseReservation(): void {
if (activeAnalyzer === this) activeAnalyzer = undefined;
}
analyze(
inspection: WebPageInspection,
sourceMaps: WebBundleAnalysis["observations"]["source_maps"],
input: AnalyzeWebBundleInput,
signal?: AbortSignal,
): Promise<Result<WebBundleAnalysis, AnalysisError>> {
if (this.#closed)
return Promise.resolve(
err(
new AnalysisCancelledError("analyze_web_bundle", {
partialObservation: inspection,
}),
),
);
if (this.#running !== undefined)
return Promise.resolve(
err(
new AnalysisResourceConstraintError(
"analyze_web_bundle",
"capacity",
"This web analyzer already owns an active analysis",
{ concurrent_web_bundle_analyses: 1 },
),
),
);
this.#controller = new AbortController();
const running = this.#analyze(
inspection,
sourceMaps,
input,
signal,
).finally(() => {
this.#running = undefined;
this.#controller = undefined;
});
this.#running = running;
return running;
}
async #analyze(
inspection: WebPageInspection,
sourceMaps: WebBundleAnalysis["observations"]["source_maps"],
input: AnalyzeWebBundleInput,
signal?: AbortSignal,
): Promise<Result<WebBundleAnalysis, AnalysisError>> {
const scripts = inspection.scripts.items.filter(isIncludedScript);
const results: WebBundleScriptAnalysis[] = [];
const signals = [
this.#controller!.signal,
...(signal === undefined ? [] : [signal]),
];
const caller = AbortSignal.any(signals);
const deadline = new ProviderStartupDeadline(
input.total_analysis_timeout_ms ??
WEB_BUNDLE_WORKER_LIMITS.totalTimeoutMs,
caller,
);
let failure: AnalysisError | undefined;
let resultCapacityExhausted = false;
const appendUnstarted = (): void => {
for (const script of scripts.slice(results.length))
results.push({
status: "unavailable",
script_key: script.script_key,
reason: "not_started",
detail: caller.aborted
? "The operation was cancelled before this script was started."
: deadline.interruption === "timeout"
? `The total captured-script analysis deadline of ${String(input.total_analysis_timeout_ms ?? WEB_BUNDLE_WORKER_LIMITS.totalTimeoutMs)} ms expired before this script was started.`
: resultCapacityExhausted
? "The host could not reserve more web-analysis result capacity; this script was not started."
: "An earlier analyzer failure prevented this script from starting.",
});
};
try {
await WebBundleAnalysisWorker.retryPending();
this.#worker = this.createWorker(
input.max_heap_mb ?? WEB_BUNDLE_WORKER_LIMITS.heapMb,
);
for (const script of scripts) {
if (deadline.signal.aborted) break;
const analyzed = await this.#worker.analyze(
script,
input.analysis_timeout_ms ?? WEB_BUNDLE_WORKER_LIMITS.scriptTimeoutMs,
deadline.signal,
);
if (analyzed.ok) results.push(analyzed.value);
else {
const error = analyzed.error.error;
results.push(
analyzed.error.completed ?? {
status: "unavailable",
script_key: script.script_key,
reason: caller.aborted
? "cancelled"
: deadline.interruption === "timeout" ||
error instanceof AnalysisTimeoutError
? "deadline"
: error instanceof AnalysisResourceConstraintError
? "resource_constraint"
: "worker_failure",
detail:
error instanceof AnalysisResourceConstraintError &&
error.reportedLimits !== null
? `${error.message} Reported limits: ${JSON.stringify(error.reportedLimits)}`
: error.message,
},
);
if (error.cleanupIncomplete) {
failure = error;
break;
}
if (analyzed.error.continuation === "result_capacity_exhausted") {
resultCapacityExhausted = true;
failure = error;
break;
}
if (error instanceof AnalysisCapabilityUnavailableError) {
failure = error;
break;
}
if (caller.aborted || deadline.signal.aborted) break;
}
}
} catch (cause) {
failure =
cause instanceof AnalysisError
? cause
: new AnalysisOutputError(
"analyze_web_bundle",
"Web analyzer could not complete its owned work",
{ cause },
);
}
appendUnstarted();
let partial: WebBundleAnalysis | undefined;
try {
partial = buildCapturedWebBundleAnalysis(inspection, sourceMaps, results);
} catch (cause) {
failure = new AnalysisOutputError(
"analyze_web_bundle",
"Completed web script facts could not be assembled",
{ cause },
);
}
const hasPrimaryFailure =
failure !== undefined &&
(!failure.cleanupIncomplete || failure.cleanupPrimary !== undefined);
try {
await this.#releaseWorker();
} catch (cause) {
const cleanup =
cause instanceof AnalysisError
? cause
: new ProviderCleanupError(
"web-bundle-static-analysis",
[],
{ reason: "Web analyzer final cleanup remains incomplete" },
{ operation: "analyze_web_bundle", cause },
);
failure =
failure === undefined
? cleanup
: analysisErrorWithCleanupFailure(
failure,
cleanup,
"analyze_web_bundle",
).bindCleanupDependencies([failure, cleanup]);
} finally {
deadline.dispose();
}
if (
!hasPrimaryFailure &&
caller.aborted &&
deadline.interruption !== "timeout"
) {
const cancelled = new AnalysisCancelledError("analyze_web_bundle", {
cause: caller.reason,
partialObservation: partial ?? inspection,
});
failure =
failure?.cleanupIncomplete === true
? analysisErrorWithCleanupFailure(
cancelled,
failure,
"analyze_web_bundle",
).bindCleanupDependencies([cancelled, failure])
: cancelled;
} else if (!hasPrimaryFailure && deadline.interruption === "timeout") {
const timeout = new AnalysisTimeoutError(
"analyze_web_bundle",
input.total_analysis_timeout_ms ??
WEB_BUNDLE_WORKER_LIMITS.totalTimeoutMs,
{
cause: deadline.signal.reason,
partialObservation: partial ?? inspection,
},
);
failure =
failure?.cleanupIncomplete === true
? analysisErrorWithCleanupFailure(
timeout,
failure,
"analyze_web_bundle",
).bindCleanupDependencies([timeout, failure])
: timeout;
}
if (failure !== undefined) {
failure.retainPartialObservation(partial ?? inspection);
return err(failure);
}
if (partial === undefined)
return err(
new AnalysisOutputError(
"analyze_web_bundle",
"Web analysis produced no result",
),
);
return ok(partial);
}
async close(): Promise<void> {
this.#closed = true;
this.#controller?.abort(
new Error("Browser provider closed during bundle analysis"),
);
await this.#running;
await this.#releaseWorker();
}
async #releaseWorker(): Promise<void> {
try {
await this.#worker?.close();
this.#worker = undefined;
IsolatedWebBundleAnalyzer.#pendingCleanup.delete(this);
} catch (cause) {
IsolatedWebBundleAnalyzer.#pendingCleanup.add(this);
throw cause;
}
}
}
@@ -24,7 +24,13 @@ const event = {
error: false,
};
it.each(["custom-apply", "listener-chain"])(
it.each([
"custom-apply",
"listener-chain",
"once-metadata",
"listener-registration",
"listener-removal",
])(
"keeps %s gaps distinct from captured IPC and retention loss",
(boundary) => {
const result = projectElectronActiveCapture(
@@ -186,7 +186,7 @@ export const projectElectronActiveCapture = (
"IPC payloads are represented by value shapes; payload values are never retained.",
...(observations.observedEventFamilies.includes("ipc-observation-gap")
? [
"IPC observation gaps have unobserved callback arguments and execution. Available ipc-observation-gap timeline records identify the custom-apply or listener-chain boundary and registered channel; dropped gap records are counted by retention. Listener-chain gaps describe registration uncertainty, not proof of IPC delivery.",
"IPC observation gaps have unobserved callback arguments and execution. Available ipc-observation-gap timeline records identify the callable, registration or removal boundary and channel where known; dropped gap records are counted by retention. Registration gaps do not prove IPC delivery, and removal gaps do not confirm which registration was removed.",
]
: []),
"IPC direction and sender/receiver identifiers are observed only where Electron exposes them at the hooked boundary.",
@@ -1,6 +1,6 @@
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
@@ -11,10 +11,11 @@ import {
} from "../../tests/fixtures/electron/fuseImage.js";
import * as processOwnership from "../process/ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js";
import * as ownershipObservation from "../process/ProcessOwnershipObservation.js";
import * as electronActions from "./PlaywrightElectronActiveActions.js";
import { electronActiveObservationInputSchema } from "../domain/javascript/electronActiveObservation.js";
import type { OwnedProcessGroup } from "../process/ProcessOwnership.js";
import type { PersistentOwnedProcessGroup } from "../process/ProcessOwnership.js";
import {
PlaywrightElectronActiveProvider,
safeElectronEnvironment,
@@ -75,12 +76,17 @@ it("passes required Windows runtime variables without inheriting unrelated value
});
});
const ownership = {
runId: "run",
leaderPid: 4242,
processGroupId: 4242,
expectedParentPid: 1,
} satisfies OwnedProcessGroup;
let ownership: PersistentOwnedProcessGroup;
beforeEach(() => {
ownership = {
runId: "run",
leaderPid: 4242,
processGroupId: 4242,
expectedParentPid: 1,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
};
});
const emptyHookSnapshot = () => ({
events: [],
@@ -694,8 +700,8 @@ it("cancels a completed capture when cancellation arrives during process cleanup
stubCompletedCapture();
const controller = new AbortController();
const reason = new Error("SIGTERM during process cleanup");
// The POSIX path ends with the token sweep; Windows ends with tree termination.
vi.spyOn(processOwnership, "verifyNoTokenOwnedProcesses").mockImplementation(
// POSIX cleanup owns the final sweep; Windows uses tree termination.
vi.spyOn(processOwnership, "cleanupOwnedProcessGroup").mockImplementation(
async () => {
controller.abort(reason);
return {
@@ -25,9 +25,10 @@ import { BrowserObservationError } from "../domain/browserObservationError.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
import type {
OwnedProcessGroup,
PersistentOwnedProcessGroup,
ProcessLineageObservation,
} from "../process/ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js";
import {
observeOwnedProcessLineage,
prepareProcessOwnershipInspection,
@@ -108,7 +109,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
options: ExecutionOptions,
): Promise<Result<ElectronActiveObservationResult, AnalysisError>> {
let application: ElectronApplication | undefined;
let ownership: OwnedProcessGroup | undefined;
let ownership: PersistentOwnedProcessGroup | undefined;
let lineage: ProcessLineageObservation | undefined;
let capture: ElectronActiveObservationResult | undefined;
let paths: ElectronPaths | undefined;
@@ -182,6 +183,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
expectedParentPid: process.pid,
expectedCommand: paths.executable,
sweepTokenOwnedProcesses: true,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
...(captureBaseline === undefined ? {} : { captureBaseline }),
};
lineage = await observeOwnedProcessLineage(ownership);
+360
View File
@@ -0,0 +1,360 @@
import { createHash, randomUUID } from "node:crypto";
import { access } from "node:fs/promises";
import { expect, it } from "vitest";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import {
resolvedAnalysisError,
type AnalysisError,
} from "../domain/analysisErrorBase.js";
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
import {
spawnOwnedProviderProcess,
type ProviderProcessHandle,
} from "../process/ProviderProcess.js";
import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js";
import { WEB_BUNDLE_WORKER_LIMITS } from "./WebBundleAnalysisWorkerProtocol.js";
const makeScript = (text: string): IncludedScript => ({
script_key: `scr_${createHash("sha256").update(text).digest("hex")}`,
frame_id: "frame-1",
url: "https://app.example.test/app.js",
origin: "https://app.example.test",
cdp_hash: null,
length: Buffer.byteLength(text),
is_module: false,
language: "JavaScript",
source_map_url: null,
resource_reconciliation: {
status: "unmatched",
reason: "no_exact_sanitized_url",
},
source: {
included: true,
artifact: createWebTextArtifact(text, "application/javascript"),
},
});
const sourceWorkerProgram = (
hangMarker?: string,
oomMarker?: string,
): string => `
const { serveWebBundleAnalysisWorker } = await import(${JSON.stringify(new URL("./WebBundleAnalysisWorkerServer.ts", import.meta.url).href)});
const { analyzeCapturedWebScript } = await import(${JSON.stringify(new URL("../domain/webBundleAnalyzer.ts", import.meta.url).href)});
const analyze = (script) => {
if (${JSON.stringify(hangMarker)} !== undefined && script.source.artifact.text === ${JSON.stringify(hangMarker)}) {
for (;;) {}
}
if (${JSON.stringify(oomMarker)} !== undefined && script.source.artifact.text === ${JSON.stringify(oomMarker)}) {
const retained = [];
for (;;) retained.push(new Array(50_000).fill({ marker: "bounded-child-oom" }));
}
return analyzeCapturedWebScript(script);
};
await serveWebBundleAnalysisWorker(analyze);
`;
type TestMode =
| {
readonly kind: "source";
readonly hangMarker?: string;
readonly oomMarker?: string;
}
| { readonly kind: "delayed-ready" }
| {
readonly kind: "protocol";
readonly failure: "wrong-id" | "oversized-control" | "oversized-result";
};
const protocolWorkerProgram = (
failure: "wrong-id" | "oversized-control" | "oversized-result",
): string => `
import { createInterface } from "node:readline";
import { getHeapStatistics } from "node:v8";
const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n");
send({ kind: "ready", heap_limit_bytes: getHeapStatistics().heap_size_limit });
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
input.once("line", (line) => {
const request = JSON.parse(line);
${
failure === "wrong-id"
? `send({ kind: "phase", id: ${JSON.stringify(randomUUID())}, phase: "analyze" });`
: failure === "oversized-control"
? `process.stdout.write("x".repeat(70 * 1024) + "\\n");`
: `send({ kind: "complete", id: request.id, result: { bytes: request.result_budget_bytes + 1, sha256: "${"0".repeat(64)}" } });`
}
});
`;
const delayedReadyProgram = `
import { createInterface } from "node:readline";
import { getHeapStatistics } from "node:v8";
setTimeout(() => {
process.stdout.write(JSON.stringify({ kind: "ready", heap_limit_bytes: getHeapStatistics().heap_size_limit }) + "\\n");
}, 1_500);
createInterface({ input: process.stdin, crlfDelay: Infinity });
`;
interface SpawnedTestProcess {
readonly process: ProviderProcessHandle;
readonly root: string | undefined;
}
const launcherFor = (
mode: TestMode,
options: {
readonly failCleanupOnce?: boolean;
readonly onSpawn?: (spawned: SpawnedTestProcess) => void;
} = {},
): typeof spawnOwnedProviderProcess => {
let failCleanup = options.failCleanupOnce ?? false;
return async (request) => {
const loader = import.meta.resolve("tsx");
const program =
mode.kind === "source"
? sourceWorkerProgram(mode.hangMarker, mode.oomMarker)
: mode.kind === "delayed-ready"
? delayedReadyProgram
: protocolWorkerProgram(mode.failure);
const spawned = await spawnOwnedProviderProcess({
...request,
arguments: [
...request.arguments.slice(0, -1),
"--import",
loader,
"--input-type=module",
"-e",
program,
],
});
options.onSpawn?.({ process: spawned.process, root: request.cwd });
return {
...spawned,
cleanup: async () => {
if (failCleanup) {
failCleanup = false;
return { cleaned: false, reason: "controlled worker cleanup retry" };
}
return spawned.cleanup === undefined
? cleanupOwnedProcessGroup(spawned.ownership)
: spawned.cleanup();
},
};
};
};
const failureOf = <Value>(
result: Awaited<ReturnType<WebBundleAnalysisWorker["analyze"]>>,
): AnalysisError => {
if (result.ok) throw new Error("Expected web worker failure");
return result.error.error;
};
const waitForExit = async (child: ProviderProcessHandle): Promise<void> => {
if (child.exitCode !== null || child.signalCode !== null) return;
await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | undefined;
const finish = (): void => {
if (timer !== undefined) clearTimeout(timer);
child.off("exit", onExit);
resolve();
};
const onExit = (): void => finish();
child.once("exit", onExit);
timer = setTimeout(() => {
child.off("exit", onExit);
reject(new Error("Owned child did not exit"));
}, 3_000);
if (child.exitCode !== null || child.signalCode !== null) finish();
});
};
it("reuses one real owned worker for completed facts from consecutive scripts", async () => {
const children: ProviderProcessHandle[] = [];
const worker = new WebBundleAnalysisWorker(
WEB_BUNDLE_WORKER_LIMITS.heapMb,
launcherFor(
{ kind: "source" },
{ onSpawn: ({ process: child }) => children.push(child) },
),
);
try {
const first = await worker.analyze(makeScript("fetch('/users');"), 10_000);
const second = await worker.analyze(
makeScript("fetch('/orders');"),
10_000,
);
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
if (!first.ok || !second.ok)
throw new Error("Expected both captured scripts to complete");
expect(first.value).toMatchObject({
status: "analyzed",
script_key: makeScript("fetch('/users');").script_key,
});
expect(second.value).toMatchObject({
status: "analyzed",
script_key: makeScript("fetch('/orders');").script_key,
});
expect(children).toHaveLength(1);
} finally {
await worker.close();
}
});
it("kills a hung owned child at the deadline and starts a fresh worker for the next script", async () => {
const children: ProviderProcessHandle[] = [];
const worker = new WebBundleAnalysisWorker(
WEB_BUNDLE_WORKER_LIMITS.heapMb,
launcherFor(
{ kind: "source", hangMarker: "hang forever" },
{ onSpawn: ({ process: child }) => children.push(child) },
),
);
try {
const timedOut = await worker.analyze(makeScript("hang forever"), 500);
expect(failureOf(timedOut)).toBeInstanceOf(AnalysisTimeoutError);
await waitForExit(children[0]!);
expect(
children[0]?.exitCode !== null || children[0]?.signalCode !== null,
).toBe(true);
const recovered = await worker.analyze(
makeScript("fetch('/after-timeout');"),
10_000,
);
expect(recovered.ok).toBe(true);
expect(children).toHaveLength(2);
} finally {
await worker.close();
}
});
it("contains child heap exhaustion and reuses the host for a later script", async () => {
const children: ProviderProcessHandle[] = [];
const roots: string[] = [];
const worker = new WebBundleAnalysisWorker(
128,
launcherFor(
{ kind: "source", oomMarker: "exhaust child heap" },
{
onSpawn: ({ process: child, root }) => {
children.push(child);
if (root !== undefined) roots.push(root);
},
},
),
);
try {
const exhausted = await worker.analyze(
makeScript("exhaust child heap"),
15_000,
);
expect(failureOf(exhausted)).toBeInstanceOf(
AnalysisResourceConstraintError,
);
await waitForExit(children[0]!);
expect(
children[0]?.exitCode !== null || children[0]?.signalCode !== null,
).toBe(true);
const recovered = await worker.analyze(
makeScript("fetch('/after-oom');"),
10_000,
);
expect(recovered.ok).toBe(true);
expect(children).toHaveLength(2);
expect(roots).toHaveLength(2);
expect(roots[0]).toBe(roots[1]);
await access(roots[0]!);
await worker.close();
await expect(access(roots[0]!)).rejects.toThrow();
} finally {
await worker.close();
}
});
it("cancels during the late ready handshake and retains failed cleanup until exact retry", async () => {
const children: SpawnedTestProcess[] = [];
let announceSpawn: () => void = () => {};
const spawned = new Promise<void>((resolve) => {
announceSpawn = resolve;
});
const worker = new WebBundleAnalysisWorker(
WEB_BUNDLE_WORKER_LIMITS.heapMb,
launcherFor(
{ kind: "delayed-ready" },
{
failCleanupOnce: true,
onSpawn: (child) => {
children.push(child);
announceSpawn();
},
},
),
);
const controller = new AbortController();
const reason = { source: "late-handshake-test" };
try {
const analyzing = worker.analyze(
makeScript("fetch('/cancelled');"),
10_000,
controller.signal,
);
await spawned;
controller.abort(reason);
const result = await analyzing;
const error = failureOf(result);
expect(error.cleanupIncomplete).toBe(true);
expect(error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(error.cleanupPrimary?.cause).toBe(reason);
expect(children[0]?.root).toBeDefined();
await access(children[0]?.root ?? "");
await worker.close();
expect(error.cleanupIncomplete).toBe(false);
expect(resolvedAnalysisError(error).cause).toBe(reason);
await expect(access(children[0]?.root ?? "")).rejects.toThrow();
await waitForExit(children[0]!.process);
} finally {
if (
children[0]?.process.exitCode === null &&
children[0]?.process.signalCode === null
)
children[0]?.process.kill("SIGKILL");
await worker.close().catch(() => undefined);
await WebBundleAnalysisWorker.retryPending().catch(() => undefined);
}
});
it.each([
["wrong-id", AnalysisOutputError],
["oversized-control", AnalysisOutputError],
["oversized-result", AnalysisResourceConstraintError],
] as const)(
"rejects %s before accepting unverified result bytes",
async (failure, ErrorType) => {
const worker = new WebBundleAnalysisWorker(
WEB_BUNDLE_WORKER_LIMITS.heapMb,
launcherFor({ kind: "protocol", failure }),
);
try {
const result = await worker.analyze(
makeScript("fetch('/not-read');"),
10_000,
);
expect(failureOf(result)).toBeInstanceOf(ErrorType);
if (failure === "oversized-result")
expect(failureOf(result).message).toContain(
"exceed the host's remaining reserved result capacity",
);
} finally {
await worker.close();
}
},
);
+612
View File
@@ -0,0 +1,612 @@
import { createHash, randomUUID } from "node:crypto";
import { open, rm } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { StringDecoder } from "node:string_decoder";
import { getHeapStatistics } from "node:v8";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
} from "../artifacts/AnalysisCleanupLease.js";
import {
ArtifactFileCancelled,
ArtifactFileCleanupFailure,
artifactFileCancellationCause,
withArtifactFileHandle,
} from "../artifacts/ArtifactFileOperation.js";
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
import type { StableArtifactSnapshot } from "../artifacts/readStableArtifact.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisTimeoutError,
AnalysisOutputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { jsonParts, bufferedJsonParts } from "../domain/jsonSerialization.js";
import { javaScriptRuntime } from "../domain/runtimeVersion.js";
import { err, ok, type Result } from "../domain/result.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import {
webBundleScriptAnalysisSchema,
type WebBundleScriptAnalysis,
} from "../domain/webBundleScriptAnalysis.js";
import { OwnedWorkerRuntime } from "../process/OwnedWorkerRuntime.js";
import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js";
import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
import { javaScriptWorkerMemoryLimitBytes } from "../process/JavaScriptWorkerMemoryLimit.js";
import {
WEB_BUNDLE_WORKER_LIMITS,
webBundleWorkerMessageSchema,
webBundleWorkerRequestSchema,
type WebBundleWorkerMessage,
} from "./WebBundleAnalysisWorkerProtocol.js";
const OPERATION = "analyze_web_bundle";
const PROVIDER = "web-bundle-static-analysis";
const MIB = 1024 * 1024;
/** Source/result buffers, decoded strings, parsed objects and validation copies. */
const RESULT_EXPANSION = 16;
type WebBundleResultDescriptor = Extract<
WebBundleWorkerMessage,
{ readonly kind: "complete" }
>["result"];
const stableSnapshotValue = (
value: unknown,
): StableArtifactSnapshot | undefined => {
if (
value === null ||
typeof value !== "object" ||
!("bytes" in value) ||
!Buffer.isBuffer(value.bytes) ||
!("sha256" in value) ||
typeof value.sha256 !== "string"
)
return undefined;
return { bytes: value.bytes, sha256: value.sha256 };
};
export interface WebBundleScriptAnalysisFailure {
readonly error: AnalysisError;
readonly completed: WebBundleScriptAnalysis | null;
readonly continuation?: "result_capacity_exhausted";
}
/** Sequential inert-source analysis; only bounded completed facts cross into the host. */
export class WebBundleAnalysisWorker {
static readonly #pendingCleanup = new Set<WebBundleAnalysisWorker>();
readonly #runtime: OwnedWorkerRuntime;
readonly #files = new ArtifactResourceScope();
readonly #cleanup: AnalysisCleanupLease;
#decoder = new StringDecoder("utf8");
#pending = "";
#frames: WebBundleWorkerMessage[] = [];
#protocolFailure: unknown;
#retainedResultBytes = 0;
#ready = false;
readonly #stdout = (chunk: Buffer | string): void => {
if (this.#protocolFailure !== undefined) return;
try {
// Refuse the chunk before appending it to any retained control text.
if (
Buffer.byteLength(chunk) + Buffer.byteLength(this.#pending) >
WEB_BUNDLE_WORKER_LIMITS.protocolBytes
)
throw new RangeError("Web analyzer exceeded its control-frame budget");
this.#pending +=
typeof chunk === "string" ? chunk : this.#decoder.write(chunk);
for (;;) {
const end = this.#pending.indexOf("\n");
if (end < 0) break;
if (this.#frames.length >= 16)
throw new RangeError("Web analyzer exceeded its control-frame queue");
this.#frames.push(
webBundleWorkerMessageSchema.parse(
JSON.parse(this.#pending.slice(0, end)),
),
);
this.#pending = this.#pending.slice(end + 1);
}
} catch (cause) {
this.#pending = "";
this.#protocolFailure = cause;
}
};
constructor(
readonly heapMb = 1024,
launcher: typeof spawnOwnedProviderProcess = spawnOwnedProviderProcess,
) {
if (!Number.isSafeInteger(heapMb) || heapMb < 128 || heapMb > 16_384)
throw new RangeError(
"Web analyzer heap must be an integer from 128 through 16384 MiB",
);
this.#runtime = new OwnedWorkerRuntime({
rootPrefix: "rea-web-bundle-analysis-",
launcher,
retryCleanup: () => this.close(),
supervisorOptions: {
captureStdout: false,
maxDiagnosticBytes: 256 * 1024,
},
});
const owner = this;
this.#cleanup = new AnalysisCleanupLease(() => this.#release(), {
get cleanupResources() {
return [
...owner.#files.cleanupResources,
...(owner.#runtime.cleanupResources ?? []),
];
},
});
}
static async retryPending(): Promise<void> {
for (const worker of [...this.#pendingCleanup]) await worker.close();
await OwnedWorkerRuntime.retryPending();
}
async analyze(
script: IncludedScript,
timeoutMs: number,
signal?: AbortSignal,
): Promise<Result<WebBundleScriptAnalysis, WebBundleScriptAnalysisFailure>> {
let deadline: ProviderStartupDeadline | undefined;
const id = randomUUID();
let result: Result<WebBundleScriptAnalysis, AnalysisError>;
let completed: WebBundleScriptAnalysis | null = null;
let continuation: "result_capacity_exhausted" | undefined;
let resultDescriptor: WebBundleResultDescriptor | undefined;
const failed = (
error: AnalysisError,
): Result<WebBundleScriptAnalysis, WebBundleScriptAnalysisFailure> =>
err({
error,
completed,
...(continuation === undefined ? {} : { continuation }),
});
try {
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, { cause: signal.reason });
const resultBudget = Math.min(
WEB_BUNDLE_WORKER_LIMITS.resultBytes - this.#retainedResultBytes,
Math.max(
0,
Math.floor(
(getHeapStatistics().heap_size_limit -
process.memoryUsage().heapUsed -
64 * MIB) /
RESULT_EXPANSION,
),
),
);
if (resultBudget <= 0) {
continuation = "result_capacity_exhausted";
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
"The host has no remaining reserved web-analysis result capacity",
{ result_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes },
);
}
await prepareProcessOwnershipInspection(signal);
deadline = new ProviderStartupDeadline(timeoutMs, signal);
await this.#start(deadline);
if (this.#frames.length > 0 || this.#pending !== "")
throw new AnalysisOutputError(
OPERATION,
"Web analyzer emitted unsolicited control data",
);
const root = this.#runtime.rootPath;
const supervisor = this.#runtime.supervisor;
if (root === undefined || supervisor === undefined)
throw new TypeError("Web analyzer resources were not acquired");
supervisor.resetOutput();
const source = await this.#files.run(() =>
this.#writeSource(join(root, `${id}.source`), script, deadline!.signal),
);
const request = webBundleWorkerRequestSchema.parse({
kind: "analyze",
id,
source,
result_budget_bytes: resultBudget,
});
const frame = `${JSON.stringify(request)}\n`;
const stdin = supervisor.launch.process.stdin;
if (stdin === null || stdin === undefined)
throw new TypeError("Web analyzer request stream is unavailable");
await new Promise<void>((resolve, reject) =>
stdin.write(frame, (error) =>
error == null ? resolve() : reject(error),
),
);
let response: WebBundleWorkerMessage;
do {
response = await this.#next(deadline);
if (response.kind === "ready" || response.id !== id)
throw new AnalysisOutputError(
OPERATION,
"Web analyzer response names another request",
);
} while (response.kind === "phase");
if (response.kind === "failure") {
if (response.reason === "resource")
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
response.message,
{ result_budget_bytes: resultBudget },
);
throw new AnalysisOutputError(
OPERATION,
`Web script analysis failed (${response.reason}): ${response.message}`,
);
}
if (response.kind !== "complete")
throw new AnalysisOutputError(
OPERATION,
"Web analyzer did not complete a result",
);
const descriptor = response.result;
resultDescriptor = descriptor;
// Reserve expansion before reading bytes, decoding JSON or schema validation.
if (
descriptor.bytes > resultBudget ||
descriptor.bytes * RESULT_EXPANSION + 64 * MIB >
getHeapStatistics().heap_size_limit - process.memoryUsage().heapUsed
)
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
"Completed script facts exceed the host's remaining reserved result capacity",
{ result_budget_bytes: resultBudget },
);
const stored = await this.#files.run(() =>
readStableArtifact(
join(root, `${id}.result`),
descriptor.bytes,
deadline!.signal,
undefined,
this.#files,
),
);
const facts = this.#validateCompletedResult(stored, descriptor, script);
this.#retainedResultBytes += descriptor.bytes;
completed = facts;
result = ok(facts);
} catch (cause) {
const snapshot = this.#completedSnapshot(cause);
if (snapshot !== undefined && resultDescriptor !== undefined) {
try {
completed = this.#validateCompletedResult(
snapshot,
resultDescriptor,
script,
);
this.#retainedResultBytes += resultDescriptor.bytes;
} catch {
// The physical read completed, but only validated facts are retained.
}
}
const cancellationCause = this.#completedReadCancellation(cause);
const interrupted =
deadline?.interruption === "timeout"
? new AnalysisTimeoutError(OPERATION, timeoutMs, { cause })
: cancellationCause !== undefined
? new AnalysisCancelledError(OPERATION, {
cause: cancellationCause.reason,
})
: deadline?.interruption === "cancelled" || signal?.aborted
? new AnalysisCancelledError(OPERATION, {
cause: signal?.aborted
? signal.reason
: deadline?.signal.reason,
})
: undefined;
const failure =
cause instanceof ArtifactFileCleanupFailure &&
cause.fileCleanupFailure.outcome.kind === "completed"
? this.#cleanupFailure(cause, interrupted)
: (interrupted ??
(cause instanceof PrivateRuntimeRootUnavailableError
? new AnalysisCapabilityUnavailableError(
PROVIDER,
OPERATION,
cause.message,
{ cause, retryable: cause.retryable },
)
: cause instanceof AnalysisError
? cause
: new AnalysisOutputError(
OPERATION,
"Owned web-script analysis failed",
{ cause },
)));
result = err(failure);
try {
const stopped = await this.#runtime.stop();
if (stopped.status === "incomplete") throw new Error(stopped.reason);
this.#reset();
} catch (cleanupCause) {
result = err(this.#cleanupFailure(cleanupCause, failure));
}
} finally {
deadline?.dispose();
}
// Never remove job files while a failed descriptor or child remains owned.
if (!result.ok && result.error.cleanupIncomplete)
return failed(result.error);
if (this.#files.hasPending)
return failed(
this.#cleanupFailure(
new Error("Web analyzer artifact cleanup is incomplete"),
result.ok ? undefined : result.error,
),
);
try {
const root = this.#runtime.rootPath;
if (root !== undefined) {
await rm(join(root, `${id}.source`), { force: true });
await rm(join(root, `${id}.result`), { force: true });
}
} catch (cause) {
return failed(
this.#cleanupFailure(cause, result.ok ? undefined : result.error),
);
}
if (signal?.aborted && result.ok)
return failed(
new AnalysisCancelledError(OPERATION, { cause: signal.reason }),
);
return result.ok ? result : failed(result.error);
}
async #writeSource(
path: string,
script: IncludedScript,
signal: AbortSignal,
): Promise<{ bytes: number; sha256: string }> {
const digest = createHash("sha256");
let bytes = 0;
const file = await open(path, "wx", 0o600);
await withArtifactFileHandle(
path,
file,
this.#files,
signal,
async (handle) => {
for (const part of bufferedJsonParts(jsonParts(script))) {
signal.throwIfAborted();
const chunk = Buffer.from(part, "utf8");
if (chunk.length > WEB_BUNDLE_WORKER_LIMITS.sourceBytes - bytes)
throw new AnalysisResourceConstraintError(
OPERATION,
"transport",
"Captured script metadata and source exceed the worker transfer budget",
{ source_bytes: WEB_BUNDLE_WORKER_LIMITS.sourceBytes },
);
await handle.writeFile(chunk);
bytes += chunk.length;
digest.update(chunk);
}
},
);
return { bytes, sha256: digest.digest("hex") };
}
async #start(deadline: ProviderStartupDeadline): Promise<void> {
if (this.#ready && this.#runtime.supervisor !== undefined) return;
const environment: NodeJS.ProcessEnv = {
...process.env,
UV_THREADPOOL_SIZE: "1",
};
delete environment.NODE_OPTIONS;
const memoryLimitBytes = javaScriptWorkerMemoryLimitBytes(this.heapMb);
await this.#runtime.start(
{
command: process.execPath,
arguments: [
`--max-old-space-size=${String(this.heapMb)}`,
"--max-semi-space-size=8",
"--v8-pool-size=1",
"--expose-gc",
"--stack_size=2048",
fileURLToPath(import.meta.resolve("#web-bundle-analysis-worker")),
],
runId: `rea-web-bundle-analysis-${randomUUID()}`,
hostEnvironment: environment,
stdin: "pipe",
signal: deadline.signal,
...(memoryLimitBytes === null ? {} : { memoryLimitBytes }),
},
async () => {
this.#runtime.onStdout(this.#stdout);
const ready = await this.#next(deadline);
if (
ready.kind !== "ready" ||
(ready.heap_limit_bytes === null) ===
(javaScriptRuntime(process.versions).id === "node")
)
throw new AnalysisOutputError(
OPERATION,
"Web analyzer ready frame has an incompatible runtime heap limit",
);
this.#ready = true;
},
);
}
async #next(
deadline: ProviderStartupDeadline,
): Promise<WebBundleWorkerMessage> {
for (;;) {
if (deadline.interruption === "timeout")
throw new AnalysisTimeoutError(OPERATION, deadline.timeoutMs);
if (deadline.interruption === "cancelled")
throw new AnalysisCancelledError(OPERATION, {
cause: deadline.signal.reason,
});
deadline.signal.throwIfAborted();
if (this.#runtime.inputFailure !== undefined)
throw new AnalysisOutputError(
OPERATION,
"Web analyzer request stream failed",
{ cause: this.#runtime.inputFailure },
);
if (this.#protocolFailure !== undefined)
throw new AnalysisOutputError(
OPERATION,
"Invalid web analyzer control frame",
{ cause: this.#protocolFailure },
);
const frame = this.#frames.shift();
if (frame !== undefined) return frame;
const supervisor = this.#runtime.supervisor;
const snapshot = supervisor?.snapshot();
if (
snapshot !== undefined &&
(snapshot.exitCode !== undefined || snapshot.signal !== undefined)
) {
if (!(await supervisor?.waitForOutputClose(10))) continue;
if (this.#frames.length > 0) continue;
const finalSnapshot = supervisor?.snapshot() ?? snapshot;
const capturedOutput = {
stdout: "",
stderr: finalSnapshot.stderr.text,
truncated: finalSnapshot.diagnosticTruncated ?? false,
exit_code: finalSnapshot.exitCode ?? null,
signal: finalSnapshot.signal ?? null,
};
if (
supervisor?.launch.process.memoryLimitReached === true ||
/heap out of memory|heap limit|allocation failed/iu.test(
finalSnapshot.stderr.text,
)
)
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
"Owned web analyzer exhausted its configured memory limit",
{
worker_heap_limit_mb: this.heapMb,
worker_process_memory_limit_bytes:
javaScriptWorkerMemoryLimitBytes(this.heapMb),
},
{ capturedOutput },
);
throw new AnalysisOutputError(
OPERATION,
"Owned web analyzer exited before completing this script",
{ capturedOutput },
);
}
await deadline.wait(10);
}
}
#completedSnapshot(cause: unknown): StableArtifactSnapshot | undefined {
if (cause instanceof ArtifactFileCancelled)
return stableSnapshotValue(cause.fileCancellation.outcome.value);
if (
cause instanceof ArtifactFileCleanupFailure &&
cause.fileCleanupFailure.outcome.kind === "completed"
)
return stableSnapshotValue(cause.fileCleanupFailure.outcome.value);
return undefined;
}
#completedReadCancellation(
cause: unknown,
): { readonly reason: unknown } | undefined {
if (cause instanceof ArtifactFileCancelled)
return { reason: artifactFileCancellationCause(cause) };
if (
cause instanceof ArtifactFileCleanupFailure &&
cause.fileCleanupFailure.outcome.kind === "completed"
)
return cause.fileCleanupFailure.completionCancellation;
return undefined;
}
#validateCompletedResult(
stored: StableArtifactSnapshot,
descriptor: WebBundleResultDescriptor,
script: IncludedScript,
): WebBundleScriptAnalysis {
if (
stored.bytes.length !== descriptor.bytes ||
stored.sha256 !== descriptor.sha256
)
throw new AnalysisOutputError(
OPERATION,
"Web analyzer result differs from its descriptor",
);
const facts = webBundleScriptAnalysisSchema.parse(
JSON.parse(stored.bytes.toString("utf8")),
);
if (facts.status !== "analyzed" || facts.script_key !== script.script_key)
throw new AnalysisOutputError(
OPERATION,
"Web analyzer facts name another script",
);
return facts;
}
#reset(): void {
this.#ready = false;
this.#frames = [];
this.#pending = "";
this.#decoder = new StringDecoder("utf8");
this.#protocolFailure = undefined;
}
#cleanupFailure(cause: unknown, primary?: AnalysisError): AnalysisError {
WebBundleAnalysisWorker.#pendingCleanup.add(this);
this.#runtime.retainForCleanup();
const cleanup = new ProviderCleanupError(
PROVIDER,
this.#cleanup.cleanupResources ?? [],
{ reason: "Owned web analyzer cleanup remains incomplete" },
{ operation: OPERATION, cause },
);
new AnalysisCleanupLeaseGroup(cleanup, [this.#cleanup]);
return primary === undefined
? cleanup
: analysisErrorWithCleanupFailure(
primary,
cleanup,
OPERATION,
).bindCleanupDependencies([primary, cleanup]);
}
async close(): Promise<void> {
try {
await this.#cleanup.release();
} catch (cause) {
throw this.#cleanupFailure(cause);
}
}
async #release(): Promise<void> {
try {
const stopped = await this.#runtime.stop();
if (stopped.status === "incomplete") throw new Error(stopped.reason);
await this.#files.close();
await this.#runtime.close();
this.#reset();
WebBundleAnalysisWorker.#pendingCleanup.delete(this);
} catch (cause) {
WebBundleAnalysisWorker.#pendingCleanup.add(this);
this.#runtime.retainForCleanup();
throw cause;
}
}
}
@@ -0,0 +1,82 @@
import { createHash } from "node:crypto";
import { expect, it, vi } from "vitest";
const heapLimit = vi.hoisted(() => ({ override: null as number | null }));
vi.mock("node:v8", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:v8")>();
return {
...actual,
getHeapStatistics: () => {
const statistics = actual.getHeapStatistics();
return heapLimit.override === null
? statistics
: { ...statistics, heap_size_limit: heapLimit.override };
},
};
});
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js";
it("refuses before spawning or reading source when no host result capacity remains", async () => {
const text = "fetch('/never-serialized');";
const base = makeScript(text);
let sourceReads = 0;
let launches = 0;
const artifact = { ...base.source.artifact };
Object.defineProperty(artifact, "text", {
configurable: true,
get: () => {
sourceReads += 1;
return text;
},
});
const script: IncludedScript = {
...base,
source: { included: true, artifact },
};
const worker = new WebBundleAnalysisWorker(1024, async () => {
launches += 1;
throw new Error(
"The worker must not launch when the host has no result capacity",
);
});
heapLimit.override = 0;
try {
const result = await worker.analyze(script, 10_000);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected result-capacity refusal");
expect(result.error.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(result.error.continuation).toBe("result_capacity_exhausted");
expect(result.error.completed).toBeNull();
expect(sourceReads).toBe(0);
expect(launches).toBe(0);
} finally {
heapLimit.override = null;
await worker.close();
}
});
const makeScript = (text: string): IncludedScript => ({
script_key: `scr_${createHash("sha256").update(text).digest("hex")}`,
frame_id: "frame-1",
url: "https://app.example.test/app.js",
origin: "https://app.example.test",
cdp_hash: null,
length: Buffer.byteLength(text),
is_module: false,
language: "JavaScript",
source_map_url: null,
resource_reconciliation: {
status: "unmatched",
reason: "no_exact_sanitized_url",
},
source: {
included: true,
artifact: createWebTextArtifact(text, "application/javascript"),
},
});
Loaded 100 of 327 files, more files were not shown because too many files have changed in this diff. Show more