From ab3893d48e638323b6c277160850caf9d7a57cca Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:59:01 +0000 Subject: [PATCH] feat(browser): deliver retained summaries of browser scenario captures Add detail summary to capture_browser_scenario (MCP and CLI) and project retained scenario Evidence through inspect_analysis_view: a compact summary and exact step/event pages. Refs #6167 Co-Authored-By: Claude Opus 5.5 --- .../references/runtime-observation.md | 2 + docs/browser-scenario-contract.md | 40 ++ .../analysisView/BrowserScenarioViews.test.ts | 272 ++++++++++ .../environment/environmentScenario.test.ts | 13 +- src/cliBrowserScenarioCommands.ts | 25 +- src/contracts/analysisViewToolContracts.ts | 2 +- src/contracts/browserScenarioToolContracts.ts | 17 +- src/domain/analysisView/analysisView.ts | 29 +- .../analysisView/browserScenarioView.ts | 502 ++++++++++++++++++ src/server/createServer.ts | 1 + src/server/registerBrowserScenarioTool.ts | 49 +- .../cli/scenarioCliInputDiagnostics.test.ts | 32 ++ tests/boundary/mcp/browserScenarioMcp.test.ts | 108 ++++ 13 files changed, 1066 insertions(+), 26 deletions(-) create mode 100644 src/application/analysisView/BrowserScenarioViews.test.ts create mode 100644 src/domain/analysisView/browserScenarioView.ts diff --git a/.agents/skills/reverse-engineer-anything/references/runtime-observation.md b/.agents/skills/reverse-engineer-anything/references/runtime-observation.md index a67648c802..baac988e82 100644 --- a/.agents/skills/reverse-engineer-anything/references/runtime-observation.md +++ b/.agents/skills/reverse-engineer-anything/references/runtime-observation.md @@ -38,6 +38,8 @@ Page-declared WebMCP tools are untrusted inventory and are never invoked by REA. browser and profile. Check `browser.cleanup` for the outcome. Request only the DOM, accessibility, or network details needed for the question. The default final capture contains the URL; step outcomes are reported separately. + For multi-step flows with trees or events, select `detail: "summary"` and page + exact steps or events with `inspect_analysis_view` kind `browser-scenario`. Add a wait action for the expected page state before capturing it. Report that REA performed the declared actions. diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md index 2be32f00de..1f112b1b25 100644 --- a/docs/browser-scenario-contract.md +++ b/docs/browser-scenario-contract.md @@ -293,6 +293,46 @@ external browser without closing it. The request itself defines the operation; there is no additional REA grant step. The browser and host still enforce their own access and process rules. +## Summary delivery + +Multi-step scenarios that select accessibility trees, DOM or network events can +produce results far larger than an MCP client accepts. Select +`"detail": "summary"` (CLI: `--detail summary --evidence_output `) to +retain the complete Evidence and receive a compact view instead: + +- one row per step with its status, error, elapsed time and `after_url`; + `before_url` appears only when it differs from the previous step's + `after_url`, and sections are listed as captured, missing or truncated + (`not_requested` sections are omitted); +- `final_accessibility`: the accessibility tree of the first failed or + cancelled step, or of the last step, falling back to the nearest earlier step + that captured one (`step_index` is `null` when none did); +- retained event counts by kind, with the budget omissions, and network + transaction counts by origin, resource type and final status class. + +Page exact records from `normalized_result.parent_evidence_id` with +`inspect_analysis_view`: + +```json +{ + "source": { "kind": "retained-evidence", "evidence_id": "ev_..." }, + "view": { + "kind": "browser-scenario", + "collection": "steps", + "step_indices": [3], + "artifacts": ["dom"] + } +} +``` + +Steps return only the captured sections named in `artifacts` and list the +others with their state. Events (`"collection": "events"`) accept +`step_indices`, `event_kinds` and `transaction_ids` and keep each event's +original `item_index`. An omitted filter selects all records; `[]` selects +none. Every page repeats the capture's completeness and dropped-event count: +exhausting the pages does not make a truncated capture complete. The default +`"detail": "complete"` keeps returning the whole capture inline. + ## Network evidence `capture.events: ["network"]` retains request, response, completion, failure, diff --git a/src/application/analysisView/BrowserScenarioViews.test.ts b/src/application/analysisView/BrowserScenarioViews.test.ts new file mode 100644 index 0000000000..f50f676264 --- /dev/null +++ b/src/application/analysisView/BrowserScenarioViews.test.ts @@ -0,0 +1,272 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + SCENARIO_ORIGIN, + scenarioCaptureEvidence, + scenarioCaptureFixture, +} from "../../../tests/fixtures/browserScenarioCapture.js"; +import { inspectAnalysisView } from "./AnalysisViewService.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { sanitizeBrowserUrl } from "../../domain/browserObservation.js"; +import type { BrowserScenarioCapture } from "../../domain/browserScenarioCapture.js"; +import type { AnalysisViewRequest } from "../../domain/analysisView/analysisView.js"; + +const tree = (text: string) => ({ + state: "captured" as const, + value: { + sha256: createHash("sha256").update(text).digest("hex"), + bytes: Buffer.byteLength(text), + text, + }, +}); + +/** Three steps: navigate to /quiz, capture its tree, then fail on a missing control. */ +const quizCapture = ( + thirdStep: "failed" | "completed" = "failed", +): BrowserScenarioCapture => { + const base = scenarioCaptureFixture(); + const [initial, browse] = base.steps; + if (initial === undefined || browse === undefined) + throw new Error("Fixture lacks steps"); + const quiz = sanitizeBrowserUrl(`${SCENARIO_ORIGIN}/quiz`); + return { + ...base, + scenario: { ...base.scenario, action_count: 2 }, + steps: [ + { + ...initial, + artifacts: { + ...initial.artifacts, + url: { state: "captured", value: initial.after_url }, + }, + }, + { + ...browse, + after_url: quiz, + artifacts: { + ...browse.artifacts, + accessibility: tree('- heading "Question 1"'), + screenshot: { state: "missing", reason: "page closed" }, + }, + }, + { + ...browse, + step_index: 2, + step_id: "answer", + action: "click", + before_url: quiz, + after_url: quiz, + ...(thirdStep === "failed" + ? { status: "failed" as const, error: "locator not found" } + : { status: "completed" as const, error: null }), + }, + ], + }; +}; + +const view = ( + capture: BrowserScenarioCapture, + request: AnalysisViewRequest, +) => { + const projected = inspectAnalysisView({ + source: { kind: "inline", evidence: scenarioCaptureEvidence(capture) }, + view: request, + }); + if (!projected.ok) throw projected.error; + return projected.value.normalized_result as Record; +}; + +describe("browser scenario analysis views", () => { + it("summarizes steps compactly and focuses the first failing step's nearest captured tree", () => { + const summary = view(quizCapture(), { kind: "summary" }); + expect(summary).toMatchObject({ + kind: "summary", + parent_operation: "capture_browser_scenario", + artifact: { path: "", sha256: null }, + coverage: { status: "complete-within-view", exhausted: true }, + }); + const { steps, final_accessibility, network, events } = + summary.summary as Record & { + steps: Record[]; + }; + // before_url repeats only across a discontinuity; not_requested sections are omitted. + expect(steps[0]).toHaveProperty("before_url"); + expect(steps[1]).not.toHaveProperty("before_url"); + expect(steps[2]).not.toHaveProperty("before_url"); + expect(steps[0]?.sections).toEqual({ + captured: ["url"], + missing: [], + truncated: [], + }); + expect(steps[1]?.sections).toEqual({ + captured: ["accessibility"], + missing: ["screenshot"], + truncated: [], + }); + expect(steps[2]).toMatchObject({ + status: "failed", + error: "locator not found", + after_url: { url: `${SCENARIO_ORIGIN}/quiz` }, + }); + expect(final_accessibility).toEqual({ + focus_step_index: 2, + focus: "first-unsuccessful-step", + step_index: 1, + accessibility: tree('- heading "Question 1"'), + }); + expect(network).toEqual({ + transactions: 8, + by_origin: [{ origin: SCENARIO_ORIGIN, transactions: 8 }], + by_resource_type: [ + { resource_type: "document", transactions: 1 }, + { resource_type: "fetch", transactions: 7 }, + ], + by_status_class: [ + { status_class: "2xx", transactions: 7 }, + { status_class: "failed", transactions: 1 }, + ], + }); + expect(events).toMatchObject({ retained: 24, dropped: 0 }); + }); + + it("reports the last step's own state when no step captured a tree", () => { + const capture = scenarioCaptureFixture(); + expect(view(capture, { kind: "summary" })).toMatchObject({ + summary: { + final_accessibility: { + focus_step_index: 1, + focus: "last-step", + step_index: null, + accessibility: { state: "not_requested" }, + }, + }, + }); + }); +}); + +describe("browser scenario analysis view pages", () => { + it("pages exact steps with only the selected captured sections", () => { + const page = view(quizCapture("completed"), { + kind: "browser-scenario", + collection: "steps", + step_indices: [1, 2], + artifacts: ["accessibility"], + offset: 0, + limit: 1, + }); + expect(page).toMatchObject({ + kind: "browser-scenario", + source_completeness: { status: "complete" }, + source_events: { retained: 24, dropped: 0 }, + coverage: { status: "page", examined: 1, total: 2, next_offset: 1 }, + items: { + collection: "steps", + rows: [ + { + step_index: 1, + artifacts: { accessibility: tree('- heading "Question 1"') }, + omitted_artifacts: [{ section: "screenshot", state: "missing" }], + }, + ], + }, + }); + }); + + it("filters events by step, kind and transaction, keeping original item indices", () => { + const capture = scenarioCaptureFixture(); + const responses = view(capture, { + kind: "browser-scenario", + collection: "events", + event_kinds: ["response", "request-failed"], + transaction_ids: ["request-2", "request-8"], + artifacts: [], + offset: 0, + limit: 64, + }) as { items: { rows: { item_index: number; event: unknown }[] } }; + expect( + responses.items.rows.map(({ item_index, event }) => ({ + item_index, + event, + })), + ).toEqual( + capture.events.items.flatMap((event, item_index) => + (event.kind === "response" || event.kind === "request-failed") && + ["request-2", "request-8"].includes(event.transaction_id) + ? [{ item_index, event }] + : [], + ), + ); + expect(responses.items.rows).toHaveLength(2); + for (const empty of [ + { step_indices: [] }, + { event_kinds: [] }, + { transaction_ids: [] }, + { step_indices: [0] }, + ]) + expect( + view(capture, { + kind: "browser-scenario", + collection: "events", + artifacts: [], + offset: 0, + limit: 64, + ...empty, + }), + ).toMatchObject({ + items: { rows: [] }, + coverage: { status: "empty", total: 0, exhausted: true }, + }); + }); + + it("states dropped events and refuses views of other parents", () => { + const base = scenarioCaptureFixture(); + const dropped = { + ...base, + events: { + ...base.events, + dropped: 3, + omissions: { + ...base.events.omissions, + by_kind: [ + { + kind: "console" as const, + count: 3, + reasons: ["max-events" as const], + }, + ], + }, + }, + completeness: { + status: "truncated" as const, + equality_eligible: false as const, + missing_sections: [], + truncated_sections: ["events" as const], + }, + }; + const summary = view(dropped, { kind: "summary" }); + expect(summary.unknowns).toContain( + "3 events were dropped by capture budgets; counts and pages cover retained events only.", + ); + expect(summary).toMatchObject({ + summary: { + events: { + dropped: 3, + omissions: { + by_kind: [{ kind: "console", count: 3 }], + network_transactions: 0, + }, + }, + }, + }); + const refused = inspectAnalysisView({ + source: { kind: "inline", evidence: scenarioCaptureEvidence() }, + view: { kind: "go", collection: "dependencies" }, + }); + if (refused.ok) throw new Error("Expected an unsupported view refusal"); + expect(projectAnalysisError(refused.error)).toMatchObject({ + code: "invalid_request", + }); + }); +}); diff --git a/src/application/environment/environmentScenario.test.ts b/src/application/environment/environmentScenario.test.ts index c326b5c0ca..1828b3bf90 100644 --- a/src/application/environment/environmentScenario.test.ts +++ b/src/application/environment/environmentScenario.test.ts @@ -76,15 +76,14 @@ describe("environment manifests from capture_browser_scenario parents", () => { kind: "inline", evidence: scenarioCaptureEvidence(scenarioCaptureFixture()), }, - view: { kind: "summary" }, + view: { kind: "dart-aot", collection: "package_uris" }, }); if (refused.ok) throw new Error("Expected the unsupported view refusal"); - expect(projectAnalysisError(refused.error)).toMatchObject({ - code: "unsupported_target", - message: expect.stringContaining( - "supports only the environment-manifest and environment-state views", - ), - }); + const projected = projectAnalysisError(refused.error); + expect(projected).toMatchObject({ code: "invalid_request" }); + expect(JSON.stringify(projected)).toContain( + "supports the summary, browser-scenario, environment-manifest and environment-state views", + ); }); it("serves and exports a scenario-built manifest from the same capture", () => { diff --git a/src/cliBrowserScenarioCommands.ts b/src/cliBrowserScenarioCommands.ts index 737c621d86..830ed9c3bc 100644 --- a/src/cliBrowserScenarioCommands.ts +++ b/src/cliBrowserScenarioCommands.ts @@ -3,6 +3,11 @@ import { z } from "zod"; import { captureBrowserScenario } from "./application/BrowserScenarioCaptureService.js"; import { withCommandCancellation } from "./cli/commandCancellation.js"; +import { + analysisEvidenceDeliveryOptions, + cliEvidenceDeliveryInputError, + publishCliAnalysisEvidence, +} from "./cli/analysisEvidenceDelivery.js"; import { CLI_COMMANDS } from "./cliCommandNames.js"; import { parseCliJsonInput, resolveCliJsonPaths } from "./cliJsonInput.js"; import { logCliCommand } from "./cliLogging.js"; @@ -16,6 +21,7 @@ import type { JsonValue } from "./domain/jsonValue.js"; import type { Logger } from "pino"; const OPERATION = "capture_browser_scenario"; +const PROVIDER_ID = "rea-playwright-browser-scenario"; /** Register the one-shot controlled browser scenario command. */ export const registerBrowserScenarioCommands = ( @@ -36,9 +42,17 @@ export const registerBrowserScenarioCommands = ( .string() .describe("Inline browser scenario JSON or JSON file path"), }), - run: ({ args }) => + options: z.object(analysisEvidenceDeliveryOptions), + run: ({ args, options: delivery }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.captureBrowserScenario, async () => { + const deliveryError = cliEvidenceDeliveryInputError({ + detail: delivery.detail, + evidenceOutput: delivery.evidence_output, + operation: OPERATION, + }); + // Refuse undeliverable summaries before launching a browser. + if (deliveryError !== undefined) return cliError(deliveryError); const input = await parseCliJsonInput(args.inputJson, OPERATION); if (!input.ok) return input.error; const scenario = browserScenarioSchema.safeParse( @@ -68,7 +82,6 @@ export const registerBrowserScenarioCommands = ( result = await captureBrowserScenario(provider, scenario.data, { signal, }); - return result.ok ? result.value : cliError(result.error); } finally { try { await provider?.close?.(); @@ -97,6 +110,14 @@ export const registerBrowserScenarioCommands = ( }); } } + if (!result.ok) return cliError(result.error); + return publishCliAnalysisEvidence(result.value, { + providerId: PROVIDER_ID, + operation: OPERATION, + signal, + detail: delivery.detail, + evidenceOutput: delivery.evidence_output, + }); }), ), }); diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 4dc7042b17..f453b3e9ae 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects or inspect_web_network_capture Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types. Select indices, fields and pages; named rows accept names, types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses accept addresses/states, preserving every folded alternative. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects, inspect_web_network_capture or capture_browser_scenario Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. capture_browser_scenario parents also support view {kind: "summary"} (step outcomes, focus accessibility tree, event and network counts) and view {kind: "browser-scenario", collection: "steps" | "events", offset: 0, limit: 64}: steps accept step_indices and artifacts (captured sections returned in full; others are named with their state); events accept step_indices, event_kinds and transaction_ids and keep item_index. Omitted filters select all; [] selects none. Pages repeat source completeness and dropped-event counts; exhausting them does not make a truncated capture complete. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types. Select indices, fields and pages; named rows accept names, types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses accept addresses/states, preserving every folded alternative. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/browserScenarioToolContracts.ts b/src/contracts/browserScenarioToolContracts.ts index 54bc1bc156..25490a98e1 100644 --- a/src/contracts/browserScenarioToolContracts.ts +++ b/src/contracts/browserScenarioToolContracts.ts @@ -1,6 +1,10 @@ import type { ToolContract } from "./toolContractTypes.js"; import { toolContractMetadata } from "./toolEffects.js"; -import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; +import { + analysisDetailOutputSchemaOf, + analysisDetailSchema, +} from "./analysisDetail.js"; +import { browserScenarioSummaryViewResultSchema } from "../domain/analysisView/analysisView.js"; import { browserScenarioInputSchema } from "../domain/browserScenario.js"; import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -26,10 +30,15 @@ export const BROWSER_SCENARIO_TOOL_CONTRACTS = [ name: "capture_browser_scenario", ...toolContractMetadata("capture_browser_scenario"), description: - "Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.", + "Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Default detail complete returns the capture inline. Detail summary retains it and returns per-step status, errors and URLs, the final or first failing step's accessibility tree, and event and network counts; page exact steps (with selected artifacts) or events through inspect_analysis_view kind browser-scenario.", kind: "browser-provider", - inputSchema: browserScenarioInputSchema, - outputSchema: evidenceResultOf(browserScenarioCaptureSchema), + inputSchema: browserScenarioInputSchema.extend({ + detail: analysisDetailSchema, + }), + outputSchema: analysisDetailOutputSchemaOf( + browserScenarioCaptureSchema, + browserScenarioSummaryViewResultSchema, + ), examples: [ { title: "Capture a deterministic browser scenario", input: example }, ], diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index f772865256..15abc20e7c 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -167,6 +167,13 @@ import { interfaceBuilderViewRequestSchema, projectInterfaceBuilderView, } from "./interfaceBuilderView.js"; +import { + browserScenarioSummarySchema, + browserScenarioViewContextSchema, + browserScenarioViewItemsSchema, + browserScenarioViewRequestSchema, + projectBrowserScenarioView, +} from "./browserScenarioView.js"; import { projectWebNetworkCaptureView, captureViewRequestSchema, @@ -322,6 +329,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ environmentManifestViewRequestSchema, environmentStateViewRequestSchema, captureViewRequestSchema, + browserScenarioViewRequestSchema, protobufViewRequestSchema, qlogViewRequestSchema, pcapViewRequestSchema, @@ -604,6 +612,13 @@ export const webNetworkCaptureSummaryViewResultSchema = z.strictObject({ summary: captureSummarySchema, }); +/** Step outcomes, focus accessibility tree and event counts for selected scenario delivery. */ +export const browserScenarioSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("capture_browser_scenario"), + summary: browserScenarioSummarySchema, +}); + /** Exact package declarations with retained decoded manifest text. */ export const androidPackageSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, @@ -754,6 +769,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ managedArtifactSummaryViewResultSchema, managedMemberSummaryViewResultSchema, webNetworkCaptureSummaryViewResultSchema, + browserScenarioSummaryViewResultSchema, androidPackageSummaryViewResultSchema, pcapCaptureSummaryViewResultSchema, dartAotSummaryViewResultSchema, @@ -788,6 +804,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ ...viewResultBase, parent_operation: z.literal("inspect_web_network_capture"), }), + z.strictObject({ + kind: z.literal("browser-scenario"), + view: browserScenarioViewRequestSchema, + items: browserScenarioViewItemsSchema, + ...viewResultBase, + ...browserScenarioViewContextSchema.shape, + parent_operation: z.literal("capture_browser_scenario"), + }), z.strictObject({ kind: z.literal("qlog"), view: qlogViewRequestSchema, @@ -1004,9 +1028,7 @@ const unsupportedParent = ( new AnalysisUnsupportedTargetError( INSPECT_ANALYSIS_VIEW_OPERATION, parentArtifactPath(parent), - parent.operation === "capture_browser_scenario" - ? `Parent Evidence operation "capture_browser_scenario" supports only the environment-manifest and environment-state views.` - : `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function/inventory, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, IL2CPP metadata, or web network capture Evidence.`, + `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function/inventory, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, IL2CPP metadata, web network capture or browser scenario Evidence.`, ); const parentArtifactPath = (parent: AnalysisViewParent): string => { @@ -1048,6 +1070,7 @@ const retainedViewProjectors = new Map([ ["inspect_managed_artifact", projectManagedArtifactView], ["inspect_managed_members", projectManagedMemberView], ["inspect_android_package", projectAndroidPackageView], + ["capture_browser_scenario", projectBrowserScenarioView], ["inspect_dart_aot", projectDartAotView], ["inspect_il2cpp_metadata", projectIl2cppMetadataView], ["inspect_model_artifact", projectModelArtifactView], diff --git a/src/domain/analysisView/browserScenarioView.ts b/src/domain/analysisView/browserScenarioView.ts new file mode 100644 index 0000000000..6f50813d1f --- /dev/null +++ b/src/domain/analysisView/browserScenarioView.ts @@ -0,0 +1,502 @@ +import { z } from "zod"; + +import type { AnalysisError } from "../analysisErrorBase.js"; +import { sanitizedBrowserUrlSchema } from "../browserObservation.js"; +import { + browserScenarioCaptureSchema, + type BrowserScenarioCapture, +} from "../browserScenarioCapture.js"; +import { + browserScenarioCompletenessSchema, + browserScenarioEventKindSchema, + browserScenarioEventSchema, + browserStepArtifactsSchema, + type BrowserScenarioStep, +} from "../browserScenarioCaptureValues.js"; +import { compareAsciiStrings } from "../canonicalOrdering.js"; +import { err, ok, type Result } from "../result.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import type { + AnalysisViewCoverage, + AnalysisViewParent, + AnalysisViewRequest, + UnsignedAnalysisView, +} from "./analysisView.js"; + +const OPERATION = "capture_browser_scenario" as const; +const ARTIFACT_SECTIONS = browserStepArtifactsSchema.keyof().options; +const artifactSectionSchema = browserStepArtifactsSchema.keyof(); +type ArtifactSection = z.infer; +type Step = BrowserScenarioStep; + +const countSchema = z.number().int().nonnegative(); +const statusClassSchema = z.enum([ + "1xx", + "2xx", + "3xx", + "4xx", + "5xx", + "failed", + "unknown", +]); +type StatusClass = z.infer; + +/** Section names by recorded state; not_requested sections are omitted. */ +const stepSectionsSchema = z.strictObject({ + captured: z.array(artifactSectionSchema), + missing: z.array(artifactSectionSchema), + truncated: z.array(artifactSectionSchema), +}); + +const summaryStepSchema = z.strictObject({ + step_index: countSchema, + step_id: z.string().min(1), + action: z.string().min(1), + status: z.enum(["completed", "failed", "cancelled"]), + error: z.string().nullable(), + elapsed_ms: countSchema, + before_url: sanitizedBrowserUrlSchema + .exactOptional() + .describe( + "Present only when it differs from the previous step's after_url (always for step 0).", + ), + after_url: sanitizedBrowserUrlSchema, + events: z.strictObject({ + sequence_start: z.number().int().min(1), + sequence_end: countSchema, + }), + sections: stepSectionsSchema, + completeness: z.enum(["complete", "incomplete", "truncated"]), +}); + +const focusAccessibilitySchema = z.strictObject({ + focus_step_index: countSchema, + focus: z.enum(["first-unsuccessful-step", "last-step"]), + /** The step whose tree is returned: the focus step, or the nearest earlier step that captured one. */ + step_index: countSchema.nullable(), + accessibility: browserStepArtifactsSchema.shape.accessibility, +}); + +/** Compact capture overview; complete records stay in the retained parent. */ +export const browserScenarioSummarySchema = z.strictObject({ + browser: browserScenarioCaptureSchema.shape.browser, + scenario: browserScenarioCaptureSchema.shape.scenario, + duration_ms: countSchema, + completeness: browserScenarioCompletenessSchema, + steps: z.array(summaryStepSchema), + final_accessibility: focusAccessibilitySchema, + events: z.strictObject({ + retained: countSchema, + dropped: countSchema, + by_kind: z.array( + z.strictObject({ + kind: browserScenarioEventKindSchema, + count: countSchema, + }), + ), + omissions: z.strictObject({ + by_kind: + browserScenarioCaptureSchema.shape.events.shape.omissions.shape.by_kind, + network_transactions: countSchema, + network_transaction_range: + browserScenarioCaptureSchema.shape.events.shape.omissions.shape + .network_transaction_range, + }), + }), + network: z.strictObject({ + transactions: countSchema, + by_origin: z.array( + z.strictObject({ + origin: z.string().nullable(), + transactions: countSchema, + }), + ), + by_resource_type: z.array( + z.strictObject({ + resource_type: z.string().min(1), + transactions: countSchema, + }), + ), + by_status_class: z + .array( + z.strictObject({ + status_class: statusClassSchema, + transactions: countSchema, + }), + ) + .describe( + "Final retained outcome per transaction: the first response status class, failed, or unknown when neither was retained.", + ), + }), + limitation_count: countSchema, +}); + +const indicesSchema = z.array(countSchema); + +/** Pages of exact retained steps or events, in original order. */ +export const browserScenarioViewRequestSchema = z.strictObject({ + kind: z.literal("browser-scenario"), + collection: z.enum(["steps", "events"]), + step_indices: indicesSchema + .optional() + .describe("Exact step indices; omitted selects all, [] selects none."), + artifacts: z + .array(artifactSectionSchema) + .default([]) + .describe( + "Steps only: captured sections returned in full. Other recorded sections are listed in omitted_artifacts with their state.", + ), + event_kinds: z + .array(browserScenarioEventKindSchema) + .optional() + .describe( + "Events only: exact kinds; omitted selects all, [] selects none.", + ), + transaction_ids: z + .array(z.string().min(1)) + .optional() + .describe( + "Events only: exact network transaction IDs; omitted selects all, [] selects none. Events without a transaction never match a transaction selection.", + ), + offset: countSchema.default(0), + limit: z.number().int().positive().default(64), +}); +export type BrowserScenarioViewRequest = z.output< + typeof browserScenarioViewRequestSchema +>; + +/** One exact step whose unselected captured sections are named rather than returned. */ +const stepRowSchema = z.strictObject({ + step_index: countSchema, + step_id: z.string().min(1), + action: z.string().min(1), + status: z.enum(["completed", "failed", "cancelled"]), + error: z.string().nullable(), + elapsed_ms: countSchema, + before_url: sanitizedBrowserUrlSchema, + after_url: sanitizedBrowserUrlSchema, + event_sequence_start: z.number().int().min(1), + event_sequence_end: countSchema, + completeness: browserScenarioCompletenessSchema, + artifacts: browserStepArtifactsSchema.partial(), + omitted_artifacts: z.array( + z.strictObject({ + section: artifactSectionSchema, + state: z.enum(["captured", "missing", "truncated"]), + }), + ), +}); +const eventRowSchema = z.strictObject({ + item_index: countSchema, + event: browserScenarioEventSchema, +}); + +export const browserScenarioViewItemsSchema = z.discriminatedUnion( + "collection", + [ + z.strictObject({ + collection: z.literal("steps"), + rows: z.array(stepRowSchema), + }), + z.strictObject({ + collection: z.literal("events"), + rows: z.array(eventRowSchema), + }), + ], +); + +/** Capture-wide facts every page repeats so exhaustion is not read as completeness. */ +export const browserScenarioViewContextSchema = z.strictObject({ + source_completeness: browserScenarioCompletenessSchema, + source_events: z.strictObject({ + retained: countSchema, + dropped: countSchema, + }), +}); + +const unsupportedView = (): AnalysisError => + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + 'Parent Evidence operation "capture_browser_scenario" supports the summary, browser-scenario, environment-manifest and environment-state views.', + }, + ]); + +const sameUrl = ( + left: Step["after_url"] | undefined, + right: Step["before_url"], +): boolean => + left !== undefined && + left.url === right.url && + left.redacted === right.redacted; + +const stepSections = (step: Step): z.infer => { + const sections: z.infer = { + captured: [], + missing: [], + truncated: [], + }; + for (const section of ARTIFACT_SECTIONS) { + const { state } = step.artifacts[section]; + if (state !== "not_requested") sections[state].push(section); + } + return sections; +}; + +const summaryStep = ( + step: Step, + previous: Step | undefined, +): z.infer => ({ + step_index: step.step_index, + step_id: step.step_id, + action: step.action, + status: step.status, + error: step.error, + elapsed_ms: step.elapsed_ms, + ...(sameUrl(previous?.after_url, step.before_url) + ? {} + : { before_url: step.before_url }), + after_url: step.after_url, + events: { + sequence_start: step.event_sequence_start, + sequence_end: step.event_sequence_end, + }, + sections: stepSections(step), + completeness: step.completeness.status, +}); + +/** The first unsuccessful step explains a failure; otherwise the final screen does. */ +const focusAccessibility = ( + steps: readonly Step[], +): z.infer => { + const unsuccessful = steps.findIndex(({ status }) => status !== "completed"); + const focusIndex = unsuccessful === -1 ? steps.length - 1 : unsuccessful; + const focus = steps[focusIndex]; + if (focus === undefined) throw new Error("Capture has no steps"); + const kind = + unsuccessful === -1 ? ("last-step" as const) : "first-unsuccessful-step"; + for (let index = focusIndex; index >= 0; index -= 1) { + const accessibility = steps[index]?.artifacts.accessibility; + if (accessibility?.state === "captured") + return { + focus_step_index: focusIndex, + focus: kind, + step_index: index, + accessibility, + }; + } + return { + focus_step_index: focusIndex, + focus: kind, + step_index: null, + accessibility: focus.artifacts.accessibility, + }; +}; + +/** Counts in canonical key order; a null origin sorts first. */ +const sortedCounts = ( + counts: ReadonlyMap, +): [Key, number][] => + [...counts].sort(([left], [right]) => + left === null ? -1 : right === null ? 1 : compareAsciiStrings(left, right), + ); + +const increment = (counts: Map, key: Key): void => { + counts.set(key, (counts.get(key) ?? 0) + 1); +}; + +/** Count retained network transactions once each, from their first retained request event. */ +const networkCounts = ( + capture: BrowserScenarioCapture, +): z.infer["network"] => { + const origins = new Map(); + const resourceTypes = new Map(); + const outcomes = new Map(); + for (const event of capture.events.items) { + if (event.kind === "request") { + if (outcomes.has(event.transaction_id)) continue; + outcomes.set(event.transaction_id, "unknown"); + increment(origins, event.url.origin); + increment(resourceTypes, event.resource_type); + } else if (event.kind === "response") { + if (outcomes.get(event.transaction_id) === "unknown") + outcomes.set( + event.transaction_id, + `${String(Math.floor(event.status / 100))}xx` as StatusClass, + ); + } else if (event.kind === "request-failed") { + if (outcomes.get(event.transaction_id) === "unknown") + outcomes.set(event.transaction_id, "failed"); + } + } + const statusClasses = new Map(); + for (const outcome of outcomes.values()) increment(statusClasses, outcome); + return { + transactions: outcomes.size, + by_origin: sortedCounts(origins).map(([origin, transactions]) => ({ + origin, + transactions, + })), + by_resource_type: sortedCounts(resourceTypes).map( + ([resource_type, transactions]) => ({ resource_type, transactions }), + ), + by_status_class: sortedCounts(statusClasses).map( + ([status_class, transactions]) => ({ status_class, transactions }), + ), + }; +}; + +const eventCounts = ( + capture: BrowserScenarioCapture, +): z.infer["events"] => { + const kinds = new Map< + BrowserScenarioCapture["events"]["items"][number]["kind"], + number + >(); + for (const event of capture.events.items) increment(kinds, event.kind); + const { omissions } = capture.events; + return { + retained: capture.events.retained, + dropped: capture.events.dropped, + by_kind: sortedCounts(kinds).map(([kind, count]) => ({ kind, count })), + omissions: { + by_kind: omissions.by_kind, + network_transactions: omissions.network_transactions.length, + network_transaction_range: omissions.network_transaction_range, + }, + }; +}; + +const selected = ( + filter: readonly Value[] | undefined, +): ((value: Value) => boolean) => { + if (filter === undefined) return () => true; + const values = new Set(filter); + return (value) => values.has(value); +}; + +const stepRow = ( + { artifacts: recorded, ...step }: Step, + selection: ReadonlySet, +): z.infer => { + const artifacts: z.infer["artifacts"] = {}; + const omitted: z.infer["omitted_artifacts"] = []; + for (const section of ARTIFACT_SECTIONS) { + const artifact = recorded[section]; + if (selection.has(section)) + Object.assign(artifacts, { [section]: artifact }); + else if (artifact.state !== "not_requested") + omitted.push({ section, state: artifact.state }); + } + return { ...step, artifacts, omitted_artifacts: omitted }; +}; + +/** One stable page of matching steps or events, in original order. */ +const pageRecords = ( + capture: BrowserScenarioCapture, + view: BrowserScenarioViewRequest, +): { + readonly items: z.infer; + readonly coverage: AnalysisViewCoverage; +} => { + const stepSelected = selected(view.step_indices); + const page = (rows: readonly Row[]) => { + const returned = rows.slice(view.offset, view.offset + view.limit); + return { + rows: returned, + coverage: pageViewCoverage(view.offset, returned.length, rows.length), + }; + }; + if (view.collection === "steps") { + const artifacts = new Set(view.artifacts); + const { rows, coverage } = page( + capture.steps.filter(({ step_index }) => stepSelected(step_index)), + ); + return { + items: { + collection: "steps", + rows: rows.map((step) => stepRow(step, artifacts)), + }, + coverage, + }; + } + const kindSelected = selected(view.event_kinds); + const transactionSelected = selected(view.transaction_ids); + const { rows, coverage } = page( + capture.events.items.flatMap((event, item_index) => + stepSelected(event.step_index) && + kindSelected(event.kind) && + (view.transaction_ids === undefined || + ("transaction_id" in event && + transactionSelected(event.transaction_id))) + ? [{ item_index, event }] + : [], + ), + ); + return { items: { collection: "events", rows }, coverage }; +}; + +/** Project retained scenario Evidence without relaunching the browser or replaying actions. */ +export const projectBrowserScenarioView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "browser-scenario") + return err(unsupportedView()); + const parsed = parseAnalysisViewResult(browserScenarioCaptureSchema, parent); + if (!parsed.ok) return parsed; + const capture = parsed.value; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: OPERATION, + parent_digest: parent.evidenceId.slice(3), + artifact: { path: "", sha256: null }, + limitations: [...parent.limitations], + unknowns: [ + "Step states record one controlled replay; they do not establish the application's current state.", + ...(capture.events.dropped > 0 + ? [ + `${String(capture.events.dropped)} events were dropped by capture budgets; counts and pages cover retained events only.`, + ] + : []), + ], + }; + if (view.kind === "summary") + return ok({ + kind: "summary", + view, + ...shared, + summary: { + browser: capture.browser, + scenario: capture.scenario, + duration_ms: capture.duration_ms, + completeness: capture.completeness, + steps: capture.steps.map((step, index) => + summaryStep(step, capture.steps[index - 1]), + ), + final_accessibility: focusAccessibility(capture.steps), + events: eventCounts(capture), + network: networkCounts(capture), + limitation_count: capture.limitations.length, + }, + coverage: completeWithinViewCoverage(1, 1), + }); + return ok({ + kind: "browser-scenario", + view, + ...shared, + source_completeness: capture.completeness, + source_events: { + retained: capture.events.retained, + dropped: capture.events.dropped, + }, + ...pageRecords(capture, view), + }); +}; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index fa98728744..38d601df1d 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -990,6 +990,7 @@ const registerObservationTools = ({ }); registerBrowserScenarioTool(server, { ...common, + evidenceById, provider: options.browserScenarioCapture, loadFailure: options.optionalProviderLoadFailures?.browserScenarioCapture, }); diff --git a/src/server/registerBrowserScenarioTool.ts b/src/server/registerBrowserScenarioTool.ts index 8c2015bc4a..09eabd00cf 100644 --- a/src/server/registerBrowserScenarioTool.ts +++ b/src/server/registerBrowserScenarioTool.ts @@ -5,6 +5,12 @@ import { type OptionalProviderLoadFailure, } from "../application/OptionalObservationProviders.js"; import { err } from "../domain/result.js"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; +import { + requireAnalysisViewRetention, + summarizeRetainedAnalysis, +} from "../application/analysisView/AnalysisViewService.js"; +import { analysisDetailSchema } from "../contracts/analysisDetail.js"; import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js"; import { captureBrowserScenario } from "../application/BrowserScenarioCaptureService.js"; @@ -23,6 +29,7 @@ interface BrowserScenarioToolRegistration { readonly loadFailure?: OptionalProviderLoadFailure | undefined; readonly provider: BrowserScenarioCapturePort | undefined; readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; + readonly evidenceById?: EvidenceLookup | undefined; readonly withAdmittedAnalysis?: WithAdmittedAnalysis; } @@ -36,20 +43,29 @@ export const registerBrowserScenarioTool = ( contract.name, toolRegistrationOptions(contract), async (input, context) => { - const parsedScenario = browserScenarioSchema.safeParse(input); - if (!parsedScenario.success) + // Delivery detail is not part of the scenario or its recorded parameters. + const { detail: requestedDetail, ...scenarioInput } = input; + const parsedScenario = browserScenarioSchema.safeParse(scenarioInput); + const parsedDetail = analysisDetailSchema.safeParse(requestedDetail); + if (!parsedScenario.success || !parsedDetail.success) { + const issues = [ + ...(parsedScenario.error?.issues ?? []), + ...(parsedDetail.error?.issues.map((issue) => ({ + ...issue, + path: ["detail", ...issue.path], + })) ?? []), + ]; return server.delivery.toCallToolResult( err( - analysisInputErrorFromIssues( - contract.name, - parsedScenario.error.issues, - input, - { cause: parsedScenario.error }, - ), + analysisInputErrorFromIssues(contract.name, issues, input, { + cause: parsedScenario.error ?? parsedDetail.error, + }), ), contract, ); + } const scenario = parsedScenario.data; + const detail = parsedDetail.data; if (options.loadFailure !== undefined) return server.delivery.toCallToolResult( err(optionalProviderUnavailable(options.loadFailure, contract.name)), @@ -61,6 +77,19 @@ export const registerBrowserScenarioTool = ( contract.name, context.mcpReq.signal, async () => { + const retention = { + recordEvidence: options.recordEvidence, + evidenceById: options.evidenceById, + }; + // Refuse summary delivery before launching a browser that could not retain its Evidence. + if (detail === "summary") { + const ready = requireAnalysisViewRetention( + contract.name, + retention, + ); + if (!ready.ok) + return server.delivery.toCallToolResult(ready, contract); + } const result = await logToolExecution( options.logger, contract.name, @@ -71,7 +100,9 @@ export const registerBrowserScenarioTool = ( ); return deliverEvidenceResult( server, - result, + detail === "summary" && result.ok + ? summarizeRetainedAnalysis(result.value, retention) + : result, contract, options.recordEvidence, ); diff --git a/tests/boundary/cli/scenarioCliInputDiagnostics.test.ts b/tests/boundary/cli/scenarioCliInputDiagnostics.test.ts index ae03568f46..ff80f8bbd9 100644 --- a/tests/boundary/cli/scenarioCliInputDiagnostics.test.ts +++ b/tests/boundary/cli/scenarioCliInputDiagnostics.test.ts @@ -37,6 +37,38 @@ describe("scenario CLI schema diagnostics", () => { }); }); + cliTest( + "refuses summary delivery without a complete Evidence bundle before launching", + async ({ cli }) => { + const scenario = { + browser: { mode: "launch", executable_path: "/nonexistent/chromium" }, + start_url: { url: "https://app.example.test/", query: [] }, + actions: [ + { step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }, + ], + }; + const result = await cli.run({ + arguments: [ + "capture-browser-scenario", + JSON.stringify(scenario), + "--detail", + "summary", + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + + expect(result.exitCode).not.toBe(0); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "capture_browser_scenario", + issues: [{ path: ["evidence_output"], reason: "invalid_value" }], + }, + }); + }, + ); + cliTest("reports Electron scenario schema paths", async ({ cli }) => { const result = await cli.run({ arguments: ["capture-electron-scenario", "{}", "--json"], diff --git a/tests/boundary/mcp/browserScenarioMcp.test.ts b/tests/boundary/mcp/browserScenarioMcp.test.ts index 6ed007354f..e8caca9da6 100644 --- a/tests/boundary/mcp/browserScenarioMcp.test.ts +++ b/tests/boundary/mcp/browserScenarioMcp.test.ts @@ -367,3 +367,111 @@ test("records a completed late-close cancellation as server Evidence", async () await Promise.all([client.close(), server.close(), session.close()]); } }); + +test("delivers a retained summary and pages its exact steps and events", async () => { + const provider = new FakeBrowserScenarioProvider(); + const session = createTestBinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(ok(null)), + })); + const server = createServer( + { kind: "session", session }, + { + browserScenarioCapture: provider, + availabilityPolicy: () => ({ + processCaptureEnabled: false, + investigationInputRoots: 0, + }), + }, + ); + const client = new Client({ name: "scenario-summary", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + const tools = (await client.listTools()).tools; + const advertised = tools.find( + ({ name }) => name === "capture_browser_scenario", + ); + if (advertised?.outputSchema === undefined) + throw new Error("Missing advertised scenario output schema"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const validateInput = ajv.compile(advertised.inputSchema); + const validateOutput = ajv.compile( + z.record(z.string(), z.unknown()).parse(advertised.outputSchema), + ); + const request = { ...minimalScenario(), detail: "summary" }; + expect(validateInput(request), JSON.stringify(validateInput.errors)).toBe( + true, + ); + + const response = await client.callTool({ + name: "capture_browser_scenario", + arguments: request, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect( + validateOutput(response.structuredContent), + JSON.stringify(validateOutput.errors), + ).toBe(true); + const summary = z + .object({ + normalized_result: z.object({ + kind: z.literal("summary"), + parent_evidence_id: z.string(), + summary: z.object({ + steps: z.array(z.object({ step_id: z.string() })), + }), + }), + }) + .parse(response.structuredContent).normalized_result; + expect(summary.summary.steps.map(({ step_id }) => step_id)).toEqual([ + "scenario_start", + "settle", + ]); + // Delivery detail is neither sent to the provider nor recorded as a scenario parameter. + expect(provider.scenarios[0]).not.toHaveProperty("detail"); + const parent = session.evidenceById(summary.parent_evidence_id); + expect(parent?.operation).toBe("capture_browser_scenario"); + expect(parent?.parameters).not.toHaveProperty("detail"); + + const page = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { + kind: "browser-scenario", + collection: "steps", + step_indices: [1], + }, + }, + }); + expect(page.isError, JSON.stringify(page)).not.toBe(true); + expect(page.structuredContent).toMatchObject({ + normalized_result: { + kind: "browser-scenario", + parent_evidence_id: summary.parent_evidence_id, + items: { + collection: "steps", + rows: [{ step_index: 1, step_id: "settle", artifacts: {} }], + }, + coverage: { total: 1, exhausted: true }, + }, + }); + + const invalid = await client.callTool({ + name: "capture_browser_scenario", + arguments: { ...minimalScenario(), detail: "brief" }, + }); + expect(parseMcpToolError(invalid)).toMatchObject({ + error: { code: "invalid_request" }, + }); + expect(provider.scenarios).toHaveLength(1); + } finally { + await Promise.all([client.close(), server.close(), session.close()]); + } +});